/** * Remote Relay — receives companion app input via WebSocket and dispatches * keyboard/mouse/scroll events into the browser, enabling the NES controller * or companion keyboard to drive the web UI from another device. */ import { ref } from 'vue' // xdotool key name → DOM key mapping const KEY_MAP: Record = { Return: 'Enter', BackSpace: 'Backspace', Escape: 'Escape', Tab: 'Tab', Delete: 'Delete', space: ' ', Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight', Home: 'Home', End: 'End', Prior: 'PageUp', Next: 'PageDown', F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4', F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8', F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12', } /** Reactive: relay WebSocket is connected to the server */ export const relayConnected = ref(false) /** Reactive: a companion app is actively sending input (received input in last 30s) */ export const companionActive = ref(false) /** Reactive: input is being received right now (flickers on each event) */ export const companionInputActive = ref(false) let ws: WebSocket | null = null let shouldReconnect = true let reconnectTimer: ReturnType | null = null let cursorEl: HTMLDivElement | null = null let companionTimeout: ReturnType | null = null let inputFlickerTimeout: ReturnType | null = null let cursorX = typeof window !== 'undefined' ? window.innerWidth / 2 : 0 let cursorY = typeof window !== 'undefined' ? window.innerHeight / 2 : 0 let cursorHideTimer: ReturnType | null = null function markCompanionActive() { companionActive.value = true companionInputActive.value = true if (inputFlickerTimeout) clearTimeout(inputFlickerTimeout) inputFlickerTimeout = setTimeout(() => { companionInputActive.value = false }, 200) if (companionTimeout) clearTimeout(companionTimeout) companionTimeout = setTimeout(() => { companionActive.value = false }, 30_000) } function createCursor(): HTMLDivElement { if (cursorEl) return cursorEl const el = document.createElement('div') el.id = 'remote-relay-cursor' el.style.cssText = ` position: fixed; z-index: 999999; pointer-events: none; width: 20px; height: 20px; border-radius: 50%; background: rgba(247, 147, 26, 0.7); border: 2px solid rgba(247, 147, 26, 0.9); transform: translate(-50%, -50%); transition: opacity 0.3s; opacity: 0; display: none; ` document.body.appendChild(el) cursorEl = el return el } function showCursor() { const el = createCursor() el.style.display = 'block' el.style.opacity = '1' el.style.left = `${cursorX}px` el.style.top = `${cursorY}px` if (cursorHideTimer) clearTimeout(cursorHideTimer) cursorHideTimer = setTimeout(() => { if (cursorEl) cursorEl.style.opacity = '0' }, 3000) } function moveCursor(dx: number, dy: number) { cursorX = Math.max(0, Math.min(window.innerWidth, cursorX + dx)) cursorY = Math.max(0, Math.min(window.innerHeight, cursorY + dy)) showCursor() } function mapKey(xdotoolKey: string): string { return KEY_MAP[xdotoolKey] ?? xdotoolKey } function handleMessage(data: string) { let msg: { t: string; k?: string; x?: number; y?: number; b?: number } try { msg = JSON.parse(data) } catch { return } if (msg.t === 'ok') return // server ready, not companion input markCompanionActive() switch (msg.t) { case 'k': { if (!msg.k) break const key = mapKey(msg.k) document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })) break } case 'm': { moveCursor(msg.x ?? 0, msg.y ?? 0) break } case 'c': { const target = document.elementFromPoint(cursorX, cursorY) if (target) { if (cursorEl) { cursorEl.style.background = 'rgba(247, 147, 26, 1)' setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150) } target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: cursorX, clientY: cursorY, })) } break } case 's': { const dy = msg.y ?? 0 document.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: dy * 100, deltaMode: WheelEvent.DOM_DELTA_PIXEL, })) break } } } function doConnect() { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { return } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const url = `${protocol}//${window.location.host}/ws/remote-relay` ws = new WebSocket(url) ws.onopen = () => { relayConnected.value = true if (import.meta.env.DEV) console.log('[RemoteRelay] Connected') } ws.onmessage = (event) => { handleMessage(event.data) } ws.onclose = () => { relayConnected.value = false ws = null if (shouldReconnect) { reconnectTimer = setTimeout(doConnect, 5000) } } ws.onerror = () => { // onclose will handle reconnect } } /** Start the remote relay listener. Connects to /ws/remote-relay. */ export function startRemoteRelay() { shouldReconnect = true doConnect() } /** Stop the remote relay listener and clean up. */ export function stopRemoteRelay() { shouldReconnect = false if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } if (companionTimeout) { clearTimeout(companionTimeout); companionTimeout = null } if (inputFlickerTimeout) { clearTimeout(inputFlickerTimeout); inputFlickerTimeout = null } if (cursorHideTimer) { clearTimeout(cursorHideTimer); cursorHideTimer = null } if (ws) { ws.onclose = null; ws.close(); ws = null } if (cursorEl) { cursorEl.remove(); cursorEl = null } relayConnected.value = false companionActive.value = false companionInputActive.value = false }