chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4911 -2494

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ use url::Url;
use crate::domain::ai::{AiCatalogDocument, AiCompleteRequest, AiProviderConfig};
use redaction::redact_sensitive;
pub(crate) use redaction::redact_sensitive;
pub struct AiCompletion {
pub text: String,
+8 -1
View File
@@ -168,14 +168,21 @@ fn set_file_permissions(_path: &std::path::Path) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEST_HOME_SEQ: AtomicU64 = AtomicU64::new(0);
fn unique_home() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir = std::env::temp_dir().join(format!("opsxshell-cred-test-{nanos}"));
let seq = TEST_HOME_SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"opsxshell-cred-test-{nanos}-{seq}-{}",
std::process::id()
));
fs::create_dir_all(&dir).expect("temp home");
dir
}
@@ -0,0 +1,105 @@
//! Clipboard text cleanup shared by VNC CutText (and tests).
//!
//! Terminal emulators wrap clipboard paste in bracketed-paste markers
//! (`ESC [ 200 ~` … `ESC [ 201 ~`). If we inject Ctrl+V into a remote
//! terminal, those markers show up as `^[[200~…~`. Strip them on both
//! directions and type Unicode keysyms instead of a paste chord.
/// Max characters typed as RFB key events. Larger payloads stay on CutText
/// and fall back to Ctrl+V so a huge clipboard cannot stall the session.
pub const MAX_TYPE_CHARS: usize = 8192;
pub fn sanitize_clipboard_text(text: &str) -> String {
let stripped = text
.replace("\u{1b}[200~", "")
.replace("\u{1b}[201~", "")
.replace("^[[200~", "")
.replace("^[[201~", "");
let mut out = String::with_capacity(stripped.len());
let mut prev_cr = false;
for ch in stripped.chars() {
if prev_cr && ch == '\n' {
prev_cr = false;
continue;
}
prev_cr = false;
match ch {
'\n' => out.push('\n'),
'\r' => {
out.push('\n');
prev_cr = true;
}
'\t' => out.push('\t'),
'\u{feff}' => {}
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
pub fn vnc_keysym_for_char(ch: char) -> Option<u32> {
match ch {
'\n' => Some(0xff0d),
'\t' => Some(0xff09),
c if c.is_control() => None,
c => {
let codepoint = c as u32;
if codepoint < 0x100 {
Some(codepoint)
} else {
Some(0x01000000 | codepoint)
}
}
}
}
pub fn should_type_as_keys(text: &str) -> bool {
!text.is_empty() && text.chars().count() <= MAX_TYPE_CHARS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_bracketed_paste_around_ip() {
let wrapped = "\u{1b}[200~47.110.68.252\u{1b}[201~";
assert_eq!(sanitize_clipboard_text(wrapped), "47.110.68.252");
}
#[test]
fn strips_caret_notation_copied_from_screen() {
assert_eq!(
sanitize_clipboard_text("^[[200~47.110.68.252^[[201~"),
"47.110.68.252"
);
}
#[test]
fn normalizes_crlf_and_drops_other_controls() {
assert_eq!(sanitize_clipboard_text("a\r\nb\rc\u{0007}d"), "a\nb\ncd");
}
#[test]
fn keeps_cjk_and_tabs() {
assert_eq!(sanitize_clipboard_text("主机\t名称"), "主机\t名称");
}
#[test]
fn keysym_for_ascii_and_return() {
assert_eq!(vnc_keysym_for_char('4'), Some(0x34));
assert_eq!(vnc_keysym_for_char('.'), Some(0x2e));
assert_eq!(vnc_keysym_for_char('\n'), Some(0xff0d));
assert_eq!(vnc_keysym_for_char('中'), Some(0x01004e2d));
}
#[test]
fn types_small_payloads_not_huge_ones() {
assert!(should_type_as_keys("47.110.68.252"));
let huge = "x".repeat(MAX_TYPE_CHARS + 1);
assert!(!should_type_as_keys(&huge));
assert!(!should_type_as_keys(""));
}
}
@@ -17,6 +17,8 @@ use tracing::{debug, warn};
use crate::domain::desktop::RdpClipboardEvent;
use super::clipboard_text::sanitize_clipboard_text;
#[derive(Debug)]
pub struct OpsxCliprdrBackend {
app: AppHandle,
@@ -144,6 +146,7 @@ impl CliprdrBackend for OpsxCliprdrBackend {
.to_unicode_string()
.or_else(|_| response.to_string())
.unwrap_or_default();
let text = sanitize_clipboard_text(&text);
if text.is_empty() {
return;
}
@@ -179,6 +182,10 @@ pub fn queue_local_clipboard_set(
want_paste_inject: &AtomicBool,
text: String,
) {
let text = sanitize_clipboard_text(&text);
if text.is_empty() {
return;
}
if let Ok(mut guard) = local_text.lock() {
*guard = Some(text);
}
+1
View File
@@ -3,6 +3,7 @@
//! IronRDP and vnc-rs drive Canvas frames over Tauri events. Session runtime
//! state lives only in memory; disconnect cancels the worker thread.
mod clipboard_text;
mod cliprdr_backend;
mod common;
mod input_map;
+66 -16
View File
@@ -16,6 +16,7 @@ use vnc::{
use crate::domain::desktop::{VncClipboardEvent, VncConfig};
use super::clipboard_text::{sanitize_clipboard_text, should_type_as_keys, vnc_keysym_for_char};
use super::common::{coalesce_desktop_inputs, emit_frame, emit_status, DesktopInput};
use super::input_map;
@@ -125,6 +126,7 @@ async fn run_session_async(
let mut fb_height = height;
let mut framebuffer: Vec<u8> = vec![0; usize::from(fb_width) * usize::from(fb_height) * 4];
let mut buttons: u8 = 0;
let mut last_outbound_cut: Option<String> = None;
let mut last_refresh = std::time::Instant::now();
emit_status(
@@ -152,7 +154,7 @@ async fn run_session_async(
let _ = vnc.input(X11Event::FullRefresh).await;
while !stop.load(Ordering::Relaxed) {
drain_input(&vnc, &input, &mut buttons).await?;
drain_input(&vnc, &input, &mut buttons, &mut last_outbound_cut).await?;
match vnc.poll_event().await {
Ok(Some(event)) => {
@@ -163,6 +165,7 @@ async fn run_session_async(
&mut fb_width,
&mut fb_height,
&mut framebuffer,
&mut last_outbound_cut,
)?;
}
Ok(None) => {
@@ -187,6 +190,7 @@ async fn drain_input(
vnc: &vnc::VncClient,
input: &Mutex<Vec<DesktopInput>>,
buttons: &mut u8,
last_outbound_cut: &mut Option<String>,
) -> Result<(), String> {
let queued = {
let mut guard = input
@@ -233,15 +237,20 @@ async fn drain_input(
.map_err(|error| format!("发送键盘事件失败: {error}"))?;
}
DesktopInput::ClipboardSet { text } => {
let cut = sanitize_clipboard_text(&text);
if cut.is_empty() {
continue;
}
// Classic RFB CutText is Latin-1, but vnc-rs writes String::as_bytes()
// (UTF-8). Prefer UTF-8 on the wire so CJK survives on modern servers;
// fall back to Latin-1 substitution only when the payload is pure BMP Latin-1.
let cut = prepare_cut_text(&text);
vnc.input(X11Event::CopyText(cut))
// (UTF-8). Prefer UTF-8 on the wire so CJK survives on modern servers.
*last_outbound_cut = Some(cut.clone());
vnc.input(X11Event::CopyText(cut.clone()))
.await
.map_err(|error| format!("发送剪贴板失败: {error}"))?;
// Most servers only update remote clipboard; inject Ctrl+V for paste UX.
inject_remote_paste(vnc).await?;
// Do not inject Ctrl+V: remote terminals wrap clipboard paste in
// bracketed-paste (`ESC[200~` … `ESC[201~`), which shows up as
// `^[[200~47.110.68.252~`. Type Unicode keysyms instead.
inject_clipboard_into_remote(vnc, &cut).await?;
}
DesktopInput::UploadFile { .. } | DesktopInput::Resize { .. } => {}
}
@@ -249,7 +258,46 @@ async fn drain_input(
Ok(())
}
async fn inject_remote_paste(vnc: &vnc::VncClient) -> Result<(), String> {
async fn inject_clipboard_into_remote(vnc: &vnc::VncClient, text: &str) -> Result<(), String> {
release_modifier_keys(vnc).await?;
if should_type_as_keys(text) {
inject_text_as_keys(vnc, text).await
} else {
inject_ctrl_v(vnc).await
}
}
/// Cmd/Ctrl was already forwarded as Super/Control-down before the V key.
/// Release modifiers so typed characters are not Super+digit / Ctrl+letter.
async fn release_modifier_keys(vnc: &vnc::VncClient) -> Result<(), String> {
const MODIFIERS: [u32; 8] = [
0xffe3, // Control_L
0xffe4, // Control_R
0xffe1, // Shift_L
0xffe2, // Shift_R
0xffe9, // Alt_L
0xffea, // Alt_R
0xffeb, // Super_L
0xffec, // Super_R
];
for keycode in MODIFIERS {
send_key(vnc, keycode, false).await?;
}
Ok(())
}
async fn inject_text_as_keys(vnc: &vnc::VncClient, text: &str) -> Result<(), String> {
for ch in text.chars() {
let Some(keycode) = vnc_keysym_for_char(ch) else {
continue;
};
send_key(vnc, keycode, true).await?;
send_key(vnc, keycode, false).await?;
}
Ok(())
}
async fn inject_ctrl_v(vnc: &vnc::VncClient) -> Result<(), String> {
const CONTROL: u32 = 0xffe3;
const KEY_V: u32 = 0x0076;
for (keycode, down) in [
@@ -258,18 +306,15 @@ async fn inject_remote_paste(vnc: &vnc::VncClient) -> Result<(), String> {
(KEY_V, false),
(CONTROL, false),
] {
vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down }))
.await
.map_err(|error| format!("注入粘贴快捷键失败: {error}"))?;
send_key(vnc, keycode, down).await?;
}
Ok(())
}
fn prepare_cut_text(text: &str) -> String {
// Send UTF-8 as-is. Classic Latin-1 servers may mojibake non-ASCII, but replacing
// CJK with '?' is worse for OpsXShell's primary locale. Extended Clipboard (UTF-8)
// needs a vnc-rs protocol upgrade beyond CopyText.
text.to_string()
async fn send_key(vnc: &vnc::VncClient, keycode: u32, down: bool) -> Result<(), String> {
vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down }))
.await
.map_err(|error| format!("发送键盘事件失败: {error}"))
}
fn decode_tight_jpeg(data: &[u8]) -> Result<Vec<u8>, String> {
@@ -292,6 +337,7 @@ fn handle_vnc_event(
fb_width: &mut u16,
fb_height: &mut u16,
framebuffer: &mut Vec<u8>,
last_outbound_cut: &mut Option<String>,
) -> Result<(), String> {
match event {
VncEvent::SetResolution(screen) => {
@@ -372,9 +418,13 @@ fn handle_vnc_event(
},
VncEvent::Error(message) => return Err(message),
VncEvent::Text(text) => {
let text = sanitize_clipboard_text(&text);
if text.is_empty() {
return Ok(());
}
if last_outbound_cut.as_ref() == Some(&text) {
return Ok(());
}
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(text.clone());
}
+13 -11
View File
@@ -96,7 +96,12 @@ impl SshService {
app: &AppHandle,
connection_id: &str,
retain_forwards: bool,
reason: Option<String>,
) -> Result<(), String> {
let reason = reason
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "connection closed".to_string());
// Dedicated tunnel sessions keep listening across terminal flaps when
// retain_forwards is true. Explicit teardown (tab close / panel disconnect)
// must still close real Listeners.
@@ -123,7 +128,7 @@ impl SshService {
for shell_id in shell_ids {
if let Some(mut shell) = self.shells.lock().map_err(lock_error)?.remove(&shell_id) {
let _ = shell.channel.close();
let _ = shell.session.disconnect(None, "closed by user", None);
let _ = shell.session.disconnect(None, &reason, None);
}
}
let sftp_ids = {
@@ -148,13 +153,13 @@ impl SshService {
.map_err(lock_error)?
.remove(connection_id)
{
let _ = connection.session.disconnect(None, "closed by user", None);
let _ = connection.session.disconnect(None, &reason, None);
}
let _ = app.emit(
"ssh:connection-closed",
ConnectionClosedEvent {
connection_id: connection_id.to_string(),
reason: "closed by user".to_string(),
reason,
},
);
Ok(())
@@ -657,15 +662,12 @@ fn classify_shell_close(output_tail: &[u8], read_error: Option<&str>) -> (String
|| output.contains("auto-logout")
|| output.contains("auto logout");
if idle_timeout {
return ("远端 Shell 因空闲策略(TMOUT)自动退出".to_string(), true);
return ("shell.idleTimeout".to_string(), true);
}
if let Some(error) = read_error {
return (format!("远端 Shell 读取失败: {error}"), false);
return (format!("shell.readFailed: {error}"), false);
}
(
"远端 Shell 已退出(可能执行了 exit、Ctrl+D,或被服务器策略关闭)".to_string(),
false,
)
("shell.exited".to_string(), false)
}
fn write_nonblocking(channel: &mut ssh2::Channel, data: &[u8]) -> io::Result<()> {
@@ -814,7 +816,7 @@ mod tests {
classify_shell_close(b"timed out waiting for input: auto-logout\r\n", None);
assert!(idle_timeout);
assert!(reason.contains("TMOUT"));
assert_eq!(reason, "shell.idleTimeout");
}
#[test]
@@ -831,7 +833,7 @@ mod tests {
let (reason, idle_timeout) = classify_shell_close(b"logout\r\n", None);
assert!(!idle_timeout);
assert!(reason.contains("Shell 已退出"));
assert_eq!(reason, "shell.exited");
}
#[test]