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:
Dorian 2026-04-02 11:10:08 +01:00
parent c6b9097f3d
commit 4295476291
6 changed files with 303 additions and 7 deletions

View File

@ -2247,7 +2247,7 @@ while true; do
--disable-background-networking \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
--user-data-dir=/home/archipelago/.config/chromium-kiosk
--user-data-dir=/var/lib/archipelago/chromium-kiosk
sleep 3
done

View File

@ -33,7 +33,7 @@ while true; do
--disable-translate \
--no-first-run \
--check-for-update-interval=31536000 \
--disable-features=TranslateUI \
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled \
--disable-session-crashed-bubble \
--disable-save-password-bubble \
--disable-suggestions-service \
@ -50,10 +50,9 @@ while true; do
--disable-breakpad \
--disable-metrics \
--disable-metrics-reporting \
--metrics-recording-only \
--disable-domain-reliability \
--js-flags="--max-old-space-size=128" \
--user-data-dir=/home/archipelago/.config/chromium-kiosk
--user-data-dir=/var/lib/archipelago/chromium-kiosk
sleep 3
done

View File

@ -37,6 +37,9 @@
<!-- PWA Install Prompt (Install app, not just Add to Home Screen) -->
<PWAInstallPrompt />
<!-- Companion app connected indicator -->
<CompanionIndicator />
<!-- Toast notifications - top right, glass style, any page -->
<Teleport to="body">
<Transition name="toast">
@ -75,6 +78,7 @@ import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import CompanionIndicator from './components/CompanionIndicator.vue'
import { useControllerNav } from '@/composables/useControllerNav'
import { playKeyboardTypingSound } from '@/composables/useLoginSounds'
import { useSpotlightStore } from '@/stores/spotlight'
@ -83,6 +87,7 @@ import { useMessageToast } from '@/composables/useMessageToast'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import { useUIModeStore } from '@/stores/uiMode'
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
const router = useRouter()
const screensaverStore = useScreensaverStore()
@ -95,16 +100,18 @@ const toastMessage = messageToast.toastMessage
useControllerNav()
// Start/stop message polling when auth state changes
// Start/stop message polling and remote relay when auth state changes
watch(() => appStore.isAuthenticated, (authenticated) => {
if (authenticated) {
messageToast.startPolling()
screensaverStore.resetInactivityTimer()
startRemoteRelay()
} else {
messageToast.stopPolling()
toastMessage.value = { show: false, text: '' }
screensaverStore.clearInactivityTimer()
screensaverStore.deactivate()
stopRemoteRelay()
}
}, { immediate: true })

View 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
}

View File

@ -0,0 +1,81 @@
<template>
<Transition name="companion-fade">
<div
v-if="companionActive"
class="companion-indicator"
title="Companion app connected"
>
<!-- Wire going down off-screen -->
<div class="companion-wire" />
<!-- Gamepad body -->
<div class="companion-pad" :class="{ 'input-flash': companionInputActive }">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<!-- Controller body -->
<rect x="3" y="7" width="18" height="11" rx="3" stroke="currentColor" stroke-width="1.5" />
<!-- D-pad vertical -->
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
<!-- D-pad horizontal -->
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
<!-- A button -->
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
<!-- B button -->
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
</svg>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { companionActive, companionInputActive } from '@/api/remote-relay'
</script>
<style scoped>
.companion-indicator {
position: fixed;
bottom: 0;
right: 24px;
z-index: 9998;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
.companion-pad {
color: rgba(247, 147, 26, 0.7);
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(247, 147, 26, 0.3);
border-bottom: none;
border-radius: 8px 8px 0 0;
padding: 6px 10px 4px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: color 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.companion-pad.input-flash {
color: rgba(247, 147, 26, 1);
border-color: rgba(247, 147, 26, 0.6);
box-shadow: 0 0 12px rgba(247, 147, 26, 0.25);
}
.companion-wire {
width: 2px;
height: 20px;
background: linear-gradient(to bottom, rgba(247, 147, 26, 0.5), rgba(247, 147, 26, 0.15));
border-radius: 1px;
order: 1;
}
.companion-pad {
order: 0;
}
/* Fade transition */
.companion-fade-enter-active { transition: opacity 0.4s ease, transform 0.4s ease; }
.companion-fade-leave-active { transition: opacity 0.3s ease, transform 0.3s ease; }
.companion-fade-enter-from { opacity: 0; transform: translateY(20px); }
.companion-fade-leave-to { opacity: 0; transform: translateY(20px); }
</style>

View File

@ -394,8 +394,10 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
.view-wrapper {
position: absolute;
inset: 0;
transform-style: preserve-3d;
backface-visibility: hidden;
/* preserve-3d + backface-visibility only during transitions (applied by
transition classes below). Keeping them always-on causes Chromium to skip
painting cards that start below the viewport they appear as transparent
ghost rectangles when scrolled into view. */
will-change: transform, opacity;
opacity: 1;
}
@ -408,6 +410,8 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
.depth-forward-enter-active.view-wrapper,
.depth-forward-leave-active.view-wrapper {
transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-style: preserve-3d;
backface-visibility: hidden;
}
.depth-forward-enter-from.view-wrapper {
@ -438,6 +442,8 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
.depth-back-enter-active.view-wrapper,
.depth-back-leave-active.view-wrapper {
transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-style: preserve-3d;
backface-visibility: hidden;
}
.depth-back-enter-from.view-wrapper {
@ -487,6 +493,8 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
.chat-open-enter-active.view-wrapper,
.chat-open-leave-active.view-wrapper {
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
transform-style: preserve-3d;
backface-visibility: hidden;
}
.chat-open-enter-from.view-wrapper {
@ -513,6 +521,8 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
.chat-close-enter-active.view-wrapper,
.chat-close-leave-active.view-wrapper {
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
transform-style: preserve-3d;
backface-visibility: hidden;
}
.chat-close-enter-from.view-wrapper {