perf: RDP event-driven frames + MCP terminal \r fix
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m28s

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) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-26 16:44:53 -04:00
parent d78cafba93
commit 661490e925
5 changed files with 35 additions and 31 deletions

View File

@ -70,7 +70,7 @@ pub async fn mcp_terminal_execute(
let before = buf.total_written(); let before = buf.total_written();
// Send command + marker echo // 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?; state.ssh.write(&session_id, full_cmd.as_bytes()).await?;
// Poll scrollback until marker appears or timeout // Poll scrollback until marker appears or timeout

View File

@ -3,23 +3,19 @@
//! Mirrors the pattern used by `ssh_commands.rs` — thin command wrappers that //! Mirrors the pattern used by `ssh_commands.rs` — thin command wrappers that
//! delegate to the `RdpService` via `State<AppState>`. //! delegate to the `RdpService` via `State<AppState>`.
use tauri::State; use tauri::{AppHandle, State};
use crate::rdp::{RdpConfig, RdpSessionInfo}; use crate::rdp::{RdpConfig, RdpSessionInfo};
use crate::AppState; use crate::AppState;
/// Connect to an RDP server. /// 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] #[tauri::command]
pub fn connect_rdp( pub fn connect_rdp(
config: RdpConfig, config: RdpConfig,
app_handle: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, String> { ) -> Result<String, String> {
state.rdp.connect(config) state.rdp.connect(config, app_handle)
} }
/// Get the current frame buffer as raw RGBA bytes (binary IPC — no base64). /// Get the current frame buffer as raw RGBA bytes (binary IPC — no base64).

View File

@ -166,7 +166,7 @@ async fn handle_terminal_type(
Json(req): Json<TerminalTypeRequest>, Json(req): Json<TerminalTypeRequest>,
) -> Json<McpResponse<String>> { ) -> Json<McpResponse<String>> {
let text = if req.press_enter.unwrap_or(true) { let text = if req.press_enter.unwrap_or(true) {
format!("{}\n", req.text) format!("{}\r", req.text)
} else { } else {
req.text.clone() req.text.clone()
}; };
@ -201,7 +201,7 @@ async fn handle_terminal_execute(
}; };
let before = buf.total_written(); 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 { if let Err(e) = state.ssh.write(&req.session_id, full_cmd.as_bytes()).await {
return err_response(e); return err_response(e);

View File

@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use base64::Engine; use base64::Engine;
use dashmap::DashMap; use dashmap::DashMap;
use log::{error, info, warn}; use log::{error, info, warn};
use tauri::Emitter;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncWrite}; use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream; use tokio::net::TcpStream;
@ -86,7 +87,7 @@ impl RdpService {
} }
} }
pub fn connect(&self, config: RdpConfig) -> Result<String, String> { pub fn connect(&self, config: RdpConfig, app_handle: tauri::AppHandle) -> Result<String, String> {
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
wraith_log!("[RDP] Connecting to {}:{} as {} (session {})", config.hostname, config.port, config.username, session_id); wraith_log!("[RDP] Connecting to {}:{} as {} (session {})", config.hostname, config.port, config.username, session_id);
let width = config.width; let width = config.width;
@ -160,6 +161,8 @@ impl RdpService {
input_rx, input_rx,
width as u16, width as u16,
height as u16, height as u16,
app_handle,
sid.clone(),
) )
.await .await
{ {
@ -333,7 +336,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u
Ok((connection_result, upgraded_framed)) Ok((connection_result, upgraded_framed))
} }
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> { 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, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> {
let (mut reader, mut writer) = split_tokio_framed(framed); let (mut reader, mut writer) = split_tokio_framed(framed);
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height); let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
let mut active_stage = ActiveStage::new(connection_result); 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(); let src = image.data();
if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); } if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); }
frame_dirty.store(true, Ordering::Relaxed); 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::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }
ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); } ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); }

View File

@ -195,7 +195,6 @@ export function useRdp(): UseRdpReturn {
const clipboardSync = ref(false); const clipboardSync = ref(false);
let animFrameId: number | null = null; let animFrameId: number | null = null;
let frameCount = 0;
/** /**
* Fetch the current frame from the Rust RDP backend. * Fetch the current frame from the Rust RDP backend.
@ -297,27 +296,32 @@ export function useRdp(): UseRdpReturn {
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
function renderLoop(): void { let fetchPending = false;
frameCount++;
// Throttle to ~24fps by rendering every 3rd rAF tick (~60/3=20fps, close to 24) // Fetch frame when backend signals a new frame is ready
// This saves 20% CPU vs 30fps with negligible visual difference for remote desktop async function onFrameReady(): Promise<void> {
if (frameCount % 3 === 0) { if (fetchPending) return; // Don't stack fetches
fetchFrame(sessionId, width, height).then((imageData) => { fetchPending = true;
if (imageData && ctx) { const imageData = await fetchFrame(sessionId, width, height);
ctx.putImageData(imageData, 0, 0); fetchPending = false;
// Mark connected on first successful frame if (imageData && ctx) {
if (!connected.value) { ctx.putImageData(imageData, 0, 0);
connected.value = true; 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; animFrameId = null;
} }
connected.value = false; connected.value = false;
frameCount = 0;
} }
function toggleKeyboardGrab(): void { function toggleKeyboardGrab(): void {