archy/neode-ui/src/api/remote-relay.ts
Dorian 4295476291 feat: frontend remote relay, kiosk hardening, CSS compositor fix
Frontend:
- Add remote-relay.ts: receives companion input via /ws/remote-relay,
  dispatches keyboard/mouse/scroll events into browser DOM
- Add CompanionIndicator.vue: NES gamepad icon when companion connected
- Wire relay start/stop to auth state in App.vue

Kiosk:
- Move Chromium data dir to /var/lib/archipelago/chromium-kiosk (encrypted)
- Disable MetricsReporting, AutofillServerCommunication, PasswordManager
- Remove --metrics-recording-only (contradicts disable-metrics)

CSS:
- Fix Chromium ghost rectangles: only apply preserve-3d + backface-visibility
  during transitions, not always-on (causes Chromium to skip painting
  off-viewport cards)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:10:08 +01:00

200 lines
5.9 KiB
TypeScript

/**
* 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<string, string> = {
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<typeof setTimeout> | null = null
let cursorEl: HTMLDivElement | null = null
let companionTimeout: ReturnType<typeof setTimeout> | null = null
let inputFlickerTimeout: ReturnType<typeof setTimeout> | null = null
let cursorX = typeof window !== 'undefined' ? window.innerWidth / 2 : 0
let cursorY = typeof window !== 'undefined' ? window.innerHeight / 2 : 0
let cursorHideTimer: ReturnType<typeof setTimeout> | 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
}