All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m4s
Four-agent parallel deployment: 1. Settings persistence — all 5 settings wired to SettingsService.Set/Get, theme picker persists, update check calls real UpdateService, external links use Browser.OpenURL, SFTP file open/save calls real service, Quick Connect creates real connection + session, exit uses Wails quit 2. SSH key management — credential dropdown in ConnectionEditDialog, collapsible "Add New Credential" panel with password/SSH key modes, CredentialService proxied through WraithApp (vault-locked guard), new CreateSSHKeyCredential method for atomic key+credential creation 3. RDP frontend wiring — useRdp.ts calls real RDPGetFrame/SendMouse/ SendKey/SendClipboard via Wails bindings, ConnectRDP on WraithApp resolves credentials and builds RDPConfig, session store handles RDP protocol, frame pipeline uses polling at 30fps 4. FreeRDP3 callback registration — PostConnect and BitmapUpdate callbacks via syscall.NewCallback, GDI mode for automatic frame decoding, freerdp_context_new() call added, settings/input/context pointers extracted from struct offsets, BGRA→RGBA channel swap in frame copy, event loop fixed to pass context not instance 11 files changed. Zero build errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
367 lines
8.5 KiB
TypeScript
367 lines
8.5 KiB
TypeScript
import { ref, onBeforeUnmount } from "vue";
|
|
import { Call } from "@wailsio/runtime";
|
|
|
|
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
|
|
|
|
/**
|
|
* RDP mouse event flags — match the Go constants in internal/rdp/input.go
|
|
*/
|
|
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 Go ScancodeMap in internal/rdp/input.go.
|
|
*/
|
|
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 (mock: always true after init) */
|
|
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) => 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 */
|
|
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.
|
|
*
|
|
* All backend calls are currently stubs that will be replaced with
|
|
* Wails bindings once the Go RDP service is wired up.
|
|
*/
|
|
export function useRdp(): UseRdpReturn {
|
|
const connected = ref(false);
|
|
const keyboardGrabbed = ref(false);
|
|
const clipboardSync = ref(false);
|
|
|
|
let animFrameId: number | null = null;
|
|
let frameCount = 0;
|
|
|
|
/**
|
|
* Fetch the current frame from the Go RDP backend.
|
|
*
|
|
* Go's GetFrame returns []byte (raw RGBA, Width*Height*4 bytes).
|
|
* Wails serialises Go []byte as a base64-encoded string over the JSON bridge,
|
|
* so we decode it back to a Uint8ClampedArray and wrap it in an ImageData.
|
|
*/
|
|
async function fetchFrame(
|
|
sessionId: string,
|
|
width = 1920,
|
|
height = 1080,
|
|
): Promise<ImageData | null> {
|
|
let raw: string;
|
|
try {
|
|
raw = (await Call.ByName(`${APP}.RDPGetFrame`, sessionId)) as string;
|
|
} catch {
|
|
// Session may not be connected yet or backend returned an error — skip frame
|
|
return null;
|
|
}
|
|
|
|
if (!raw) 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 Go WraithApp.RDPSendMouse(sessionId, x, y, flags).
|
|
* Fire-and-forget — mouse events are best-effort.
|
|
*/
|
|
function sendMouse(
|
|
sessionId: string,
|
|
x: number,
|
|
y: number,
|
|
flags: number,
|
|
): void {
|
|
Call.ByName(`${APP}.RDPSendMouse`, 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 Go WraithApp.RDPSendKey(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;
|
|
|
|
Call.ByName(`${APP}.RDPSendKey`, sessionId, scancode, pressed).catch(
|
|
(err: unknown) => {
|
|
console.warn("[useRdp] sendKey failed:", err);
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Send clipboard text to the remote RDP session.
|
|
* Calls Go WraithApp.RDPSendClipboard(sessionId, text).
|
|
*/
|
|
function sendClipboard(sessionId: string, text: string): void {
|
|
Call.ByName(`${APP}.RDPSendClipboard`, sessionId, text).catch(
|
|
(err: unknown) => {
|
|
console.warn("[useRdp] sendClipboard failed:", err);
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Start the rendering loop. Fetches frames and draws them on the canvas
|
|
* using requestAnimationFrame.
|
|
*/
|
|
function startFrameLoop(
|
|
sessionId: string,
|
|
canvas: HTMLCanvasElement,
|
|
width: number,
|
|
height: number,
|
|
): void {
|
|
connected.value = true;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
function renderLoop(): void {
|
|
frameCount++;
|
|
|
|
// Throttle to ~30fps (skip every other frame at 60fps rAF)
|
|
if (frameCount % 2 === 0) {
|
|
fetchFrame(sessionId, width, height).then((imageData) => {
|
|
if (imageData && ctx) {
|
|
ctx.putImageData(imageData, 0, 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
animFrameId = requestAnimationFrame(renderLoop);
|
|
}
|
|
|
|
animFrameId = requestAnimationFrame(renderLoop);
|
|
}
|
|
|
|
/**
|
|
* Stop the rendering loop.
|
|
*/
|
|
function stopFrameLoop(): void {
|
|
if (animFrameId !== null) {
|
|
cancelAnimationFrame(animFrameId);
|
|
animFrameId = null;
|
|
}
|
|
connected.value = false;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|