perf: RDP event-driven frames + MCP terminal \r fix
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m28s
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:
parent
d78cafba93
commit
661490e925
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(()); }
|
||||
|
||||
@ -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<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);
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user