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

View File

@ -3,23 +3,19 @@
//! Mirrors the pattern used by `ssh_commands.rs` — thin command wrappers that
//! delegate to the `RdpService` via `State<AppState>`.
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<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).

View File

@ -166,7 +166,7 @@ async fn handle_terminal_type(
Json(req): Json<TerminalTypeRequest>,
) -> Json<McpResponse<String>> {
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);

View File

@ -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<String, String> {
pub fn connect(&self, config: RdpConfig, app_handle: tauri::AppHandle) -> Result<String, 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);
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<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 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(()); }

View File

@ -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) => {
// Fetch frame when backend signals a new frame is ready
async function onFrameReady(): Promise<void> {
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);
// Mark connected on first successful frame
if (!connected.value) {
connected.value = true;
if (!connected.value) connected.value = true;
}
}
// 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;
});
});
}
animFrameId = requestAnimationFrame(renderLoop);
}
animFrameId = requestAnimationFrame(renderLoop);
// 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 {