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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
81
neode-ui/src/components/CompanionIndicator.vue
Normal file
81
neode-ui/src/components/CompanionIndicator.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user