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
@@ -0,0 +1,11 @@
**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.
@@ -0,0 +1,37 @@
........................................................................ [ 19%]
..............................................sssss..................... [ 39%]
........................................................................ [ 59%]
........................................................................ [ 78%]
........................................................................ [ 98%]
..... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 197 10 95% 108, 113, 117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 709 31 96%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
360 passed, 5 skipped, 1 warning in 102.18s (0:01:42)
All checks passed!
34 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,9 @@
All gates green. Task complete.
**Report**
- **Implemented:** `GET /remote` route in `src/mirror/app.py` (+ `PAGES` no-store); `static/remote.html` (bar + net pill, readout `#remote-state` with stable hooks, `#remote-back`/`#remote-fullscreen` controls, `#remote-list` + row template, empty state with share URL); `static/remote.js` stub (config, refusal notice, socket hello as plain `role:"viewer"` with no `kiosk` flag, net pill to online); `app.css` `.remote` styles (mono readout strip, wrapping control row, 320 px-safe rows, `#remote-fullscreen[aria-pressed="true"]` accent = tile audio button treatment); entry links in `watch.html` (`#remote-link`) and `index.html` (4th card); `/kiosk` untouched; `test_app.py` guards extended to the fourth page (render/no-store/asset/node-check/element-exists/stylesheet-module/id-collision over 5 pages with `{"net","notice","toast"}` as the only overlap, address gate, plus new `test_a_controller_is_media_free` and `test_the_remote_page_declares_its_control_hooks`).
- **Results:** `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — 349 passed, 5 skipped (realdisplay opt-in), 100% green; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 documented platform skip; `uv run pytest tests/test_app.py` — 104 passed; coverage 95% overall, new route fully covered. Live checks: `curl -s/-I localhost:8055/remote` serves the page with `cache-control: no-store`; browser at 320 px: net pill online, empty state + share URL, zero console errors, both buttons reachable, no horizontal scroll.
- **Docs:** README — `/remote` row in the page table + quickstart URL line (SECURITY.md/full controller docs left to task 03 per the phase plan). AGENTS.md — one stale fact only: "Four pages" → "Five pages".
- **Deviation:** the task text names both the readout marker span and the Full screen button `remote-fullscreen` (a duplicate id that would fail the page's own uniqueness guard). The button keeps `#remote-fullscreen` (the `aria-pressed` CSS rule and the task's guard target it); the readout marker is `#remote-fullscreen-mark`, noted in the markup and asserted by the new hook guard.
- **Next pending task:** `.agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md`
@@ -0,0 +1,36 @@
........................................................................ [ 20%]
......................................sssss............................. [ 40%]
........................................................................ [ 61%]
........................................................................ [ 81%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 197 11 94% 108, 113, 116-117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 709 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
349 passed, 5 skipped, 1 warning in 95.96s (0:01:35)
All checks passed!
33 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,14 @@
All green. Final report:
**Implemented (task 02 — `remote.js` controller logic):**
- `src/mirror/static/remote.js`: `mirrors` Map + `layout` state; roster frames (`welcome` seeds, added/updated upsert, removed deletes, `signal` ignored); `renderMirrors()` (template rows, `data-on`/`aria-current`/`On the wall`, empty state); `renderState()` (staged title/`(gone)`/`no mirror`, fullscreen on/off on marker + button `aria-pressed`/label, followers plural handling); one-line hint (`no display following` / `offline`); `arrange()` writers with event delegation on `#remote-list`; one-time "…ended — back to the wall" toast; stale readout on socket close, re-sync on `welcome`.
- `remote.html`: added `#remote-hint`; `app.css`: `.remote-hint` + dimmed `[data-stale]` readout.
- `tests/test_app.py`: media-free guard extended (`MediaStream`, `navigator.mediaDevices`), new `test_a_controller_sends_exactly_one_frame_type_besides_hello`, hook list gains `remote-hint`, 2 protocol tests (controller drives layout, not counted in `followers`; `arrange` changes no subscriptions/audience).
**Gates (all 4, exact commands):** ruff check+format clean · pyright 0 errors · `uv run pytest` **352 passed, 5 skipped** (documented realdisplay skips) · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` **4 passed, 1 documented skip** · coverage 95%, byte-identical to verified baseline (no `src/` Python touched).
**Docs:** no impact — README already describes `/remote` accurately (task 01); deep docs are task 03's scope; no stale AGENTS.md statements.
**Notable:** spec's "three spans" named `#remote-fullscreen`, but task 01's built HTML (locked contract, asserted in tests) uses `#remote-fullscreen-mark` for the readout span and `#remote-fullscreen` for the button — followed the built HTML (marker carries on/off, button carries label/aria). Pre-existing, verified in a reconstructed baseline: the non-gate combined run `MIRROR_REALDISPLAY=1 uv run pytest` errors 5 x11 fixture setups (Playwright sync-API double context); both official invocations are green, left untouched.
**Next pending task:** `03_remote_e2e_docs_and_kiosk_url.md`.
@@ -0,0 +1,36 @@
........................................................................ [ 20%]
......................................sssss............................. [ 40%]
........................................................................ [ 60%]
........................................................................ [ 80%]
..................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 197 11 94% 108, 113, 116-117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 709 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
352 passed, 5 skipped, 1 warning in 96.76s (0:01:36)
All checks passed!
33 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,12 @@
All changes verified. The AGENTS.md diff ("Five pages") was from task 01 — I left AGENTS.md untouched (no stale statement in it). Everything is complete and green.
**Report**
- **New `tests/e2e/test_remote_control.py`** (8 tests, `e2e` + `timeout(180)`, same `device()`/`open_device` setup as the kiosk suite): the click loop (`.show` → display `data-layout="stage"` + staged tile + `/api/kiosk`, `#remote-back` → wall), pixels-not-classes (`frames_matching` width>0, distinct≥2 on the display), fullscreen (server state + `body[data-fullscreen]`, `document.fullscreenElement` never asserted, comment says why), two-controller convergence, controller-costs-nothing (stream `viewers: 0` / `stats.watching: 0` with only `/remote`, moves when `/watch` joins), followers agreement (0/1/1 — a controller is not a display), kiosk `n` keypress moving the remote readout, teardown (display unstages, row/readout clear), `errors == []` in every case.
- **Fix found while testing**: the kiosk suite leaves `fullscreen: true` on the shared session server (teardown resets `staged` only), which broke the toggle-based fullscreen test; the test now normalizes the shared state through the product at start (documented in the test).
- **SECURITY.md**: §2 gains the "rearrange anybody's wall display" bullet (names `/remote`, same trust decision as watching, bounded by `MIRROR_ALLOW_CIDRS`) and the not-possible audio bullet; §3 gains the "No remote audio" row naming the real enforcing tests.
- **README**: new "Driving an unattended wall" section (keys on the box visible to controllers, fullscreen requested-not-promised, in-memory state); Kiosk-client paragraph updated for the new default. Page table/Quickstart already had both endpoints.
- **Silverblue tree → `/kiosk` in one change**: `install-kiosk.sh` default + comment, `kiosk.conf` URL + comment, `gnome-kiosk-script` fallback, `check-kiosk.sh` (accepts both walls, warning kept for the third case), `kiosk-silverblue.md` (top description, `KIOSK_URL` example, controller/migration/title paragraphs de-staled). `sh -n` clean on all scripts; checker logic verified via harness: `/kiosk` and `/watch` → ok, other → warn.
- **Gates**: `uv run pytest tests/e2e/test_remote_control.py` → 8 passed; `uv run pytest -m e2e` → 118 passed; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed (1 documented platform skip); full `uv run pytest` → **360 passed, 5 skipped, 0 failed**; `ruff check`/`ruff format --check`/`pyright` all clean.
- **Note**: one one-off `test_kiosk_layout.py` tile-id flake in a single full run (not reproduced in two subsequent full runs; my suite runs after it in file order, so it cannot be the cause). No doc impact on AGENTS.md.
- **Next pending task**: none — task 03 is the last task of phase 09.
@@ -0,0 +1,37 @@
........................................................................ [ 19%]
..............................................sssss..................... [ 39%]
........................................................................ [ 59%]
........................................................................ [ 78%]
........................................................................ [ 98%]
..... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 197 10 95% 108, 113, 117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 709 31 96%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
360 passed, 5 skipped, 1 warning in 102.09s (0:01:42)
All checks passed!
34 files already formatted
0 errors, 0 warnings, 0 informations
+1 -1
View File
@@ -41,7 +41,7 @@ is a roster and a signaling relay. Linux is the reference platform.
| Rule | Why it exists | | Rule | Why it exists |
|---|---| |---|---|
| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestPages::test_nothing_is_loaded_from_a_cdn` fails the build if you add one | | **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestPages::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
| **No framework, no bundler** in `src/mirror/static/` | Four pages and one WebSocket. `node --check` is the entire frontend toolchain | | **No framework, no bundler** in `src/mirror/static/` | Five pages and one WebSocket. `node --check` is the entire frontend toolchain |
| **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 | | **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 |
| **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage | | **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage |
| **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test | | **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test |
+19 -3
View File
@@ -10,6 +10,7 @@ your network:
| **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. | | **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. | | **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
| **Kiosk** | `/kiosk` | A chrome-free wall for an unattended display: no header, no counts — the net pill is the only chrome, and the shared layout state (below) arranges it. | | **Kiosk** | `/kiosk` | A chrome-free wall for an unattended display: no header, no counts — the net pill is the only chrome, and the shared layout state (below) arranges it. |
| **Remote** | `/remote` | A controller for the shared wall, from a laptop or a phone: lists the live mirrors and the wall's state and sends the decisions (show this, back to the wall, full screen) — no video, no bandwidth. |
| **Index** | `/` | Signpost + live count of mirrors. | | **Index** | `/` | Signpost + live count of mirrors. |
Any number of people can mirror at the same time and any number of people can Any number of people can mirror at the same time and any number of people can
@@ -32,6 +33,7 @@ Then, from another machine on the LAN:
http://<this-machine>:8055/mirror # to share http://<this-machine>:8055/mirror # to share
http://<this-machine>:8055/watch # to watch http://<this-machine>:8055/watch # to watch
http://<this-machine>:8055/kiosk # the unattended wall (kiosk display) http://<this-machine>:8055/kiosk # the unattended wall (kiosk display)
http://<this-machine>:8055/remote # drive the wall from this device
``` ```
Linux is the primary target. On Linux the native picker is X11/Wayland's own Linux is the primary target. On Linux the native picker is X11/Wayland's own
@@ -78,6 +80,20 @@ same `arrange` intent, so a controller and a display converge on one state
(`m` is local-only: a display never becomes audible because another machine (`m` is local-only: a display never becomes audible because another machine
asked). asked).
### Driving an unattended wall
The shared state exists for a box with no keyboard: point the box at `/kiosk`,
open `/remote` on a phone, and press **Show on wall** — the box stages that
mirror, and **Back to the wall** and **Full screen** do the same for their
decisions. When a keyboard is plugged into the box, the wall keys still work
there, and a keypress performs the change *and* publishes the same `arrange`
intent, so a key on the box is seen by every controller — a controller and a
display converge on one state. Fullscreen is **requested, not promised**: a
remote command carries no user activation, so the browser may refuse the
request; the in-page stage fills the viewport anyway (and the kiosk box's
window manager forces the window fullscreen). The state lives in the server's
memory only: a restart returns the wall to a wall.
## Configuration ## Configuration
Environment variables (all optional; a `.env` file is loaded if present): Environment variables (all optional; a `.env` file is loaded if present):
@@ -180,9 +196,9 @@ from this side:
A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk
session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at
the wall. It is set up and repaired entirely over SSH — no keyboard, no console, the wall. It is set up and repaired entirely over SSH — no keyboard, no console,
no monitor. The display page is `/kiosk`; the installer default still opens no monitor. The display page is `/kiosk`, and it is the installer default
`/watch` and an existing `kiosk.conf` URL wins, so moving a box is the too; an existing `kiosk.conf` URL wins over the default, so moving a box is
one-line re-point in [the runbook](docs/kiosk-silverblue.md#two-endpoints-watch-and-kiosk). A brand-new box layers the kiosk packages once first the one-line re-point in [the runbook](docs/kiosk-silverblue.md#two-endpoints-watch-and-kiosk). A brand-new box layers the kiosk packages once first
(`rpm-ostree install gnome-kiosk gnome-kiosk-script-session`, then a reboot); (`rpm-ostree install gnome-kiosk gnome-kiosk-script-session`, then a reboot);
after that these two commands are both the setup and the repair path: after that these two commands are both the setup and the repair path:
+11 -5
View File
@@ -31,11 +31,16 @@ limiting, no accounts, and no audit trail.
`MIRROR_MAX_BITRATE`; the sharer's quality preset scales that bound only `MIRROR_MAX_BITRATE`; the sharer's quality preset scales that bound only
*down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's *down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's
ceiling). ceiling).
* Point the shared wall at any live mirror and toggle its fullscreen state * **Rearrange anybody's wall display**: choose which mirror is on stage and
(the `arrange` frame on `/ws`), which every kiosk display follows. The whether it is fullscreen, on every `/kiosk` client, immediately — open
state has no audio field: a remote hand can stage and scale, but it can `/remote` on a phone, or send one `arrange` frame on `/ws`. This is the
never make a display *sound* — that still needs a gesture on the device same trust decision as "watch anything anyone shares": it is visible, it is
playing it (see §4 “Audio”). presentation-only (no sound, see below), and `MIRROR_ALLOW_CIDRS` is what
bounds it.
* What a visitor **cannot** do: make a display audible. The layout carries no
sound field, and every audible state traces to a gesture on the device that
plays it (the §4 “Audio” gesture rule) — a remote hand can stage and scale,
but a stranger's display never sounds because they asked.
## 3. What they cannot do (enforced and tested) ## 3. What they cannot do (enforced and tested)
@@ -46,6 +51,7 @@ limiting, no accounts, and no audit trail.
| **No identity spoofing of the roster.** A viewer cannot publish a mirror; a peer cannot claim another's stream | role checks (`not_sharer`, `not_viewer`), stream ids are server-assigned | `test_one_peer_cannot_impersonate_another`, `test_a_viewer_cannot_publish` | | **No identity spoofing of the roster.** A viewer cannot publish a mirror; a peer cannot claim another's stream | role checks (`not_sharer`, `not_viewer`), stream ids are server-assigned | `test_one_peer_cannot_impersonate_another`, `test_a_viewer_cannot_publish` |
| **No oversized signaling.** Frames above 64 KiB are dropped before parsing | `MAX_MESSAGE_BYTES` | `test_an_oversized_frame_is_refused` | | **No oversized signaling.** Frames above 64 KiB are dropped before parsing | `MAX_MESSAGE_BYTES` | `test_an_oversized_frame_is_refused` |
| **No protocol fuzzing into a crash.** Bad JSON, non-object frames, unknown types and unknown roles produce an error frame; only fatal ones close the socket | `Hub.serve`/`_dispatch` | `TestSignalingSocket` | | **No protocol fuzzing into a crash.** Bad JSON, non-object frames, unknown types and unknown roles produce an error frame; only fatal ones close the socket | `Hub.serve`/`_dispatch` | `TestSignalingSocket` |
| **No remote audio, no remote mute override.** The layout carries no sound field, and every audible state traces to a gesture on the device that plays it | the layout model has no audio field, plus the `kiosk.js`/`remote.js` source guards | `test_kiosk_never_publishes_an_audio_field`, `test_a_controller_is_media_free`, `test_m_flips_the_staged_videos_muted_flag`, `test_only_the_staged_mirror_can_be_unmuted` |
| **No leaked peer connections.** Every teardown path notifies every viewer | `Registry._drop_stream`, `leave()` | `test_sharer_disconnect_removes_the_mirror`, `test_unpublish_clears_the_tile_for_every_viewer` | | **No leaked peer connections.** Every teardown path notifies every viewer | `Registry._drop_stream`, `leave()` | `test_sharer_disconnect_removes_the_mirror`, `test_unpublish_clears_the_tile_for_every_viewer` |
| **Nothing leaves the network.** No CDN, no remote font, no analytics, no STUN by default | A10/A4 + a build-failing test | `test_nothing_is_loaded_from_a_cdn`, `test_config_does_not_leak_server_secrets` | | **Nothing leaves the network.** No CDN, no remote font, no analytics, no STUN by default | A10/A4 + a build-failing test | `test_nothing_is_loaded_from_a_cdn`, `test_config_does_not_leak_server_secrets` |
| **The server cannot see your screen.** Media is SRTP between peers; the server relays JSON only | A3 | Architectural: no media code exists in Python | | **The server cannot see your screen.** Media is SRTP between peers; the server relays JSON only | A3 | Architectural: no media code exists in Python |
+19 -16
View File
@@ -1,7 +1,7 @@
# Kiosk client — Fedora Silverblue # Kiosk client — Fedora Silverblue
The target client for a wall display is **Fedora Silverblue running GNOME Kiosk, 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 fullscreen in Firefox.** One box takes about five minutes over SSH and needs no
keyboard, no monitor access, and no console. 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: other than the production wall, pass the URL:
```bash ```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 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` 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 intent, so a controller and a display converge on one state instead of
disagreeing (`m` is local-only — a display never becomes audible because disagreeing (`m` is local-only — a display never becomes audible because
another machine asked). The **controller side** — a page a person on another another machine asked). The **controller side** is `/remote`: a page a person
machine drives the wall from — arrives in phase 09 (`/remote`); until then a on another machine (a phone) opens to list the live mirrors, read the wall's
controller is any client that can send an `arrange` frame on `/ws`, `websocat` state, and send the decisions — show this, back to the wall, full screen. Any
included. `GET /api/kiosk` is the state's read surface: what is on stage, client that can send an `arrange` frame on `/ws` is a controller too,
whether it is fullscreen, and how many displays (`followers`) are obeying it. `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 **The migration rule, stated honestly.** Existing clients keep their own
`MIRROR_KIOSK_URL`: an existing `kiosk.conf` URL wins over the built-in `MIRROR_KIOSK_URL` until an operator re-points it and restarts `gdm`, because
default, and the installer's default still points at `/watch` in this phase. the installer deliberately preserves a client's URL: an existing `kiosk.conf`
So **nothing changes under a box until its `kiosk.conf` is re-pointed and URL wins over the built-in default (which now points at `/kiosk`). So
`gdm` restarted**: **nothing changes under a box until its `kiosk.conf` is re-pointed and `gdm`
restarted**:
```bash ```bash
sudo sed -i 's|^MIRROR_KIOSK_URL=.*|MIRROR_KIOSK_URL="https://…/kiosk"|' \ 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 (`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) — `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 `gdm` is the clean restart when you want the whole session back.) Both walls
page serves `<title>Wall · mirror`; `check-kiosk.sh`, untouched in this are served by the same tree; `check-kiosk.sh` accepts both titles
phase, still greps for `<title>Watch · mirror`, and the checker is widened to (`<title>Watch · mirror</title>` and `<title>Wall · mirror</title>`) and
accept the kiosk title in phase 09, when the default URL moves with it. 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` | | 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 | | 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` | | 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 | | 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 | | 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` | | 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) "$HOME_DIR/.config/mirror/kiosk.conf" 2>/dev/null | head -1)
ok "kiosk URL: ${URL:-<empty>}" ok "kiosk URL: ${URL:-<empty>}"
case "$URL" in case "$URL" in
*/watch) : ;; */watch|*/kiosk) : ;;
"") bad "kiosk URL is empty" ;; "") 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 esac
else else
bad "kiosk config $HOME_DIR/.config/mirror/kiosk.conf is missing" 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) CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 "$URL" 2>/dev/null)
if printf '%s' "$BODY" | grep -q "Watch · mirror"; then if printf '%s' "$BODY" | grep -q "Watch · mirror"; then
ok "$URL serves the mirror watch page (HTTP $CODE)" 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 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." 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)" info "title: $(printf '%s' "$BODY" | sed -n 's/.*<title>\(.*\)<\/title>.*/\1/p' | head -1)"
fi fi
+1 -1
View File
@@ -25,7 +25,7 @@ if [ -r "$CONF" ]; then
. "$CONF" . "$CONF"
fi 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}" PROFILE="${MIRROR_KIOSK_PROFILE:-$HOME/.mirror-kiosk}"
LOG="${MIRROR_KIOSK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/mirror-kiosk.log}" LOG="${MIRROR_KIOSK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/mirror-kiosk.log}"
POLL="${MIRROR_KIOSK_POLL:-5}" POLL="${MIRROR_KIOSK_POLL:-5}"
+4 -1
View File
@@ -17,7 +17,10 @@ set -eu
KIOSK_USER="${KIOSK_USER:-kiosk}" KIOSK_USER="${KIOSK_USER:-kiosk}"
# The default is for a NEW client only. An existing client keeps its own URL # 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. # 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_URL="${KIOSK_URL:-}"
KIOSK_SESSION="gnome-kiosk-script-wayland" KIOSK_SESSION="gnome-kiosk-script-wayland"
HERE=$(cd "$(dirname "$0")" && pwd) 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 # 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. # 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 # The controllable wall. `/kiosk` is the display endpoint: it obeys the shared
# tap to get anywhere, which is wrong for a display nobody is standing at. # layout state, so a device with a keyboard (the /remote page, or any
MIRROR_KIOSK_URL="https://mirror.reeseapps.com/watch" # `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): # Optional overrides (uncomment to change):
+8 -1
View File
@@ -316,6 +316,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
itself from the shared layout (phase 07/08).""" itself from the shared layout (phase 07/08)."""
return _page("kiosk.html") return _page("kiosk.html")
@app.get("/remote", response_class=HTMLResponse, include_in_schema=False)
async def remote_page() -> FileResponse:
"""The controller: lists mirrors and drives the shared layout. Shows no
media and subscribes to nothing — a controller must not cost a sharer
bandwidth (PLAN.md §6)."""
return _page("remote.html")
@app.get("/api/config") @app.get("/api/config")
async def client_config() -> JSONResponse: async def client_config() -> JSONResponse:
return JSONResponse( return JSONResponse(
@@ -356,7 +363,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
return return
await hub.serve(websocket) await hub.serve(websocket)
PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk") PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk", "/remote")
@app.middleware("http") @app.middleware("http")
async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def] async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
+96
View File
@@ -683,6 +683,102 @@ body.staged .stage { display: block; }
pointer-events: none; pointer-events: none;
} }
/* --------------------------------------------------------------- remote */
/* /remote is a controller: a readout, the decision buttons and a list of
rows — no video, no tiles (PLAN.md §6). It is the phone page of the app,
so it must stay usable down to 320 px, where the share page's narrow-
viewport rules are the model: wrap, ellipsis, never run off the edge. */
/* The readout: one strip under the bar in the monospace face — what the
server says the wall is doing, verbatim. `aria-live` on the section
(markup) is what makes it speak to a screen reader; the CSS only styles. */
.wall-state {
margin: 14px 16px 0;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--bg-2);
font-family: var(--mono);
font-size: 13px;
color: var(--fg-dim);
}
.wall-state #remote-staged { color: var(--fg); }
/* The decision buttons: one row that wraps, so at 320 px they stack and
stay reachable instead of running off the edge. */
.remote-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 14px 16px 0;
}
/* The "on" state of the Full screen button: the same accent treatment the
tile's audio button gets when its aria-pressed is true, so "on" looks
the same everywhere in this app. */
#remote-fullscreen[aria-pressed="true"] {
border-color: var(--accent);
color: var(--accent);
}
/* The one-line hint under the controls: "no display following yet" (the
state is stored — a display pointed at /kiosk picks it up) and
"offline". The global `[hidden]` rule is what hides it; no rule of its
own may fight that. */
.remote-hint {
margin: 10px 16px 0;
font-family: var(--mono);
font-size: 12px;
color: var(--fg-dim);
}
/* A readout that is a snapshot, not a statement: the socket dropped, so the
last staged label is shown dimmed until the next welcome re-syncs it. */
.wall-state #remote-staged[data-stale] {
opacity: 0.55;
}
/* One row per mirror: title + sub + the decision. `flex-wrap` is the 320 px
escape hatch (the button drops under the title rather than clipping), and
`min-width: 0` on the grow is what lets the title ellipsis instead of
pushing the button out. */
.mirror-list {
list-style: none;
margin: 14px 16px 0;
padding: 0;
display: grid;
gap: 10px;
}
.mirror-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 12px;
}
.mirror-row .grow { flex: 1; min-width: 0; }
.mirror-row .title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mirror-row .sub {
color: var(--fg-dim);
font-family: var(--mono);
font-size: 11.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; } * { animation: none !important; transition: none !important; }
} }
+8
View File
@@ -51,6 +51,14 @@
itself from the shared layout. No pointer, no keyboard, nothing to set up at the box. itself from the shared layout. No pointer, no keyboard, nothing to set up at the box.
</p> </p>
</a> </a>
<a class="card" href="/remote">
<div class="kicker">GET /remote</div>
<h2>Point the wall from another device</h2>
<p>
Open it on a laptop or a phone and drive the shared wall: list the live mirrors,
show one on the display, back to the wall, full screen — no video, no bandwidth.
</p>
</a>
</div> </div>
<div class="livebar" id="livebar">Checking for mirrors…</div> <div class="livebar" id="livebar">Checking for mirrors…</div>
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>Remote · mirror</title>
<link rel="stylesheet" href="/static/app.css" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='12' fill='%234fd1c5'/%3E%3C/svg%3E"
/>
</head>
<!-- A controller, not a viewer: this page shows no media and subscribes to
nothing (PLAN.md §6 — the mesh uploads N × bitrate per sharer, so a
controller that streamed would cost every sharer a leg). It lists the
live mirrors and the wall's state, and sends the decisions as
`arrange` frames. On the wire it is a plain viewer — no `kiosk`
flag — so `followers` counts displays only, which is what makes
"no display following" mean what it says. -->
<body class="remote">
<header class="bar">
<div class="brand"><span class="glyph"></span> mirror</div>
<div class="spacer"></div>
<span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span>
</header>
<div class="notice" id="notice" hidden></div>
<!-- The readout: what the server says the wall is doing, verbatim —
"Wall: <label>" (or "Wall: no mirror"), a fullscreen marker, and the
follower clause. Each piece has a stable hook so the script and the
tests never have to parse a sentence. The marker span is
`remote-fullscreen-mark` (not `remote-fullscreen`): the Full screen
*button* below owns that id, and an id declared twice on one page
fails the uniqueness guard. -->
<section class="wall-state" id="remote-state" aria-live="polite">
Wall: <span id="remote-staged">no mirror</span
><span id="remote-fullscreen-mark" hidden> · fullscreen</span
><span id="remote-followers"> · no display following</span>
</section>
<!-- The three decisions. Task 02 makes them send `arrange` frames and
reflects the server's state; they never paint a result locally. -->
<div class="remote-controls">
<button class="btn" id="remote-back">Back to the wall</button>
<button class="btn ghost" id="remote-fullscreen" aria-pressed="false">Full screen</button>
</div>
<!-- The one-line hint under the controls. It appears when the readout
says "no display following" (the state is still stored, so a display
pointed at /kiosk picks it up immediately) and when the socket is
offline. It never disables the controls: the layout is server-side.
The hint is hidden by the global `[hidden]` rule, as everything else. -->
<p class="remote-hint" id="remote-hint" hidden></p>
<ul class="mirror-list" id="remote-list" hidden></ul>
<div class="empty" id="remote-empty">
<h2>No mirrors live</h2>
<p>
Share from <code id="remote-share-url">…</code>
</p>
</div>
<!-- One row per mirror: title + sub + the decision. No media. -->
<template id="remote-row-template">
<li class="mirror-row" data-stream="">
<div class="grow">
<div class="title">Shared screen</div>
<div class="sub"></div>
</div>
<button class="btn ghost show">Show on wall</button>
</li>
</template>
<div class="toast" id="toast" hidden></div>
<script type="module" src="/static/remote.js"></script>
</body>
</html>
+308
View File
@@ -0,0 +1,308 @@
/* The remote page: the hand that drives the wall.
It lists the live mirrors and the wall's current state, and offers the
three decisions (show this, back to the wall, full screen) as `arrange`
frames the display obeys. The page's correctness rests on one rule: the
only thing it displays about the wall is what the server last told it.
A control never paints a result locally and hopes the server agrees —
with two controllers open, an optimistic paint is a lie the moment the
other one acts, and a lie in the readout is worse than a 40 ms delay.
What this page never does: show media, or ask for a stream's track —
the mesh uploads N × bitrate per sharer, so a controller that consumed
a leg just to know what to point at would cost everyone (PLAN.md §6).
It keeps a list of mirrors, not tiles: no <video> element, no media
connection, no read of the screen-capture APIs — the "no media in a
controller" guards in test_app.py enforce it. On the wire it is a plain
viewer: no `kiosk` flag in its hello, which is what makes `followers`
count displays only, and "no display following" mean what it says.
There is no key map on this page (AGENTS.md §6): the buttons are native,
focusable and phone-tappable, and a second copy of the wall's key map
would be a second thing to keep in sync. */
import {
$,
connectSignaling,
deviceLabel,
loadConfig,
refusalNotice,
showNotice,
toast,
} from "./net.js";
const config = await loadConfig();
// The element-existence guard (test_every_element_the_script_reaches_for_exists)
// enforces that every one of these lives in remote.html; the control hooks
// are additionally asserted on the markup (test_the_remote_page_declares_its_control_hooks)
// so this page and its markup cannot drift.
const ui = {
state: $("#remote-state"),
staged: $("#remote-staged"),
fullscreenMark: $("#remote-fullscreen-mark"),
followers: $("#remote-followers"),
back: $("#remote-back"),
fullscreen: $("#remote-fullscreen"),
list: $("#remote-list"),
template: $("#remote-row-template"),
empty: $("#remote-empty"),
shareUrl: $("#remote-share-url"),
hint: $("#remote-hint"),
net: $("#net"),
notice: $("#notice"),
};
// The controller is usually the device that goes and asks somebody to share.
if (ui.shareUrl) ui.shareUrl.textContent = `${location.origin}/mirror`;
function setNet(next, detail) {
if (!ui.net) return;
ui.net.dataset.state = next;
const label = ui.net.querySelector("span");
if (label) label.textContent = detail || next;
}
const state = {
ws: null,
// The socket dropped: the readout is a snapshot of the last layout frame,
// not a statement about the wall, until the next welcome re-syncs it.
offline: false,
// A layout frame has landed: the follower clause may only be reported as
// fact after the server has actually said it.
synced: false,
// The mirror that was on the wall died: its label, held so the layout
// frame that clears the stage can say "<label> ended — back to the wall"
// exactly once.
endedStagedLabel: null,
};
/** id -> {id, name, label, displaySurface, width, height, audio}, in server
* roster order (welcome.streams is oldest-first, stream_added appends). The
* controller keeps a list, not tiles: no <video>, no media stream, no
* media connection — see the "no media in a controller" guard in
* test_app.py. */
const mirrors = new Map();
let layout = { staged: null, fullscreen: false, followers: 0 };
/* -------------------------------------------------------------- rendering */
function titleOf(stream) {
return stream.label || stream.name || "Shared screen";
}
function surfaceShort(setting) {
return { window: "window", monitor: "screen", browser: "tab" }[setting] || setting || "";
}
// The same words wall.js's paintTile() prints, so the two pages describe a
// mirror identically: `name · surface · W×H · audio|silent`.
function subOf(stream) {
const dims = stream.width && stream.height ? `${stream.width}×${stream.height}` : "";
const parts = [surfaceShort(stream.displaySurface), dims, stream.audio ? "audio" : "silent"].filter(
Boolean,
);
return `${stream.name || "Unknown device"}${parts.length ? ` · ${parts.join(" · ")}` : ""}`;
}
function renderMirrors() {
const list = ui.list;
list.innerHTML = "";
for (const [id, stream] of mirrors) {
const row = ui.template.content.firstElementChild.cloneNode(true);
row.dataset.stream = id;
row.querySelector(".title").textContent = titleOf(stream);
row.querySelector(".sub").textContent = subOf(stream);
const show = row.querySelector(".show");
if (layout.staged === id) {
row.dataset.on = "";
show.textContent = "On the wall";
show.setAttribute("aria-current", "true");
} else {
show.textContent = "Show on wall";
}
list.appendChild(row);
}
const any = mirrors.size > 0;
ui.empty.hidden = any;
list.hidden = !any;
}
function renderHint() {
// The controls stay enabled in every state: the layout is stored
// server-side, so a display pointed at /kiosk picks it up the moment it
// connects — the hint says that, it never disables anything.
if (state.offline) {
ui.hint.textContent = "offline — the wall is not following you";
ui.hint.hidden = false;
return;
}
if (state.synced && (layout.followers || 0) === 0) {
ui.hint.textContent =
"no display following yet — the state is stored, so a display pointed at /kiosk picks it up";
ui.hint.hidden = false;
} else {
ui.hint.hidden = true;
}
}
function renderState() {
// The readout is filled from the last layout frame and nothing else.
const stagedId = layout.staged;
if (stagedId === null) {
ui.staged.textContent = "no mirror";
} else if (mirrors.has(stagedId)) {
ui.staged.textContent = titleOf(mirrors.get(stagedId));
} else {
// The frame in transit: the mirror is gone and the server has not
// cleared the stage yet.
ui.staged.textContent = "(gone)";
}
// Fullscreen is reported, not promised: the marker and the button both
// say what the state asks for, and only the state.
const full = layout.fullscreen === true;
ui.fullscreenMark.hidden = false;
ui.fullscreenMark.textContent = full ? " · fullscreen: on" : " · fullscreen: off";
ui.fullscreen.setAttribute("aria-pressed", String(full));
ui.fullscreen.textContent = full ? "Full screen: on" : "Full screen: off";
const n = layout.followers || 0;
ui.followers.textContent =
n === 0 ? " · no display following" : ` · ${n} display${n === 1 ? "" : "s"} following`;
renderHint();
}
/* ------------------------------------------------------------------ intent */
/**
* Send an intent and let the server's `layout` frame paint it. Never update
* `layout` here: with two controllers open, an optimistic local paint is a
* lie the moment the other one acts, and a lie in the readout is worse
* than a 40 ms delay.
*/
function arrange(patch) {
if (!state.ws.send({ type: "arrange", ...patch })) {
toast("not connected — nothing sent");
}
}
// One listener on the list container (event delegation, the way watch.js
// handles per-tile buttons): the rows are re-created on every roster frame,
// so a per-row listener would be orphaned by the very render it served.
ui.list.addEventListener("click", (event) => {
const button = event.target instanceof Element ? event.target.closest(".show") : null;
const row = button && button.closest(".mirror-row");
if (!row || !row.dataset.stream) return;
arrange({ staged: row.dataset.stream });
});
// The two page-level buttons live for the page's whole life: one listener
// each, wired once.
ui.back.addEventListener("click", () => arrange({ staged: null }));
ui.fullscreen.addEventListener("click", () => {
// Toggle from the server-reported value, never from the button's own
// aria-pressed: with two controllers open, the button may be mid-paint.
arrange({ fullscreen: !layout.fullscreen });
});
/* ------------------------------------------------------------------ socket */
state.ws = connectSignaling({
// A refused address never opens a socket (net.js says so via the stub);
// the notice is the one thing the page owes the person holding the phone.
refused: !!config.refused,
refusedReason: config.refusedMessage,
onOpen() {
// A plain viewer on the wire: role "viewer" and NO `kiosk` flag, so the
// layout's `followers` count (displays only) never includes this page.
state.ws.send({ type: "hello", role: "viewer", name: "", device: deviceLabel() });
},
onStatus(next, detail) {
setNet(next, detail);
},
onClose() {
// The readout is a snapshot now, not a statement: mark it stale and say
// the wall is no longer following. A fresh welcome after reconnect
// re-syncs everything and clears the mark.
state.offline = true;
ui.staged.setAttribute("data-stale", "");
renderHint();
},
onMessage(message) {
switch (message.type) {
case "welcome": {
// Arrival is a full re-sync: the roster and the layout arrive in
// one frame, so a reload (or a reconnect) lands already arranged.
mirrors.clear();
for (const stream of message.streams || []) mirrors.set(stream.id, stream);
layout = message.layout || { staged: null, fullscreen: false, followers: 0 };
state.offline = false;
state.synced = true;
state.endedStagedLabel = null;
ui.staged.removeAttribute("data-stale");
renderMirrors();
renderState();
break;
}
case "stream_added":
case "stream_updated": {
const stream = message.stream;
if (stream && stream.id) mirrors.set(stream.id, stream);
// An update can change the readout without changing the list: the
// staged mirror's label sits in it.
renderMirrors();
renderState();
break;
}
case "stream_removed": {
const id = message.streamId;
const stream = mirrors.get(id);
if (stream && layout.staged === id) {
// The mirror that was on the wall died: hold its label so the
// layout frame that clears the stage can say so.
state.endedStagedLabel = titleOf(stream);
}
mirrors.delete(id);
renderMirrors();
renderState();
break;
}
case "layout": {
layout = message;
state.synced = true;
if (layout.staged === null && state.endedStagedLabel) {
// On a controller the change is invisible otherwise: the row is
// already gone, and nothing is "on the wall" now.
toast(`${state.endedStagedLabel} ended — back to the wall`);
state.endedStagedLabel = null;
}
renderState();
renderMirrors();
break;
}
case "signal":
// This page never negotiates: a signal is somebody else's
// conversation, and it stays ignored even when a sharer misbehaves.
break;
case "error":
// An `unknown_stream` from a stale row is exactly the case where
// silence would look like a broken button.
toast(message.message || message.code);
break;
default:
break;
}
},
});
// Same rule as the other pages: if the server refused this address, say so
// rather than showing a controller that will never reach the wall.
const refusal = refusalNotice(config);
if (refusal) showNotice(ui.notice, refusal);
// The settled starting line: the empty roster and the idle layout, so the
// first paint agrees with the markup before any frame has landed.
renderMirrors();
renderState();
+1
View File
@@ -17,6 +17,7 @@
<span class="meta" id="counts">no mirrors</span> <span class="meta" id="counts">no mirrors</span>
<div class="spacer"></div> <div class="spacer"></div>
<span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span> <span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span>
<a class="btn ghost" id="remote-link" href="/remote">Drive the wall</a>
<a class="btn ghost" id="share-link" href="/mirror">Share a screen</a> <a class="btn ghost" id="share-link" href="/mirror">Share a screen</a>
</header> </header>
+423
View File
@@ -0,0 +1,423 @@
"""End-to-end: a hand on another machine drives the /kiosk display.
Phase 09, task 03: the whole loop the TODO item claims — a mirror on the wall,
a box across the room with no keyboard, and a person on a laptop or phone who
can put a mirror on the stage, send it fullscreen, or put the wall back. The
controller is asserted *on the display's DOM and on the server's numbers*,
never on the controller's optimism (a page that paints a result and hopes is
the regression this suite exists to catch), and the media is asserted as
decoded, changing pixels on the display's staged ``<video>`` (AGENTS.md §5).
Unlike the kiosk suite, this one **clicks**: a person with a mouse driving a
machine that has none is the entire feature. Isolated contexts are the unit
of "a device": each is its own socket and its own peer id, which is exactly
what "another client" means to the server. Uncaught page errors and console
errors are captured from the first line of every test and surfaced through
the ``errors == []`` assertion.
Two invariants are asserted here because they are the product's, not the
browser's:
* **A controller costs a sharer nothing** — with only ``/remote`` open, the
stream reports ``viewers: 0`` and the server's ``watching`` count is 0: the
controller never subscribes and never opens a media leg (PLAN.md §6's mesh
cost). Opening a real ``/watch`` is what moves those numbers.
* **``followers`` counts displays, never controllers** — a second ``/remote``
does not raise the count, so "no display following" keeps meaning what it
says.
Fullscreen is asserted on the page's own knobs — ``body[data-fullscreen]``
and the server's state — and **never on ``document.fullscreenElement``**:
headless Chromium may refuse an element ``requestFullscreen()`` without user
activation, and the real kiosk's window manager forces the window fullscreen
anyway (``docs/kiosk-silverblue/window-config.ini``). Asserting on the
browser's concession would turn this suite into a flake.
"""
from __future__ import annotations
import time
import httpx
import pytest
from playwright.sync_api import expect
from .conftest import frames_matching, start_sharing, stop_sharing
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
@pytest.fixture
def device(base_url, browser, request):
"""Open a page as its own device (isolated context, fresh peer id).
Uncaught page errors and console errors are captured from the first line
of the test and surfaced through the ``errors == []`` assertion
(AGENTS.md §8). Contexts are closed by finalizers, so a leaked peer here
cannot surface as a tile count in another suite's test (they share one
session-scoped server).
"""
opened = []
def _open(path: str = "/", errors: list | None = None):
context, page = open_device(base_url, browser, request, path, errors)
opened.append(context)
return page
return _open
def open_device(base_url, browser, request, path: str, errors: list | None = None):
"""Open one device; return ``(context, page)``.
The context is closed exactly once — by a finalizer at test end, or by
the test itself (the guard flag is what makes a finalizer after an
explicit close a no-op).
"""
context = browser.new_context(viewport={"width": 1100, "height": 720})
closed = [False]
def _finalize() -> None:
if not closed[0]:
closed[0] = True
context.close()
request.addfinalizer(_finalize)
page = context.new_page()
if errors is not None:
page.on("pageerror", lambda error: errors.append(f"pageerror: {error}"))
page.on(
"console",
lambda message: (
errors.append(f"console: {message.text}") if message.type == "error" else None
),
)
page.goto(f"{base_url}{path}", wait_until="domcontentloaded")
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
return context, page
def mirror_ids(base_url, count: int, timeout: float = 15.0) -> list[str]:
"""The server's truth about what is live (AGENTS.md §8, rung 1)."""
deadline = time.time() + timeout
last: list[str] = []
while time.time() < deadline:
payload = httpx.get(f"{base_url}/api/streams", timeout=5).json()
last = [mirror["id"] for mirror in payload["mirrors"]]
if len(last) == count:
return last
time.sleep(0.2)
raise AssertionError(f"expected {count} mirror(s) on /api/streams, saw {last}")
def streams_api(base_url) -> dict:
return httpx.get(f"{base_url}/api/streams", timeout=5).json()
def kiosk_state(base_url) -> dict:
return httpx.get(f"{base_url}/api/kiosk", timeout=5).json()
def wait_for_kiosk(base_url, predicate, what: str, timeout: float = 15.0) -> dict:
deadline = time.time() + timeout
last: dict = {}
while time.time() < deadline:
last = kiosk_state(base_url)
if predicate(last):
return last
time.sleep(0.2)
raise AssertionError(f"{what}; last /api/kiosk was {last}")
def wait_for_streams(base_url, predicate, what: str, timeout: float = 15.0) -> dict:
deadline = time.time() + timeout
last: dict = {}
while time.time() < deadline:
last = streams_api(base_url)
if predicate(last):
return last
time.sleep(0.2)
raise AssertionError(f"{what}; last /api/streams was {last}")
def wait_row(remote, timeout: int = 15_000) -> None:
"""The controller's list holds one row: `welcome` has landed."""
expect(remote.locator("#remote-list .mirror-row")).to_have_count(1, timeout=timeout)
class TestTheLoop:
def test_show_on_wall_and_back_to_the_wall(self, device, base_url):
"""The whole loop: a click on the controller arranges the display, and
"back to the wall" undoes it — asserted on the display, not the click."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
expect(display.locator("body")).to_have_attribute("data-layout", "wall")
controller = device("/remote", errors)
wait_row(controller)
# The row the click lands on names the live mirror.
assert controller.locator(".mirror-row").get_attribute("data-stream") == stream_id
# One click: "Show on wall". The display must obey, on the server's
# numbers as well as its own DOM.
controller.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect(display.locator(f"#kiosk-stage .tile[data-stream='{stream_id}']")).to_have_count(
1, timeout=15_000
)
state = wait_for_kiosk(base_url, lambda s: s["staged"] == stream_id, "the display staged")
assert state["staged"] == stream_id, state
# The controller reflects the server's state, not its own click: the
# row says the mirror is on the wall.
expect(controller.locator(".mirror-row[data-on]")).to_have_count(1, timeout=15_000)
controller.locator("#remote-back").click()
expect(display.locator("body")).to_have_attribute("data-layout", "wall", timeout=15_000)
expect(display.locator("#kiosk-stage .tile")).to_have_count(0)
wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the display unstaged")
assert errors == [], f"uncaught browser errors: {errors}"
def test_the_controller_moves_real_media_to_the_display(self, device, base_url):
"""Pixels, not classes: the assertion is decoded, *changing* frames on
the display's staged <video> (AGENTS.md §5) — a CSS class would prove
nothing crossed the wire."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
controller = device("/remote", errors)
wait_row(controller)
controller.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect(
display.locator(f"#kiosk-stage .tile[data-stream='{stream_id}'][data-state='live']")
).to_have_count(1, timeout=25_000)
frames = frames_matching(display, "#kiosk-stage video")
assert frames["width"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["height"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["distinct"] >= 2, f"the staged video is frozen: {frames}"
assert errors == [], f"uncaught browser errors: {errors}"
class TestFullscreen:
def test_fullscreen_is_reported_not_promised(self, device, base_url):
"""The controller requests fullscreen; the display obeys in its state.
Never assert ``document.fullscreenElement`` here: headless Chromium
may refuse an element ``requestFullscreen()`` without user activation,
and the real box's window manager forces the window fullscreen anyway
(``docs/kiosk-silverblue/window-config.ini``). The page's own knob and
the server's state are what the app controls; the browser's
concession is a bonus, not the guarantee."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
mirror_ids(base_url, 1)
display = device("/kiosk", errors)
controller = device("/remote", errors)
wait_row(controller)
# Normalize the shared server state: the suites share one session
# server, and the kiosk suite's first test leaves `fullscreen: true`
# behind (a teardown resets `staged`, never `fullscreen`). The button
# toggles from the server's value, so a leaked true would make this
# click flip it to false — reset it through the product, the way a
# controller would.
if kiosk_state(base_url)["fullscreen"]:
controller.locator("#remote-fullscreen").click()
wait_for_kiosk(base_url, lambda s: s["fullscreen"] is False, "the state normalized")
controller.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
controller.locator("#remote-fullscreen").click()
state = wait_for_kiosk(
base_url, lambda s: s["fullscreen"] is True, "the server's state went fullscreen"
)
assert state["fullscreen"] is True, state
expect(display.locator("body")).to_have_attribute("data-fullscreen", "true", timeout=15_000)
# The controller's button reads the server's value, not its own click.
expect(controller.locator("#remote-fullscreen")).to_have_attribute("aria-pressed", "true")
assert errors == [], f"uncaught browser errors: {errors}"
class TestConvergence:
def test_two_controllers_converge_without_touching_the_second(self, device, base_url):
"""One acts, the other was never touched: its readout and the
``data-on`` row update from the server's frame, so two open
controllers can never disagree (task 02's no-optimistic-paint rule,
proven from the outside)."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
first = device("/remote", errors)
second = device("/remote", errors) # nothing is ever clicked here
wait_row(first)
wait_row(second)
first.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
# The untouched controller: its row carries the marker and the button
# reads the server's state.
row = second.locator(".mirror-row[data-on]")
expect(row).to_have_count(1, timeout=15_000)
assert row.get_attribute("data-stream") == stream_id
expect(row.locator(".show")).to_have_text("On the wall")
# Its readout agrees too: the staged mirror's title is the row's title.
title = row.locator(".title").inner_text()
expect(second.locator("#remote-staged")).to_have_text(title, timeout=15_000)
# And the server, which the display obeys, names the same mirror.
state = kiosk_state(base_url)
assert state["staged"] == stream_id, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestACostsNothing:
def test_a_controller_costs_the_sharer_nothing(self, device, base_url):
"""The guard that keeps /remote from becoming a stealth watcher:
the controller never subscribes, so the sharer uploads nothing for
it — the per-stream ``viewers`` and the server's ``watching`` count
stay at zero until a real ``/watch`` joins (PLAN.md §6)."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
def only(stream_id):
def check(payload):
mirror = next(m for m in payload["mirrors"] if m["id"] == stream_id)
return mirror["viewers"] == 0 and payload["stats"]["watching"] == 0
return check
# The baseline: nothing is watching before anyone opens a page.
wait_for_streams(base_url, only(stream_id), "the idle baseline")
controller = device("/remote", errors)
wait_row(controller) # the page is connected and rendered…
# …and still consumes nothing: no subscription, no leg.
wait_for_streams(base_url, only(stream_id), "the controller not counted")
device("/watch", errors)
wait_for_streams(
base_url,
lambda payload: (
next(m for m in payload["mirrors"] if m["id"] == stream_id)["viewers"] == 1
and payload["stats"]["watching"] == 1
),
"the real watcher counted",
)
assert errors == [], f"uncaught browser errors: {errors}"
class TestFollowers:
def test_followers_and_the_readout_agree_and_a_controller_is_not_a_display(
self, device, base_url
):
"""`/api/kiosk` and the controller's `#remote-followers` read the same
number at 0, at 1 with a display open, and still at 1 with a second
/remote added — a controller is not a display, so "1 display
following" keeps meaning what it says."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
assert kiosk_state(base_url)["followers"] == 0, "the suite must start with no displays"
controller = device("/remote", errors)
wait_row(controller) # welcome has landed: the readout is the server's
assert kiosk_state(base_url)["followers"] == 0
expect(controller.locator("#remote-followers")).to_contain_text("no display following")
device("/kiosk", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the display counted")
expect(controller.locator("#remote-followers")).to_contain_text(
"1 display following", timeout=15_000
)
second = device("/remote", errors)
wait_row(second)
assert kiosk_state(base_url)["followers"] == 1, "a controller is not a display"
expect(second.locator("#remote-followers")).to_contain_text(
"1 display following", timeout=15_000
)
assert errors == [], f"uncaught browser errors: {errors}"
class TestTheDisplayIsAControllerToo:
def test_a_keypress_on_the_display_moves_the_remote_readout(self, device, base_url):
"""The TODO's "any client can manipulate", in the direction that would
otherwise be forgotten: with a keyboard plugged in to the box, `n`
stages a mirror *and* publishes the intent, so the controller — which
receives no keypress — follows it."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
controller = device("/remote", errors)
wait_row(controller)
expect(controller.locator("#remote-staged")).to_have_text("no mirror")
# The one input in this test: a keypress on the display.
display.keyboard.press("n")
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
# The untouched controller follows: its readout names the staged
# mirror and its row carries the marker.
row = controller.locator(".mirror-row")
title = row.locator(".title").inner_text()
expect(controller.locator("#remote-staged")).to_have_text(title, timeout=15_000)
expect(row).to_have_count(1)
expect(controller.locator(".mirror-row[data-on]")).to_have_count(1, timeout=15_000)
assert row.get_attribute("data-stream") == stream_id
assert errors == [], f"uncaught browser errors: {errors}"
class TestGoneIsAState:
def test_an_ended_mirror_unstages_the_display_and_clears_the_controller(self, device, base_url):
"""Gone is a state: when the mirror ends, the display falls back to
the wall and the controller's row and readout clear — no stale tile,
no stale readout, no stale server state."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
controller = device("/remote", errors)
wait_row(controller)
controller.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect(display.locator(f"#kiosk-stage .tile[data-stream='{stream_id}']")).to_have_count(1)
stop_sharing(sharer)
# No display sits on a dead tile.
expect(display.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(display.locator("#kiosk-stage .tile")).to_have_count(0)
# The controller: the row is gone and the readout says no mirror.
expect(controller.locator("#remote-list .mirror-row")).to_have_count(0, timeout=10_000)
expect(controller.locator("#remote-staged")).to_have_text("no mirror", timeout=10_000)
# And the server cleared the stage.
wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the layout cleared")
assert errors == [], f"uncaught browser errors: {errors}"
+167 -9
View File
@@ -81,6 +81,7 @@ class TestPages:
("/share", "Share my screen"), ("/share", "Share my screen"),
("/watch", "tile-template"), ("/watch", "tile-template"),
("/kiosk", "kiosk-tile-template"), ("/kiosk", "kiosk-tile-template"),
("/remote", "remote-row-template"),
], ],
) )
def test_pages_render(self, client, path, marker): def test_pages_render(self, client, path, marker):
@@ -89,7 +90,7 @@ class TestPages:
assert response.headers["content-type"].startswith("text/html") assert response.headers["content-type"].startswith("text/html")
assert marker in response.text assert marker in response.text
@pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk"]) @pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk", "/remote"])
def test_pages_are_not_cacheable(self, client, path): def test_pages_are_not_cacheable(self, client, path):
assert client.get(path).headers["cache-control"] == "no-store" assert client.get(path).headers["cache-control"] == "no-store"
@@ -115,7 +116,7 @@ class TestPages:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"asset", "asset",
["app.css", "net.js", "share.js", "watch.js", "wall.js", "kiosk.js"], ["app.css", "net.js", "share.js", "watch.js", "wall.js", "kiosk.js", "remote.js"],
) )
def test_static_assets_are_served(self, client, asset): def test_static_assets_are_served(self, client, asset):
assert client.get(f"/static/{asset}").status_code == 200 assert client.get(f"/static/{asset}").status_code == 200
@@ -131,7 +132,9 @@ class TestClientScripts:
NODE = shutil.which("node") NODE = shutil.which("node")
@pytest.mark.skipif(NODE is None, reason="node is not installed") @pytest.mark.skipif(NODE is None, reason="node is not installed")
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.js", "wall.js", "kiosk.js"]) @pytest.mark.parametrize(
"script", ["net.js", "share.js", "watch.js", "wall.js", "kiosk.js", "remote.js"]
)
def test_scripts_parse_as_es_modules(self, script, tmp_path): def test_scripts_parse_as_es_modules(self, script, tmp_path):
copy = tmp_path / "subject.mjs" copy = tmp_path / "subject.mjs"
copy.write_text((STATIC / script).read_text()) copy.write_text((STATIC / script).read_text())
@@ -144,6 +147,7 @@ class TestClientScripts:
("share.js", "mirror.html"), ("share.js", "mirror.html"),
("watch.js", "watch.html"), ("watch.js", "watch.html"),
("kiosk.js", "kiosk.html"), ("kiosk.js", "kiosk.html"),
("remote.js", "remote.html"),
], ],
) )
def test_every_element_the_script_reaches_for_exists(self, script, page): def test_every_element_the_script_reaches_for_exists(self, script, page):
@@ -240,11 +244,79 @@ class TestClientScripts:
f"an arrange frame carries an audio field: {literal}" f"an arrange frame carries an audio field: {literal}"
) )
def test_a_controller_is_media_free(self):
"""The /remote page shows no video and subscribes to nothing (PLAN.md §6).
The mesh gives every sharer one upload per connected leg, so a controller
that consumed a leg just to know what to point at would add an upload to
every sharer for every controller open. The promise is enforced at the
source level so a well-meant future "preview thumbnail" fails the build
instead of being caught in review: `remote.js` may never construct a media
connection or send a subscribe frame, may never hold a media stream or
read the screen-capture APIs, and `remote.html` may never embed a
`<video>`.
"""
source = (STATIC / "remote.js").read_text()
assert "RTCPeerConnection" not in source, (
"remote.js must not open a media connection: a controller that streams "
"costs every sharer a leg (PLAN.md §6)"
)
assert "MediaStream" not in source, (
"remote.js must not hold a media stream: the controller keeps a list, "
"not tiles — a preview is the regression this guard exists for"
)
assert "navigator.mediaDevices" not in source, (
"remote.js must not read the screen-capture APIs: the controller never "
"asks the browser for a surface"
)
assert 'type: "subscribe"' not in source and "type:'subscribe'" not in source, (
"remote.js must not send a subscribe frame: a controller subscribes to nothing"
)
html = (STATIC / "remote.html").read_text()
assert "<video" not in html, (
"remote.html must not embed a <video>: the controller shows no media"
)
def test_a_controller_sends_exactly_one_frame_type_besides_hello(self):
"""The controller's whole job is the `arrange` frame (plus the hello that
introduces it): the file must actually send one, and must not send any
other roster frame. The presence half matters: a guard that only bans
strings would pass on a controller whose buttons had gone dead."""
source = (STATIC / "remote.js").read_text()
assert 'type: "arrange"' in source, (
"remote.js sends no arrange frame: the controller's buttons are dead"
)
assert 'type: "subscribe"' not in source and "type:'subscribe'" not in source, (
"remote.js must not send a subscribe frame: a controller subscribes to nothing"
)
def test_the_remote_page_declares_its_control_hooks(self):
"""The readout, the buttons and the list are the contract task 02 and the
e2e suite code against; assert them on the markup so task 01's stub and
task 02's page cannot drift (the element guard covers the script→page
direction once remote.js looks them up)."""
html = (STATIC / "remote.html").read_text()
for element_id in (
"remote-state",
"remote-staged",
"remote-fullscreen-mark",
"remote-followers",
"remote-back",
"remote-fullscreen",
"remote-list",
"remote-row-template",
"remote-empty",
"remote-share-url",
"remote-hint",
):
assert f'id="{element_id}"' in html, f"remote.html is missing #{element_id}"
def test_the_pages_load_the_stylesheet_and_their_module(self): def test_the_pages_load_the_stylesheet_and_their_module(self):
for page, script in ( for page, script in (
("mirror.html", "share.js"), ("mirror.html", "share.js"),
("watch.html", "watch.js"), ("watch.html", "watch.js"),
("kiosk.html", "kiosk.js"), ("kiosk.html", "kiosk.js"),
("remote.html", "remote.js"),
): ):
html = (STATIC / page).read_text() html = (STATIC / page).read_text()
assert 'href="/static/app.css"' in html assert 'href="/static/app.css"' in html
@@ -396,23 +468,27 @@ class TestClientScripts:
This is not hypothetical: a `#stage` on both pages once made the share This is not hypothetical: a `#stage` on both pages once made the share
page's preview inherit `display: none` from the watch page's overlay. page's preview inherit `display: none` from the watch page's overlay.
The kiosk page's ids carry the `kiosk-` prefix for exactly this reason; The kiosk page's ids carry the `kiosk-` prefix and the remote page's the
the only ids it may share with another page are the shared chrome `remote-` prefix for exactly this reason; the only ids a page may share
(net, notice, toast). with another are the shared chrome (net, notice, toast).
""" """
shared = {"net", "notice", "toast"} shared = {"net", "notice", "toast"}
ids = { ids = {
page: set(re.findall(r'id="([A-Za-z0-9_-]+)"', (STATIC / page).read_text())) page: set(re.findall(r'id="([A-Za-z0-9_-]+)"', (STATIC / page).read_text()))
for page in ("mirror.html", "watch.html", "index.html", "kiosk.html") for page in ("mirror.html", "watch.html", "index.html", "kiosk.html", "remote.html")
} }
assert ids["mirror.html"] & ids["watch.html"] == shared assert ids["mirror.html"] & ids["watch.html"] == shared
assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"} assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"}
assert ids["kiosk.html"] & ids["watch.html"] == shared assert ids["kiosk.html"] & ids["watch.html"] == shared
assert ids["kiosk.html"] & ids["mirror.html"] == shared assert ids["kiosk.html"] & ids["mirror.html"] == shared
assert ids["remote.html"] & ids["watch.html"] == shared
assert ids["remote.html"] & ids["mirror.html"] == shared
assert ids["remote.html"] & ids["kiosk.html"] == shared
# index.html has no notice/toast (a signpost, not an app page), so its # index.html has no notice/toast (a signpost, not an app page), so its
# intersection with the kiosk is a subset of the shared chrome — the # intersection with an app page is a subset of the shared chrome — the
# same shape as the index/mirror assertion above. # same shape as the index/mirror assertion above.
assert ids["kiosk.html"] & ids["index.html"] <= shared assert ids["kiosk.html"] & ids["index.html"] <= shared
assert ids["remote.html"] & ids["index.html"] <= shared
for page, found in ids.items(): for page, found in ids.items():
assert len(found) == len(set(found)), f"{page} declares an id twice" assert len(found) == len(set(found)), f"{page} declares an id twice"
@@ -597,7 +673,16 @@ class TestAddressGate:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path", "path",
["/", "/mirror", "/watch", "/kiosk", "/api/streams", "/api/kiosk", "/static/app.css"], [
"/",
"/mirror",
"/watch",
"/kiosk",
"/remote",
"/api/streams",
"/api/kiosk",
"/static/app.css",
],
) )
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path): def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
with gated(self.ALLOW) as client: with gated(self.ALLOW) as client:
@@ -1140,3 +1225,76 @@ class TestSignalingSocket:
assert wait_for(viewer1, "layout")["staged"] is None assert wait_for(viewer1, "layout")["staged"] is None
assert wait_for(viewer2, "layout")["staged"] is None assert wait_for(viewer2, "layout")["staged"] is None
assert client.get("/api/kiosk").json()["staged"] is None assert client.get("/api/kiosk").json()["staged"] is None
def test_a_controller_peer_drives_the_layout_without_counting_in_followers(self, client):
"""A /remote-shaped peer — `hello{role:"viewer"}` with no `kiosk` flag —
may drive the wall, but must not be counted in it: `followers` is the
display count, and the phone that holds the wall is not a display. The
read endpoint and the frames must agree on the same number."""
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as controller,
client.websocket_connect("/ws") as display,
):
sharer_id = handshake(sharer, "sharer", "A")
handshake(controller, "viewer", "phone") # no `kiosk` flag
display.send_json({"type": "hello", "role": "viewer", "name": "K", "kiosk": True})
assert display.receive_json()["type"] == "welcome"
# The display's join is reported to the other viewers; the controller
# is one of them and hears the count of one.
assert wait_for(controller, "layout")["followers"] == 1
assert client.get("/api/kiosk").json()["followers"] == 1
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(controller, "stream_added")
# The controller drives the wall...
controller.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
sent = wait_for(controller, "layout")
assert sent["staged"] == sharer_id
assert sent["fullscreen"] is True
# ...the display obeys it...
assert wait_for(display, "layout")["staged"] == sharer_id
# ...and the read endpoint agrees with both — the controller still
# does not count: the wall is driven by one, obeyed by one, and the
# count says one.
body = client.get("/api/kiosk").json()
assert (body["staged"], body["fullscreen"], body["followers"]) == (sharer_id, True, 1)
assert sent["followers"] == 1
def test_arranging_a_mirror_a_viewer_is_watching_changes_no_subscriptions(self, client):
"""Driving the wall must not disturb the people watching it: an
`arrange` for a mirror a second viewer is receiving touches no
subscription — /api/streams reports the same audience before and after,
and the controller's exit removes nothing, because it never watched."""
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as viewer,
client.websocket_connect("/ws") as controller,
):
sharer_id = handshake(sharer, "sharer", "A")
handshake(viewer, "viewer", "V")
handshake(controller, "viewer", "phone") # no `kiosk` flag
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(viewer, "stream_added")
viewer.send_json({"type": "subscribe", "streamId": sharer_id})
wait_for(sharer, "peer_joined")
before = client.get("/api/streams").json()
assert before["stats"]["watching"] == 1
assert before["mirrors"][0]["viewers"] == 1
controller.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
sent = wait_for(controller, "layout")
assert sent["staged"] == sharer_id
after = client.get("/api/streams").json()
assert after["stats"]["watching"] == 1
assert after["mirrors"][0]["viewers"] == 1
# The controller leaving removes no subscription: it never watched,
# so its exit touches the audience not at all.
controller.close()
final = client.get("/api/streams").json()
assert final["stats"]["watching"] == 1
assert final["mirrors"][0]["viewers"] == 1