phase: 09_wall_remote_page
Build and Push Containers / test (push) Successful in 45s
Build and Push Containers / build-and-push-app (push) Successful in 13s

**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**

**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.

**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.

**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.

**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.

**Next pending phase** — none; `todo/` held only phase 09.
This commit is contained in:
2026-11-03 23:50:43 -05:00
parent cd29156a7d
commit d0077e0db4
36 changed files with 1354 additions and 43 deletions
+19 -16
View File
@@ -1,7 +1,7 @@
# Kiosk client — Fedora Silverblue
The target client for a wall display is **Fedora Silverblue running GNOME Kiosk,
auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/watch`
auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/kiosk`
fullscreen in Firefox.** One box takes about five minutes over SSH and needs no
keyboard, no monitor access, and no console.
@@ -56,7 +56,7 @@ be skipped when the packages are already layered. To point a client somewhere
other than the production wall, pass the URL:
```bash
sudo KIOSK_URL=https://mirror.reeseapps.com/watch sh install-kiosk.sh
sudo KIOSK_URL=https://mirror.reeseapps.com/kiosk sh install-kiosk.sh
```
Only `kiosk.conf` differs between clients; every other file stays byte-identical
@@ -87,17 +87,20 @@ for the moment a keyboard is plugged in (see [the key table](#operating-the-wall
and a keypress there performs the change *and* publishes the same `arrange`
intent, so a controller and a display converge on one state instead of
disagreeing (`m` is local-only — a display never becomes audible because
another machine asked). The **controller side** — a page a person on another
machine drives the wall from — arrives in phase 09 (`/remote`); until then a
controller is any client that can send an `arrange` frame on `/ws`, `websocat`
included. `GET /api/kiosk` is the state's read surface: what is on stage,
whether it is fullscreen, and how many displays (`followers`) are obeying it.
another machine asked). The **controller side** is `/remote`: a page a person
on another machine (a phone) opens to list the live mirrors, read the wall's
state, and send the decisions — show this, back to the wall, full screen. Any
client that can send an `arrange` frame on `/ws` is a controller too,
`websocat` included. `GET /api/kiosk` is the state's read surface: what is on
stage, whether it is fullscreen, and how many displays (`followers`) are
obeying it.
**The migration rule, stated honestly.** Existing clients keep their own
`MIRROR_KIOSK_URL`: an existing `kiosk.conf` URL wins over the built-in
default, and the installer's default still points at `/watch` in this phase.
So **nothing changes under a box until its `kiosk.conf` is re-pointed and
`gdm` restarted**:
`MIRROR_KIOSK_URL` until an operator re-points it and restarts `gdm`, because
the installer deliberately preserves a client's URL: an existing `kiosk.conf`
URL wins over the built-in default (which now points at `/kiosk`). So
**nothing changes under a box until its `kiosk.conf` is re-pointed and `gdm`
restarted**:
```bash
sudo sed -i 's|^MIRROR_KIOSK_URL=.*|MIRROR_KIOSK_URL="https://…/kiosk"|' \
@@ -107,10 +110,10 @@ sudo systemctl restart gdm
(`pkill -u kiosk firefox` is the shorter path — the loop re-sources
`kiosk.conf` on every relaunch, see [the section below](#the-two-facts-that-make-this-work) —
`gdm` is the clean restart when you want the whole session back.) The kiosk
page serves `<title>Wall · mirror`; `check-kiosk.sh`, untouched in this
phase, still greps for `<title>Watch · mirror`, and the checker is widened to
accept the kiosk title in phase 09, when the default URL moves with it.
`gdm` is the clean restart when you want the whole session back.) Both walls
are served by the same tree; `check-kiosk.sh` accepts both titles
(`<title>Watch · mirror</title>` and `<title>Wall · mirror</title>`) and
warns for anything else.
---
@@ -347,7 +350,7 @@ The console is not an interface. Every claim below is checkable over SSH.
| The page loaded | the **server's** `GET /api/streams` shows the kiosk in `viewers` and its socket in `ss -tn` |
| Media really flows | the sharer's own page renders each viewer's `RTCPeerConnection.connectionState`; `connected` = ICE+DTLS+SRTP up |
| It is truly fullscreen | `dim-probe.py`: `viewport == window == screen == available` |
| The URL is serving *mirror* | `check-kiosk.sh` greps the page for `<title>Watch · mirror` |
| The URL is serving *mirror* | `check-kiosk.sh` greps the page for the wall title — `<title>Watch · mirror` or `<title>Wall · mirror` |
| The kiosk holds the screen | `/sys/class/tty/tty0/active` equals the kiosk session's TTY and the session is `Active` — the one check that catches a covered wall |
| No greeter is fighting it | `journalctl -b \| grep -c "New session.*gdm-greeter"` is `0`; any non-zero number is a login screen on your wall |
| It survives a power cut | `sudo systemctl reboot`, then re-run `check-kiosk.sh` |
+5 -3
View File
@@ -64,9 +64,9 @@ if [ -n "$HOME_DIR" ]; then
"$HOME_DIR/.config/mirror/kiosk.conf" 2>/dev/null | head -1)
ok "kiosk URL: ${URL:-<empty>}"
case "$URL" in
*/watch) : ;;
*/watch|*/kiosk) : ;;
"") bad "kiosk URL is empty" ;;
*) warn "kiosk URL is not the /watch wall: $URL" ;;
*) warn "kiosk URL is neither wall (/watch or /kiosk): $URL — check DNS and the nginx vhost" ;;
esac
else
bad "kiosk config $HOME_DIR/.config/mirror/kiosk.conf is missing"
@@ -213,8 +213,10 @@ if [ -n "$URL" ]; then
CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 "$URL" 2>/dev/null)
if printf '%s' "$BODY" | grep -q "Watch · mirror"; then
ok "$URL serves the mirror watch page (HTTP $CODE)"
elif printf '%s' "$BODY" | grep -q "Wall · mirror"; then
ok "$URL serves the mirror kiosk page (HTTP $CODE)"
else
warn "$URL answered HTTP $CODE but it is NOT the mirror watch page — the kiosk"
warn "$URL answered HTTP $CODE but it is NOT a mirror wall — the kiosk"
warn "will happily display whatever this is. Check DNS and the nginx vhost."
info "title: $(printf '%s' "$BODY" | sed -n 's/.*<title>\(.*\)<\/title>.*/\1/p' | head -1)"
fi
+1 -1
View File
@@ -25,7 +25,7 @@ if [ -r "$CONF" ]; then
. "$CONF"
fi
URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/watch}"
URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/kiosk}"
PROFILE="${MIRROR_KIOSK_PROFILE:-$HOME/.mirror-kiosk}"
LOG="${MIRROR_KIOSK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/mirror-kiosk.log}"
POLL="${MIRROR_KIOSK_POLL:-5}"
+4 -1
View File
@@ -17,7 +17,10 @@ set -eu
KIOSK_USER="${KIOSK_USER:-kiosk}"
# The default is for a NEW client only. An existing client keeps its own URL
# unless the operator names one — see "the URL is the one per-client value" below.
KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/watch"
# The default is the DISPLAY endpoint: /kiosk obeys the shared wall state, so
# a device with a keyboard (the /remote page) can arrange a box that has none.
# /watch is the operator's page — a wall a person stands at.
KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/kiosk"
KIOSK_URL="${KIOSK_URL:-}"
KIOSK_SESSION="gnome-kiosk-script-wayland"
HERE=$(cd "$(dirname "$0")" && pwd)
+6 -3
View File
@@ -7,9 +7,12 @@
# After editing: `sudo systemctl restart gdm` (or log the kiosk user out) to
# re-read it. The browser is restarted by the kiosk script's own loop.
# The wall of every live mirror. `/` is the Share/Watch chooser and needs a
# tap to get anywhere, which is wrong for a display nobody is standing at.
MIRROR_KIOSK_URL="https://mirror.reeseapps.com/watch"
# The controllable wall. `/kiosk` is the display endpoint: it obeys the shared
# layout state, so a device with a keyboard (the /remote page, or any
# `arrange` frame on /ws) decides what is on stage and whether it is
# fullscreen — a box that has no keyboard can still be arranged. `/watch` is
# the operator's page: a wall a person stands at.
MIRROR_KIOSK_URL="https://mirror.reeseapps.com/kiosk"
# Optional overrides (uncomment to change):