From 949ed6e67240581c478be9c9167a1464c6e9ae33 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 17 Mar 2026 07:17:35 -0400 Subject: [PATCH] feat: RDP canvas renderer and input handling frontend Add RdpView component with HTML canvas rendering via requestAnimationFrame at ~30fps, mouse event capture (move, click, scroll) mapped to RDP flags, and keyboard event capture with JS-to-scancode translation. Include toolbar with keyboard grab toggle, clipboard sync toggle, and fullscreen button. Add useRdp composable with mock frame generation (gradient + animated shapes) matching the Go MockBackend pattern. Update SessionContainer to render RdpView for RDP sessions instead of the Phase 4 placeholder. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/rdp/RdpView.vue | 382 ++++++++++++++++++ .../components/session/SessionContainer.vue | 25 +- frontend/src/composables/useRdp.ts | 379 +++++++++++++++++ 3 files changed, 775 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/rdp/RdpView.vue create mode 100644 frontend/src/composables/useRdp.ts diff --git a/frontend/src/components/rdp/RdpView.vue b/frontend/src/components/rdp/RdpView.vue new file mode 100644 index 0000000..75f5b13 --- /dev/null +++ b/frontend/src/components/rdp/RdpView.vue @@ -0,0 +1,382 @@ + + + + + diff --git a/frontend/src/components/session/SessionContainer.vue b/frontend/src/components/session/SessionContainer.vue index 4c8e0b8..36355e1 100644 --- a/frontend/src/components/session/SessionContainer.vue +++ b/frontend/src/components/session/SessionContainer.vue @@ -13,19 +13,17 @@ /> - +
-
-

- Session: {{ sessionStore.activeSession.name }} -

-

- RDP viewer will render here (Phase 4) -

-
+
@@ -49,10 +47,15 @@ import { computed } from "vue"; import { useSessionStore } from "@/stores/session.store"; import TerminalView from "@/components/terminal/TerminalView.vue"; +import RdpView from "@/components/rdp/RdpView.vue"; const sessionStore = useSessionStore(); const sshSessions = computed(() => sessionStore.sessions.filter((s) => s.protocol === "ssh"), ); + +const rdpSessions = computed(() => + sessionStore.sessions.filter((s) => s.protocol === "rdp"), +); diff --git a/frontend/src/composables/useRdp.ts b/frontend/src/composables/useRdp.ts new file mode 100644 index 0000000..17a2a74 --- /dev/null +++ b/frontend/src/composables/useRdp.ts @@ -0,0 +1,379 @@ +import { ref, onBeforeUnmount } from "vue"; + +/** + * 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 = { + // 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>; + /** Whether keyboard capture is enabled */ + keyboardGrabbed: ReturnType>; + /** Whether clipboard sync is enabled */ + clipboardSync: ReturnType>; + /** Fetch the current frame as RGBA ImageData */ + fetchFrame: (sessionId: string) => Promise; + /** 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 backend. + * TODO: Replace with Wails binding — RDPService.GetFrame(sessionId) + * Mock: generates a gradient test pattern. + */ + async function fetchFrame( + sessionId: string, + width = 1920, + height = 1080, + ): Promise { + void sessionId; + + // Mock: generate a test frame with animated gradient + const imageData = new ImageData(width, height); + const data = imageData.data; + const t = Date.now() / 1000; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const nx = x / width; + const ny = y / height; + const diag = (nx + ny) / 2; + + data[i + 0] = Math.floor(20 + diag * 40); // R + data[i + 1] = Math.floor(25 + (1 - diag) * 30); // G + data[i + 2] = Math.floor(80 + diag * 100); // B + data[i + 3] = 255; // A + + // Grid lines every 100px + if (x % 100 === 0 || y % 100 === 0) { + data[i + 0] = Math.min(data[i + 0] + 20, 255); + data[i + 1] = Math.min(data[i + 1] + 20, 255); + data[i + 2] = Math.min(data[i + 2] + 20, 255); + } + } + } + + // Animated pulsing circle at center + const cx = width / 2; + const cy = height / 2; + const radius = 40 + 20 * Math.sin(t * 2); + + for (let dy = -70; dy <= 70; dy++) { + for (let dx = -70; dx <= 70; dx++) { + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist <= radius && dist >= radius - 4) { + const px = Math.floor(cx + dx); + const py = Math.floor(cy + dy); + if (px >= 0 && px < width && py >= 0 && py < height) { + const i = (py * width + px) * 4; + data[i + 0] = 88; + data[i + 1] = 166; + data[i + 2] = 255; + data[i + 3] = 255; + } + } + } + } + + return imageData; + } + + /** + * Send a mouse event. + * TODO: Replace with Wails binding — RDPService.SendMouse(sessionId, x, y, flags) + */ + function sendMouse( + sessionId: string, + x: number, + y: number, + flags: number, + ): void { + void sessionId; + void x; + void y; + void flags; + // Mock: no-op — will call Wails binding when wired + } + + /** + * Send a key event, mapping JS code to RDP scancode. + * TODO: Replace with Wails binding — RDPService.SendKey(sessionId, scancode, pressed) + */ + function sendKey( + sessionId: string, + code: string, + pressed: boolean, + ): void { + const scancode = jsKeyToScancode(code); + if (scancode === null) return; + + void sessionId; + void pressed; + // Mock: no-op — will call Wails binding when wired + } + + /** + * Send clipboard text to the remote session. + * TODO: Replace with Wails binding — RDPService.SendClipboard(sessionId, text) + */ + function sendClipboard(sessionId: string, text: string): void { + void sessionId; + void text; + // Mock: no-op + } + + /** + * 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, + }; +}