TV OS — overnight build: primitives shipped, mocks, and why it's easy to build on

tv • 2026-10-20

TL;DR

The TV OS core is built, tested, and pushed (xpoes123/tv): phones pair to the TV by scanning a QR, a semantic intent bus, server-side native apps with per-phone chameleon views, external-app launching with socket-level key injection, and a resilient remote. 57 tests pass, including a browser test where two phones drive a real TV page. Before writing code I had four red-team agents attack the design (extensibility, security, reliability, code-correctness) — they found real gaps, all folded in. Below: the mocks, the architecture walkthrough you asked for (each choice and what it buys you when you add the next app), and the three things only you can do to get it onto the actual TV.

The mocks

These are screenshots of the real pages (not drawings) in populated states — Tokyo Night, deliberately plain; we can restyle with Claude Design later without touching the architecture.

TV · Home Screen — app grid with the cursor, the pairing QR, and both phones connected
TV · Home Screen — app grid with the cursor, the pairing QR, and both phones connected
TV · Echo (app #1) — presses listed by player name, including a typed message
TV · Echo (app #1) — presses listed by player name, including a typed message
Phone · the remote while on Home / in mpv — d-pad, transport, back/home
Phone · the remote while on Home / in mpv — d-pad, transport, back/home
Phone · the same page inside Echo — it became a BUZZ button (declared by the app)
Phone · the same page inside Echo — it became a BUZZ button (declared by the app)

What's done vs. what's waiting on you

  • Done (headless, tested): router, pairing, intents, device registry (persisted), NativeApp handler protocol + Echo, declarative phone views, injector, remote page, TV shell, Playwright e2e, deploy units, Caddy HTTPS config. Prereqs installed on the homelab (ydotool / mpv / chromium, uinput access for davidj).
  • Needs the box at the TV (you, ~10 min): the Task 1 "Wayland spike" — in davidj's KDE session: systemctl --user enable --now ydotool.service, paru -S kdotool, launch mpv via systemd-run --user --scope and confirm a ydotool key lands in it, and find the one kdotool command that brings the kiosk back to the front. It's a checklist in deploy/README.md; the results go into .env.
  • Needs a secret from you: a Cloudflare API token (Zone:DNS:Edit on djiang.xyz) so Caddy can get a cert for tv.djiang.xyz via DNS-01. Why this matters: the phone's Screen Wake Lock only works on HTTPS — on plain HTTP the phone's screen sleeps mid-YouTube and the connection drops. Pair phones only after HTTPS is up (the origin change would reset their identities).
  • Then: copy the two systemd --user units, run the done-when checklist (deploy/README.md), and FreeTube becomes the next increment — a registry entry, zero shell changes.
  • Architecture walkthrough — each choice, and what it buys you

    1. Native apps are server-side handlers; the TV page is a pure renderer.

    Choice: a game's state and rules live in a Python NativeApp object on the router (onintent / state / phoneview / ondevicejoin), not in browser JS. Rejected: logic in the TV page (dies on a kiosk reload, can't tell phones anything). What it buys you: a quizbowl game is one class — buzz lockout, scoring, who's-in — plus one VIEWS.quizbowl(state) render function. It survives TV reloads and phone reconnects for free, because the router owns the truth.

    2. Phone views are declarative widget lists, per device, pushable any time.

    Choice: an app returns {title, items:[{kind:"button"}, {kind:"dpad"}, …]} for *each* phone; the remote renders it generically. Rejected: hand-written layouts in remote.js per app. What it buys you: you never edit the remote again. "You're locked out" on one phone and a live BUZZ on five others is just phone_view(dev) returning different lists. Music = transport + label. FreeTube = dpad + a text prompt for search.

    3. Semantic intents, versioned; keymaps only at the edge.

    Choice: phones send nav.up, media.playpause, button.buzz (with client timestamp + press/release phase) — never keycodes. External apps map intents→keycodes in a per-app keymap (chords allowed). Rejected: phones sending raw keys. What it buys you: one remote drives everything (FreeTube, Steam, your games) and adding motion later is a new type that breaks nothing. Buzzer fairness can order by the phone's own clock.

    4. Identity is the phone's, persisted, gated by a QR pair token.

    Choice: the phone generates its id and keeps it; the router persists pairings to disk; the first hello from an unknown phone needs the token in the QR, after which it's free forever. Raw ids never leave the server (TV and apps see a hashed handle). Rejected: server-minted session ids (lost on every restart/screen-lock → re-pairing hell), and no pairing at all (anyone on the Wi-Fi could launch Steam). What it buys you: scan once per phone, ever. Deploys, reboots, 2-minute screen-locks and Wi-Fi blips don't lose a player or their score; guests can't drive your TV without being in the room.

    5. One single-page shell; external apps are launched over it; Home is global.

    Choice: the TV runs one web page; native apps are views in it; mpv/FreeTube are real windows launched via systemd-run --user --scope and brought to front with KWin; home always escapes (long-press on phones while a multiplayer app runs, so one thumb can't end a 6-player game). What it buys you: smooth transitions and shared state for your apps, and off-the-shelf apps need no integration beyond a launch command + keymap.

    6. The router is one state machine with strict lock discipline; injection never blocks.

    Choice: state mutates under one lock, all network I/O happens after it's released; key presses are written as input_event structs straight to the ydotoold socket (sub-millisecond, no process spawn); killing an app escalates terminate→kill on a tick. Rejected: subprocess.run inside the lock (a kid mashing ⏯ would stall every phone and trigger reconnect storms). What it buys you: the <50 ms press→TV target and no cross-phone interference.

    7. Connection quality is a primitive, not plumbing.

    Choice: 2 s heartbeats, server-side liveness, reconnect that *resumes* (the dead-old-socket race is handled), fast resume on iOS unlock, per-device rate limiting, LAN allowlist (tailnet/Docker can't reach it), loopback-only TV socket, XSS-safe names. What it buys you: the phone is a dependable tool for music, YouTube and games — the whole point you raised.

    Net: adding a thing to the OS is now a declaration, not a feature.

  • External app (FreeTube): one App(...) entry — launch command, window class, keymap, accepts_text.
  • Game: one NativeApp class + one TV render function. Lobby/scoring/rounds will be designed *with* the first game, where they belong.
  • New input (motion): one intent type.
  • New phone: scan once.
  • What the red-team changed

    Four independent agents attacked the v1 design before any code existed. The big ones:

  • Extensibility: native apps had no server-side handler and phone views were static → app #1 would have forced a router rewrite. Fixed (primitives 1–2 above).
  • Security: no pairing at all; raw ids broadcast to the TV; router reachable from the tailnet; a stored-XSS via device names. Fixed (4, 7).
  • Reliability: a reconnect race that silently killed live phones; blocking subprocess calls under the lock; Wake Lock needs HTTPS (hence the Cloudflare ask); identity lost on router restart. Fixed (4, 6, 7).
  • Code-correctness: actually ran the plan's code — 42/43 passing; the one mismatch and three latent bugs fixed before building.
  • Post-build code review

    After the build, a fresh-eyes reviewer read the spec, every source file and the tests, and probed the real ydotoold on this box. It found eight real defects — all fixed and re-tested before this brief was published:

  • ydotoold reads one input_event per datagram — I was sending all four events of a key press in one datagram, so only *key-down* would ever have reached uinput (a stuck key, chords impossible). Now one datagram per event. *(high)*
  • Wrong socket path for a user service — ydotoold under systemd --user puts its socket in $XDGRUNTIMEDIR, not /tmp; injection would have silently failed on the box. The router now resolves it automatically. *(high)*
  • last_seen wasn't persisted on disconnect — after any restart, phones paired >24 h ago would have been pruned on the first tick and told to re-scan — exactly what the spec forbids. Fixed. *(high)*
  • Reconnect handlers weren't bound to their socket — a superseded socket closing late could stop the live socket's heartbeat. Handlers are now per-socket. *(medium)*
  • The rehello recovery path was dead (the server rejects a second hello on the same socket; the phone retried on the same socket). The phone now reconnects fresh. *(medium)*
  • Three small remote fixes: skip re-rendering identical views (keeps hold-to-home timers alive), ask for the name before any socket exists (a slow typer could be silent-closed), and reap killed processes on the following tick. *(low)*
  • Verified sound by the same review: systemd-run --user --scope keeps the process handle meaningful (terminate/kill act on mpv itself), the 24-byte input_event layout, the router's lock discipline (no network I/O under the lock), the rate limiter, the allowlist behind the Caddy proxy, and that every untrusted string is rendered via textContent.

    Where it is

  • Repo: https://github.com/xpoes123/tv — spec (docs/superpowers/specs/), plan, code, tests, deploy/.
  • Run locally: cd ~/code/tv && ./venv/bin/pytest (57 tests, ~5 s) or ./venv/bin/python -m src.tv.main then open / and /remote?t=.
  • Homelab prereqs done tonight: ydotool mpv chromium installed; davidj in input; ydotool runs as a user service. Nothing graphical was started (the LLM session's GPU work is untouched).
  • Commit log

    
    0f232d0 fix: post-build review — ydotoold per-event datagrams, XDG socket path, persisted last_seen, remote handlers, rehello
    fe7fc2f feat: TV OS core primitives — router, pairing, intents, native-app handler, remote, shell
    51c6c6e docs: spec + plan v2 after 4-lens red-team
    3e21f4b docs: implementation plan for TV OS core primitives (12 TDD tasks)
    c8b021d spec: connection quality as a primitive + app-driven phone surface
    79025bc docs: TV OS core primitives design spec