/** * 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 // Exponential backoff for the relay socket. It's a secondary feature (companion // input), so when the backend is down it must NOT hammer a fixed-interval // reconnect — that floods the console/network with failed-WS noise for the whole // outage. Back off 1s → 30s, reset on a successful open. (Mirrors websocket.ts.) let relayReconnectAttempts = 0 const RELAY_RECONNECT_BASE_MS = 1000 const RELAY_RECONNECT_MAX_MS = 30_000 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 } /** types that accept free-text entry (so we should type into them). */ const TEXT_INPUT_TYPES = new Set([ 'text', 'search', 'url', 'tel', 'password', 'email', 'number', '', ]) export function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement { if (!el) return false if (el.tagName === 'TEXTAREA') return true if (el.tagName === 'INPUT') { const type = ((el as HTMLInputElement).type || 'text').toLowerCase() return TEXT_INPUT_TYPES.has(type) } return false } /** * elementFromPoint that descends through SAME-ORIGIN iframes, so the cursor * can target elements *inside* embedded apps (gitea, uptime-kuma, AIUI — any * app served same-origin via /app/… or /aiui/). Cross-origin iframes (apps on * direct ports) are opaque to the parent by browser security policy, so the * deepest reachable element there is the