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:
parent
1b7b1a0051
commit
fca6ed023e
@ -4,6 +4,7 @@
|
|||||||
//! delegate to the `RdpService` via `State<AppState>`.
|
//! delegate to the `RdpService` via `State<AppState>`.
|
||||||
|
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
use tauri::ipc::Response;
|
||||||
|
|
||||||
use crate::rdp::{RdpConfig, RdpSessionInfo};
|
use crate::rdp::{RdpConfig, RdpSessionInfo};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@ -18,16 +19,18 @@ pub fn connect_rdp(
|
|||||||
state.rdp.connect(config, app_handle)
|
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.
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn rdp_get_frame(
|
pub async fn rdp_get_frame(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<u8>, String> {
|
) -> Result<Response, String> {
|
||||||
state.rdp.get_frame(&session_id).await
|
let frame = state.rdp.get_frame(&session_id).await?;
|
||||||
|
Ok(Response::new(frame))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a mouse event to an RDP session.
|
/// Send a mouse event to an RDP session.
|
||||||
|
|||||||
@ -60,17 +60,18 @@ impl AppState {
|
|||||||
std::fs::create_dir_all(&data_dir)?;
|
std::fs::create_dir_all(&data_dir)?;
|
||||||
let database = Database::open(&data_dir.join("wraith.db"))?;
|
let database = Database::open(&data_dir.join("wraith.db"))?;
|
||||||
database.migrate()?;
|
database.migrate()?;
|
||||||
|
let settings = SettingsService::new(database.clone());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
db: database.clone(),
|
db: database.clone(),
|
||||||
vault: Mutex::new(None),
|
vault: Mutex::new(None),
|
||||||
settings: SettingsService::new(database.clone()),
|
|
||||||
connections: ConnectionService::new(database.clone()),
|
connections: ConnectionService::new(database.clone()),
|
||||||
credentials: Mutex::new(None),
|
credentials: Mutex::new(None),
|
||||||
ssh: SshService::new(database.clone()),
|
ssh: SshService::new(database.clone()),
|
||||||
sftp: SftpService::new(),
|
sftp: SftpService::new(),
|
||||||
rdp: RdpService::new(),
|
rdp: RdpService::new(),
|
||||||
theme: ThemeService::new(database.clone()),
|
theme: ThemeService::new(database),
|
||||||
workspace: WorkspaceService::new(SettingsService::new(database.clone())),
|
workspace: WorkspaceService::new(settings.clone()),
|
||||||
|
settings,
|
||||||
pty: PtyService::new(),
|
pty: PtyService::new(),
|
||||||
scrollback: ScrollbackRegistry::new(),
|
scrollback: ScrollbackRegistry::new(),
|
||||||
error_watcher: std::sync::Arc::new(ErrorWatcher::new()),
|
error_watcher: std::sync::Arc::new(ErrorWatcher::new()),
|
||||||
|
|||||||
@ -40,13 +40,25 @@ impl ScrollbackBuffer {
|
|||||||
|
|
||||||
/// Append bytes to the buffer. Old data is overwritten when full.
|
/// Append bytes to the buffer. Old data is overwritten when full.
|
||||||
pub fn push(&self, bytes: &[u8]) {
|
pub fn push(&self, bytes: &[u8]) {
|
||||||
let mut buf = self.inner.lock().unwrap();
|
if bytes.is_empty() {
|
||||||
for &b in bytes {
|
return;
|
||||||
let pos = buf.write_pos;
|
|
||||||
buf.data[pos] = b;
|
|
||||||
buf.write_pos = (pos + 1) % buf.capacity;
|
|
||||||
buf.total_written += 1;
|
|
||||||
}
|
}
|
||||||
|
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.
|
/// 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
|
buf.push(b"ABCD"); // 4 more, wraps
|
||||||
assert_eq!(buf.total_written(), 12);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ use crate::db::Database;
|
|||||||
///
|
///
|
||||||
/// All operations acquire the shared DB mutex for their duration and
|
/// All operations acquire the shared DB mutex for their duration and
|
||||||
/// return immediately — no async needed for a local SQLite store.
|
/// return immediately — no async needed for a local SQLite store.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct SettingsService {
|
pub struct SettingsService {
|
||||||
db: Database,
|
db: Database,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -184,7 +184,7 @@ export interface UseRdpReturn {
|
|||||||
* Composable that manages an RDP session's rendering and input.
|
* Composable that manages an RDP session's rendering and input.
|
||||||
*
|
*
|
||||||
* Uses Tauri's invoke() to call Rust commands:
|
* 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_mouse → fire-and-forget
|
||||||
* rdp_send_key → fire-and-forget
|
* rdp_send_key → fire-and-forget
|
||||||
* rdp_send_clipboard → fire-and-forget
|
* rdp_send_clipboard → fire-and-forget
|
||||||
@ -208,16 +208,16 @@ export function useRdp(): UseRdpReturn {
|
|||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
): Promise<ImageData | null> {
|
): Promise<ImageData | null> {
|
||||||
let raw: number[];
|
let raw: ArrayBuffer;
|
||||||
try {
|
try {
|
||||||
raw = await invoke<number[]>("rdp_get_frame", { sessionId });
|
raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 bytes = new Uint8ClampedArray(raw);
|
||||||
|
|
||||||
const expected = width * height * 4;
|
const expected = width * height * 4;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user