wraith/src/composables/useRdp.ts
Vantz Stockwell 99ecbe739e
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m49s
feat: RDP clipboard paste, keyboard grab default ON, frame dirty flag
1. Clipboard paste (rdp_send_clipboard): simulates typing each character
   via scancode key press/release events. Full ASCII coverage including
   all symbols, numbers, and shifted characters. Handles 32-char
   generated passwords without manual typing.

2. Keyboard grab defaults to ON so RDP sessions accept keyboard input
   immediately without requiring the user to click the toolbar toggle.

3. Frame dirty flag: GraphicsUpdate sets an AtomicBool, get_frame only
   encodes + returns base64 when dirty (returns empty string otherwise).
   Eliminates ~8MB/frame base64 encoding on unchanged frames at 30fps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:57:16 -04:00

366 lines
8.8 KiB
TypeScript

import { ref, onBeforeUnmount } from "vue";
import { invoke } from "@tauri-apps/api/core";
/**
* RDP mouse event flags — match the Rust constants in src-tauri/src/rdp/input.rs
*/
export const MouseFlag = {
Move: 0x0800,
Button1: 0x1000, // Left
Button2: 0x2000, // Right
Button3: 0x4000, // Middle
Down: 0x8000,
Wheel: 0x0200,
WheelNeg: 0x0100,
HWheel: 0x0400,
} as const;
/**
* JavaScript KeyboardEvent.code → RDP scancode mapping.
* Mirrors the Rust ScancodeMap in src-tauri/src/rdp/input.rs.
*/
export const ScancodeMap: Record<string, number> = {
// Row 0: Escape + Function keys
Escape: 0x0001,
F1: 0x003b,
F2: 0x003c,
F3: 0x003d,
F4: 0x003e,
F5: 0x003f,
F6: 0x0040,
F7: 0x0041,
F8: 0x0042,
F9: 0x0043,
F10: 0x0044,
F11: 0x0057,
F12: 0x0058,
// Row 1: Number row
Backquote: 0x0029,
Digit1: 0x0002,
Digit2: 0x0003,
Digit3: 0x0004,
Digit4: 0x0005,
Digit5: 0x0006,
Digit6: 0x0007,
Digit7: 0x0008,
Digit8: 0x0009,
Digit9: 0x000a,
Digit0: 0x000b,
Minus: 0x000c,
Equal: 0x000d,
Backspace: 0x000e,
// Row 2: QWERTY
Tab: 0x000f,
KeyQ: 0x0010,
KeyW: 0x0011,
KeyE: 0x0012,
KeyR: 0x0013,
KeyT: 0x0014,
KeyY: 0x0015,
KeyU: 0x0016,
KeyI: 0x0017,
KeyO: 0x0018,
KeyP: 0x0019,
BracketLeft: 0x001a,
BracketRight: 0x001b,
Backslash: 0x002b,
// Row 3: Home row
CapsLock: 0x003a,
KeyA: 0x001e,
KeyS: 0x001f,
KeyD: 0x0020,
KeyF: 0x0021,
KeyG: 0x0022,
KeyH: 0x0023,
KeyJ: 0x0024,
KeyK: 0x0025,
KeyL: 0x0026,
Semicolon: 0x0027,
Quote: 0x0028,
Enter: 0x001c,
// Row 4: Bottom row
ShiftLeft: 0x002a,
KeyZ: 0x002c,
KeyX: 0x002d,
KeyC: 0x002e,
KeyV: 0x002f,
KeyB: 0x0030,
KeyN: 0x0031,
KeyM: 0x0032,
Comma: 0x0033,
Period: 0x0034,
Slash: 0x0035,
ShiftRight: 0x0036,
// Row 5: Bottom modifiers + space
ControlLeft: 0x001d,
MetaLeft: 0xe05b,
AltLeft: 0x0038,
Space: 0x0039,
AltRight: 0xe038,
MetaRight: 0xe05c,
ContextMenu: 0xe05d,
ControlRight: 0xe01d,
// Navigation cluster
PrintScreen: 0xe037,
ScrollLock: 0x0046,
Pause: 0x0045,
Insert: 0xe052,
Home: 0xe047,
PageUp: 0xe049,
Delete: 0xe053,
End: 0xe04f,
PageDown: 0xe051,
// Arrow keys
ArrowUp: 0xe048,
ArrowLeft: 0xe04b,
ArrowDown: 0xe050,
ArrowRight: 0xe04d,
// Numpad
NumLock: 0x0045,
NumpadDivide: 0xe035,
NumpadMultiply: 0x0037,
NumpadSubtract: 0x004a,
Numpad7: 0x0047,
Numpad8: 0x0048,
Numpad9: 0x0049,
NumpadAdd: 0x004e,
Numpad4: 0x004b,
Numpad5: 0x004c,
Numpad6: 0x004d,
Numpad1: 0x004f,
Numpad2: 0x0050,
Numpad3: 0x0051,
NumpadEnter: 0xe01c,
Numpad0: 0x0052,
NumpadDecimal: 0x0053,
};
/**
* Look up the RDP scancode for a JS KeyboardEvent.code string.
*/
export function jsKeyToScancode(code: string): number | null {
return ScancodeMap[code] ?? null;
}
export interface UseRdpReturn {
/** Whether the RDP session is connected (first frame received) */
connected: ReturnType<typeof ref<boolean>>;
/** Whether keyboard capture is enabled */
keyboardGrabbed: ReturnType<typeof ref<boolean>>;
/** Whether clipboard sync is enabled */
clipboardSync: ReturnType<typeof ref<boolean>>;
/** Fetch the current frame as RGBA ImageData */
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>;
/** Send a mouse event to the backend */
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
/** Send a key event to the backend */
sendKey: (sessionId: string, code: string, pressed: boolean) => void;
/** Send clipboard text to the remote session */
sendClipboard: (sessionId: string, text: string) => void;
/** Start the frame rendering loop targeting ~30fps */
startFrameLoop: (
sessionId: string,
canvas: HTMLCanvasElement,
width: number,
height: number,
) => void;
/** Stop the frame rendering loop */
stopFrameLoop: () => void;
/** Toggle keyboard grab */
toggleKeyboardGrab: () => void;
/** Toggle clipboard sync */
toggleClipboardSync: () => void;
}
/**
* 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_send_mouse → fire-and-forget
* rdp_send_key → fire-and-forget
* rdp_send_clipboard → fire-and-forget
*/
export function useRdp(): UseRdpReturn {
const connected = ref(false);
const keyboardGrabbed = ref(true);
const clipboardSync = ref(false);
let animFrameId: number | null = null;
let frameCount = 0;
/**
* Fetch the current frame from the Rust RDP backend.
*
* rdp_get_frame returns raw RGBA bytes (width*height*4) serialised as a
* base64 string over Tauri's IPC bridge. We decode it to Uint8ClampedArray
* and wrap in an ImageData for putImageData().
*/
async function fetchFrame(
sessionId: string,
width: number,
height: number,
): Promise<ImageData | null> {
let raw: string;
try {
raw = await invoke<string>("rdp_get_frame", { sessionId });
} catch {
// Session may not be connected yet or backend returned an error — skip frame
return null;
}
if (!raw || raw.length === 0) return null;
// Decode base64 → binary string → Uint8ClampedArray
const binaryStr = atob(raw);
const bytes = new Uint8ClampedArray(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
// Validate: RGBA requires exactly width * height * 4 bytes
const expected = width * height * 4;
if (bytes.length !== expected) {
console.warn(
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`,
);
return null;
}
return new ImageData(bytes, width, height);
}
/**
* Send a mouse event to the remote session.
* Calls Rust rdp_send_mouse(sessionId, x, y, flags).
* Fire-and-forget — mouse events are best-effort.
*/
function sendMouse(
sessionId: string,
x: number,
y: number,
flags: number,
): void {
invoke("rdp_send_mouse", { sessionId, x, y, flags }).catch(
(err: unknown) => {
console.warn("[useRdp] sendMouse failed:", err);
},
);
}
/**
* Send a key event, mapping the JS KeyboardEvent.code to an RDP scancode.
* Calls Rust rdp_send_key(sessionId, scancode, pressed).
* Unmapped keys are silently dropped — not every JS key has an RDP scancode.
*/
function sendKey(sessionId: string, code: string, pressed: boolean): void {
const scancode = jsKeyToScancode(code);
if (scancode === null) return;
invoke("rdp_send_key", { sessionId, scancode, pressed }).catch(
(err: unknown) => {
console.warn("[useRdp] sendKey failed:", err);
},
);
}
/**
* Send clipboard text to the remote RDP session.
* Calls Rust rdp_send_clipboard(sessionId, text).
*/
function sendClipboard(sessionId: string, text: string): void {
invoke("rdp_send_clipboard", { sessionId, text }).catch(
(err: unknown) => {
console.warn("[useRdp] sendClipboard failed:", err);
},
);
}
/**
* Start the rendering loop. Fetches frames and draws them on the canvas.
*
* Targets ~30fps by skipping every other rAF tick (rAF fires at ~60fps).
* Sets connected = true as soon as the loop starts — the overlay dismisses
* on first successful frame render.
*/
function startFrameLoop(
sessionId: string,
canvas: HTMLCanvasElement,
width: number,
height: number,
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = width;
canvas.height = height;
function renderLoop(): void {
frameCount++;
// Throttle to ~30fps by skipping odd-numbered rAF ticks
if (frameCount % 2 === 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;
}
}
});
}
animFrameId = requestAnimationFrame(renderLoop);
}
animFrameId = requestAnimationFrame(renderLoop);
}
/**
* Stop the rendering loop and reset connected state.
*/
function stopFrameLoop(): void {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
connected.value = false;
frameCount = 0;
}
function toggleKeyboardGrab(): void {
keyboardGrabbed.value = !keyboardGrabbed.value;
}
function toggleClipboardSync(): void {
clipboardSync.value = !clipboardSync.value;
}
onBeforeUnmount(() => {
stopFrameLoop();
});
return {
connected,
keyboardGrabbed,
clipboardSync,
fetchFrame,
sendMouse,
sendKey,
sendClipboard,
startFrameLoop,
stopFrameLoop,
toggleKeyboardGrab,
toggleClipboardSync,
};
}