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>
This commit is contained in:
199
neode-ui/src/api/remote-relay.ts
Normal file
199
neode-ui/src/api/remote-relay.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user