evdev->uinput gamepad-as-keyboard daemon on kiosk nodes (works in any iframe, any origin) + shell-side focus handoff via useControllerNav; CDP/per-app injection rejected. Implementation order included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4.3 KiB
TV input: keyboard/gamepad inside iframe apps — design
Goal (2026-07-23, user requirement): on a TV/kiosk node, keyboard and gamepad control must work inside iframe apps (IndeeHub, Jellyfin, fedimint UI, AIUI…), easily and globally — no per-app hacks.
What already exists
neode-ui/src/composables/useControllerNav.ts— a complete spatial-nav system for the shell: reads gamepads (navigator.getGamepads), moves focus betweendata-controller-containerregions, plays nav sounds. It stops at iframe boundaries: nothing is forwarded into frames.- Keyboard focus DOES enter iframes natively (click/Tab into the frame), and a focused iframe receives all keys — same- or cross-origin. The gap is gamepad→app and deliberate focus handoff shell↔frame.
- Kiosk Chromium is our process (archipelago-kiosk-launcher), X11, and the ISO
already ships
xdotool. Most app iframes are cross-origin (http://host:port), so shell-side script injection is impossible for them; only the nginx-proxied/app/...ones are same-origin.
Recommended architecture — two layers, both global
Layer 1 (OS, kiosk nodes): gamepad → virtual keyboard, kernel-level
A small host daemon (archipelago-gamepad-keys) on kiosk nodes:
- reads game controllers via evdev (
/dev/input/event*, capability BTN_GAMEPAD), hotplug-aware (udev monitor or 5s rescan — same pattern as the audio router); - emits a uinput virtual keyboard: D-pad/left-stick → arrow keys, A →
Enter, B → Escape, X → Space (play/pause), Y →
f(fullscreen in most players), shoulders → Tab / Shift+Tab, Start → Enter, Select → Escape; - ships exactly like the audio router:
image-recipe/configs/script + unit, spliced into the ISO,include_str!self-heal inbootstrap.rs, gated on the kiosk being installed. Thearchipelagouser is ininputgroup OR the unit runs as root (uinput needs it anyway — run as root, it's ~100 lines of evdev→uinput with no network).
Why this layer wins: the browser sees a real keyboard, so every iframe — any origin, any app — just works the way it does for a physical keyboard today. Video players, web games, AIUI: all of them already have keyboard bindings. Zero app cooperation, zero web-platform security fights.
Layer 2 (shell): deliberate focus handoff into/out of frames
Small extension to useControllerNav:
- When spatial nav selects an app-session container and the user presses
A/Enter: call
iframe.focus()(works cross-origin) — keys (real or virtual) now flow into the app. - A dedicated exit chord the daemon maps from the gamepad (e.g. Home
button → F12 or a rarely-used key): the shell listens with a capturing
window listener; on seeing it,
iframe.blur()+ return focus to the shell nav. Keyboard users get the same via a documented chord (e.g. long Escape / Ctrl+Escape — plain Escape stays with the app, players use it). - Same-origin frames (the
/app/...proxied set) can additionally get the full spatial-nav treatment by running the existing nav overiframe.contentDocument— nice-to-have after the layers above land.
Optional layer 3 (per-app polish): postMessage contract
For OUR app UIs only (AIUI, fedimint, launcher pages): a tiny
archipelago:input postMessage contract for semantic actions (back, home,
context-menu) where raw keys aren't expressive enough. Documented in the app
packaging docs; never required for an app to be usable.
What NOT to do
- ❌ CDP (
--remote-debugging-port+ Input.dispatchKeyEvent): works but adds a privileged debug port to the kiosk and a daemon↔browser coupling; the uinput route gets the same result at kernel level with no attack surface. - ❌ Per-app nav scripts injected into iframes: cross-origin makes this impossible for most apps, and it's exactly the per-app hack the requirement rules out.
Implementation order
archipelago-gamepad-keysdaemon (evdev→uinput, ~python3 stdlib or small Rust bin) + unit + ISO splice + bootstrap self-heal. Test on Framework PT with any USB/BT controller.useControllerNav: A-button →iframe.focus()on the focused app session; exit-chord capture listener to reclaim focus.- (Later) same-origin spatial nav inside
/app/...frames; postMessage contract for our own app UIs.