From 99ecbe739edb73a9914fdaaf2c86d2ef1b14e24d Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 24 Mar 2026 17:57:16 -0400 Subject: [PATCH] feat: RDP clipboard paste, keyboard grab default ON, frame dirty flag 1. Clipboard paste (rdp_send_clipboard): simulates typing each character via scancode key press/release events. Full ASCII coverage including all symbols, numbers, and shifted characters. Handles 32-char generated passwords without manual typing. 2. Keyboard grab defaults to ON so RDP sessions accept keyboard input immediately without requiring the user to click the toolbar toggle. 3. Frame dirty flag: GraphicsUpdate sets an AtomicBool, get_frame only encodes + returns base64 when dirty (returns empty string otherwise). Eliminates ~8MB/frame base64 encoding on unchanged frames at 30fps. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/rdp_commands.rs | 10 +++ src-tauri/src/lib.rs | 2 +- src-tauri/src/rdp/mod.rs | 94 +++++++++++++++++++++++++- src/composables/useRdp.ts | 4 +- 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/rdp_commands.rs b/src-tauri/src/commands/rdp_commands.rs index 05abef7..b95bdf3 100644 --- a/src-tauri/src/commands/rdp_commands.rs +++ b/src-tauri/src/commands/rdp_commands.rs @@ -74,6 +74,16 @@ pub async fn rdp_send_key( state.rdp.send_key(&session_id, scancode, pressed) } +/// Send clipboard text to an RDP session by simulating keystrokes. +#[tauri::command] +pub async fn rdp_send_clipboard( + session_id: String, + text: String, + state: State<'_, AppState>, +) -> Result<(), String> { + state.rdp.send_clipboard(&session_id, &text) +} + /// Disconnect an RDP session. /// /// Sends a graceful shutdown to the RDP server and removes the session. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b89906..2d45f7c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,7 +105,7 @@ pub fn run() { commands::credentials::list_credentials, commands::credentials::create_password, commands::credentials::create_ssh_key, commands::credentials::delete_credential, commands::credentials::decrypt_password, commands::credentials::decrypt_ssh_key, commands::ssh_commands::connect_ssh, commands::ssh_commands::connect_ssh_with_key, commands::ssh_commands::ssh_write, commands::ssh_commands::ssh_resize, commands::ssh_commands::disconnect_ssh, commands::ssh_commands::disconnect_session, commands::ssh_commands::list_ssh_sessions, commands::sftp_commands::sftp_list, commands::sftp_commands::sftp_read_file, commands::sftp_commands::sftp_write_file, commands::sftp_commands::sftp_mkdir, commands::sftp_commands::sftp_delete, commands::sftp_commands::sftp_rename, - commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions, + commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions, commands::theme_commands::list_themes, commands::theme_commands::get_theme, commands::ai_commands::set_gemini_auth, commands::ai_commands::gemini_chat, commands::ai_commands::is_gemini_authenticated, ]) diff --git a/src-tauri/src/rdp/mod.rs b/src-tauri/src/rdp/mod.rs index a4be97f..99a6ea0 100644 --- a/src-tauri/src/rdp/mod.rs +++ b/src-tauri/src/rdp/mod.rs @@ -3,6 +3,7 @@ pub mod input; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use base64::Engine; use dashmap::DashMap; @@ -60,6 +61,7 @@ enum InputEvent { scancode: u16, pressed: bool, }, + Clipboard(String), Disconnect, } @@ -69,6 +71,7 @@ struct RdpSessionHandle { width: u16, height: u16, frame_buffer: Arc>>, + frame_dirty: Arc, input_tx: mpsc::UnboundedSender, } @@ -95,6 +98,7 @@ impl RdpService { pixel[3] = 255; } let frame_buffer = Arc::new(TokioMutex::new(initial_buf)); + let frame_dirty = Arc::new(AtomicBool::new(false)); let (input_tx, input_rx) = mpsc::unbounded_channel(); @@ -104,6 +108,7 @@ impl RdpService { width, height, frame_buffer: frame_buffer.clone(), + frame_dirty: frame_dirty.clone(), input_tx, }); @@ -149,6 +154,7 @@ impl RdpService { connection_result, framed, frame_buffer, + frame_dirty, input_rx, width as u16, height as u16, @@ -179,6 +185,9 @@ impl RdpService { pub async fn get_frame(&self, session_id: &str) -> Result { let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?; + if !handle.frame_dirty.swap(false, Ordering::Relaxed) { + return Ok(String::new()); + } let buf = handle.frame_buffer.lock().await; let encoded = base64::engine::general_purpose::STANDARD.encode(&*buf); Ok(encoded) @@ -190,6 +199,11 @@ impl RdpService { Ok(buf.clone()) } + pub fn send_clipboard(&self, session_id: &str, text: &str) -> Result<(), String> { + let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?; + handle.input_tx.send(InputEvent::Clipboard(text.to_string())).map_err(|_| format!("RDP session {} input channel closed", session_id)) + } + pub fn send_mouse(&self, session_id: &str, x: u16, y: u16, flags: u32) -> Result<(), String> { let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?; handle.input_tx.send(InputEvent::Mouse { x, y, flags }).map_err(|_| format!("RDP session {} input channel closed", session_id)) @@ -283,7 +297,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u Ok((connection_result, upgraded_framed)) } -async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, frame_buffer: Arc>>, mut input_rx: mpsc::UnboundedReceiver, width: u16, height: u16) -> Result<(), String> { +async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, frame_buffer: Arc>>, frame_dirty: Arc, mut input_rx: mpsc::UnboundedReceiver, width: u16, height: u16) -> Result<(), String> { let (mut reader, mut writer) = split_tokio_framed(framed); let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height); let mut active_stage = ActiveStage::new(connection_result); @@ -313,6 +327,28 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade let events = input_db.apply([op]); active_stage.process_fastpath_input(&mut image, &events).map_err(|e| format!("Failed to process keyboard input: {}", e))? } + Some(InputEvent::Clipboard(text)) => { + let shift_sc = Scancode::from_u16(0x002A); + let mut all_outputs = Vec::new(); + for ch in text.chars() { + if let Some((sc_val, shift)) = char_to_scancode(ch) { + let sc = Scancode::from_u16(sc_val); + if shift { + let evts = input_db.apply([Operation::KeyPressed(shift_sc)]); + all_outputs.extend(active_stage.process_fastpath_input(&mut image, &evts).map_err(|e| format!("clipboard input error: {}", e))?); + } + let evts = input_db.apply([Operation::KeyPressed(sc)]); + all_outputs.extend(active_stage.process_fastpath_input(&mut image, &evts).map_err(|e| format!("clipboard input error: {}", e))?); + let evts = input_db.apply([Operation::KeyReleased(sc)]); + all_outputs.extend(active_stage.process_fastpath_input(&mut image, &evts).map_err(|e| format!("clipboard input error: {}", e))?); + if shift { + let evts = input_db.apply([Operation::KeyReleased(shift_sc)]); + all_outputs.extend(active_stage.process_fastpath_input(&mut image, &evts).map_err(|e| format!("clipboard input error: {}", e))?); + } + } + } + all_outputs + } } } }; @@ -323,6 +359,7 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade let mut buf = frame_buffer.lock().await; let src = image.data(); if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); } + frame_dirty.store(true, Ordering::Relaxed); } ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); } ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); } @@ -332,6 +369,61 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade } } +/// Map an ASCII character to (scancode, needs_shift) for RDP keystroke injection. +fn char_to_scancode(ch: char) -> Option<(u16, bool)> { + match ch { + 'a'..='z' => { + let offsets: &[u16] = &[ + 0x1E, 0x30, 0x2E, 0x20, 0x12, 0x21, 0x22, 0x23, 0x17, 0x24, + 0x25, 0x26, 0x32, 0x31, 0x18, 0x19, 0x10, 0x13, 0x1F, 0x14, + 0x16, 0x2F, 0x11, 0x2D, 0x15, 0x2C, + ]; + Some((offsets[(ch as u8 - b'a') as usize], false)) + } + 'A'..='Z' => { + char_to_scancode(ch.to_ascii_lowercase()).map(|(sc, _)| (sc, true)) + } + '0' => Some((0x0B, false)), + '1'..='9' => Some(((ch as u16 - '0' as u16) + 1, false)), + ')' => Some((0x0B, true)), + '!' => Some((0x02, true)), + '@' => Some((0x03, true)), + '#' => Some((0x04, true)), + '$' => Some((0x05, true)), + '%' => Some((0x06, true)), + '^' => Some((0x07, true)), + '&' => Some((0x08, true)), + '*' => Some((0x09, true)), + '(' => Some((0x0A, true)), + '-' => Some((0x0C, false)), + '_' => Some((0x0C, true)), + '=' => Some((0x0D, false)), + '+' => Some((0x0D, true)), + '[' => Some((0x1A, false)), + '{' => Some((0x1A, true)), + ']' => Some((0x1B, false)), + '}' => Some((0x1B, true)), + '\\' => Some((0x2B, false)), + '|' => Some((0x2B, true)), + ';' => Some((0x27, false)), + ':' => Some((0x27, true)), + '\'' => Some((0x28, false)), + '"' => Some((0x28, true)), + ',' => Some((0x33, false)), + '<' => Some((0x33, true)), + '.' => Some((0x34, false)), + '>' => Some((0x34, true)), + '/' => Some((0x35, false)), + '?' => Some((0x35, true)), + '`' => Some((0x29, false)), + '~' => Some((0x29, true)), + ' ' => Some((0x39, false)), + '\n' | '\r' => Some((0x1C, false)), + '\t' => Some((0x0F, false)), + _ => None, + } +} + fn translate_mouse_flags(x: u16, y: u16, flags: u32) -> Vec { let mut ops = Vec::new(); let pos = MousePosition { x, y }; diff --git a/src/composables/useRdp.ts b/src/composables/useRdp.ts index 454eb7b..e16d6eb 100644 --- a/src/composables/useRdp.ts +++ b/src/composables/useRdp.ts @@ -191,7 +191,7 @@ export interface UseRdpReturn { */ export function useRdp(): UseRdpReturn { const connected = ref(false); - const keyboardGrabbed = ref(false); + const keyboardGrabbed = ref(true); const clipboardSync = ref(false); let animFrameId: number | null = null; @@ -217,7 +217,7 @@ export function useRdp(): UseRdpReturn { return null; } - if (!raw) return null; + if (!raw || raw.length === 0) return null; // Decode base64 → binary string → Uint8ClampedArray const binaryStr = atob(raw);