From 661490e925d5d7ae3a1e8ae116b44d40ccf74d56 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 26 Mar 2026 16:44:53 -0400 Subject: [PATCH] perf: RDP event-driven frames + MCP terminal \r fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RDP performance overhaul: - Switched from polling (rAF loop calling rdp_get_frame every tick) to event-driven rendering (backend emits rdp:frame:{id} when frame buffer updates, frontend fetches on demand) - Eliminates thousands of empty IPC round-trips per second when the screen is idle - Backend passes AppHandle into run_active_session for event emission - Frontend uses listen() instead of requestAnimationFrame polling MCP terminal fix: - terminal_type and terminal_execute now send \r (carriage return) instead of \n (newline) — PTY terminals expect CR to submit - Fixes commands not auto-sending, requiring manual Enter press Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/mcp_commands.rs | 2 +- src-tauri/src/commands/rdp_commands.rs | 10 ++----- src-tauri/src/mcp/server.rs | 4 +-- src-tauri/src/rdp/mod.rs | 9 ++++-- src/composables/useRdp.ts | 41 ++++++++++++++------------ 5 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/commands/mcp_commands.rs b/src-tauri/src/commands/mcp_commands.rs index 1b3fc6a..1e284fb 100644 --- a/src-tauri/src/commands/mcp_commands.rs +++ b/src-tauri/src/commands/mcp_commands.rs @@ -70,7 +70,7 @@ pub async fn mcp_terminal_execute( let before = buf.total_written(); // Send command + marker echo - let full_cmd = format!("{}\necho {}\n", command, marker); + let full_cmd = format!("{}\recho {}\r", command, marker); state.ssh.write(&session_id, full_cmd.as_bytes()).await?; // Poll scrollback until marker appears or timeout diff --git a/src-tauri/src/commands/rdp_commands.rs b/src-tauri/src/commands/rdp_commands.rs index e2f7825..79ec24a 100644 --- a/src-tauri/src/commands/rdp_commands.rs +++ b/src-tauri/src/commands/rdp_commands.rs @@ -3,23 +3,19 @@ //! Mirrors the pattern used by `ssh_commands.rs` — thin command wrappers that //! delegate to the `RdpService` via `State`. -use tauri::State; +use tauri::{AppHandle, State}; use crate::rdp::{RdpConfig, RdpSessionInfo}; use crate::AppState; /// Connect to an RDP server. -/// -/// Performs the full connection handshake (TCP -> TLS -> CredSSP -> RDP) and -/// starts streaming frame updates in the background. -/// -/// Returns the session UUID. #[tauri::command] pub fn connect_rdp( config: RdpConfig, + app_handle: AppHandle, state: State<'_, AppState>, ) -> Result { - state.rdp.connect(config) + state.rdp.connect(config, app_handle) } /// Get the current frame buffer as raw RGBA bytes (binary IPC — no base64). diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index 0eb203a..c24d0d6 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -166,7 +166,7 @@ async fn handle_terminal_type( Json(req): Json, ) -> Json> { let text = if req.press_enter.unwrap_or(true) { - format!("{}\n", req.text) + format!("{}\r", req.text) } else { req.text.clone() }; @@ -201,7 +201,7 @@ async fn handle_terminal_execute( }; let before = buf.total_written(); - let full_cmd = format!("{}\necho {}\n", req.command, marker); + let full_cmd = format!("{}\recho {}\r", req.command, marker); if let Err(e) = state.ssh.write(&req.session_id, full_cmd.as_bytes()).await { return err_response(e); diff --git a/src-tauri/src/rdp/mod.rs b/src-tauri/src/rdp/mod.rs index 8dcd531..51b2ac7 100644 --- a/src-tauri/src/rdp/mod.rs +++ b/src-tauri/src/rdp/mod.rs @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use base64::Engine; use dashmap::DashMap; use log::{error, info, warn}; +use tauri::Emitter; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; @@ -86,7 +87,7 @@ impl RdpService { } } - pub fn connect(&self, config: RdpConfig) -> Result { + pub fn connect(&self, config: RdpConfig, app_handle: tauri::AppHandle) -> Result { let session_id = uuid::Uuid::new_v4().to_string(); wraith_log!("[RDP] Connecting to {}:{} as {} (session {})", config.hostname, config.port, config.username, session_id); let width = config.width; @@ -160,6 +161,8 @@ impl RdpService { input_rx, width as u16, height as u16, + app_handle, + sid.clone(), ) .await { @@ -333,7 +336,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>>, frame_dirty: 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, app_handle: tauri::AppHandle, session_id: String) -> 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); @@ -396,6 +399,8 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade 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); + // Push frame notification to frontend — no data, just a signal to fetch + let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ()); } ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); } ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); } diff --git a/src/composables/useRdp.ts b/src/composables/useRdp.ts index 9eebc94..dcdc285 100644 --- a/src/composables/useRdp.ts +++ b/src/composables/useRdp.ts @@ -195,7 +195,6 @@ export function useRdp(): UseRdpReturn { const clipboardSync = ref(false); let animFrameId: number | null = null; - let frameCount = 0; /** * Fetch the current frame from the Rust RDP backend. @@ -297,27 +296,32 @@ export function useRdp(): UseRdpReturn { canvas.width = width; canvas.height = height; - function renderLoop(): void { - frameCount++; + let fetchPending = false; - // Throttle to ~24fps by rendering every 3rd rAF tick (~60/3=20fps, close to 24) - // This saves 20% CPU vs 30fps with negligible visual difference for remote desktop - if (frameCount % 3 === 0) { - fetchFrame(sessionId, width, height).then((imageData) => { - if (imageData && ctx) { - ctx.putImageData(imageData, 0, 0); - // Mark connected on first successful frame - if (!connected.value) { - connected.value = true; - } - } - }); + // Fetch frame when backend signals a new frame is ready + async function onFrameReady(): Promise { + if (fetchPending) return; // Don't stack fetches + fetchPending = true; + const imageData = await fetchFrame(sessionId, width, height); + fetchPending = false; + if (imageData && ctx) { + ctx.putImageData(imageData, 0, 0); + if (!connected.value) connected.value = true; } - - animFrameId = requestAnimationFrame(renderLoop); } - animFrameId = requestAnimationFrame(renderLoop); + // Listen for frame events from the backend (push model) + import("@tauri-apps/api/event").then(({ listen }) => { + listen(`rdp:frame:${sessionId}`, () => { + onFrameReady(); + }).then((unlisten) => { + // Store unlisten so we can clean up + (canvas as any).__wraith_unlisten = unlisten; + }); + }); + + // Also do an initial poll in case frames arrived before listener was set up + onFrameReady(); } /** @@ -329,7 +333,6 @@ export function useRdp(): UseRdpReturn { animFrameId = null; } connected.value = false; - frameCount = 0; } function toggleKeyboardGrab(): void {