TV OS — overnight build: primitives shipped, mocks, and why it's easy to build on
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.
What's done vs. what's waiting on you
davidj).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.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).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.
App(...) entry — launch command, window class, keymap, accepts_text.NativeApp class + one TV render function. Lobby/scoring/rounds will be designed *with* the first game, where they belong.What the red-team changed
Four independent agents attacked the v1 design before any code existed. The big ones:
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:
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)*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)*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)*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
https://github.com/xpoes123/tv — spec (docs/superpowers/specs/), plan, code, tests, deploy/.cd ~/code/tv && ./venv/bin/pytest (57 tests, ~5 s) or ./venv/bin/python -m src.tv.main then open / and /remote?t=.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