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>
200 lines
5.9 KiB
TypeScript
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
|
|
}
|