feat: RDP clipboard paste, keyboard grab default ON, frame dirty flag
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m49s
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m49s
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) <noreply@anthropic.com>
This commit is contained in:
parent
2dfe4f9d7a
commit
99ecbe739e
@ -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.
|
||||
|
||||
@ -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,
|
||||
])
|
||||
|
||||
@ -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<TokioMutex<Vec<u8>>>,
|
||||
frame_dirty: Arc<AtomicBool>,
|
||||
input_tx: mpsc::UnboundedSender<InputEvent>,
|
||||
}
|
||||
|
||||
@ -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<String, String> {
|
||||
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<TokioMutex<Vec<u8>>>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, width: u16, height: u16) -> Result<(), String> {
|
||||
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, frame_buffer: Arc<TokioMutex<Vec<u8>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, 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<Operation> {
|
||||
let mut ops = Vec::new();
|
||||
let pos = MousePosition { x, y };
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user