chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进
This commit is contained in:
1 parent
f71eef0eba
commit
ae6093c9b3
163 files changed
+4911
-2494
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user