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) <noreply@anthropic.com>
This commit is contained in:
parent
6c65f171f8
commit
949ed6e672
382
frontend/src/components/rdp/RdpView.vue
Normal file
382
frontend/src/components/rdp/RdpView.vue
Normal file
@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<div class="rdp-container" ref="containerRef">
|
||||
<!-- Toolbar -->
|
||||
<div class="rdp-toolbar">
|
||||
<div class="rdp-toolbar-left">
|
||||
<span class="rdp-toolbar-label">RDP</span>
|
||||
<span class="rdp-toolbar-session">{{ sessionId }}</span>
|
||||
</div>
|
||||
<div class="rdp-toolbar-right">
|
||||
<button
|
||||
class="rdp-toolbar-btn"
|
||||
:class="{ active: keyboardGrabbed }"
|
||||
title="Toggle keyboard capture"
|
||||
@click="handleToggleKeyboard"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm1 0v7h12V4H2z"/>
|
||||
<path d="M3 6h2v1H3V6zm3 0h2v1H6V6zm3 0h2v1H9V6zm3 0h1v1h-1V6zM3 8h1v1H3V8zm2 0h6v1H5V8zm7 0h1v1h-1V8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="rdp-toolbar-btn"
|
||||
:class="{ active: clipboardSync }"
|
||||
title="Toggle clipboard sync"
|
||||
@click="handleToggleClipboard"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="rdp-toolbar-btn"
|
||||
title="Fullscreen"
|
||||
@click="handleFullscreen"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<div class="rdp-canvas-wrapper" ref="canvasWrapper">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="rdp-canvas"
|
||||
tabindex="0"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
@mousemove="handleMouseMove"
|
||||
@wheel.prevent="handleWheel"
|
||||
@contextmenu.prevent
|
||||
@keydown.prevent="handleKeyDown"
|
||||
@keyup.prevent="handleKeyUp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Connection status overlay -->
|
||||
<div v-if="!connected" class="rdp-overlay">
|
||||
<div class="rdp-overlay-content">
|
||||
<div class="rdp-spinner" />
|
||||
<p>Connecting to RDP session...</p>
|
||||
<p class="rdp-overlay-sub">Session: {{ sessionId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
import { useRdp, MouseFlag } from "@/composables/useRdp";
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string;
|
||||
isActive: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const canvasWrapper = ref<HTMLElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const rdpWidth = props.width ?? 1920;
|
||||
const rdpHeight = props.height ?? 1080;
|
||||
|
||||
const {
|
||||
connected,
|
||||
keyboardGrabbed,
|
||||
clipboardSync,
|
||||
sendMouse,
|
||||
sendKey,
|
||||
startFrameLoop,
|
||||
stopFrameLoop,
|
||||
toggleKeyboardGrab,
|
||||
toggleClipboardSync,
|
||||
} = useRdp();
|
||||
|
||||
/**
|
||||
* Convert canvas-relative mouse coordinates to RDP coordinates,
|
||||
* accounting for CSS scaling of the canvas.
|
||||
*/
|
||||
function toRdpCoords(
|
||||
e: MouseEvent,
|
||||
): { x: number; y: number } | null {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return null;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = rdpWidth / rect.width;
|
||||
const scaleY = rdpHeight / rect.height;
|
||||
|
||||
return {
|
||||
x: Math.floor((e.clientX - rect.left) * scaleX),
|
||||
y: Math.floor((e.clientY - rect.top) * scaleY),
|
||||
};
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent): void {
|
||||
const coords = toRdpCoords(e);
|
||||
if (!coords) return;
|
||||
|
||||
let buttonFlag = 0;
|
||||
switch (e.button) {
|
||||
case 0:
|
||||
buttonFlag = MouseFlag.Button1;
|
||||
break;
|
||||
case 1:
|
||||
buttonFlag = MouseFlag.Button3;
|
||||
break; // middle
|
||||
case 2:
|
||||
buttonFlag = MouseFlag.Button2;
|
||||
break;
|
||||
}
|
||||
|
||||
sendMouse(
|
||||
props.sessionId,
|
||||
coords.x,
|
||||
coords.y,
|
||||
buttonFlag | MouseFlag.Down,
|
||||
);
|
||||
}
|
||||
|
||||
function handleMouseUp(e: MouseEvent): void {
|
||||
const coords = toRdpCoords(e);
|
||||
if (!coords) return;
|
||||
|
||||
let buttonFlag = 0;
|
||||
switch (e.button) {
|
||||
case 0:
|
||||
buttonFlag = MouseFlag.Button1;
|
||||
break;
|
||||
case 1:
|
||||
buttonFlag = MouseFlag.Button3;
|
||||
break;
|
||||
case 2:
|
||||
buttonFlag = MouseFlag.Button2;
|
||||
break;
|
||||
}
|
||||
|
||||
sendMouse(props.sessionId, coords.x, coords.y, buttonFlag);
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
const coords = toRdpCoords(e);
|
||||
if (!coords) return;
|
||||
sendMouse(props.sessionId, coords.x, coords.y, MouseFlag.Move);
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent): void {
|
||||
const coords = toRdpCoords(e);
|
||||
if (!coords) return;
|
||||
|
||||
let flags = MouseFlag.Wheel;
|
||||
if (e.deltaY > 0) {
|
||||
flags |= MouseFlag.WheelNeg;
|
||||
}
|
||||
|
||||
sendMouse(props.sessionId, coords.x, coords.y, flags);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (!keyboardGrabbed.value) return;
|
||||
sendKey(props.sessionId, e.code, true);
|
||||
}
|
||||
|
||||
function handleKeyUp(e: KeyboardEvent): void {
|
||||
if (!keyboardGrabbed.value) return;
|
||||
sendKey(props.sessionId, e.code, false);
|
||||
}
|
||||
|
||||
function handleToggleKeyboard(): void {
|
||||
toggleKeyboardGrab();
|
||||
// Focus canvas when grabbing keyboard
|
||||
if (keyboardGrabbed.value && canvasRef.value) {
|
||||
canvasRef.value.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleClipboard(): void {
|
||||
toggleClipboardSync();
|
||||
}
|
||||
|
||||
function handleFullscreen(): void {
|
||||
const wrapper = canvasWrapper.value;
|
||||
if (!wrapper) return;
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
wrapper.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
startFrameLoop(
|
||||
props.sessionId,
|
||||
canvasRef.value,
|
||||
rdpWidth,
|
||||
rdpHeight,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFrameLoop();
|
||||
});
|
||||
|
||||
// Focus canvas when this tab becomes active
|
||||
watch(
|
||||
() => props.isActive,
|
||||
(active) => {
|
||||
if (active && keyboardGrabbed.value && canvasRef.value) {
|
||||
setTimeout(() => {
|
||||
canvasRef.value?.focus();
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rdp-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--wraith-bg-primary, #0d1117);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rdp-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background: var(--wraith-bg-secondary, #161b22);
|
||||
border-bottom: 1px solid var(--wraith-border, #30363d);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rdp-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rdp-toolbar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wraith-accent, #58a6ff);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.rdp-toolbar-session {
|
||||
font-size: 11px;
|
||||
color: var(--wraith-text-muted, #484f58);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rdp-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rdp-toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--wraith-text-secondary, #8b949e);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.rdp-toolbar-btn:hover {
|
||||
background: var(--wraith-bg-tertiary, #21262d);
|
||||
color: var(--wraith-text-primary, #e6edf3);
|
||||
}
|
||||
|
||||
.rdp-toolbar-btn.active {
|
||||
background: var(--wraith-accent, #58a6ff);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.rdp-canvas-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rdp-canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.rdp-canvas:focus {
|
||||
outline: 2px solid var(--wraith-accent, #58a6ff);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.rdp-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(13, 17, 23, 0.85);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.rdp-overlay-content {
|
||||
text-align: center;
|
||||
color: var(--wraith-text-secondary, #8b949e);
|
||||
}
|
||||
|
||||
.rdp-overlay-content p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rdp-overlay-sub {
|
||||
font-size: 12px !important;
|
||||
color: var(--wraith-text-muted, #484f58);
|
||||
}
|
||||
|
||||
.rdp-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0 auto 16px;
|
||||
border: 3px solid var(--wraith-border, #30363d);
|
||||
border-top-color: var(--wraith-accent, #58a6ff);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -13,19 +13,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RDP placeholder -->
|
||||
<!-- RDP views — v-show keeps them alive across tab switches -->
|
||||
<div
|
||||
v-if="sessionStore.activeSession && sessionStore.activeSession.protocol === 'rdp'"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
v-for="session in rdpSessions"
|
||||
:key="session.id"
|
||||
v-show="session.id === sessionStore.activeSessionId"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-[var(--wraith-text-secondary)] text-sm">
|
||||
Session: <span class="text-[var(--wraith-text-primary)]">{{ sessionStore.activeSession.name }}</span>
|
||||
</p>
|
||||
<p class="text-[var(--wraith-text-muted)] text-xs mt-1">
|
||||
RDP viewer will render here (Phase 4)
|
||||
</p>
|
||||
</div>
|
||||
<RdpView
|
||||
:session-id="session.id"
|
||||
:is-active="session.id === sessionStore.activeSessionId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- No session placeholder -->
|
||||
@ -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"),
|
||||
);
|
||||
</script>
|
||||
|
||||
379
frontend/src/composables/useRdp.ts
Normal file
379
frontend/src/composables/useRdp.ts
Normal file
@ -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<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 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<ImageData | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user