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();
|
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
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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(()); }
|
||||||
|
|||||||
@ -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;
|
||||||
|
const imageData = await fetchFrame(sessionId, width, height);
|
||||||
|
fetchPending = false;
|
||||||
if (imageData && ctx) {
|
if (imageData && ctx) {
|
||||||
ctx.putImageData(imageData, 0, 0);
|
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);
|
// Also do an initial poll in case frames arrived before listener was set up
|
||||||
}
|
onFrameReady();
|
||||||
|
|
||||||
animFrameId = requestAnimationFrame(renderLoop);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user