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);