perf: PERF-1/2/3 scrollback bulk write, RDP binary IPC, settings dedup

- Scrollback: bulk copy_from_slice replaces byte-by-byte loop
- RDP frames: tauri::ipc::Response for zero-overhead binary transfer
- SettingsService: derive Clone, eliminate duplicate instance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-29 16:40:08 -04:00
parent 1b7b1a0051
commit fca6ed023e
5 changed files with 73 additions and 18 deletions

View File

@ -4,6 +4,7 @@
//! delegate to the `RdpService` via `State<AppState>`.
use tauri::{AppHandle, State};
use tauri::ipc::Response;
use crate::rdp::{RdpConfig, RdpSessionInfo};
use crate::AppState;
@ -18,16 +19,18 @@ pub fn connect_rdp(
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 via binary IPC.
///
/// Uses `tauri::ipc::Response` to return raw bytes without JSON serialization.
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin.
/// Returns empty Vec if frame hasn't changed since last call.
/// Returns empty payload if frame hasn't changed since last call.
#[tauri::command]
pub async fn rdp_get_frame(
session_id: String,
state: State<'_, AppState>,
) -> Result<Vec<u8>, String> {
state.rdp.get_frame(&session_id).await
) -> Result<Response, String> {
let frame = state.rdp.get_frame(&session_id).await?;
Ok(Response::new(frame))
}
/// Send a mouse event to an RDP session.

View File

@ -60,17 +60,18 @@ impl AppState {
std::fs::create_dir_all(&data_dir)?;
let database = Database::open(&data_dir.join("wraith.db"))?;
database.migrate()?;
let settings = SettingsService::new(database.clone());
Ok(Self {
db: database.clone(),
vault: Mutex::new(None),
settings: SettingsService::new(database.clone()),
connections: ConnectionService::new(database.clone()),
credentials: Mutex::new(None),
ssh: SshService::new(database.clone()),
sftp: SftpService::new(),
rdp: RdpService::new(),
theme: ThemeService::new(database.clone()),
workspace: WorkspaceService::new(SettingsService::new(database.clone())),
theme: ThemeService::new(database),
workspace: WorkspaceService::new(settings.clone()),
settings,
pty: PtyService::new(),
scrollback: ScrollbackRegistry::new(),
error_watcher: std::sync::Arc::new(ErrorWatcher::new()),

View File

@ -40,13 +40,25 @@ impl ScrollbackBuffer {
/// Append bytes to the buffer. Old data is overwritten when full.
pub fn push(&self, bytes: &[u8]) {
let mut buf = self.inner.lock().unwrap();
for &b in bytes {
let pos = buf.write_pos;
buf.data[pos] = b;
buf.write_pos = (pos + 1) % buf.capacity;
buf.total_written += 1;
if bytes.is_empty() {
return;
}
let mut buf = self.inner.lock().unwrap();
let cap = buf.capacity;
// If input exceeds capacity, only keep the last `cap` bytes
let data = if bytes.len() > cap {
&bytes[bytes.len() - cap..]
} else {
bytes
};
let write_pos = buf.write_pos;
let first_len = (cap - write_pos).min(data.len());
buf.data[write_pos..write_pos + first_len].copy_from_slice(&data[..first_len]);
if first_len < data.len() {
buf.data[..data.len() - first_len].copy_from_slice(&data[first_len..]);
}
buf.write_pos = (write_pos + data.len()) % cap;
buf.total_written += bytes.len();
}
/// Read the last `n` lines from the buffer, with ANSI escape codes stripped.
@ -192,4 +204,42 @@ mod tests {
buf.push(b"ABCD"); // 4 more, wraps
assert_eq!(buf.total_written(), 12);
}
#[test]
fn push_empty_is_noop() {
let buf = ScrollbackBuffer::with_capacity(8);
buf.push(b"hello");
buf.push(b"");
assert_eq!(buf.total_written(), 5);
assert!(buf.read_raw().contains("hello"));
}
#[test]
fn push_larger_than_capacity() {
let buf = ScrollbackBuffer::with_capacity(4);
buf.push(b"ABCDEFGH"); // 8 bytes into 4-byte buffer
let raw = buf.read_raw();
assert_eq!(raw, "EFGH"); // only last 4 bytes kept
assert_eq!(buf.total_written(), 8);
}
#[test]
fn push_exact_capacity() {
let buf = ScrollbackBuffer::with_capacity(8);
buf.push(b"12345678");
let raw = buf.read_raw();
assert_eq!(raw, "12345678");
assert_eq!(buf.total_written(), 8);
}
#[test]
fn push_wrap_around_boundary() {
let buf = ScrollbackBuffer::with_capacity(8);
buf.push(b"123456"); // write_pos = 6
buf.push(b"ABCD"); // wraps: 2 at end, 2 at start
let raw = buf.read_raw();
// Buffer: [C, D, 3, 4, 5, 6, A, B], write_pos=2
// Read from pos 2: "3456AB" + wrap: no, read from write_pos to end then start
assert_eq!(raw, "3456ABCD");
}
}

View File

@ -8,6 +8,7 @@ use crate::db::Database;
///
/// All operations acquire the shared DB mutex for their duration and
/// return immediately — no async needed for a local SQLite store.
#[derive(Clone)]
pub struct SettingsService {
db: Database,
}

View File

@ -184,7 +184,7 @@ export interface UseRdpReturn {
* Composable that manages an RDP session's rendering and input.
*
* Uses Tauri's invoke() to call Rust commands:
* rdp_get_frame base64 RGBA string
* rdp_get_frame raw RGBA ArrayBuffer (binary IPC)
* rdp_send_mouse fire-and-forget
* rdp_send_key fire-and-forget
* rdp_send_clipboard fire-and-forget
@ -208,16 +208,16 @@ export function useRdp(): UseRdpReturn {
width: number,
height: number,
): Promise<ImageData | null> {
let raw: number[];
let raw: ArrayBuffer;
try {
raw = await invoke<number[]>("rdp_get_frame", { sessionId });
raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
} catch {
return null;
}
if (!raw || raw.length === 0) return null;
if (!raw || raw.byteLength === 0) return null;
// Binary IPC — Tauri returns Vec<u8> as number array
// Binary IPC — tauri::ipc::Response delivers raw bytes as ArrayBuffer
const bytes = new Uint8ClampedArray(raw);
const expected = width * height * 4;