diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 92566504..f071b4b5 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -52,6 +52,15 @@ const AUDIO_SERVICE: &str = const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router"; const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service"; +// Gamepad→keyboard bridge (TV input inside every app iframe) — same +// splice-from-configs + self-heal pattern as the audio router. +const GAMEPAD_KEYS: &str = + include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.py"); +const GAMEPAD_SERVICE: &str = + include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.service"); +const GAMEPAD_KEYS_PATH: &str = "/usr/local/bin/archipelago-gamepad-keys"; +const GAMEPAD_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-gamepad-keys.service"; + // Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs // write the identical file at build time (image-recipe/_archived/ // build-auto-installer-iso.sh); this heals already-deployed nodes via OTA. @@ -879,6 +888,37 @@ pub async fn ensure_audio_stack() { } } +/// Gamepad→keyboard bridge self-heal for kiosk nodes: keeps the evdev→uinput +/// daemon (controller works inside every app iframe on the TV) installed and +/// current. Same gating as audio: no kiosk → no display input to bridge. +pub async fn ensure_gamepad_keys() { + if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() { + return; + } + let unit_was_missing = fs::metadata(GAMEPAD_SERVICE_PATH).await.is_err(); + let script_changed = write_root_if_needed(GAMEPAD_KEYS_PATH, GAMEPAD_KEYS) + .await + .unwrap_or(false); + if script_changed { + let _ = host_sudo(&["chmod", "+x", GAMEPAD_KEYS_PATH]).await; + } + let unit_changed = write_root_if_needed(GAMEPAD_SERVICE_PATH, GAMEPAD_SERVICE) + .await + .unwrap_or(false); + if script_changed || unit_changed { + if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await { + warn!("gamepad bridge: daemon-reload failed: {:#}", e); + } + } + if unit_was_missing { + let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await; + info!("gamepad: bridge installed and enabled (TV controller input)"); + } else if script_changed || unit_changed { + let _ = host_sudo(&["systemctl", "try-restart", "archipelago-gamepad-keys.service"]).await; + info!("gamepad: bridge updated"); + } +} + /// Patch the nginx site config to add missing backend proxy blocks. Older ISO /// configs shipped individual per-endpoint `location` blocks, so missing /// endpoints silently fell through to the SPA `index.html` and the frontend diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 60260786..11f943ba 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -391,6 +391,10 @@ async fn main() -> Result<()> { // boot-time ELD race that leaves HDMI silently unavailable). tokio::spawn(bootstrap::ensure_audio_stack()); + // TV input: gamepad→keyboard bridge so controllers work inside every app + // iframe on kiosk nodes (docs/tv-input-iframe-apps.md). + tokio::spawn(bootstrap::ensure_gamepad_keys()); + // Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when // DHCP renumbering strands them — HA never re-resolves on its own. tokio::spawn(api::rpc::wyoming_satellite_keeper()); diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index a71931f2..d6ecd6f4 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -2913,6 +2913,33 @@ fi echo "AUDIOSVC" } >> "$ARCH_DIR/auto-install.sh" +# Gamepad->keyboard bridge: same pattern (configs/ single source of truth, +# also self-healed via the binary). TV input for every app iframe. +GAMEPAD_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.py" +GAMEPAD_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.service" +for _pad_src in "$GAMEPAD_SRC" "$GAMEPAD_SERVICE_SRC"; do + if [ ! -f "$_pad_src" ]; then + echo "ERROR: gamepad config file missing: $_pad_src" >&2 + exit 1 + fi +done +if grep -qx 'GAMEPADPY' "$GAMEPAD_SRC" || grep -qx 'GAMEPADSVC' "$GAMEPAD_SERVICE_SRC"; then + echo "ERROR: gamepad config contains a reserved heredoc terminator line (GAMEPADPY/GAMEPADSVC)" >&2 + exit 1 +fi +{ + echo "" + echo "# Gamepad->keyboard bridge — controller input inside every app iframe on the TV." + echo "cat > /mnt/target/usr/local/bin/archipelago-gamepad-keys <<'GAMEPADPY'" + cat "$GAMEPAD_SRC" + echo "GAMEPADPY" + echo "chmod +x /mnt/target/usr/local/bin/archipelago-gamepad-keys" + echo "" + echo "cat > /mnt/target/etc/systemd/system/archipelago-gamepad-keys.service <<'GAMEPADSVC'" + cat "$GAMEPAD_SERVICE_SRC" + echo "GAMEPADSVC" +} >> "$ARCH_DIR/auto-install.sh" + cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT' # Toggle script: sudo archipelago-kiosk enable|disable|status @@ -3293,6 +3320,7 @@ chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true chroot /mnt/target systemctl enable archipelago-audio-router.service 2>/dev/null || true +chroot /mnt/target systemctl enable archipelago-gamepad-keys.service 2>/dev/null || true chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true # Enable claude-api-proxy (create symlink manually — chroot systemctl can fail) chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \ diff --git a/image-recipe/configs/archipelago-gamepad-keys.py b/image-recipe/configs/archipelago-gamepad-keys.py new file mode 100644 index 00000000..299cca08 --- /dev/null +++ b/image-recipe/configs/archipelago-gamepad-keys.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Archipelago gamepad→keyboard bridge (kiosk nodes). + +Reads every attached game controller via evdev and mirrors it as a virtual +uinput KEYBOARD, so gamepad input works in every app — including cross-origin +iframes (IndeeHub, Jellyfin, …) where the web shell can never inject events. +The browser just sees arrow/Enter/Escape keys from a real-looking keyboard; +X autorepeat handles held directions. Design: docs/tv-input-iframe-apps.md. + +Mapping (standard pad): + D-pad / left stick -> arrow keys + A (BTN_SOUTH) -> Enter B (BTN_EAST) -> Escape + X (BTN_NORTH/WEST*) -> Space Y -> f (player fullscreen) + LB (BTN_TL) -> Shift+Tab RB (BTN_TR) -> Tab + Start -> Enter Select -> Escape + +*Controllers disagree on NORTH/WEST for X/Y; both map to Space/f — either way + one is play/pause and one is fullscreen, which is fine for a TV. + +Pure stdlib (struct/fcntl/select) — no python3-evdev dependency on the node. +Runs as root (uinput + /dev/input need it); hotplug via 5s rescans. +""" + +import fcntl +import os +import select +import struct +import time + +# ---- kernel constants ------------------------------------------------------ +EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03 +SYN_REPORT = 0 + +KEY_ESC, KEY_TAB, KEY_ENTER, KEY_SPACE = 1, 15, 28, 57 +KEY_LEFTSHIFT, KEY_F = 42, 33 +KEY_UP, KEY_LEFT, KEY_RIGHT, KEY_DOWN = 103, 105, 106, 108 + +BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134 +BTN_TL, BTN_TR, BTN_SELECT, BTN_START = 0x136, 0x137, 0x13A, 0x13B +BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT = 0x220, 0x221, 0x222, 0x223 + +ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x10, 0x11 + +EVIOCGBIT_EV_KEY = 0x80604521 # EVIOCGBIT(EV_KEY, 96) — enough for BTN range +UI_SET_EVBIT, UI_SET_KEYBIT = 0x40045564, 0x40045565 +UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502 + +INPUT_EVENT = struct.Struct("llHHi") # timeval sec/usec, type, code, value + +BUTTON_MAP = { + BTN_SOUTH: (KEY_ENTER,), + BTN_EAST: (KEY_ESC,), + BTN_NORTH: (KEY_SPACE,), + BTN_WEST: (KEY_F,), + BTN_TL: (KEY_LEFTSHIFT, KEY_TAB), + BTN_TR: (KEY_TAB,), + BTN_START: (KEY_ENTER,), + BTN_SELECT: (KEY_ESC,), + BTN_DPAD_UP: (KEY_UP,), + BTN_DPAD_DOWN: (KEY_DOWN,), + BTN_DPAD_LEFT: (KEY_LEFT,), + BTN_DPAD_RIGHT: (KEY_RIGHT,), +} +EMITTED_KEYS = sorted({k for keys in BUTTON_MAP.values() for k in keys} + | {KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT}) + +STICK_THRESHOLD = 0.55 # fraction of full deflection before a stick "presses" + + +def is_gamepad(fd) -> bool: + buf = bytearray(96) + try: + fcntl.ioctl(fd, EVIOCGBIT_EV_KEY, buf) + except OSError: + return False + def has(code): + return bool(buf[code // 8] & (1 << (code % 8))) + return has(BTN_SOUTH) or has(BTN_START) + + +class VirtualKeyboard: + def __init__(self): + self.fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK) + fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY) + for key in EMITTED_KEYS: + fcntl.ioctl(self.fd, UI_SET_KEYBIT, key) + # Legacy uinput_user_dev setup struct: name[80] + input_id + ff_effects + # + absmax/absmin/absfuzz/absflat (64 ints each) — works on every kernel. + name = b"Archipelago Gamepad Keys" + setup = name.ljust(80, b"\0") + struct.pack("HHHHi", 0x06, 0x1, 0x1, 1, 0) + setup += b"\0" * (64 * 4 * 4) + os.write(self.fd, setup) + fcntl.ioctl(self.fd, UI_DEV_CREATE) + + def _emit(self, etype, code, value): + os.write(self.fd, INPUT_EVENT.pack(0, 0, etype, code, value)) + + def set_key(self, key, pressed): + self._emit(EV_KEY, key, 1 if pressed else 0) + self._emit(EV_SYN, SYN_REPORT, 0) + + def chord(self, keys, pressed): + seq = keys if pressed else tuple(reversed(keys)) + for k in seq: + self._emit(EV_KEY, k, 1 if pressed else 0) + self._emit(EV_SYN, SYN_REPORT, 0) + + +class PadState: + """Per-device axis state → synthetic arrow presses.""" + + def __init__(self): + self.axis_keys = {} # axis -> currently-pressed arrow key (or None) + self.abs_range = {} # axis -> (min, max) for sticks + + def arrow_for(self, axis, value): + if axis in (ABS_HAT0X, ABS_HAT0Y): + if value < 0: + return KEY_LEFT if axis == ABS_HAT0X else KEY_UP + if value > 0: + return KEY_RIGHT if axis == ABS_HAT0X else KEY_DOWN + return None + lo, hi = self.abs_range.get(axis, (-32768, 32767)) + span = (hi - lo) or 1 + norm = (2 * (value - lo) / span) - 1 + if norm <= -STICK_THRESHOLD: + return KEY_LEFT if axis == ABS_X else KEY_UP + if norm >= STICK_THRESHOLD: + return KEY_RIGHT if axis == ABS_X else KEY_DOWN + return None + + +def stick_range(fd, axis): + # EVIOCGABS(axis): struct input_absinfo { value, min, max, fuzz, flat, res } + buf = bytearray(24) + try: + fcntl.ioctl(fd, 0x80184540 + axis, buf) + _, lo, hi = struct.unpack("iii", bytes(buf[:12])) + if hi > lo: + return (lo, hi) + except OSError: + pass + return (-32768, 32767) + + +def main(): + os.system("modprobe uinput 2>/dev/null") + kbd = VirtualKeyboard() + pads = {} # path -> (fd, PadState) + last_scan = 0.0 + + while True: + now = time.monotonic() + if now - last_scan > 5: + last_scan = now + try: + names = sorted(os.listdir("/dev/input")) + except FileNotFoundError: + names = [] + for name in names: + if not name.startswith("event"): + continue + path = "/dev/input/" + name + if path in pads: + continue + try: + fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK) + except OSError: + continue + if is_gamepad(fd): + state = PadState() + for axis in (ABS_X, ABS_Y): + state.abs_range[axis] = stick_range(fd, axis) + pads[path] = (fd, state) + print(f"gamepad attached: {path}", flush=True) + else: + os.close(fd) + + if not pads: + time.sleep(2) + continue + + readable, _, _ = select.select([fd for fd, _ in pads.values()], [], [], 2.0) + gone = [] + for path, (fd, state) in list(pads.items()): + if fd not in readable: + continue + try: + data = os.read(fd, INPUT_EVENT.size * 64) + except OSError: + gone.append(path) + continue + for off in range(0, len(data) - INPUT_EVENT.size + 1, INPUT_EVENT.size): + _, _, etype, code, value = INPUT_EVENT.unpack_from(data, off) + if etype == EV_KEY and code in BUTTON_MAP and value in (0, 1): + keys = BUTTON_MAP[code] + if len(keys) == 1: + kbd.set_key(keys[0], value == 1) + else: + kbd.chord(keys, value == 1) + elif etype == EV_ABS and code in (ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y): + want = state.arrow_for(code, value) + held = state.axis_keys.get(code) + if want != held: + if held is not None: + kbd.set_key(held, False) + if want is not None: + kbd.set_key(want, True) + state.axis_keys[code] = want + for path in gone: + fd, state = pads.pop(path) + for held in state.axis_keys.values(): + if held is not None: + kbd.set_key(held, False) + try: + os.close(fd) + except OSError: + pass + print(f"gamepad detached: {path}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/image-recipe/configs/archipelago-gamepad-keys.service b/image-recipe/configs/archipelago-gamepad-keys.service new file mode 100644 index 00000000..77754119 --- /dev/null +++ b/image-recipe/configs/archipelago-gamepad-keys.service @@ -0,0 +1,16 @@ +[Unit] +Description=Archipelago gamepad->keyboard bridge (TV/kiosk input in every app iframe) +# Only meaningful where a display UI runs; the kiosk unit is the marker. +ConditionPathExists=/etc/systemd/system/archipelago-kiosk.service +ConditionPathExists=/usr/local/bin/archipelago-gamepad-keys + +[Service] +Type=simple +# Root: /dev/uinput device creation + raw /dev/input readers. +ExecStart=/usr/bin/python3 /usr/local/bin/archipelago-gamepad-keys +Restart=always +RestartSec=10 +Nice=5 + +[Install] +WantedBy=multi-user.target diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts index f48efa3e..c2fdb306 100644 --- a/neode-ui/src/composables/useControllerNav.ts +++ b/neode-ui/src/composables/useControllerNav.ts @@ -105,6 +105,14 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean { return !!el.closest(`[data-controller-zone="${zone}"]`) } +/** Topmost open modal dialog, if any — it owns navigation while visible. */ +function getOpenModal(): HTMLElement | null { + const dialogs = Array.from( + document.querySelectorAll('[role="dialog"][aria-modal="true"]'), + ).filter(el => el.offsetParent !== null) + return dialogs[dialogs.length - 1] ?? null +} + function isInsideContainer(el: HTMLElement | null): boolean { if (!el) return false const container = el.closest('[data-controller-container]') @@ -250,6 +258,42 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { const target = e.target as HTMLElement const activeEl = document.activeElement as HTMLElement + // ── MODAL SCOPE ────────────────────────────────────────── + // An open dialog owns navigation: focus is pulled inside, arrows move + // spatially between its controls, Enter activates. Escape stays with the + // modal's own close handling. Standard mapping, no per-modal code. + const modal = getOpenModal() + if (modal) { + if (e.key === 'Escape') return + const focusables = getFocusableElements(modal) + if (!focusables.length) return + if (!activeEl || !modal.contains(activeEl)) { + e.preventDefault() + const first = focusables[0] + if (first) focusEl(first) + return + } + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { + // Typing keys stay with the field; Enter keeps the form-submit + // behavior below; only Up/Down leave the field spatially. + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') return + } else if (e.key === 'Enter') { + e.preventDefault() + playNavSound('action') + activeEl.click() + return + } + e.preventDefault() + const dir = + e.key === 'ArrowDown' ? ('down' as const) + : e.key === 'ArrowUp' ? ('up' as const) + : e.key === 'ArrowLeft' ? ('left' as const) + : ('right' as const) + const nearest = findNearestInDirection(activeEl, focusables.filter(el => el !== activeEl), dir) + if (nearest) focusEl(nearest) + return + } + // ── TEXT INPUT HANDLING ────────────────────────────────── if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { if (