Compare commits

...
2 Commits
Author SHA1 Message Date
ducoterra d0077e0db4 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.
2026-11-03 23:50:43 -05:00
ducoterra cd29156a7d phase: 08_kiosk_page
Build and Push Containers / test (push) Successful in 42s
Build and Push Containers / build-and-push-app (push) Successful in 9s
**Phase 08 final verification pass — complete, all green.**

**Verified (all 5 tasks already implemented):**
- `static/wall.js` (`createWall` + `installWallKeys`), `kiosk.html`/`kiosk.js`, `GET /kiosk` route — guards: no `$("#…")` in wall.js, one keydown listener, kiosk ids no-collision across 3 pages, no audio field in `arrange`, no header/share link in markup, address gate covers `/kiosk`
- Cross-page e2e: arrival-arrangement, keypress-on-one-display moves the other (asserted on the second display's decoded moving pixels), dead mirror → `data-layout="wall"` on both, `followers` count, pointer-free meta-test

**Gates (exact outcomes):**
- `uv run ruff check . && uv run ruff format --check .` — passed
- `uv run pyright` — 0 errors
- `uv run pytest` — 341 passed, 5 skipped (realdisplay-gated), 96s
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented: host has no audio source for real capture)
- Coverage 96% (>90%); only warning is a starlette/httpx third-party deprecation

**Completion criteria:** all 9 ✅ — e2e green incl. `test_kiosk_layout.py` (the one e2e diff is the phase-mandated address-gate extension to `/kiosk`; assertion logic untouched), full suite 100% passing, README documents `/kiosk`, PLAN.md untouched.

**Documentation:**
- README: fixed the Kiosk-client section that implied the display box is permanently pointed at `/watch` — now states the display page is `/kiosk` with the runbook's one-line re-point (installer default still `/watch` this phase, per docs). Rest of README verified against code (routes, config table, CLI flag, presets, key table) — consistent.
- AGENTS.md: fixed 2 stale statements — CDN test moved to `TestPages` (was `TestClientScripts`), suite runtime ~50s → ~95s. No content added.

**Notable:** PLAN.md §6 says "per-leg `maxBitrate` cap (default 12 Mbit/s)" but code/README default is 25 Mbit/s — pre-existing, left untouched (locked file; flagged for a user decision).

**Next pending phase:** `09_wall_remote_page`.
2026-11-03 22:38:22 -05:00
73 changed files with 3716 additions and 766 deletions
@@ -0,0 +1,22 @@
**Phase 08 final verification pass — complete, all green.**
**Verified (all 5 tasks already implemented):**
- `static/wall.js` (`createWall` + `installWallKeys`), `kiosk.html`/`kiosk.js`, `GET /kiosk` route — guards: no `$("#…")` in wall.js, one keydown listener, kiosk ids no-collision across 3 pages, no audio field in `arrange`, no header/share link in markup, address gate covers `/kiosk`
- Cross-page e2e: arrival-arrangement, keypress-on-one-display moves the other (asserted on the second display's decoded moving pixels), dead mirror → `data-layout="wall"` on both, `followers` count, pointer-free meta-test
**Gates (exact outcomes):**
- `uv run ruff check . && uv run ruff format --check .` — passed
- `uv run pyright` — 0 errors
- `uv run pytest` — 341 passed, 5 skipped (realdisplay-gated), 96s
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented: host has no audio source for real capture)
- Coverage 96% (>90%); only warning is a starlette/httpx third-party deprecation
**Completion criteria:** all 9 ✅ — e2e green incl. `test_kiosk_layout.py` (the one e2e diff is the phase-mandated address-gate extension to `/kiosk`; assertion logic untouched), full suite 100% passing, README documents `/kiosk`, PLAN.md untouched.
**Documentation:**
- README: fixed the Kiosk-client section that implied the display box is permanently pointed at `/watch` — now states the display page is `/kiosk` with the runbook's one-line re-point (installer default still `/watch` this phase, per docs). Rest of README verified against code (routes, config table, CLI flag, presets, key table) — consistent.
- AGENTS.md: fixed 2 stale statements — CDN test moved to `TestPages` (was `TestClientScripts`), suite runtime ~50s → ~95s. No content added.
**Notable:** PLAN.md §6 says "per-leg `maxBitrate` cap (default 12 Mbit/s)" but code/README default is 25 Mbit/s — pre-existing, left untouched (locked file; flagged for a user decision).
**Next pending phase:** `09_wall_remote_page`.
@@ -0,0 +1,36 @@
........................................................................ [ 20%]
......................................sssss............................. [ 41%]
........................................................................ [ 62%]
........................................................................ [ 83%]
.......................................................... [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 194 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 706 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
341 passed, 5 skipped, 1 warning in 96.44s (0:01:36)
All checks passed!
33 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,16 @@
Task 01 complete — all gates green.
**Implemented**
- New `src/mirror/static/wall.js`: `createWall({grid, stageEl, template, send, rtc, onChange, stats})` owning tiles, peers, stage, selection, mute policy, autofade, fullscreen-exit race, stats ticker, `closePeers()`; returns the task's surface plus `selectedTile()`, `revealStageControls()`, `handleFullscreenChange()`. Zero document queries; socket via page's `send` (guarded on `state.ws` in the page closure, so the wall builds before the socket opens).
- `watch.js` now a thin page: socket, `renderCounts` as the `onChange` callback, `fullscreenchange`/pointer/key bindings, help, refusal, share link. Dead `ui.unstage` branch deleted with a comment pointing at the per-tile `.exit` button.
- `app.css`: `#watch-stage` → `.stage` (all 10 rules, comments updated to class-scoped/source-order rationale); `watch.html` stage gains `class="stage"`.
- **Collision found & fixed:** the share page already used `class="stage"` — the literal re-key would have made `#preview-stage` inherit `position: fixed; inset: 0; display: none` (same specificity, later rule wins). Renamed the share preview to `.preview-stage` (matching its id) so `.stage` is wall-only; `test_share_layout`/`test_live_mirror` pass unedited, proving the share page is unchanged.
- `test_app.py`: `wall.js` in parse/asset lists; new `test_wall_never_looks_up_elements_in_the_document` (cites the id-collision test as its reason); `test_the_two_pages_use_the_media_apis_they_claim` re-pointed at `wall.js` (RTCPeerConnection moved); the two TestStylesheet tests mechanically re-pointed to `.preview-stage`.
**Gates** — `ruff check` + `format --check` clean; `pyright` 0 errors; targeted e2e (`-k "keyboard or live_mirror or audio or stage or share_layout"`) 92 passed **unedited**; full e2e 103 passed; full `uv run pytest` 322 passed / 5 skipped (realdisplay-marked); `MIRROR_REALDISPLAY=1 pytest -m realdisplay` 4 passed / 1 platform-documented skip. No e2e file touched.
**Docs** — no doc impact (pure refactor; README/AGENTS.md state nothing this changed).
**Decisions** — `.preview-stage` rename (above); `rtc` added to the wall options (peers moved into the wall, config stays page-owned); redundant `renderCounts()` in the `welcome` case dropped (per-`absorb` `onChange` covers it, identical final state).
**Next pending task** — `02_extract_wall_key_router.md` (phase 08).
@@ -0,0 +1,36 @@
........................................................................ [ 22%]
...............................sssss.................................... [ 44%]
........................................................................ [ 66%]
........................................................................ [ 88%]
....................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/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.14.8-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 191 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 703 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
322 passed, 5 skipped, 1 warning in 98.55s (0:01:38)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,23 @@
All done. Final state verified — here's the report:
## Task 02 report — `installWallKeys()` in `wall.js`
**Implemented**
- `wall.js`: new `installWallKeys({ wall, onCommand, root? })` — the one `keydown` listener: case-insensitive key→command switch (`next`, `previous`, `stage`, `fullscreen`, `wall`, `step_next`, `step_previous`, `mute`, `jump` w/ 0-based `index`, `help`, `escape`), chord/text-field bail-outs, staged-reveal before dispatch (never preventDefaults), and `preventDefault()` on exactly the keys that changed something. Wall commands carry module defaults (moved verbatim from `watch.js`, with their "why" comments); `help`/`escape` have no default (page policy).
- `wall.js`: `createWall` surface gains `reRequestFullscreen()` so the `f`-when-staged default can re-request on the stage element the wall owns (same element `/watch` used via `ui.stage`).
- `watch.js`: deleted `onWallKey`, `stageSelectedOrFirst`, `stepStage`, `toggleHelp` and the `document.addEventListener("keydown", …)` line; now `installWallKeys({ wall, onCommand })` with `onCommand` answering only `help` (toggle `#wall-help`, always handled) and `escape` (overlay first, then `wall.unstage()`, handled iff either happened) — the `?` focus comment moved with the behaviour.
- `test_app.py`: new guard — `installWallKeys` defined in `wall.js`, `wall.js` holds exactly one keydown listener, and no wall-page module (watch.js, future kiosk/remote) adds its own.
**Results (all gates)**
- `uv run ruff check . && uv run ruff format --check .` → clean
- `uv run pyright` → 0 errors
- `uv run pytest tests/e2e/test_keyboard_wall.py` **unedited** → 35/35 passed
- `uv run pytest -m e2e` → 103 passed, 5 skipped (realdisplay-gated)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (no audio source on host — pre-existing env skip)
- `uv run pytest` (full) → **323 passed, 5 skipped**, 0 failed; coverage ~95% (JS router covered by browser assertions, per AGENTS.md §4)
**Docs:** no doc impact (pure refactor, no user-visible change; README/AGENTS.md unaffected).
**Notable decision:** `grep 'addEventListener("keydown"' static/*.js` shows the one `wall.js` hit *plus* `share.js`'s pre-existing `/mirror` s/x/Esc listener (share-page policy, not a wall page — untouched, out of scope); the test guard encodes the "one listener per wall page" rule exactly. `fullscreen` got a module default (not listed in the task's default list, but required for item 5's thin-observer `/watch` and for `/kiosk` to publish while the module performs).
**Next pending task:** `08_kiosk_page/03_kiosk_page_and_route.md`
@@ -0,0 +1,36 @@
........................................................................ [ 21%]
...............................sssss.................................... [ 43%]
........................................................................ [ 65%]
........................................................................ [ 87%]
........................................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/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.14.8-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 191 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 703 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
323 passed, 5 skipped, 1 warning in 92.90s (0:01:32)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,19 @@
All done. Final state is 100% green across all four gates.
**Report — phase 08, task 03 (`GET /kiosk` + page skeleton)**
**Implemented**
- `GET /kiosk` route in `app.py` (beside `/watch`), `/kiosk` added to `PAGES` (no-store); fixed the stale "Two pages" module docstring
- `static/kiosk.html`: chrome-free wall — `body.kiosk[data-layout="wall"][data-fullscreen="false"]`, `#kiosk-grid`, `#kiosk-empty` + `#kiosk-share-url`, `#kiosk-stage` with mandatory `class="stage"`, `#kiosk-hud`, `#kiosk-tile-template`, shared `net`/`notice`/`toast` only, no header, `<title>Wall · mirror</title>` (the check-kiosk.sh contract)
- Placeholder `static/kiosk.js` (serves, fills the share URL, shows refusal notice; wall/layout are task 04's)
- `app.css`: `.kiosk .grid` full-bleed, `.kiosk .net` fixed top-right (z 60, above stage), `.hud` fixed bottom-left (mono, dim, pointer-events none)
- `index.html`: third card "GET /kiosk — An unattended wall"
- `test_app.py` guards extended to the third page (render, no-store×5, asset, `node --check`, element-reaches-for, stylesheet+module — renamed, video muted/playsinline, id-collision incl. `kiosk` with `#kiosk-*` prefix, new stage-class + structural-element guard, address-gate refused + admitted 200); `test_live_mirror.py` refused-address list + `#kiosk-grid`/`#kiosk-stage` selectors
**Gates** — `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` **334 passed**, 5 skipped (realdisplay marker) ✅ · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` 4 passed, 1 skipped (host has no audio source — the test's own environmental skip, pre-existing) ✅ · curl: `GET /kiosk` 200 + `no-store`; browser check: empty-state wall, share URL filled, no header, net pill fixed top-right, zero console errors
**Docs** — README: `/kiosk` in the page table, quickstart, and the shared-state paragraph (replaced "arrives in a later release"); AGENTS.md: "Three pages" → "Four pages" (stale count)
**Decisions** — placeholder also imports `showNotice` (the only display path in `net.js`; "show a refusal notice" is impossible with only the two named imports); `kiosk ∩ index` id assertion is `<= shared` (index.html has no `notice`/`toast` by structure — same shape as the existing index assertion); id-guard test kept its original name (AGENTS.md and `wall.js` reference it)
**Next task** — `08_kiosk_page/04_kiosk_layout_applier.md`
@@ -0,0 +1,36 @@
........................................................................ [ 21%]
................................sssss................................... [ 42%]
........................................................................ [ 63%]
........................................................................ [ 84%]
................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/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.14.8-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 194 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 706 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
334 passed, 5 skipped, 1 warning in 98.11s (0:01:38)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,11 @@
All done. Final report:
**Phase 08, Task 04 — `kiosk.js`: obey the layout, publish the keystrokes — complete**
- Verified the prior partial run's `kiosk.js` against the task spec (module shape, `hello{kiosk:true}`, idempotent `applyLayout()`, remembered-intent retry, best-effort never-awaited fullscreen with the required comment, body `data-layout`/`data-fullscreen` + title mirroring, per-command `arrange` publishing, `m` local-only with the SECURITY.md §4 comment, no `fullscreenchange` policy) — spec-compliant; fixed one broken indentation block in the `onMessage` switch
- Added the missing `test_app.py` source-level guard: `test_kiosk_never_publishes_an_audio_field` — brace-matches every `type:"arrange"` object literal in `kiosk.js` (member-order independent), asserts none carries `muted:`/`audio:`, and asserts at least one `arrange` frame exists; negative controls verified to be caught
- Verified every completion criterion with a throwaway real-browser/real-server script (deleted after): arrive-after-arrange shows the staged mirror with no wall flash (grid never paints a tile) and moving pixels on the stage; raw-socket (websocat) `arrange` re-arranges an open tab; `Enter`/`g`/`n`/`p`/`1`/`f` change `GET /api/kiosk`; `j`/`k` publish nothing; `m` flips `video.muted` with `/api/kiosk` byte-identical; a second kiosk display follows the first and teardown returns both to the wall; `followers` counts both kiosks only; zero page errors
- Gates: `uv run ruff check . && uv run ruff format --check .` → all checks passed, 32 files formatted; `uv run pyright` → 0 errors; `uv run pytest` → 335 passed, 5 skipped (realdisplay-gated), 96% coverage; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host audio-source limitation)
- README.md: one sentence added — kiosk keypresses publish the same `arrange` intent (controller/display converge) and `m` is local-only; no AGENTS.md changes needed for this task (the "four pages" edit was task 03's and is correct)
- Deviation note: task 04's kiosk.js was already written by the aborted prior run; my work was full spec verification, the indentation fix, and the missing guard — no behavior code was needed
- Next pending task: `.agents/phases/todo/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md` (the permanent `test_kiosk_layout.py` suite + docs)
@@ -0,0 +1,36 @@
........................................................................ [ 21%]
................................sssss................................... [ 42%]
........................................................................ [ 63%]
........................................................................ [ 84%]
.................................................... [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 194 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 706 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
335 passed, 5 skipped, 1 warning in 92.04s (0:01:32)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,21 @@
Everything is in place and verified. All gates were run after the final code/doc state (the last edit was a cosmetic link-label fix in the docs).
## Report
**Implemented**
- `tests/e2e/test_kiosk_layout.py`: 5 behavioural tests + meta-guard — arrival-is-arrangement (driver `arrange` over a raw `page.evaluate` WebSocket *before* `/kiosk` opens; asserts `body[data-layout="stage"]`, `data-fullscreen="true"`, staged tile, decoded moving frames on `#kiosk-stage video`), one keypress on one display re-arranging a second pointer-free display (asserted on the second's decoded pixels + `/api/kiosk`), `n`/`p` stepping with wrap, teardown returning both displays to `data-layout="wall"` with no dead tiles, and `followers` counting displays only over a real socket lifecycle. Established `device()` shape (isolated contexts, finalizers, `#net` online wait, pageerror+console from the first line); the self-guard (same style as `test_keyboard_wall.py`, literals built from parts) bans pointer calls and explicitly allows the wire-level driver; the `document.fullscreenElement` non-assertion is commented as required.
- `docs/kiosk-silverblue.md`: new "Two endpoints: `/watch` and `/kiosk`" section (operator vs. display, keys still work on `/kiosk`, controller arrives in phase 09 `/remote`, honest `MIRROR_KIOSK_URL` migration rule with re-point + `gdm` restart; no changes to `install-kiosk.sh`/`kiosk.conf`/`gnome-kiosk-script`/`check-kiosk.sh`).
- `README.md`: one sentence in the Keys section (wall keys work on `/kiosk`; a change made there is what every other display follows). The `/kiosk` page-table row already existed from task 03.
**Gates (all green)**
- `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors
- `uv run pytest tests/e2e/test_kiosk_layout.py -x` — 6/6 passed
- `uv run pytest -m e2e` — 110 passed, 5 skipped (realdisplay-only)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented host-has-no-audio-source)
- `uv run pytest` — 341 passed, 5 skipped, 0 failed; coverage 95% (unchanged; task adds no product code)
**Docs** — `docs/kiosk-silverblue.md` + `README.md` updated as above; `AGENTS.md` no stale facts → untouched.
**Decisions** — driver closes its socket after the server's `layout` ack (state is process-level, so no `window` global / no leaked peer); mid-test context close in the followers test is guarded so the finalizer can't double-close.
**Next pending task** — none in `todo/` (phase 08 is complete; phase 09 `/remote` is not yet authored).
@@ -0,0 +1,36 @@
........................................................................ [ 20%]
......................................sssss............................. [ 41%]
........................................................................ [ 62%]
........................................................................ [ 83%]
.......................................................... [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 194 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 706 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
341 passed, 5 skipped, 1 warning in 96.45s (0:01:36)
All checks passed!
33 files already formatted
0 errors, 0 warnings, 0 informations
@@ -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
+3 -3
View File
@@ -40,8 +40,8 @@ is a roster and a signaling relay. Linux is the reference platform.
| Rule | Why it exists |
|---|---|
| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestClientScripts::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
| **No framework, no bundler** in `src/mirror/static/` | Three pages and one WebSocket. `node --check` is the entire frontend toolchain |
| **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/` | 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 |
| **`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 |
@@ -57,7 +57,7 @@ is a roster and a signaling relay. Linux is the reference platform.
```bash
uv run ruff check . && uv run ruff format --check .
uv run pyright # must be 0 errors
uv run pytest # unit + API + browser E2E (~50s)
uv run pytest # unit + API + browser E2E (~95s)
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture
```
+34 -5
View File
@@ -9,6 +9,8 @@ your network:
| ------------ | ------------------ | ----------------------------------------------------------------------- |
| **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. |
| **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. |
Any number of people can mirror at the same time and any number of people can
@@ -30,6 +32,8 @@ Then, from another machine on the LAN:
```
http://<this-machine>:8055/mirror # to share
http://<this-machine>:8055/watch # to watch
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
@@ -67,9 +71,28 @@ field is "no opinion", `"staged": null` is "back to the wall". A change is pushe
immediately as a `layout` frame to every viewer (and back to the sender), and the
same object is readable at `GET /api/kiosk` — `curl`-debuggable like `/api/streams`,
`Cache-Control: no-store`. The state has no audio: sound always traces back to a
gesture on the device that plays it (see [SECURITY.md](SECURITY.md) §4). The kiosk
page that follows this state arrives in a later release; the wire and the read
surface are what any client builds on.
gesture on the device that plays it (see [SECURITY.md](SECURITY.md) §4). The
`/kiosk` page is the display that follows this state: a chrome-free wall that
subscribes to every mirror like `/watch` and does what the state says —
`Cache-Control: no-store` on the read and on the page itself. If a keyboard is
plugged in, the same keys as `/watch` work, and every keypress publishes the
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
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
@@ -172,8 +195,10 @@ from this side:
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
`/watch`. It is set up and repaired entirely over SSH — no keyboard, no console,
no monitor. A brand-new box layers the kiosk packages once first
the wall. It is set up and repaired entirely over SSH — no keyboard, no console,
no monitor. The display page is `/kiosk`, and it is the installer default
too; an existing `kiosk.conf` URL wins over the default, so moving a box is
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);
after that these two commands are both the setup and the repair path:
@@ -212,6 +237,10 @@ in. On `/watch`:
| `?` | Show / hide this list on screen. |
| `Esc` | Close the key list, then leave the stage. |
The same wall keys work on `/kiosk` too — the unattended display — and a
change made there (a keypress stages the mirror *and* publishes the same
`arrange` intent) is what every other display follows.
A staged mirror's caption bar and control cluster are **hidden at rest**, so
the shared video — a terminal's bottom line included — is never covered by
them. A pointer move over the stage (a tap, for touch) or any key press
+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
*down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's
ceiling).
* Point the shared wall at any live mirror and toggle its fullscreen state
(the `arrange` frame on `/ws`), which every kiosk display follows. The
state has no audio field: a remote hand can stage and scale, but it can
never make a display *sound* — that still needs a gesture on the device
playing it (see §4 “Audio”).
* **Rearrange anybody's wall display**: choose which mirror is on stage and
whether it is fullscreen, on every `/kiosk` client, immediately — open
`/remote` on a phone, or send one `arrange` frame on `/ws`. This is the
same trust decision as "watch anything anyone shares": it is visible, it is
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)
@@ -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 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 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` |
| **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 |
+56 -3
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
@@ -64,6 +64,59 @@ so the fleet is auditable.
---
## Two endpoints: `/watch` and `/kiosk`
The wall has two client pages, and they are not the same page twice:
* **`/watch` is a wall a person operates.** The operator's page: the grid,
the keys, and the chrome a person standing in front of it needs — header,
share link, counts.
* **`/kiosk` is a display that obeys a wall.** No chrome at all: no header,
no counts, no share button — the net pill is the only thing on screen that
is not a mirror. It subscribes to every mirror like `/watch` does and then
does what the shared layout state says — on arrival *and* on every change —
with no input from anyone standing at the box. Point the box at it and it
arranges itself: whichever mirror is on stage, fullscreen or not, is
applied before the first tile finishes painting, so a person who points a
kiosk at `/kiosk` never has to touch it. When a staged mirror goes away,
every display falls back to the wall in the same frame set; no display sits
on a dead tile.
The state is the boss, locally too: **the wall keys still work on `/kiosk`**
for the moment a keyboard is plugged in (see [the key table](#operating-the-wall-keys)),
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** 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` 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"|' \
/var/home/kiosk/.config/mirror/kiosk.conf
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.) 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.
---
## The two facts that make this work
**Silverblue's mutable seams.** `/usr` is read-only and belongs to the OSTree
@@ -297,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):
+16 -3
View File
@@ -1,7 +1,7 @@
"""The HTTP + signaling server.
Two pages and one WebSocket. Media flows browser-to-browser; the server only
shuffles JSON describing who is mirroring what.
A handful of pages and one WebSocket. Media flows browser-to-browser; the
server only shuffles JSON describing who is mirroring what.
"""
from __future__ import annotations
@@ -310,6 +310,19 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"""The page that shows every live mirror and can stage one fullscreen."""
return _page("watch.html")
@app.get("/kiosk", response_class=HTMLResponse, include_in_schema=False)
async def kiosk_page() -> FileResponse:
"""A wall for an unattended display: subscribes like /watch and arranges
itself from the shared layout (phase 07/08)."""
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")
async def client_config() -> JSONResponse:
return JSONResponse(
@@ -350,7 +363,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
return
await hub.serve(websocket)
PAGES = ("/", "/mirror", "/share", "/watch")
PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk", "/remote")
@app.middleware("http")
async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
+174 -29
View File
@@ -225,15 +225,19 @@ a { color: var(--accent); }
align-items: start;
}
/* The stage is ONE cell. Its children — the <video>, the idle placeholder,
the LIVE badge — share that cell, so the placeholder overlays the video the
way the markup intends instead of stacking into a row below it.
/* The preview stage is ONE cell. Its children — the <video>, the idle
placeholder, the LIVE badge — share that cell, so the placeholder overlays
the video the way the markup intends instead of stacking into a row below
it. It is `preview-stage`, not `stage`: the wall's stage (the container a
staged tile moves into on /watch and /kiosk) is the only thing that may
carry `class="stage"` in this stylesheet — sharing the class would let the
two stages inherit each other's rules (fixed overlay vs inline preview).
Invariant for the next reader: a child added to #preview-stage shares that
cell and must never push another child out of view. `overflow: hidden` is
there to clip the live video to the rounded corners; a box whose content no
longer fits a fixed-aspect stage is the size rule's job, not this one's
(the floor/ceiling below). */
.stage {
.preview-stage {
position: relative;
/* Definite (100%), not stretched-auto: with an aspect-ratio, a stretched
item whose height is clamped by max-height gets its *width* recomputed
@@ -271,22 +275,22 @@ a { color: var(--accent); }
overlap instead of stacking. DOM order paints the placeholder above the video
(no z-index); .livetag stays position: absolute, so grid-area is inert for
it. */
.stage > * { grid-area: 1 / 1; }
.preview-stage > * { grid-area: 1 / 1; }
.stage video { width: 100%; height: 100%; object-fit: contain; display: block; }
.preview-stage video { width: 100%; height: 100%; object-fit: contain; display: block; }
/* Escape hatch: the placeholder may never be taller than the stage; if its
content outgrows it, it scrolls. Paired with `safe center` above, content
that is too tall is always reachable, whichever way the geometry falls. */
.stage .placeholder {
.preview-stage .placeholder {
text-align: center;
padding: 24px;
color: var(--fg-dim);
max-height: 100%;
overflow: auto;
}
.stage .placeholder h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; }
.stage .placeholder p { margin: 0 0 18px; font-size: 14px; }
.preview-stage .placeholder h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; }
.preview-stage .placeholder p { margin: 0 0 18px; font-size: 14px; }
/* Narrow-width shape rules come *after* the base rules above: same
specificity, so source order decides, and a phone must not be left with
@@ -296,15 +300,15 @@ a { color: var(--accent); }
the box from looking like a letterbox. */
@media (max-width: 900px) {
.share { grid-template-columns: 1fr; }
.stage { aspect-ratio: 4 / 3; }
.preview-stage { aspect-ratio: 4 / 3; }
}
/* A phone (≤480px) gets a stage as tall as its content rather than any
letterbox, and the placeholder's padding buys back 16px. min-height still
does the real work in both cases. */
@media (max-width: 480px) {
.stage { aspect-ratio: auto; }
.stage .placeholder { padding: 16px; }
.preview-stage { aspect-ratio: auto; }
.preview-stage .placeholder { padding: 16px; }
}
.livetag {
@@ -468,13 +472,13 @@ ul.audience .empty { border: 1px dashed var(--line); color: var(--fg-dim); justi
/* The staged tile: the mark shows the live state — sound on, or muted with
audio available (dimmed, so a staged-but-quiet mirror is still readable). */
#watch-stage .tile[data-audio="on"]::after {
.stage .tile[data-audio="on"]::after {
content: "🔊";
font-size: 18px;
line-height: 1;
}
#watch-stage .tile[data-audio="off"]::after {
.stage .tile[data-audio="off"]::after {
content: "🔇";
font-size: 18px;
line-height: 1;
@@ -562,7 +566,9 @@ kbd {
/* Staging: one mirror fills the viewport; the grid is hidden behind it. */
body.staged .grid, body.staged .empty { display: none; }
#watch-stage {
/* The wall's stage (class-keyed so /watch and /kiosk share the rules; the
share page's preview is `preview-stage`, never `stage`). */
.stage {
position: fixed;
inset: 0;
z-index: 50;
@@ -570,11 +576,11 @@ body.staged .grid, body.staged .empty { display: none; }
display: none;
}
body.staged #watch-stage { display: block; }
body.staged .stage { display: block; }
/* The staged tile *is* the stage: the element itself moves here, so its
RTCPeerConnection and <video> never have to be torn down. */
#watch-stage .tile {
.stage .tile {
width: 100%;
height: 100%;
border: 0;
@@ -584,14 +590,14 @@ body.staged #watch-stage { display: block; }
flex-direction: column;
}
#watch-stage .tile video {
.stage .tile video {
flex: 1 1 auto;
min-height: 0;
height: auto;
aspect-ratio: auto;
}
#watch-stage .tile .cap {
.stage .tile .cap {
position: absolute;
left: 0;
right: 0;
@@ -607,17 +613,19 @@ body.staged #watch-stage { display: block; }
bottom line included — is never covered. `data-reveal` (pointer activity
or a wall keypress) is the only staged reveal: it shows the caption and
the control cluster briefly and fades them back on idle. The small
`::after` audio mark is a separate rule and is untouched. The id-scoped
selectors outrank the grid rules (`.tile[data-selected] .actions`,
`::after` audio mark is a separate rule and is untouched. The stage-scoped
selectors win over the grid rules (`.tile[data-selected] .actions`,
`.tile:focus-within .actions`, the `hover: none` media rule) which also
match the staged tile — it keeps `data-selected` and holds focus. */
#watch-stage .tile .actions { opacity: 0; }
match the staged tile: they tie on specificity, source order decides, and
these rules sit after the grid rules. The staged tile keeps `data-selected`
and holds focus. */
.stage .tile .actions { opacity: 0; }
/* A `pointer-events: none` container does not shield children whose default
is `auto`: scope it to the container *and its children* so the hidden
Exit/mute buttons are not live dead zones over the video. */
#watch-stage .tile .cap, #watch-stage .tile .cap *,
#watch-stage .tile .actions, #watch-stage .tile .actions * { pointer-events: none; }
.stage .tile .cap, .stage .tile .cap *,
.stage .tile .actions, .stage .tile .actions * { pointer-events: none; }
/* `data-reveal` (pointer activity on the stage, or a wall keypress while
staged) is the only staged reveal: it restores opacity and makes the
@@ -626,14 +634,151 @@ body.staged #watch-stage { display: block; }
shields an `auto` child (setting `opacity: 1` on the children themselves
is harmless). The transitions on the base rules give the fade in and
out; under `prefers-reduced-motion` the global transition-kill makes the
reveal instant, which is the correct degraded behaviour. The id-scoped
selectors outrank every grid rule that also matches the staged tile. */
#watch-stage .tile[data-reveal] .cap, #watch-stage .tile[data-reveal] .cap *,
#watch-stage .tile[data-reveal] .actions, #watch-stage .tile[data-reveal] .actions * {
reveal instant, which is the correct degraded behaviour. The stage-scoped
selectors win over every grid rule that also matches the staged tile
(tied on specificity, source order decides). */
.stage .tile[data-reveal] .cap, .stage .tile[data-reveal] .cap *,
.stage .tile[data-reveal] .actions, .stage .tile[data-reveal] .actions * {
opacity: 1;
pointer-events: auto;
}
/* --------------------------------------------------------------- kiosk */
/* /kiosk is a display, not a page: no header bar, no counts, no share
button. The grid is the whole viewport — there is no chrome above it to
clear — and it scrolls when the wall outgrows the room. */
.kiosk .grid {
height: 100%;
overflow-y: auto;
}
/* The shared net pill pinned where a screenshot must find it. The kiosk has
no header to hold it, so it fixes itself top-right: the same pill, the
same states (.net owns its look) plus a backdrop so it stays legible over
the wall. It sits above the stage (z-index 60 over 50) on purpose: "the
server is gone" must be readable even with a tile on stage. */
.kiosk .net {
position: fixed;
top: 14px;
right: 16px;
z-index: 60;
padding: 6px 12px;
background: rgba(11, 13, 16, 0.88);
border: 1px solid var(--line);
border-radius: 999px;
}
/* The wall's self-description for a room with no keyboard: one fixed line
bottom-left (kiosk.js owns it — "N mirrors · wall", or
"… · <label> · fullscreen"), never interactive. */
.hud {
position: fixed;
left: 16px;
bottom: 12px;
z-index: 40;
font-family: var(--mono);
font-size: 12px;
color: var(--fg-dim);
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) {
* { animation: none !important; transition: none !important; }
}
+16
View File
@@ -43,6 +43,22 @@
jump to it.
</p>
</a>
<a class="card" href="/kiosk">
<div class="kicker">GET /kiosk</div>
<h2>An unattended wall</h2>
<p>
A chrome-free wall for a kiosk display: it shows every live mirror and arranges
itself from the shared layout. No pointer, no keyboard, nothing to set up at the box.
</p>
</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 class="livebar" id="livebar">Checking for mirrors…</div>
+74
View File
@@ -0,0 +1,74 @@
<!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" />
<!-- The title is a contract, not a label: docs/kiosk-silverblue/check-kiosk.sh
proves a client is showing what it should by grepping the served page's
<title>. -->
<title>Wall · 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>
<!-- The two layout knobs, mirrored onto the body so a screenshot and a test
can read the state without reaching into JS (the tile's data-audio is
seeded for the same reason). The layout applier (kiosk.js, phase 08
task 04) is the only writer. -->
<body class="kiosk" data-layout="wall" data-fullscreen="false">
<!-- No header bar, no counts, no "Share a screen" link: a display shows
mirrors. The net pill is the one chrome a wall keeps — it is how a
wall reports "the server is gone" in a screenshot, and every e2e
device() helper waits on it. -->
<span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span>
<div class="notice" id="notice" hidden></div>
<main class="grid" id="kiosk-grid" hidden></main>
<div class="empty" id="kiosk-empty">
<h2>No mirrors live</h2>
<p>
Share from <code id="kiosk-share-url">…</code>
</p>
</div>
<!-- A staged tile is moved into this container, so its video never
restarts. class="stage" is mandatory: the stage rules are
class-keyed (phase 08 task 01), and a stage without the class renders
a full-size unstyled video. -->
<div class="stage" id="kiosk-stage" aria-live="polite"></div>
<!-- The wall's self-description for a room with no keyboard: one line the
script owns — "N mirrors · wall", or "… · <label> · fullscreen". -->
<div class="hud" id="kiosk-hud" aria-live="polite"></div>
<div class="toast" id="toast" hidden></div>
<!-- A copy of watch.html's #tile-template, renamed: the id-collision guard
is the reason two pages may not share an id, and relaxing it for
convenience is how the #stage bug happened. -->
<template id="kiosk-tile-template">
<article class="tile" tabindex="0" data-state="connecting">
<video autoplay playsinline muted></video>
<div class="badge">connecting…</div>
<div class="actions">
<button class="icon-btn audio" aria-pressed="false" title="Unmute" hidden>🔇</button>
<button class="icon-btn expand" title="Full screen">⤢</button>
</div>
<div class="cap">
<div class="grow">
<div class="title">Shared screen</div>
<div class="sub"></div>
</div>
<button class="btn ghost exit" hidden>Exit</button>
</div>
</article>
</template>
<script type="module" src="/static/kiosk.js"></script>
</body>
</html>
+363
View File
@@ -0,0 +1,363 @@
/* The kiosk page: a chrome-free wall for an unattended display.
It is a display, not a controller: it announces `kiosk: true` in its hello
(so the layout frame's `followers` counts it), subscribes to every mirror
like /watch, and does what the shared layout says — on arrival
(`welcome.layout`) and on every `layout` frame after. A keypress on the box
(rare, but it happens when a keyboard is plugged in to diagnose) performs
the change AND publishes the same intent with an `arrange` frame, so the
controller and the display converge instead of disagreeing: one writer path,
everyone re-reads.
What this page never does:
* stage or unstage on its own — the state is the boss, locally too; an empty
state means the wall, and nothing auto-stages (never invent state);
* publish anything about sound — the layout has no audio field
(SECURITY.md §4), so `m` is local-only and a display must not become
audible because another machine asked;
* react to the browser's own fullscreen exits — the window manager dropping
fullscreen is not a person asking to leave the stage (on /watch it is a
deliberate policy; on a wall it would fight the state), and a refused
request is the browser's answer, not a preference to echo.
*/
import {
$,
connectSignaling,
deviceLabel,
loadConfig,
refusalNotice,
rtcConfig,
showNotice,
toast,
} from "./net.js";
import { createWall, installWallKeys } from "./wall.js";
const config = await loadConfig();
const rtc = rtcConfig(config);
const ui = {
grid: $("#kiosk-grid"),
stage: $("#kiosk-stage"),
template: $("#kiosk-tile-template"),
empty: $("#kiosk-empty"),
shareUrl: $("#kiosk-share-url"),
hud: $("#kiosk-hud"),
net: $("#net"),
toast: $("#toast"),
notice: $("#notice"),
};
// Nobody is standing at the box to click anything, so the person reading the
// screen needs a URL to type elsewhere.
if (ui.shareUrl) ui.shareUrl.textContent = `${location.origin}/mirror`;
const state = {
ws: null,
// The last layout frame: the single source of truth this page arranges from.
layout: null,
// What the page last applied — the applier's idempotency memory.
applied: null,
mirrors: 0,
};
/* -------------------------------------------------------------- rendering */
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;
}
function renderHud() {
// The same hidden toggles /watch does in renderCounts(): the grid shows when
// there is something to show, the empty state when there is not.
ui.grid.hidden = state.mirrors === 0;
ui.empty.hidden = state.mirrors > 0;
const n = state.mirrors;
const words = `${n} mirror${n === 1 ? "" : "s"}`;
const stagedId = wall.stagedId();
const tile = stagedId ? wall.tile(stagedId) : null;
let text = tile ? `${words} · ${tile.title.textContent}` : `${words} · wall`;
// The wall's self-description for a room with no keyboard: it names the state
// knob, not the browser's concession — a refused request never reads as
// "not fullscreen" here.
if (state.layout && state.layout.fullscreen) text += " · fullscreen";
ui.hud.textContent = text;
}
function onChange({ mirrors }) {
state.mirrors = mirrors;
renderHud();
}
/* ---------------------------------------------------------------- applier */
/**
* Make the page match the server's layout. Idempotent: a frame that repeats
* what is already applied changes nothing, so a controller that re-sends (or a
* follower join that re-broadcasts) cannot re-request fullscreen forever.
* The idempotency check has two halves: the frame matches what was last
* applied, and the wall is actually in the state the frame names. The second
* half is what keeps a *remembered* intent alive — a frame that named a mirror
* not in the wall yet (it died on the way here, or has not arrived) leaves the
* page on the wall, so the wall does not match the frame, and the retry (the
* next roster frame re-runs this on `state.layout`) stages the tile the moment
* it lands.
*
* The caller is never trusted to have waited: `staged` names a live mirror when
* the server accepts it, and may have died on the way here, so an unknown id
* keeps the wall and is retried by the next roster frame.
*/
function applyLayout(next) {
if (!next) return;
const wants = next.staged || null;
const fullscreen = !!next.fullscreen;
const settled =
state.applied !== null &&
state.applied.staged === wants &&
state.applied.fullscreen === fullscreen &&
(wants === null || wall.stagedId() === wants);
if (settled) return;
state.applied = { staged: wants, fullscreen };
if (wants) {
const tile = wall.tile(wants);
if (tile) {
// fullscreen: false — the one fullscreen request for this frame is the
// one below, aimed at the target the page is actually showing.
wall.stage(wants, { fullscreen: false });
} else {
// The frame named a mirror the wall does not have: it died on the way
// here. Back to the wall; `state.layout` keeps the intent, and the next
// roster frame re-applies it.
wall.unstage();
}
} else {
wall.unstage();
}
if (fullscreen) {
// A remote command carries no user activation, so requestFullscreen() may
// be refused. The request is a courtesy, never awaited, and its refusal is
// never echoed back: the .stage CSS fills the viewport anyway (the stage
// is the guarantee) and the Silverblue window is already fullscreen
// (docs/kiosk-silverblue/window-config.ini). Do not "fix" this by
// awaiting or by publishing the refusal — the next reader will be tempted
// to.
const target = wall.stagedId() ? ui.stage : document.documentElement;
if (target.requestFullscreen) {
target.requestFullscreen({ navigationUI: "hide" }).catch(() => {
/* the in-page stage is the guarantee; the fullscreen is a bonus */
});
}
}
// Mirror both knobs onto the body (a screenshot and a test read them without
// reaching into JS) and set the title: a window list and a screenshot are
// the only views a headless box offers.
const stagedId = wall.stagedId();
document.body.dataset.layout = stagedId ? "stage" : "wall";
document.body.dataset.fullscreen = fullscreen ? "true" : "false";
const tile = stagedId ? wall.tile(stagedId) : null;
document.title = tile ? `mirror — ${tile.title.textContent}` : "mirror — wall";
renderHud();
}
/* ------------------------------------------------------------------ wall */
const wall = createWall({
grid: ui.grid,
stageEl: ui.stage,
template: ui.template,
rtc,
// Guarded on the socket the way watch.js guards it, so the wall can be
// built before the socket opens (net.js stays the only place a socket
// exists, AGENTS.md §6).
send: (payload) => (state.ws ? state.ws.send(payload) : false),
onChange,
});
/* ------------------------------------------------------------------ keys */
// One writer path: the display performs the keypress and publishes the same
// intent it performed, so a controller and a display converge on one state.
function publish(frame) {
if (state.ws) state.ws.send(frame);
}
function onCommand(command, detail) {
switch (command) {
case "stage": {
// Same target the wall's default would stage — the cursor, else the
// first tile — same no-silent-no-op toasts.
const tile = wall.selectedTile() || wall.ordered()[0];
if (!tile) {
toast("no mirrors to stage");
return true;
}
wall.stage(tile.id);
publish({ type: "arrange", staged: tile.id });
return true;
}
case "wall":
case "escape": {
// Both leave the stage locally and publish the same intent. Like on
// /watch, a key that changed nothing is not "handled": the browser keeps
// its default (on a fullscreen wall that is the browser's own exit, and
// work item six says the page does not publish those).
const wasStaged = wall.stagedId() !== null;
wall.unstage();
if (wasStaged) publish({ type: "arrange", staged: null });
return wasStaged;
}
case "step_next":
case "step_previous": {
const ordered = wall.ordered();
if (ordered.length === 0) {
toast("no mirrors to switch to");
return true;
}
const selected = wall.selectedId();
const delta = command === "step_next" ? 1 : -1;
let index = selected ? ordered.findIndex((t) => t.id === selected) : -1;
if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n first, p last
const next = ordered[(index + delta + ordered.length) % ordered.length];
wall.stage(next.id);
publish({ type: "arrange", staged: next.id });
return true;
}
case "jump": {
const tile = wall.ordered()[detail.index];
if (!tile) {
toast(`no mirror ${detail.index + 1}`);
return true;
}
wall.stage(tile.id);
publish({ type: "arrange", staged: tile.id });
return true;
}
case "fullscreen": {
// The local effect is the *request*; the state's `fullscreen` knob is
// what the frame publishes. On the wall the target is the document, and
// pressing `f` there must not stage: that would invent a `staged` the
// frame does not carry.
if (wall.stagedId()) {
wall.reRequestFullscreen();
} else if (document.documentElement.requestFullscreen) {
document.documentElement
.requestFullscreen({ navigationUI: "hide" })
.catch(() => {
/* no user activation on a kiosk: the .stage CSS fills the
viewport anyway; the window manager already forces fullscreen */
});
}
publish({ type: "arrange", fullscreen: true });
return true;
}
case "mute": {
// Never published: SECURITY.md §4 — sound only ever starts from a
// gesture on the device that plays it, so a display must not become
// audible because another machine asked.
const stagedId = wall.stagedId();
const tile = (stagedId && wall.tile(stagedId)) || wall.selectedTile();
if (!tile) {
toast("no mirror to mute");
} else {
wall.setMuted(tile, !tile.video.muted);
if (tile.audioBtn.hidden) toast("this mirror has no audio");
}
return true;
}
case "next":
case "previous":
// The cursor is local to this page: the module moves the selection and
// nothing is published (there is no cursor in the shared state).
return false;
case "help":
// This page has no key-list markup: `?` does nothing and keeps its
// browser default.
return false;
default:
return false;
}
}
/* ------------------------------------------------------------------ socket */
state.ws = connectSignaling({
// A refused address never opens a socket (net.js says so via the stub);
// the notice is the only thing the page owes the person reading the screen.
refused: !!config.refused,
refusedReason: config.refusedMessage,
onOpen() {
// A display, not a controller: `kiosk: true` is what feeds `followers`,
// and a /kiosk page must be counted while a /remote page must not.
state.ws.send({ type: "hello", role: "viewer", kiosk: true, name: "", device: deviceLabel() });
},
onStatus(next, detail) {
setNet(next, detail);
},
onClose() {
// The roster is invalid the moment the socket drops: every leg is closed
// and its tile marked stale/reconnecting. The fresh `welcome` after
// reconnect re-applies the layout, which is what makes a server restart
// recover on its own.
wall.closePeers();
},
onMessage(message) {
switch (message.type) {
case "welcome": {
// Arrival is arrangement: the layout is applied before the roster
// paints, so the wall never flashes between the roster and the stage.
const layout = message.layout || { staged: null, fullscreen: false };
state.layout = layout;
applyLayout(layout);
for (const stream of message.streams || []) wall.absorb(stream);
// A layout that named a mirror the wall did not have yet (the roster
// arrives in the same frame) lands now that the tile does.
applyLayout(state.layout);
break;
}
case "layout":
state.layout = message;
applyLayout(message);
break;
case "stream_added":
case "stream_updated":
wall.absorb(message.stream);
// A remembered intent (a layout naming a mirror that was not in the
// wall yet) lands the moment its tile does.
applyLayout(state.layout);
break;
case "stream_removed":
// The wall unstages its own dropped tile, and the server pushes the
// cleared layout behind this frame — do not "helpfully" unstage here,
// or two frames will fight.
wall.drop(message.streamId);
break;
case "signal":
wall.onSignal(message);
break;
case "error":
console.warn("server:", message.code, message.message);
if (message.code === "capacity") toast(message.message);
break;
default:
break;
}
},
});
installWallKeys({ wall, onCommand });
/* -------------------------------------------------------------------- misc */
// Same rule as the share and watch pages: if the server refused this address,
// say so rather than showing an empty wall that will never fill.
const refusal = refusalNotice(config);
if (refusal) showNotice(ui.notice, refusal);
// The settled starting line: "0 mirrors · wall", grid hidden, empty shown.
renderHud();
+5 -1
View File
@@ -22,7 +22,11 @@
<div class="notice" id="notice" hidden></div>
<main class="share">
<section class="stage" id="preview-stage">
<!-- `preview-stage`, not `stage`: the wall's stage (the container a
staged tile moves into, /watch and /kiosk) is the only thing that
may carry `class="stage"` in this stylesheet — sharing the class
would let the two stages inherit each other's rules. -->
<section class="preview-stage" id="preview-stage">
<video id="preview" autoplay playsinline muted></video>
<div class="placeholder" id="placeholder">
<h2>Nothing is being shared</h2>
+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();
+847
View File
@@ -0,0 +1,847 @@
/* The wall: one tile per live mirror, one RTCPeerConnection per tile, and a
* stage the staged tile's own element moves into (so its <video> and its
* connection never restart — AGENTS.md §6).
*
* Nothing here queries the document: the page hands over the elements it owns,
* because one module now serves /watch, /kiosk and (phase 09) /remote, whose
* ids must not collide (test_the_two_pages_share_no_element_ids…).
*
* @param {{grid: Element, stageEl: Element, template: HTMLTemplateElement,
* send: (payload: object) => boolean,
* rtc?: object,
* onChange?: (summary: {mirrors: number, live: number}) => void,
* stats?: boolean}} options
* @returns the surface the pages drive the wall with: absorb, drop, onSignal,
* stage, unstage, reRequestFullscreen, setMuted, ordered, stagedId,
* selectedId, selectedTile, setSelected, moveSelection, tile, closePeers,
* revealStageControls, handleFullscreenChange
*
* The module's second export, installWallKeys(), is the one keydown listener
* a wall page gets: it maps keys to named commands, hands them to the page's
* onCommand, and performs the wall commands itself when the page does not.
*/
import { bitrateFormat, toast } from "./net.js";
export function createWall(options) {
const { grid, stageEl, template, send, rtc } = options;
const onChange = options.onChange || null;
/** streamId -> {id, sharerId, el, video, badge, sub, pc, pending, attempts, stream, stats} */
const tiles = new Map();
const state = {
staged: null,
stageParent: null,
stageNext: null,
cursor: null,
};
/* -------------------------------------------------------------- rendering */
// The count/empty chrome (#counts, #empty) belongs to the page, not the
// wall: the wall reports the numbers wherever renderCounts() ran before the
// extraction, and the page paints them.
function publishCounts() {
if (!onChange) return;
const n = tiles.size;
const live = [...tiles.values()].filter((t) => t.pc && t.pc.connectionState === "connected").length;
onChange({ mirrors: n, live });
}
function surfaceShort(setting) {
return { window: "window", monitor: "screen", browser: "tab" }[setting] || setting || "";
}
function makeTile(stream) {
const node = template.content.firstElementChild.cloneNode(true);
const tile = {
id: stream.id,
sharerId: stream.sharerId,
el: node,
video: node.querySelector("video"),
badge: node.querySelector(".badge"),
title: node.querySelector(".title"),
sub: node.querySelector(".sub"),
audioBtn: node.querySelector(".audio"),
exitBtn: node.querySelector(".exit"),
pc: null,
pending: [],
attempts: 0,
stream: null,
bytes: 0,
at: Date.now(),
name: stream.name || "Unnamed",
};
node.dataset.stream = stream.id;
node.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
if (state.staged === stream.id) return;
stage(stream.id, { fullscreen: true });
});
tile.audioBtn.addEventListener("click", (event) => {
event.stopPropagation();
setMuted(tile, !tile.video.muted);
});
tile.exitBtn.hidden = true;
tile.exitBtn.addEventListener("click", (event) => {
event.stopPropagation();
unstage();
});
node.querySelector(".expand").addEventListener("click", (event) => {
event.stopPropagation();
if (state.staged === stream.id) unstage();
else stage(stream.id, { fullscreen: true });
});
grid.appendChild(node);
tiles.set(stream.id, tile);
return tile;
}
function paintTile(tile, stream) {
tile.name = stream.name || tile.name;
const label = stream.label || stream.name || "Shared screen";
tile.title.textContent = label;
const dims = stream.width && stream.height ? `${stream.width}×${stream.height}` : "";
const parts = [surfaceShort(stream.displaySurface), dims, stream.audio ? "audio" : "silent"].filter(
Boolean,
);
tile.sub.textContent = `${stream.name || "Unknown device"}${parts.length ? ` · ${parts.join(" · ")}` : ""}`;
tile.el.setAttribute("aria-label", `Watch ${label}`);
tile.audioBtn.hidden = !stream.audio;
// The wall's audio state, readable without a pointer: a screenshot from
// another machine (docs/kiosk-silverblue/dim-probe.py) must show what the
// wall is doing, so the state lives on the element, not in the hover-only
// button. paintTile() seeds it from the server's `audio` flag; setMuted()
// keeps it in step with the element's live state.
tile.el.dataset.audio = stream.audio ? (tile.video.muted ? "off" : "on") : "none";
}
function drop(streamId) {
const tile = tiles.get(streamId);
if (!tile) return;
const wasCursor = state.cursor === streamId;
const slot = wasCursor ? ordered().findIndex((t) => t.id === streamId) : -1;
if (state.staged === streamId) unstage();
tiles.delete(streamId);
closePeer(tile);
// closePeer() is a no-op when the leg never negotiated; the stream is
// reset here too so a dropped tile never keeps a dead MediaStream.
tile.stream = null;
tile.el.remove();
publishCounts();
if (wasCursor) {
const remaining = ordered();
if (remaining.length === 0) {
state.cursor = null;
} else {
// The tile that takes over the dropped slot is the nearest; if the
// dropped tile was last, wrap to the first (the cursor wraps too).
setSelected(remaining[slot % remaining.length].id);
}
}
}
function closePeer(tile) {
if (!tile.pc) return;
const pc = tile.pc;
pc.onicecandidate = null;
pc.ontrack = null;
pc.onconnectionstatechange = null;
try {
pc.close();
} catch {
/* already closed */
}
tile.pc = null;
tile.pending = [];
// A retry builds a fresh connection and must not reattach dead tracks.
tile.stream = null;
}
/* ------------------------------------------------------------------ media */
function ensurePeer(tile) {
if (tile.pc) return tile.pc;
const pc = new RTCPeerConnection(rtc);
tile.pc = pc;
pc.onicecandidate = (event) => {
if (event.candidate && send) {
send({ type: "signal", to: tile.sharerId, payload: { candidate: event.candidate } });
}
};
// Do NOT `addTransceiver("audio", { direction: "recvonly" })` here. PLAN
// A4: the viewer only answers, and the sharer owns the track set. An
// answerer-side transceiver adds an m-line the offerer never sent and the
// m-lines must stay in the offerer's order — this is the first thing a
// future reader will be tempted to try when audio misbehaves.
pc.ontrack = (event) => {
// One MediaStream per tile, built by us: Chrome and Firefox disagree
// about whether addTransceiver tracks share a stream id, and the app
// must behave identically on both (the kiosk is Firefox, the sharers
// are usually Chrome). The old code assigned a fresh one-track stream
// per ontrack, so the second track to arrive erased the first: the
// audio callback's stream replaced the video's srcObject, the video
// kept playing from the still-attached transceiver, and the sound
// simply never came.
tile.stream = tile.stream || new MediaStream();
tile.stream.addTrack(event.track);
if (tile.video.srcObject !== tile.stream) {
tile.video.srcObject = tile.stream;
tile.video.play().catch(() => {
/* autoplay is covered by `muted`; a rejection means a gesture is needed */
});
}
// A track that ends is removed from the composed stream, not the
// connection: the video leg keeps working when a source's audio goes
// away (the video-only case), and the mute button tracks the live
// track set, so a source that stops offering audio loses its button.
event.track.addEventListener("ended", () => {
if (!tile.stream) return; // the peer was already closed; nothing to clean up
tile.stream.removeTrack(event.track);
const hasAudio = tile.stream.getAudioTracks().length > 0;
tile.audioBtn.hidden = !hasAudio;
tile.el.dataset.audio = hasAudio ? (tile.video.muted ? "off" : "on") : "none";
// The one tile that may be audible just lost its audio: re-mute it
// (which also resets the button/glyph, so the dead 🔇 cannot sit on
// an unpressed state) and say why the sound went — a silent wall in a
// shared room otherwise looks like a bug.
if (!hasAudio && state.staged === tile.id) {
setMuted(tile, true);
toast("This mirror stopped sharing audio.");
}
});
};
pc.onconnectionstatechange = () => {
tile.el.dataset.state = pc.connectionState === "connected" ? "live" : pc.connectionState;
tile.badge.textContent =
pc.connectionState === "connected"
? "live"
: pc.connectionState === "connecting"
? "connecting…"
: pc.connectionState;
publishCounts();
if (pc.connectionState === "failed") {
if (tile.attempts < 3) {
tile.attempts += 1;
tile.badge.textContent = `retry ${tile.attempts}/3`;
closePeer(tile);
setTimeout(() => {
if (tiles.has(tile.id) && send) send({ type: "subscribe", streamId: tile.id });
}, 700 * tile.attempts);
} else {
tile.badge.textContent = "unreachable";
}
}
};
return pc;
}
async function onSignal(message) {
const tile = [...tiles.values()].find((t) => t.sharerId === message.from);
if (!tile) return;
const pc = ensurePeer(tile);
const payload = message.payload || {};
try {
if (payload.sdp) {
await pc.setRemoteDescription(payload.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
send({ type: "signal", to: message.from, payload: { sdp: pc.localDescription } });
const queued = tile.pending.splice(0);
for (const candidate of queued) await pc.addIceCandidate(candidate).catch(() => {});
} else if (payload.candidate) {
if (pc.remoteDescription && pc.remoteDescription.type) {
await pc.addIceCandidate(payload.candidate);
} else {
tile.pending.push(payload.candidate);
}
}
} catch (err) {
console.warn("viewer signal rejected", err);
}
}
/* ------------------------------------------------------------------- audio */
/**
* The one place mute state changes: the tile's audio button, the `m` key and
* the staged-only audio policy all go through here, so the video's
* `muted`/`volume`, the button text, `aria-pressed` and `title` cannot drift
* apart. Kept as `(tile, muted)` on purpose.
*
* The policy: **only the staged mirror may be audible.** Unmuting a tile in
* the grid stages it first — a wall of twenty mirrors must not become twenty
* sound sources, and staging is what a person who wants a mirror's audio was
* about to do anyway — and every other tile is muted through this same
* helper, so the button, the key and the policy can never disagree. Unmuting
* never happens on its own: every path that calls `setMuted(tile, false)`
* starts from a click or a keypress (SECURITY.md §4), and `stage()` and
* `unstage()` never change mute state on their own — joining or leaving the
* stage is never a sound source, and audio is never restored after an
* unstage or a reconnect.
*/
function setMuted(tile, muted) {
if (!muted && state.staged !== tile.id) {
stage(tile.id, { fullscreen: true }); // a grid tile may not become audible in the grid
}
tile.video.muted = muted;
tile.video.volume = 1;
if (!muted) {
// Exactly one tile may be audible: mute every other tile first, then
// this one, so there is never a frame in which two mirrors play.
for (const other of tiles.values()) {
if (other !== tile) setMuted(other, true);
}
tile.video.play().catch(() => {}); // a muted play may be suspended
}
tile.audioBtn.textContent = muted ? "🔇" : "🔊";
tile.audioBtn.setAttribute("aria-pressed", String(!muted));
tile.audioBtn.title = muted ? "Unmute" : "Mute";
// The button tracks the live track set (paintTile and the `ended` handler
// keep it honest), so it is the one source of truth for "does this mirror
// have audio at all" — a toggle on a silent mirror is "none", never "on".
const hasAudio = !tile.audioBtn.hidden;
tile.el.dataset.audio = hasAudio ? (muted ? "off" : "on") : "none";
}
/* ------------------------------------------------------------------ stage */
/* The staged overlay (caption bar + control cluster) is hidden at rest so a
shared terminal's bottom line is never covered. Pointer activity on the
stage, and any wall keypress while staged (the kiosk has no mouse),
reveal it via `data-reveal`; this timeout is what fades it back, so a
parked pointer or a one-time keypress never pins it up. */
const STAGE_REVEAL_IDLE_MS = 3000;
let stageRevealTimer = 0;
function revealStageControls() {
if (!state.staged) return;
const tile = tiles.get(state.staged);
if (!tile) return;
tile.el.dataset.reveal = "";
clearTimeout(stageRevealTimer);
stageRevealTimer = setTimeout(() => {
stageRevealTimer = 0;
// The tile may have been unstaged or replaced (n/p, 1-9, a click)
// before the timer fires; only the tile still on stage loses the
// attribute. (unstage() clears the timer, so this guard also covers
// a replacement whose keepFullscreen unstage ran in between.)
if (state.staged === tile.id) delete tile.el.dataset.reveal;
}, STAGE_REVEAL_IDLE_MS);
}
/* A `document.exitFullscreen()` that `unstage()` started is in flight.
The exit settles asynchronously, and a keypress in that window (g followed
by an immediate Enter) stages a new tile before the exit's
`fullscreenchange` event lands: `state.staged` is then the NEW tile, and
the handler would tear the fresh stage down with the old exit. While the
flag is set, an exit event belongs to the unstage that already tore the
stage down synchronously, so it is ignored; the flag is cleared when the
document becomes fullscreen again (a re-stage's request won over the exit)
or when the exit settles. */
let fullscreenExitInFlight = false;
function stage(streamId, options = {}) {
const tile = tiles.get(streamId);
if (!tile) return;
if (state.staged && state.staged !== streamId) {
// Replacing the tile on stage: keep the stage's fullscreen. The fullscreen
// element is the stage container itself, so the replacement moves into a
// stage that is already fullscreen. Exiting first would deliver a
// fullscreenchange event after state.staged is already the new tile, and
// the handler would unstage the replacement — n/p stepping, 1-9 jumps and
// clicking another tile all go through this path on a kiosk.
unstage({ keepFullscreen: true });
}
state.stageParent = tile.el.parentNode;
state.stageNext = tile.el.nextSibling;
stageEl.appendChild(tile.el);
document.body.classList.add("staged");
state.staged = streamId;
// The cursor sits on whatever is on stage, so the selection stays coherent
// while the wall is hidden behind it.
state.cursor = streamId;
tile.el.dataset.selected = "";
tile.exitBtn.hidden = false;
// The cursor moves with the stage: focus the staged tile so Esc/m/g keep
// working no matter what held focus before (the grid is hidden behind it).
tile.el.focus({ preventScroll: true });
if (options.fullscreen !== false) {
const target = stageEl;
if (target.requestFullscreen) {
target.requestFullscreen({ navigationUI: "hide" }).catch(() => {
/* in-page stage still covers the viewport; fullscreen is a bonus */
});
}
}
tile.video.play().catch(() => {});
}
function unstage(options = {}) {
if (!state.staged) return;
const tile = state.staged ? tiles.get(state.staged) : null;
state.staged = null;
document.body.classList.remove("staged");
// The reveal belongs to the stage: every exit (Exit click, `g`, Esc, or a
// replacement's keepFullscreen) clears the pending timeout and the
// departing tile's `data-reveal`, so a stale reveal can never leak back
// into the grid — grid tiles never read the attribute, and re-staging
// must start hidden.
clearTimeout(stageRevealTimer);
stageRevealTimer = 0;
if (tile) delete tile.el.dataset.reveal;
if (tile) {
tile.exitBtn.hidden = true;
if (state.stageParent) {
state.stageParent.insertBefore(tile.el, state.stageNext);
} else {
grid.appendChild(tile.el);
}
}
state.stageParent = null;
state.stageNext = null;
if (tile) {
// Walking back to the wall is always silent: the kiosk in a shared room
// depends on it, and on a replacement (keepFullscreen) the departing tile
// goes quiet the same way. Re-staging the tile does not restore sound —
// every audible state must trace back to a gesture (SECURITY.md §4).
setMuted(tile, true);
}
const restoreFocus = () => {
// The tile is back in the grid at its old position; the cursor goes with
// it. The grid's focusin handler then agrees the selection with focus —
// focusTile() declines when focus is already on the tile, so this cannot
// loop. If a different tile was staged in the meantime (the exit is
// asynchronous), that tile already holds the cursor, so don't steal it.
if (tile && state.staged !== tile.id) tile.el.focus({ preventScroll: true });
};
// keepFullscreen: the caller is re-staging into the same stage container in
// the same tick, so its fullscreen (if granted) is left in place and its
// focus() lands the cursor on the new tile.
if (!options.keepFullscreen && document.fullscreenElement && document.exitFullscreen) {
// Exiting fullscreen makes the browser re-validate focus and drops it to
// <body>, which would cancel a focus() made earlier in this tick — so
// the cursor is handed back to the tile only after the exit has settled.
// The in-flight flag keeps the exit's late `fullscreenchange` from
// tearing down a stage re-entered in the meantime (see the handler).
fullscreenExitInFlight = true;
document.exitFullscreen().then(restoreFocus).catch(() => {
// A rejected exit never settles: if the document is still fullscreen
// the exit was pre-empted (or lost user activation) and the flag is
// stale — drop it so a genuine later exit still unstages.
if (document.fullscreenElement) fullscreenExitInFlight = false;
restoreFocus();
});
} else {
restoreFocus();
}
}
/* `f` while staged: re-request fullscreen on the stage container. The
in-page stage fills the viewport anyway, so the request is best-effort
and a refusal (a remote command has no user activation) is not an error —
the stage is the guarantee, the fullscreen is a bonus (the same stance
installWallKeys documents for the whole key map). */
function reRequestFullscreen() {
if (stageEl.requestFullscreen) {
stageEl.requestFullscreen({ navigationUI: "hide" }).catch(() => {
/* in-page stage still covers the viewport; fullscreen is a bonus */
});
}
}
/* The page owns the `fullscreenchange` listener — what leaving fullscreen
means is page policy (/watch unstages; the kiosk will do something else,
phase 08 task 04). This hook owns the in-flight flag and the two
behaviours it implements. */
function handleFullscreenChange(hasFullscreen) {
if (hasFullscreen) {
// (Re-)entered fullscreen — a re-stage's request won over our exit, so
// the in-flight flag is stale from this point on.
fullscreenExitInFlight = false;
return;
}
if (fullscreenExitInFlight) {
// Our own unstage's exit settling: the stage was already torn down
// synchronously, and a keypress in the exit window may have re-staged —
// the event must not take that new stage down with it.
fullscreenExitInFlight = false;
return;
}
// The browser (or the kiosk's window manager) left fullscreen on its own;
// the in-page stage is what the operator asked to leave.
if (state.staged) unstage();
}
/* ------------------------------------------------------------------ socket */
function subscribe(streamId) {
if (send) send({ type: "subscribe", streamId });
}
function absorb(stream) {
if (!stream || !stream.id) return;
const tile = tiles.get(stream.id) || makeTile(stream);
paintTile(tile, stream);
publishCounts();
if (!tile.pc) subscribe(stream.id);
}
/* The socket drop is the page's event; the wall owns what the drop means
for its tiles: every leg is closed and marked stale/reconnecting. */
function closePeers() {
for (const streamId of [...tiles.keys()]) {
const tile = tiles.get(streamId);
closePeer(tile);
tile.el.dataset.state = "stale";
tile.badge.textContent = "reconnecting…";
}
}
/* ----------------------------------------------------------------- ticker */
if (options.stats) {
setInterval(async () => {
for (const tile of tiles.values()) {
if (!tile.pc) continue;
try {
const report = await tile.pc.getStats();
let fps = 0;
let bytes = 0;
let w = 0;
let h = 0;
report.forEach((entry) => {
if (entry.type === "inbound-rtp" && entry.kind === "video") {
fps = entry.framesPerSecond || 0;
bytes = entry.bytesReceived || 0;
w = entry.frameWidth || w;
h = entry.frameHeight || h;
}
});
const now = Date.now();
const dt = Math.max(0.25, (now - tile.at) / 1000);
const bps = ((bytes - tile.bytes) / dt) * 8;
tile.bytes = bytes;
tile.at = now;
if (w && h) {
const dims = `${w}×${h}`;
const head = tile.sub.textContent.split(" · ").slice(0, 1);
tile.sub.textContent = [...head, dims, bitrateFormat(bps), `${Math.round(fps)} fps`].join(
" · ",
);
}
} catch {
/* stats are advisory */
}
}
}, 1500);
}
/* --------------------------------------------------------- wall accessors */
function ordered() {
// DOM order == insertion order, which is the grid order the page's count
// renderer uses.
return [...tiles.values()];
}
function stagedId() {
return state.staged;
}
function selectedId() {
return state.cursor;
}
function selectedTile() {
return state.cursor ? tiles.get(state.cursor) || null : null;
}
function tile(id) {
return tiles.get(id);
}
function focusTile(tile) {
// Focus follows selection (tiles are tabindex="0"). preventScroll keeps the
// grid from jumping while a remote hand walks it. If focus is already inside
// this tile — Tab reached one of its buttons — the selection already agrees,
// so don't steal focus back off the control the operator is on.
const active = document.activeElement;
if (active === tile.el || (active && tile.el.contains(active))) return;
tile.el.focus({ preventScroll: true });
}
function setSelected(streamId) {
const tile = tiles.get(streamId);
if (!tile) return;
const previous = selectedTile();
if (previous && previous !== tile) delete previous.el.dataset.selected;
tile.el.dataset.selected = "";
state.cursor = streamId;
focusTile(tile);
}
function moveSelection(delta) {
const list = ordered();
const n = list.length;
if (n === 0) return;
const index = state.cursor ? list.findIndex((tile) => tile.id === state.cursor) : -1;
if (index === -1) {
// No live cursor yet: the first j lands on the first tile, the first k on
// the last. A wall with twenty mirrors is navigated in a loop from a
// remote, so the ends wrap.
setSelected(list[delta > 0 ? 0 : n - 1].id);
return;
}
setSelected(list[(index + delta + n) % n].id);
}
return {
absorb,
drop,
onSignal,
stage,
unstage,
reRequestFullscreen,
setMuted,
ordered,
stagedId,
selectedId,
selectedTile,
setSelected,
moveSelection,
tile,
closePeers,
revealStageControls,
handleFullscreenChange,
};
}
/* ----------------------------------------------------------------- keys */
/**
* The one keydown listener for a wall page. It maps keys to named commands and
* hands them to the page; it never decides what a command does to shared state.
*
* A page returns `true` only when the key actually changed something — the
* router calls `preventDefault()` on exactly those, which is today's discipline
* on /watch (`g` unstaged and `Esc` with nothing open are not "handled" and
* keep their browser default).
*
* The wall commands (`next`, `previous`, `stage`, `fullscreen`, `wall`,
* `step_next`, `step_previous`, `mute`, `jump`) have a default implementation
* here, used when the page returns nothing for them, so `onCommand` can be a
* thin observer: /watch answers only `help` and `escape`, and a page like
* /kiosk can publish what a key did (`arrange`) while the module performs it.
* `help` and `escape` have **no** default: they are page policy, and a page
* that ignores them must not break the router.
*
* `next`/`previous` run in the module on purpose: moving the selection is wall
* state, not page policy. The page still sees the command, so a page that
* wants to publish the new cursor can.
*
* Any keypress while staged reveals the overlay first, handled or not: a key on
* a mouseless kiosk is the only way to read the caption and reach the controls
* (a silent no-op key flashing the caption is the debuggable outcome). The idle
* timeout fades it again, and the reveal never preventDefaults, so unhandled
* keys keep their defaults.
*
* Never intercepted: any chord with Ctrl/Cmd/Alt (back, reload, copy belong to
* the browser) and every key inside an <input>/<textarea>/<select>/contenteditable.
*
* @param {{wall: object, onCommand: (command: string, detail: {key: string, index?: number, event: KeyboardEvent}) => boolean, root?: Document}} options
*/
export function installWallKeys(options) {
const wall = options.wall;
const onCommand = options.onCommand || null;
const root = options.root || document;
/* ------------------------------------------------- default wall commands */
function defaultStage() {
// Enter and `f` (when unstaged) share a target: the selected tile, or the
// first tile when nothing is selected yet. With nothing on the wall there
// is nothing to show, so say why instead of staying silent.
const tile = wall.selectedTile() || wall.ordered()[0] || null;
if (!tile) {
toast("no mirrors to stage");
return true;
}
wall.stage(tile.id, { fullscreen: true });
return true;
}
function defaultFullscreen() {
if (wall.stagedId()) {
// The kiosk's window manager can drop the *window* out of fullscreen
// without mirror noticing (docs/kiosk-silverblue.md: window-config.ini
// forces the window fullscreen; an element requestFullscreen() on top
// of that is allowed and simply no-ops if the browser refuses). So
// `f` re-requests rather than tracking state.
wall.reRequestFullscreen();
} else {
// When unstaged it stages like Enter.
defaultStage();
}
return true;
}
function defaultWall() {
const wasStaged = wall.stagedId() !== null;
wall.unstage();
// When already unstaged the wall *is* the visible result "back to the
// wall", so there is nothing to toast; the key was only handled if it
// changed state.
return wasStaged;
}
function stepStage(delta) {
// `n`/`p` *stage* the neighbour, not just move the selection: a remote
// operator cannot reach an Enter afterwards, so the step is the switch.
// The cursor sits on the staged tile while staged (stage() moves it), so
// stepping from the stage walks the wall; stage() unstages the previous
// tile, and unstaging moves — never recreates — the tile element.
const ordered = wall.ordered();
if (ordered.length === 0) {
toast("no mirrors to switch to");
return true;
}
const selectedId = wall.selectedId();
let index = selectedId ? ordered.findIndex((tile) => tile.id === selectedId) : -1;
if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n lands first, p wraps to last
const next = ordered[(index + delta + ordered.length) % ordered.length];
wall.stage(next.id, { fullscreen: true });
return true;
}
function defaultMute() {
const stagedId = wall.stagedId();
const tile = (stagedId && wall.tile(stagedId)) || wall.selectedTile();
if (!tile) {
toast("no mirror to mute");
} else {
wall.setMuted(tile, !tile.video.muted);
// A stream with no audio track still toggles the element's `muted`,
// but "I muted a silent mirror" and "the keyboard is broken" look
// identical without a word.
if (tile.audioBtn.hidden) toast("this mirror has no audio");
}
return true;
}
function defaultJump(detail) {
// 1-9: jump to the Nth mirror in grid order. A no-op gets a toast — a
// silent no-op is the one outcome a keyboard-only operator cannot debug.
const tile = wall.ordered()[detail.index];
if (tile) {
wall.stage(tile.id, { fullscreen: true });
} else {
toast(`no mirror ${detail.index + 1}`);
}
return true;
}
const defaults = {
next: () => {
wall.moveSelection(1);
return true;
},
previous: () => {
wall.moveSelection(-1);
return true;
},
stage: defaultStage,
fullscreen: defaultFullscreen,
wall: defaultWall,
step_next: () => stepStage(1),
step_previous: () => stepStage(-1),
mute: defaultMute,
jump: defaultJump,
};
function onKey(event) {
if (event.defaultPrevented) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
return; // a key typed in a text field is text, not a command
}
// Any wall keypress while staged reveals the overlay, handled or not —
// before dispatch, so an unhandled key still gets the flash. (See the
// docstring: this never preventDefaults.)
if (wall.stagedId()) wall.revealStageControls();
// Match case-insensitively: a caps-locked kiosk keyboard, or a remote hand
// holding Shift, must drive the wall exactly the same. "?" is the only key
// that needs Shift; it lowercases to itself, so the same match covers it.
const key = event.key.toLowerCase();
let command = null;
const detail = { key, event };
switch (key) {
case "j":
case "arrowdown":
command = "next";
break;
case "k":
case "arrowup":
command = "previous";
break;
case "enter":
command = "stage";
break;
case "f":
command = "fullscreen";
break;
case "g":
command = "wall";
break;
case "n":
command = "step_next";
break;
case "p":
command = "step_previous";
break;
case "m":
command = "mute";
break;
case "?":
command = "help";
break;
case "escape":
command = "escape";
break;
default:
if (/^[1-9]$/.test(key)) {
command = "jump";
detail.index = Number(key) - 1; // 0-based
}
break;
}
if (!command) return;
// The page speaks first: when it does not handle the key (returns nothing
// or false), the module performs the wall command. A page that returns true
// has handled it itself — for `help`/`escape` that is the only way.
let handled = onCommand ? onCommand(command, detail) === true : false;
if (!handled && defaults[command]) {
handled = defaults[command](detail) === true;
}
if (handled) event.preventDefault();
}
root.addEventListener("keydown", onKey);
}
+6 -2
View File
@@ -17,6 +17,7 @@
<span class="meta" id="counts">no mirrors</span>
<div class="spacer"></div>
<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>
</header>
@@ -31,8 +32,11 @@
</p>
</div>
<!-- A staged tile is moved into this container, so its video never restarts. -->
<div id="watch-stage" aria-live="polite"></div>
<!-- A staged tile is moved into this container, so its video never restarts.
The stage is class-keyed (not id-keyed) so a second page's stage (the
/kiosk wall, phase 08) picks up the same rules; #watch-stage stays so
the e2e suites and wall's selectors keep one anchor. -->
<div id="watch-stage" class="stage" aria-live="polite"></div>
<!-- The key list: on a mouseless kiosk `?` is the only way to see the key
map, so it lives in the page (static markup) where a translator can
+73 -685
View File
@@ -1,8 +1,12 @@
/* The watch page: a grid of every live mirror. Click one and it takes the
* stage — the tile element itself moves into the stage container, so the
* RTCPeerConnection and the <video> keep running through the transition. */
* RTCPeerConnection and the <video> keep running through the transition.
* The tile/peer/stage machinery and the one keydown listener live in wall.js
* (createWall, installWallKeys); this page owns the socket, the page chrome
* (counts, net, help, share link) and the key policy (help/escape). */
import { $, bitrateFormat, connectSignaling, loadConfig, refusalNotice, rtcConfig, showNotice, toast } from "./net.js";
import { $, connectSignaling, loadConfig, refusalNotice, rtcConfig, showNotice, toast } from "./net.js";
import { createWall, installWallKeys } from "./wall.js";
const ui = {
grid: $("#grid"),
@@ -22,17 +26,22 @@ const ui = {
const config = await loadConfig();
const rtc = rtcConfig(config);
/** streamId -> {id, sharerId, el, video, badge, sub, pc, pending, attempts, stream, stats} */
const tiles = new Map();
const state = { ws: null };
const state = {
ws: null,
staged: null,
stageParent: null,
stageNext: null,
ticker: 0,
cursor: null,
};
/* The wall owns the tiles, the peers and the stage; the page hands it its
elements and the socket's send — guarded on the socket the way the moved
code guarded on state.ws, so the wall can be built before the socket opens
(net.js stays the only place a socket exists, AGENTS.md §6) — and learns
the counts back through renderCounts(). */
const wall = createWall({
grid: ui.grid,
stageEl: ui.stage,
template: ui.template,
rtc,
send: (payload) => (state.ws ? state.ws.send(payload) : false),
onChange: renderCounts,
stats: true,
});
/* -------------------------------------------------------------- rendering */
@@ -43,456 +52,37 @@ function setNet(next, detail) {
if (label) label.textContent = detail || next;
}
function renderCounts() {
const n = tiles.size;
const live = [...tiles.values()].filter((t) => t.pc && t.pc.connectionState === "connected").length;
function renderCounts({ mirrors, live }) {
const n = mirrors;
ui.counts.textContent =
n === 0 ? "no mirrors" : `${n} mirror${n === 1 ? "" : "s"}${live ? ` · ${live} live` : ""}`;
ui.empty.hidden = n > 0;
ui.grid.hidden = n === 0;
}
function surfaceShort(setting) {
return { window: "window", monitor: "screen", browser: "tab" }[setting] || setting || "";
}
function makeTile(stream) {
const node = ui.template.content.firstElementChild.cloneNode(true);
const tile = {
id: stream.id,
sharerId: stream.sharerId,
el: node,
video: node.querySelector("video"),
badge: node.querySelector(".badge"),
title: node.querySelector(".title"),
sub: node.querySelector(".sub"),
audioBtn: node.querySelector(".audio"),
exitBtn: node.querySelector(".exit"),
pc: null,
pending: [],
attempts: 0,
stream: null,
bytes: 0,
at: Date.now(),
name: stream.name || "Unnamed",
};
node.dataset.stream = stream.id;
node.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
if (state.staged === stream.id) return;
stage(stream.id, { fullscreen: true });
});
tile.audioBtn.addEventListener("click", (event) => {
event.stopPropagation();
setMuted(tile, !tile.video.muted);
});
tile.exitBtn.hidden = true;
tile.exitBtn.addEventListener("click", (event) => {
event.stopPropagation();
unstage();
});
node.querySelector(".expand").addEventListener("click", (event) => {
event.stopPropagation();
if (state.staged === stream.id) unstage();
else stage(stream.id, { fullscreen: true });
});
ui.grid.appendChild(node);
tiles.set(stream.id, tile);
return tile;
}
function paintTile(tile, stream) {
tile.name = stream.name || tile.name;
const label = stream.label || stream.name || "Shared screen";
tile.title.textContent = label;
const dims = stream.width && stream.height ? `${stream.width}×${stream.height}` : "";
const parts = [surfaceShort(stream.displaySurface), dims, stream.audio ? "audio" : "silent"].filter(
Boolean,
);
tile.sub.textContent = `${stream.name || "Unknown device"}${parts.length ? ` · ${parts.join(" · ")}` : ""}`;
tile.el.setAttribute("aria-label", `Watch ${label}`);
tile.audioBtn.hidden = !stream.audio;
// The wall's audio state, readable without a pointer: a screenshot from
// another machine (docs/kiosk-silverblue/dim-probe.py) must show what the
// wall is doing, so the state lives on the element, not in the hover-only
// button. paintTile() seeds it from the server's `audio` flag; setMuted()
// keeps it in step with the element's live state.
tile.el.dataset.audio = stream.audio ? (tile.video.muted ? "off" : "on") : "none";
}
function dropTile(streamId) {
const tile = tiles.get(streamId);
if (!tile) return;
const wasCursor = state.cursor === streamId;
const slot = wasCursor ? orderedTiles().findIndex((t) => t.id === streamId) : -1;
if (state.staged === streamId) unstage();
tiles.delete(streamId);
closePeer(tile);
// closePeer() is a no-op when the leg never negotiated; the stream is
// reset here too so a dropped tile never keeps a dead MediaStream.
tile.stream = null;
tile.el.remove();
renderCounts();
if (wasCursor) {
const remaining = orderedTiles();
if (remaining.length === 0) {
state.cursor = null;
} else {
// The tile that takes over the dropped slot is the nearest; if the
// dropped tile was last, wrap to the first (the cursor wraps too).
setSelected(remaining[slot % remaining.length].id);
}
}
}
function closePeer(tile) {
if (!tile.pc) return;
const pc = tile.pc;
pc.onicecandidate = null;
pc.ontrack = null;
pc.onconnectionstatechange = null;
try {
pc.close();
} catch {
/* already closed */
}
tile.pc = null;
tile.pending = [];
// A retry builds a fresh connection and must not reattach dead tracks.
tile.stream = null;
}
/* ------------------------------------------------------------------ media */
function ensurePeer(tile) {
if (tile.pc) return tile.pc;
const pc = new RTCPeerConnection(rtc);
tile.pc = pc;
pc.onicecandidate = (event) => {
if (event.candidate && state.ws) {
state.ws.send({ type: "signal", to: tile.sharerId, payload: { candidate: event.candidate } });
}
};
// Do NOT `addTransceiver("audio", { direction: "recvonly" })` here. PLAN
// A4: the viewer only answers, and the sharer owns the track set. An
// answerer-side transceiver adds an m-line the offerer never sent and the
// m-lines must stay in the offerer's order — this is the first thing a
// future reader will be tempted to try when audio misbehaves.
pc.ontrack = (event) => {
// One MediaStream per tile, built by us: Chrome and Firefox disagree
// about whether addTransceiver tracks share a stream id, and the app
// must behave identically on both (the kiosk is Firefox, the sharers
// are usually Chrome). The old code assigned a fresh one-track stream
// per ontrack, so the second track to arrive erased the first: the
// audio callback's stream replaced the video's srcObject, the video
// kept playing from the still-attached transceiver, and the sound
// simply never came.
tile.stream = tile.stream || new MediaStream();
tile.stream.addTrack(event.track);
if (tile.video.srcObject !== tile.stream) {
tile.video.srcObject = tile.stream;
tile.video.play().catch(() => {
/* autoplay is covered by `muted`; a rejection means a gesture is needed */
});
}
// A track that ends is removed from the composed stream, not the
// connection: the video leg keeps working when a source's audio goes
// away (the video-only case), and the mute button tracks the live
// track set, so a source that stops offering audio loses its button.
event.track.addEventListener("ended", () => {
if (!tile.stream) return; // the peer was already closed; nothing to clean up
tile.stream.removeTrack(event.track);
const hasAudio = tile.stream.getAudioTracks().length > 0;
tile.audioBtn.hidden = !hasAudio;
tile.el.dataset.audio = hasAudio ? (tile.video.muted ? "off" : "on") : "none";
// The one tile that may be audible just lost its audio: re-mute it
// (which also resets the button/glyph, so the dead 🔇 cannot sit on
// an unpressed state) and say why the sound went — a silent wall in a
// shared room otherwise looks like a bug.
if (!hasAudio && state.staged === tile.id) {
setMuted(tile, true);
toast("This mirror stopped sharing audio.");
}
});
};
pc.onconnectionstatechange = () => {
tile.el.dataset.state = pc.connectionState === "connected" ? "live" : pc.connectionState;
tile.badge.textContent =
pc.connectionState === "connected"
? "live"
: pc.connectionState === "connecting"
? "connecting…"
: pc.connectionState;
renderCounts();
if (pc.connectionState === "failed") {
if (tile.attempts < 3) {
tile.attempts += 1;
tile.badge.textContent = `retry ${tile.attempts}/3`;
closePeer(tile);
setTimeout(() => {
if (tiles.has(tile.id) && state.ws) state.ws.send({ type: "subscribe", streamId: tile.id });
}, 700 * tile.attempts);
} else {
tile.badge.textContent = "unreachable";
}
}
};
return pc;
}
async function onSignal(message) {
const tile = [...tiles.values()].find((t) => t.sharerId === message.from);
if (!tile) return;
const pc = ensurePeer(tile);
const payload = message.payload || {};
try {
if (payload.sdp) {
await pc.setRemoteDescription(payload.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
state.ws.send({ type: "signal", to: message.from, payload: { sdp: pc.localDescription } });
const queued = tile.pending.splice(0);
for (const candidate of queued) await pc.addIceCandidate(candidate).catch(() => {});
} else if (payload.candidate) {
if (pc.remoteDescription && pc.remoteDescription.type) {
await pc.addIceCandidate(payload.candidate);
} else {
tile.pending.push(payload.candidate);
}
}
} catch (err) {
console.warn("viewer signal rejected", err);
}
}
/* ------------------------------------------------------------------- audio */
/**
* The one place mute state changes: the tile's audio button, the `m` key and
* the staged-only audio policy all go through here, so the video's
* `muted`/`volume`, the button text, `aria-pressed` and `title` cannot drift
* apart. Kept as `(tile, muted)` on purpose.
*
* The policy: **only the staged mirror may be audible.** Unmuting a tile in
* the grid stages it first — a wall of twenty mirrors must not become twenty
* sound sources, and staging is what a person who wants a mirror's audio was
* about to do anyway — and every other tile is muted through this same
* helper, so the button, the key and the policy can never disagree. Unmuting
* never happens on its own: every path that calls `setMuted(tile, false)`
* starts from a click or a keypress (SECURITY.md §4), and `stage()` and
* `unstage()` never change mute state on their own — joining or leaving the
* stage is never a sound source, and audio is never restored after an
* unstage or a reconnect.
*/
function setMuted(tile, muted) {
if (!muted && state.staged !== tile.id) {
stage(tile.id, { fullscreen: true }); // a grid tile may not become audible in the grid
}
tile.video.muted = muted;
tile.video.volume = 1;
if (!muted) {
// Exactly one tile may be audible: mute every other tile first, then
// this one, so there is never a frame in which two mirrors play.
for (const other of tiles.values()) {
if (other !== tile) setMuted(other, true);
}
tile.video.play().catch(() => {}); // a muted play may be suspended
}
tile.audioBtn.textContent = muted ? "🔇" : "🔊";
tile.audioBtn.setAttribute("aria-pressed", String(!muted));
tile.audioBtn.title = muted ? "Unmute" : "Mute";
// The button tracks the live track set (paintTile and the `ended` handler
// keep it honest), so it is the one source of truth for "does this mirror
// have audio at all" — a toggle on a silent mirror is "none", never "on".
const hasAudio = !tile.audioBtn.hidden;
tile.el.dataset.audio = hasAudio ? (muted ? "off" : "on") : "none";
}
/* ------------------------------------------------------------------ stage */
/* The staged overlay (caption bar + control cluster) is hidden at rest so a
shared terminal's bottom line is never covered. Pointer activity on the
stage, and any wall keypress while staged (the kiosk has no mouse),
reveal it via `data-reveal`; this timeout is what fades it back, so a
parked pointer or a one-time keypress never pins it up. */
const STAGE_REVEAL_IDLE_MS = 3000;
let stageRevealTimer = 0;
function revealStageControls() {
if (!state.staged) return;
const tile = tiles.get(state.staged);
if (!tile) return;
tile.el.dataset.reveal = "";
clearTimeout(stageRevealTimer);
stageRevealTimer = setTimeout(() => {
stageRevealTimer = 0;
// The tile may have been unstaged or replaced (n/p, 1-9, a click)
// before the timer fires; only the tile still on stage loses the
// attribute. (unstage() clears the timer, so this guard also covers
// a replacement whose keepFullscreen unstage ran in between.)
if (state.staged === tile.id) delete tile.el.dataset.reveal;
}, STAGE_REVEAL_IDLE_MS);
}
/* A `document.exitFullscreen()` that `unstage()` started is in flight.
The exit settles asynchronously, and a keypress in that window (g followed
by an immediate Enter) stages a new tile before the exit's
`fullscreenchange` event lands: `state.staged` is then the NEW tile, and
the handler would tear the fresh stage down with the old exit. While the
flag is set, an exit event belongs to the unstage that already tore the
stage down synchronously, so it is ignored; the flag is cleared when the
document becomes fullscreen again (a re-stage's request won over the exit)
or when the exit settles. */
let fullscreenExitInFlight = false;
export function stage(streamId, options = {}) {
const tile = tiles.get(streamId);
if (!tile) return;
if (state.staged && state.staged !== streamId) {
// Replacing the tile on stage: keep the stage's fullscreen. The fullscreen
// element is the stage container itself, so the replacement moves into a
// stage that is already fullscreen. Exiting first would deliver a
// fullscreenchange event after state.staged is already the new tile, and
// the handler would unstage the replacement — n/p stepping, 1-9 jumps and
// clicking another tile all go through this path on a kiosk.
unstage({ keepFullscreen: true });
}
state.stageParent = tile.el.parentNode;
state.stageNext = tile.el.nextSibling;
ui.stage.appendChild(tile.el);
document.body.classList.add("staged");
state.staged = streamId;
// The cursor sits on whatever is on stage, so the selection stays coherent
// while the wall is hidden behind it.
state.cursor = streamId;
tile.el.dataset.selected = "";
tile.exitBtn.hidden = false;
// The cursor moves with the stage: focus the staged tile so Esc/m/g keep
// working no matter what held focus before (the grid is hidden behind it).
tile.el.focus({ preventScroll: true });
if (options.fullscreen !== false) {
const target = ui.stage;
if (target.requestFullscreen) {
target.requestFullscreen({ navigationUI: "hide" }).catch(() => {
/* in-page stage still covers the viewport; fullscreen is a bonus */
});
}
}
tile.video.play().catch(() => {});
}
export function unstage(options = {}) {
if (!state.staged) return;
const tile = state.staged ? tiles.get(state.staged) : null;
state.staged = null;
document.body.classList.remove("staged");
// The reveal belongs to the stage: every exit (Exit click, `g`, Esc, or a
// replacement's keepFullscreen) clears the pending timeout and the
// departing tile's `data-reveal`, so a stale reveal can never leak back
// into the grid — grid tiles never read the attribute, and re-staging
// must start hidden.
clearTimeout(stageRevealTimer);
stageRevealTimer = 0;
if (tile) delete tile.el.dataset.reveal;
if (tile) {
tile.exitBtn.hidden = true;
if (state.stageParent) {
state.stageParent.insertBefore(tile.el, state.stageNext);
} else {
ui.grid.appendChild(tile.el);
}
}
state.stageParent = null;
state.stageNext = null;
if (tile) {
// Walking back to the wall is always silent: the kiosk in a shared room
// depends on it, and on a replacement (keepFullscreen) the departing tile
// goes quiet the same way. Re-staging the tile does not restore sound —
// every audible state must trace back to a gesture (SECURITY.md §4).
setMuted(tile, true);
}
const restoreFocus = () => {
// The tile is back in the grid at its old position; the cursor goes with
// it. The grid's focusin handler then agrees the selection with focus —
// focusTile() declines when focus is already on the tile, so this cannot
// loop. If a different tile was staged in the meantime (the exit is
// asynchronous), that tile already holds the cursor, so don't steal it.
if (tile && state.staged !== tile.id) tile.el.focus({ preventScroll: true });
};
// keepFullscreen: the caller is re-staging into the same stage container in
// the same tick, so its fullscreen (if granted) is left in place and its
// focus() lands the cursor on the new tile.
if (!options.keepFullscreen && document.fullscreenElement && document.exitFullscreen) {
// Exiting fullscreen makes the browser re-validate focus and drops it to
// <body>, which would cancel a focus() made earlier in this tick — so
// the cursor is handed back to the tile only after the exit has settled.
// The in-flight flag keeps the exit's late `fullscreenchange` from
// tearing down a stage re-entered in the meantime (see the handler).
fullscreenExitInFlight = true;
document.exitFullscreen().then(restoreFocus).catch(() => {
// A rejected exit never settles: if the document is still fullscreen
// the exit was pre-empted (or lost user activation) and the flag is
// stale — drop it so a genuine later exit still unstages.
if (document.fullscreenElement) fullscreenExitInFlight = false;
restoreFocus();
});
} else {
restoreFocus();
}
}
// The browser's fullscreen state is page policy: /watch unstages when the
// browser (or the kiosk's window manager) leaves fullscreen on its own. The
// exit-race flag and its two behaviours live in wall.js (the
// handleFullscreenChange hook); the listener stays here so the kiosk page can
// observe the same event without the /watch policy (phase 08 task 04).
document.addEventListener("fullscreenchange", () => {
if (document.fullscreenElement) {
// (Re-)entered fullscreen — a re-stage's request won over our exit, so
// the in-flight flag is stale from this point on.
fullscreenExitInFlight = false;
return;
}
if (fullscreenExitInFlight) {
// Our own unstage's exit settling: the stage was already torn down
// synchronously, and a keypress in the exit window may have re-staged —
// the event must not take that new stage down with it.
fullscreenExitInFlight = false;
return;
}
// The browser (or the kiosk's window manager) left fullscreen on its own;
// the in-page stage is what the operator asked to leave.
if (state.staged) unstage();
wall.handleFullscreenChange(document.fullscreenElement !== null);
});
if (ui.unstage) ui.unstage.addEventListener("click", unstage);
// Pointer activity on the stage reveals the overlay. `pointerdown` — not
// just `pointermove` — is a deliberate touch tap, so a phone watcher can
// reach the controls without a hover; the idle timeout is what fades them
// again, so a parked pointer never pins the overlay up.
ui.stage.addEventListener("pointermove", revealStageControls);
ui.stage.addEventListener("pointerdown", revealStageControls);
ui.stage.addEventListener("pointermove", wall.revealStageControls);
ui.stage.addEventListener("pointerdown", wall.revealStageControls);
// The only stage-exit control is the per-tile `.exit` button: there is no
// page-level unstage control, and there must not be a second one.
/* ------------------------------------------------------------------ socket */
function subscribe(streamId) {
if (state.ws) state.ws.send({ type: "subscribe", streamId });
}
function absorb(stream) {
if (!stream || !stream.id) return;
const tile = tiles.get(stream.id) || makeTile(stream);
paintTile(tile, stream);
renderCounts();
if (!tile.pc) subscribe(stream.id);
}
state.ws = connectSignaling({
refused: !!config.refused,
refusedReason: config.refusedMessage,
@@ -505,30 +95,24 @@ state.ws = connectSignaling({
onClose() {
// The roster is invalid the moment the socket drops; the fresh `welcome`
// after reconnect rebuilds it.
for (const streamId of [...tiles.keys()]) {
const tile = tiles.get(streamId);
closePeer(tile);
tile.el.dataset.state = "stale";
tile.badge.textContent = "reconnecting…";
}
wall.closePeers();
},
onMessage(message) {
switch (message.type) {
case "welcome":
for (const stream of message.streams || []) absorb(stream);
renderCounts();
for (const stream of message.streams || []) wall.absorb(stream);
break;
case "stream_added":
absorb(message.stream);
wall.absorb(message.stream);
break;
case "stream_updated":
absorb(message.stream);
wall.absorb(message.stream);
break;
case "stream_removed":
dropTile(message.streamId);
wall.drop(message.streamId);
break;
case "signal":
onSignal(message);
wall.onSignal(message);
break;
case "error":
console.warn("server:", message.code, message.message);
@@ -540,248 +124,52 @@ state.ws = connectSignaling({
},
});
/* ----------------------------------------------------------------- ticker */
state.ticker = setInterval(async () => {
for (const tile of tiles.values()) {
if (!tile.pc) continue;
try {
const report = await tile.pc.getStats();
let fps = 0;
let bytes = 0;
let w = 0;
let h = 0;
report.forEach((entry) => {
if (entry.type === "inbound-rtp" && entry.kind === "video") {
fps = entry.framesPerSecond || 0;
bytes = entry.bytesReceived || 0;
w = entry.frameWidth || w;
h = entry.frameHeight || h;
}
});
const now = Date.now();
const dt = Math.max(0.25, (now - tile.at) / 1000);
const bps = ((bytes - tile.bytes) / dt) * 8;
tile.bytes = bytes;
tile.at = now;
if (w && h) {
const dims = `${w}×${h}`;
const head = tile.sub.textContent.split(" · ").slice(0, 1);
tile.sub.textContent = [...head, dims, bitrateFormat(bps), `${Math.round(fps)} fps`].join(" · ");
}
} catch {
/* stats are advisory */
}
}
}, 1500);
/* -------------------------------------------------------- keyboard router */
/* The kiosk rule this page lives by: no command may require a pointer, and no
pointer affordance may be the only way to see state. This is the ONE place
a keypress on /watch is read — one listener, one switch. Every command in the
key map (j/k/arrows, Enter/f, g, n/p, 1-9, m, ?, Esc) is a case here, never
a new listener. Chords with Ctrl/Cmd/Alt belong to the browser (back, reload,
copy), and a key in a text field is text, not a command, so both bail out
before the switch. Only keys this router actually handled are
preventDefault'ed. */
pointer affordance may be the only way to see state. The one keydown
listener is installed by wall.js (installWallKeys): one listener, one
switch. Every command in the key map (j/k/arrows, Enter/f, g, n/p, 1-9, m,
?, Esc) is a case there, never a new listener. Chords with Ctrl/Cmd/Alt
belong to the browser (back, reload, copy), and a key in a text field is
text, not a command, so both bail out before the switch. Only keys the
router actually handled are preventDefault'ed.
function orderedTiles() {
// DOM order == insertion order, which is the grid order renderCounts() uses.
return [...tiles.values()];
}
/watch's onCommand is a thin observer: the wall commands (j/k, Enter, f, g,
n/p, m, 1-9) run in wall.js's defaults, and the page answers only `help`
and `escape` — page policy, no default. */
function selectedTile() {
return state.cursor ? tiles.get(state.cursor) || null : null;
}
function focusTile(tile) {
// Focus follows selection (tiles are tabindex="0"). preventScroll keeps the
// grid from jumping while a remote hand walks it. If focus is already inside
// this tile — Tab reached one of its buttons — the selection already agrees,
// so don't steal focus back off the control the operator is on.
const active = document.activeElement;
if (active === tile.el || (active && tile.el.contains(active))) return;
tile.el.focus({ preventScroll: true });
}
function setSelected(streamId) {
const tile = tiles.get(streamId);
if (!tile) return;
const previous = selectedTile();
if (previous && previous !== tile) delete previous.el.dataset.selected;
tile.el.dataset.selected = "";
state.cursor = streamId;
focusTile(tile);
}
function moveSelection(delta) {
const ordered = orderedTiles();
const n = ordered.length;
if (n === 0) return;
const index = state.cursor ? ordered.findIndex((tile) => tile.id === state.cursor) : -1;
if (index === -1) {
// No live cursor yet: the first j lands on the first tile, the first k on
// the last. A wall with twenty mirrors is navigated in a loop from a
// remote, so the ends wrap.
setSelected(ordered[delta > 0 ? 0 : n - 1].id);
return;
}
setSelected(ordered[(index + delta + n) % n].id);
}
/**
* `?` shows and hides the key list. Focus is deliberately never touched: the
* selected tile holds it throughout, so `?` → `?` → `Esc` lands the operator
* exactly where they were — stage included.
*/
function toggleHelp() {
ui.help.hidden = !ui.help.hidden;
}
function stageSelectedOrFirst() {
// Enter and `f` (when unstaged) share a target: the selected tile, or the
// first tile when nothing is selected yet. With nothing on the wall there
// is nothing to show, so say why instead of staying silent.
const tile = selectedTile() || orderedTiles()[0] || null;
if (!tile) {
toast("no mirrors to stage");
return;
}
stage(tile.id, { fullscreen: true });
}
function stepStage(delta) {
// `n`/`p` *stage* the neighbour, not just move the selection: a remote
// operator cannot reach an Enter afterwards, so the step is the switch.
// The cursor sits on the staged tile while staged (stage() moves it), so
// stepping from the stage walks the wall; stage() unstages the previous
// tile, and unstaging moves — never recreates — the tile element.
const ordered = orderedTiles();
if (ordered.length === 0) {
toast("no mirrors to switch to");
return;
}
let index = state.cursor ? ordered.findIndex((tile) => tile.id === state.cursor) : -1;
if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n lands first, p wraps to last
const next = ordered[(index + delta + ordered.length) % ordered.length];
stage(next.id, { fullscreen: true });
}
function onWallKey(event) {
if (event.defaultPrevented) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
return; // a key typed in a text field is text, not a command
}
// Any wall keypress while staged reveals the overlay, handled or not: a
// key in a mouseless kiosk is the only way to read the caption and reach
// the controls (a silent no-op key flashing the caption is the
// debuggable outcome). The idle timeout fades it again, and this call
// never preventDefaults, so unhandled keys keep their defaults.
if (state.staged) revealStageControls();
// Match case-insensitively: a caps-locked kiosk keyboard, or a remote hand
// holding Shift, must drive the wall exactly the same. "?" is the only key
// that needs Shift; it lowercases to itself, so the same match covers it.
const key = event.key.toLowerCase();
switch (key) {
case "j":
case "arrowdown":
moveSelection(1);
event.preventDefault();
break;
case "k":
case "arrowup":
moveSelection(-1);
event.preventDefault();
break;
case "enter":
stageSelectedOrFirst();
event.preventDefault(); // the default would click a focused button instead
break;
case "f":
if (state.staged) {
// The kiosk's window manager can drop the *window* out of fullscreen
// without mirror noticing (docs/kiosk-silverblue.md: window-config.ini
// forces the window fullscreen; an element requestFullscreen() on top
// of that is allowed and simply no-ops if the browser refuses). So
// `f` re-requests rather than tracking state. When unstaged it stages
// like Enter.
if (ui.stage.requestFullscreen) {
ui.stage.requestFullscreen({ navigationUI: "hide" }).catch(() => {});
}
} else {
stageSelectedOrFirst();
}
event.preventDefault();
break;
case "g": {
const wasStaged = state.staged !== null;
unstage();
// When already unstaged the wall *is* the visible result "back to the
// wall", so there is nothing to toast; the key was only handled if it
// changed state.
if (wasStaged) event.preventDefault();
break;
}
case "n":
case "p":
stepStage(key === "n" ? 1 : -1);
event.preventDefault();
break;
case "m": {
const tile = (state.staged && tiles.get(state.staged)) || selectedTile();
if (!tile) {
toast("no mirror to mute");
} else {
setMuted(tile, !tile.video.muted);
// A stream with no audio track still toggles the element's `muted`,
// but "I muted a silent mirror" and "the keyboard is broken" look
// identical without a word.
if (tile.audioBtn.hidden) toast("this mirror has no audio");
}
event.preventDefault();
break;
}
case "?":
toggleHelp();
event.preventDefault();
break;
function onCommand(command, detail) {
switch (command) {
case "help":
// `?` shows and hides the key list. Focus is deliberately never touched:
// the selected tile holds it throughout, so `?` → `?` → `Esc` lands the
// operator exactly where they were — stage included. The toggle always
// flips, so the key was always handled.
ui.help.hidden = !ui.help.hidden;
return true;
case "escape":
// The overlay sits above the stage (z-index 70 over 50), so Esc closes
// it FIRST and only leaves the stage when it was not open: ? over a
// staged mirror is two Escapes out of the wall, in that order.
if (!ui.help.hidden) {
ui.help.hidden = true;
event.preventDefault();
} else if (state.staged) {
unstage();
event.preventDefault();
return true;
}
break;
if (wall.stagedId()) {
wall.unstage();
return true;
}
// Nothing open: not handled — the browser keeps its default.
return false;
default:
// 1-9: jump to the Nth mirror in grid order. A no-op gets a toast — a
// silent no-op is the one outcome a keyboard-only operator cannot debug.
if (/^[1-9]$/.test(key)) {
const tile = orderedTiles()[Number(key) - 1];
if (tile) {
stage(tile.id, { fullscreen: true });
} else {
toast(`no mirror ${key}`);
}
event.preventDefault();
}
break;
// j/k, Enter, f, g, n/p, m, 1-9 are wall commands: the defaults in
// wall.js perform them; /watch has no policy of its own for them.
return false;
}
}
document.addEventListener("keydown", onWallKey);
installWallKeys({ wall, onCommand });
// Tab is the browser's own cursor, and it must agree with j/k: when a tile
// (or a control inside one) receives focus, the selection moves with it.
@@ -790,8 +178,8 @@ document.addEventListener("keydown", onWallKey);
// to a tile's own button is not stolen back to the tile.
ui.grid.addEventListener("focusin", (event) => {
const node = event.target instanceof Element ? event.target.closest(".tile") : null;
if (node && node.dataset.stream && tiles.has(node.dataset.stream)) {
setSelected(node.dataset.stream);
if (node && node.dataset.stream && wall.tile(node.dataset.stream)) {
wall.setSelected(node.dataset.stream);
}
});
@@ -801,4 +189,4 @@ const refusal = refusalNotice(config);
if (refusal) showNotice(ui.notice, refusal);
if (ui.share) ui.share.href = `${location.origin}/mirror`;
renderCounts();
renderCounts({ mirrors: 0, live: 0 });
+360
View File
@@ -0,0 +1,360 @@
"""End-to-end: the /kiosk display arranges itself, and obeys one client over another.
Phase 08, task 05: the two properties the TODO item claims — **it arranges
itself on arrival** (point a display at /kiosk and it does what the shared
layout already says, with nobody standing at the box) and **one client's
action moves another client's display** (a keypress on one kiosk re-arranges
the other, asserted on the second display's decoded pixels) — plus the
``followers`` count (displays only, not plain viewers) and the teardown
fallback (a dead mirror returns every display to the wall in the same frame
set; no display may sit on a dead tile).
Isolated contexts are the unit of "a client": each is its own socket and its
own peer id, which is exactly what "another client" means to the server. The
media path itself is proven by ``test_live_mirror.py``; this suite proves the
*state* path and still asserts on real decoded frames, so nothing is taken on
trust.
The suite is pointer-free: a test that reaches for the mouse proves nothing
about a kiosk. The only input a display receives here is a keypress; the
server state is driven by raw ``WebSocket`` frames in ``page.evaluate`` — a
wire-level client, not a pointer. That is what the meta-test at the bottom
permits: it bans pointer calls, not the driver's socket.
Fullscreen is asserted on the page's own knobs — ``body[data-fullscreen]``
and the staged 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``). The knobs are what the
app controls (the same stance ``kiosk.js`` documents for its best-effort
request); a "strengthened" assertion on ``fullscreenElement`` turns this
suite into a flake.
"""
from __future__ import annotations
import time
from pathlib import Path
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 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}")
# The driver: a plain WebSocket client, opened from inside a page so the
# handshake runs in a real browser. It is wire-level state, not a pointer —
# the meta-test below allows it on exactly that ground. It closes the socket
# once the server has acknowledged the change: the layout is process state,
# not the driver's, so the kiosk's `welcome` (which carries it) cannot race
# the driver's exit.
DRIVER = """
async ([url, staged, fullscreen]) => {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = () => reject(new Error('driver socket did not open'));
});
const nextFrame = (type) => new Promise((resolve, reject) => {
const timer = setTimeout(
() => { ws.close(); reject(new Error(`driver timed out waiting for ${type}`)); },
10_000);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === type) { clearTimeout(timer); resolve(message); }
};
});
ws.send(JSON.stringify({ type: 'hello', role: 'viewer', name: '', device: 'e2e layout driver' }));
const welcome = await nextFrame('welcome');
ws.send(JSON.stringify({ type: 'arrange', staged, fullscreen }));
const layout = await nextFrame('layout');
ws.close();
return { peerId: welcome.peerId, layout };
}
"""
def drive_layout(page, base_url, staged: str | None, fullscreen: bool) -> dict:
"""Send one `arrange` frame over a raw socket; return the server's ack."""
ws_url = base_url.replace("http://", "ws://") + "/ws"
return page.evaluate(DRIVER, [ws_url, staged, fullscreen])
def expect_staged(page, stream_id: str, timeout: int = 15_000) -> None:
"""The display's stage holds exactly the tile the layout names."""
expect(page.locator("#kiosk-stage .tile")).to_have_count(1, timeout=timeout)
assert page.locator("#kiosk-stage .tile").get_attribute("data-stream") == stream_id
class TestArrivalIsArrangement:
def test_a_display_opened_after_an_arrange_shows_it_without_input(self, device, base_url):
"""Arrival is arrangement: the state is applied before the first tile
paints, so the person pointing a kiosk at /kiosk touches nothing."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
# The controller acts first, over a raw socket: stage + fullscreen, and
# the server's own ack says it took.
driver = device("/watch", errors)
ack = drive_layout(driver, base_url, stream_id, True)
assert ack["layout"]["staged"] == stream_id
assert ack["layout"]["fullscreen"] is True
# The display opens *after* the arrange. No key, no pointer: it must
# settle on the stage.
kiosk = device("/kiosk", errors)
expect(kiosk.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
# The page's own knob, not document.fullscreenElement: headless may
# refuse the request and the window manager's window-fullscreen is the
# guarantee, so the knob (what the app controls) is the assertion.
expect(kiosk.locator("body")).to_have_attribute("data-fullscreen", "true", timeout=15_000)
expect_staged(kiosk, stream_id)
# The frame assertion is what proves media crossed to the display, not
# just a CSS class (AGENTS.md §5): decoded, *changing* pixels on the
# staged <video>.
expect(kiosk.locator("#kiosk-stage .tile[data-state='live']")).to_have_count(
1, timeout=25_000
)
frames = frames_matching(kiosk, "#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}"
# One source of truth: the server says what the pixels show.
state = kiosk_state(base_url)
assert state["staged"] == stream_id
assert state["fullscreen"] is True
# The display is counted; the driver (a plain viewer) is not.
assert state["followers"] == 1, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestOneClientMovesAnother:
def test_a_keypress_on_one_display_rearranges_the_other(self, device, base_url):
"""One action, two displays, one source of truth — and the second
display never receives a pointer call (or any input at all)."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
first = device("/kiosk", errors)
second = device("/kiosk", errors) # nothing is ever typed or pointed here
expect(first.locator("body")).to_have_attribute("data-layout", "wall")
expect(second.locator("body")).to_have_attribute("data-layout", "wall")
# The only input in this test: one keypress on the first display.
first.keyboard.press("Enter")
expect(first.locator("body")).to_have_attribute("data-layout", "stage", timeout=5_000)
expect(second.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect_staged(second, stream_id)
# The display is showing it, not merely labelled: a live, moving leg.
expect(second.locator("#kiosk-stage .tile[data-state='live']")).to_have_count(
1, timeout=25_000
)
frames = frames_matching(second, "#kiosk-stage video")
assert frames["width"] > 0, f"no decoded frame on the second display: {frames}"
assert frames["distinct"] >= 2, f"the second display's video is frozen: {frames}"
# The server agrees with both displays.
state = kiosk_state(base_url)
assert state["staged"] == stream_id, state
assert errors == [], f"uncaught browser errors: {errors}"
def test_n_and_p_on_one_display_walk_the_other_through_the_mirrors(self, device, base_url):
errors: list = []
for _ in range(2):
start_sharing(device("/mirror", errors))
first_id, second_id = mirror_ids(base_url, 2)
first = device("/kiosk", errors)
second = device("/kiosk", errors)
expect(second.locator("body")).to_have_attribute("data-layout", "wall")
expect(second.locator(".tile")).to_have_count(2, timeout=15_000)
# n: no cursor yet, so it lands on the first mirror…
first.keyboard.press("n")
expect_staged(second, first_id)
# …p walks to the second…
first.keyboard.press("p")
expect_staged(second, second_id)
# …and wraps back to the first.
first.keyboard.press("p")
expect_staged(second, first_id)
# The server walked the same way (checked at the wrap, where a stale
# broadcast would still show the second mirror).
state = kiosk_state(base_url)
assert state["staged"] == first_id, state
# Both mirrors survived the walking: the display's wall holds both.
expect(second.locator(".tile")).to_have_count(2)
expect(second.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_dead_mirror_returns_every_display_to_the_wall(self, device, base_url):
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
first = device("/kiosk", errors)
second = device("/kiosk", errors)
first.keyboard.press("Enter")
expect(first.locator("body")).to_have_attribute("data-layout", "stage", timeout=5_000)
expect_staged(second, stream_id)
stop_sharing(sharer)
# No display may sit on a dead tile: both back on the wall, in time.
expect(first.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(second.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(first.locator("#kiosk-stage .tile")).to_have_count(0)
expect(second.locator("#kiosk-stage .tile")).to_have_count(0)
# And the wall is actually empty, not just unstaged: the dead tile is
# gone, so there is no stale <video> to clear.
expect(first.locator(".tile")).to_have_count(0, timeout=10_000)
expect(second.locator(".tile")).to_have_count(0, timeout=10_000)
state = wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the layout cleared")
assert state["followers"] == 2, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestFollowers:
def test_followers_counts_displays_only_over_a_real_socket_lifecycle(
self, base_url, browser, request
):
"""`followers` is how many *displays* are obeying the wall — a plain
viewer (a /watch tab) is not one, and a display leaving is noticed
without any state to poll: the count is derived from the roster."""
errors: list = []
assert kiosk_state(base_url)["followers"] == 0, "the suite must start with no displays"
context, _kiosk = open_device(base_url, browser, request, "/kiosk", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the display counted")
# A plain viewer joins: the wall follows the same state, but the count
# is about displays, not about who is reading the state.
open_device(base_url, browser, request, "/watch", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the viewer not counted")
# The display leaves (its socket closes through a real context close)
# and the count falls back with it.
context.close()
wait_for_kiosk(base_url, lambda s: s["followers"] == 0, "the display counted out")
assert errors == [], f"uncaught browser errors: {errors}"
class TestSuiteIsPointerFree:
"""The kiosk guarantee, enforced on this file itself.
A test that reaches for the mouse proves nothing about a kiosk. The
guarantee lives in the code, not in reviewer attention, so this meta-test
bans pointer calls on this file and fails the suite the moment one slips
back in. The driver's raw ``WebSocket`` frames (``page.evaluate``) are
wire-level, not a pointer, and are allowed *explicitly* on that ground —
this guard bans the two pointer calls (click and hover) anywhere in this
file, nothing else, and does not loosen to skip the driver's block: a
pointer call anywhere in this file is a failure.
"""
def test_the_suite_never_reaches_for_the_pointer(self):
source = Path(__file__).read_text()
# Built from parts so this very assertion does not contain the banned
# literals (a plain string here would trip the check on itself).
assert ("cli" + "ck(") not in source, "a pointer call slipped into the kiosk suite"
assert ("hover" + "(") not in source, "a pointer call slipped into the kiosk suite"
+4 -2
View File
@@ -544,7 +544,7 @@ class TestAddressGate:
like a bug in the tool.
"""
@pytest.mark.parametrize("path", ["/mirror", "/watch"])
@pytest.mark.parametrize("path", ["/mirror", "/watch", "/kiosk"])
def test_a_refused_address_sees_why_and_none_of_the_app(self, browser, restricted_url, path):
context = browser.new_context(viewport={"width": 1100, "height": 720})
page = context.new_page()
@@ -557,7 +557,9 @@ class TestAddressGate:
# front of a proxy is undiagnosable.
expect(page.locator("body")).to_contain_text("127.0.0.1")
# None of the app shell leaked past the gate.
expect(page.locator("#start, #grid, #notice")).to_have_count(0)
expect(page.locator("#start, #grid, #notice, #kiosk-grid, #kiosk-stage")).to_have_count(
0
)
finally:
context.close()
+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}"
+316 -20
View File
@@ -80,6 +80,8 @@ class TestPages:
("/mirror", "Share my screen"),
("/share", "Share my screen"),
("/watch", "tile-template"),
("/kiosk", "kiosk-tile-template"),
("/remote", "remote-row-template"),
],
)
def test_pages_render(self, client, path, marker):
@@ -88,14 +90,17 @@ class TestPages:
assert response.headers["content-type"].startswith("text/html")
assert marker in response.text
def test_pages_are_not_cacheable(self, client):
assert client.get("/watch").headers["cache-control"] == "no-store"
@pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk", "/remote"])
def test_pages_are_not_cacheable(self, client, path):
assert client.get(path).headers["cache-control"] == "no-store"
def test_the_two_pages_use_the_media_apis_they_claim(self):
# The watch page's media path lives in wall.js (phase 08): the page
# builds the wall, and the wall owns the RTCPeerConnection.
share = (STATIC / "share.js").read_text()
watch = (STATIC / "watch.js").read_text()
wall = (STATIC / "wall.js").read_text()
assert "getDisplayMedia" in share
assert "RTCPeerConnection" in share and "RTCPeerConnection" in watch
assert "RTCPeerConnection" in share and "RTCPeerConnection" in wall
assert "new WebSocket" in (STATIC / "net.js").read_text()
def test_nothing_is_loaded_from_a_cdn(self):
@@ -109,7 +114,10 @@ class TestPages:
offenders.append(f"{file.name} (@import)")
assert offenders == []
@pytest.mark.parametrize("asset", ["app.css", "net.js", "share.js", "watch.js"])
@pytest.mark.parametrize(
"asset",
["app.css", "net.js", "share.js", "watch.js", "wall.js", "kiosk.js", "remote.js"],
)
def test_static_assets_are_served(self, client, asset):
assert client.get(f"/static/{asset}").status_code == 200
@@ -124,7 +132,9 @@ class TestClientScripts:
NODE = shutil.which("node")
@pytest.mark.skipif(NODE is None, reason="node is not installed")
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.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):
copy = tmp_path / "subject.mjs"
copy.write_text((STATIC / script).read_text())
@@ -133,7 +143,12 @@ class TestClientScripts:
@pytest.mark.parametrize(
("script", "page"),
[("share.js", "mirror.html"), ("watch.js", "watch.html")],
[
("share.js", "mirror.html"),
("watch.js", "watch.html"),
("kiosk.js", "kiosk.html"),
("remote.js", "remote.html"),
],
)
def test_every_element_the_script_reaches_for_exists(self, script, page):
html = (STATIC / page).read_text()
@@ -141,8 +156,168 @@ class TestClientScripts:
missing = {name for name in wanted if f'id="{name}"' not in html}
assert not missing, f"{script} looks up {sorted(missing)} which {page} does not render"
def test_both_pages_load_the_stylesheet_and_their_module(self):
for page, script in (("mirror.html", "share.js"), ("watch.html", "watch.js")):
def test_wall_never_looks_up_elements_in_the_document(self):
"""wall.js serves /watch and /kiosk (and phase 09's /remote) from one
module; an element lookup would resolve in whichever page happens to
load it. The elements come from the page instead, which is why the
element ids must not collide across pages (see
test_the_two_pages_share_no_element_ids_but_the_shared_chrome) — so
the module is guarded at the source level, here, next to the other
client-script guards."""
source = (STATIC / "wall.js").read_text()
offenders = re.findall(r"""\$\(['\"]#([A-Za-z0-9_-]+)['\"]\)""", source)
assert not offenders, f"wall.js must not query the document; found {offenders}"
def test_the_wall_key_router_is_one_listener_in_wall_js(self):
"""One listener, one switch, per wall page — phase 01's whole point.
`installWallKeys` (wall.js) is the single keydown listener a wall page
gets (/watch, /kiosk, phase 09's /remote): a second listener on the
same document would run the same key twice (j would move the cursor
twice) or race the first. The share page is not a wall page — its
s/x/Esc listener lives in share.js and is page policy — but a wall
page module must never add a keydown listener of its own, so a second
`addEventListener("keydown"` in a wall page module fails the build.
"""
wall = (STATIC / "wall.js").read_text()
assert "export function installWallKeys" in wall, (
"installWallKeys must be defined in wall.js (the one keydown listener)"
)
assert len(re.findall(r'addEventListener\(\s*["\']keydown["\']', wall)) == 1, (
"wall.js must hold exactly one keydown listener (the router)"
)
offenders = [
file.name
for file in sorted(STATIC.glob("*.js"))
if file.name not in ("share.js", "wall.js")
and re.search(r'addEventListener\(\s*["\']keydown["\']', file.read_text())
]
assert offenders == [], (
"a wall page module added its own keydown listener; the one listener "
f"per wall page lives in wall.js: {offenders}"
)
@staticmethod
def _object_literal_around(source, pos):
"""The object literal containing ``source[pos:``.
``pos`` points at a ``type: "arrange"`` key. Walking back, the nearest
``{`` that is not inside a deeper ``{...}`` span is the literal's start
— the literal itself when ``type`` is its first member, the enclosing
object when it is not. Forward brace matching then closes it.
"""
depth = 0
start = None
for i in range(pos - 1, -1, -1):
if source[i] == "}":
depth += 1
elif source[i] == "{":
if depth == 0:
start = i
break
depth -= 1
assert start is not None, "no object literal before the arrange key"
depth = 0
for i in range(start, len(source)):
if source[i] == "{":
depth += 1
elif source[i] == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise AssertionError("unbalanced braces around the arrange frame")
def test_kiosk_never_publishes_an_audio_field(self):
"""The layout has no audio field (SECURITY.md §4): a display must not
become audible because another machine asked, so ``kiosk.js`` may never
put a ``muted``/``audio`` field in an ``arrange`` frame. The rule is a
security rule, so it fails the build here instead of living in a
comment (phase 08 task 04). At least one ``arrange`` frame must exist:
the display is a first-class controller, and this guard must not pass
on a file that simply stopped publishing."""
source = (STATIC / "kiosk.js").read_text()
matches = list(re.finditer(r"type\s*:\s*[\"\']arrange[\"\']", source))
assert matches, "kiosk.js publishes no arrange frames at all"
for match in matches:
literal = self._object_literal_around(source, match.start())
assert not re.search(r"\b(muted|audio)\s*:", 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):
for page, script in (
("mirror.html", "share.js"),
("watch.html", "watch.js"),
("kiosk.html", "kiosk.js"),
("remote.html", "remote.js"),
):
html = (STATIC / page).read_text()
assert 'href="/static/app.css"' in html
assert f'src="/static/{script}"' in html
@@ -289,23 +464,59 @@ class TestClientScripts:
)
def test_the_two_pages_share_no_element_ids_but_the_shared_chrome(self):
"""Both pages consume one stylesheet, so a duplicated id means a colliding rule.
"""The pages consume one stylesheet, so a duplicated id means a colliding rule.
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.
The kiosk page's ids carry the `kiosk-` prefix and the remote page's the
`remote-` prefix for exactly this reason; the only ids a page may share
with another are the shared chrome (net, notice, toast).
"""
shared = {"net", "notice", "toast"}
ids = {
page: set(re.findall(r'id="([A-Za-z0-9_-]+)"', (STATIC / page).read_text()))
for page in ("mirror.html", "watch.html", "index.html")
for page in ("mirror.html", "watch.html", "index.html", "kiosk.html", "remote.html")
}
assert ids["mirror.html"] & ids["watch.html"] == shared
assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"}
assert ids["kiosk.html"] & ids["watch.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
# intersection with an app page is a subset of the shared chrome — the
# same shape as the index/mirror assertion above.
assert ids["kiosk.html"] & ids["index.html"] <= shared
assert ids["remote.html"] & ids["index.html"] <= shared
for page, found in ids.items():
assert len(found) == len(set(found)), f"{page} declares an id twice"
def test_the_wall_pages_render_their_stage_classed(self):
"""The stage rules are class-keyed (`.stage`, phase 08 task 01): a page
whose stage lost the class renders a full-size unstyled video, which is
exactly the class of bug the stylesheet guards exist for. Both wall
pages keep it, and the kiosk's structural elements (grid, stage, hud)
exist for the layout applier to take them. (There was no
elements-a-page-must-have check before; this is the one.)
"""
kiosk = (STATIC / "kiosk.html").read_text()
for element_id in ("kiosk-grid", "kiosk-stage", "kiosk-hud"):
assert f'id="{element_id}"' in kiosk, f"kiosk.html is missing #{element_id}"
for page, stage_id in (("watch.html", "watch-stage"), ("kiosk.html", "kiosk-stage")):
stage_tags = [
tag
for tag in re.findall(r"<div[^>]*>", (STATIC / page).read_text())
if f'id="{stage_id}"' in tag
]
assert stage_tags, f'{page} is missing <div id="{stage_id}">'
assert 'class="stage"' in stage_tags[0], (
f'{page}#{stage_id} lost class="stage": the stage rules are '
"class-keyed, so an unclassed stage renders a full-size unstyled video"
)
def test_videos_are_muted_and_inline_so_autoplay_is_not_blocked(self):
for page in ("mirror.html", "watch.html"):
for page in ("mirror.html", "watch.html", "kiosk.html"):
for tag in re.findall(r"<video[^>]*>", (STATIC / page).read_text()):
assert "muted" in tag, f"{page}: {tag} would be blocked by autoplay policy"
assert "playsinline" in tag, f"{page}: {tag} would hijack iOS into fullscreen"
@@ -378,8 +589,9 @@ class TestStylesheet:
The flat ``[^{}]+\{[^{}]*\}`` pass treats an ``@media`` header as a
selector and the first child's header as its body; the child rules
that follow still come back as their own pairs, which is all these
guards need — every assertion is about a top-level block (``.stage``,
``.stage > *``) or about properties that must sit in the *same* block
guards need — every assertion is about a top-level block
(``.preview-stage``, ``.preview-stage > *``) or about properties that
must sit in the *same* block
(``overflow``/``aspect-ratio``/``min-height`` together).
"""
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
@@ -396,8 +608,8 @@ class TestStylesheet:
without the other stacks the rows back.
"""
rules = self._rules()
stage_bodies = [body for sel, body in rules if sel == ".stage"]
assert stage_bodies, ".stage rule missing from app.css"
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
assert stage_bodies, ".preview-stage rule missing from app.css"
assert any(
re.search(r"grid-template\s*:\s*1fr\s*/\s*1fr", body)
or (
@@ -409,7 +621,7 @@ class TestStylesheet:
".stage must declare an explicit single cell (grid-template: 1fr / 1fr, "
"or grid-template-rows/columns of 1fr) — otherwise its children stack into rows"
)
child_bodies = [body for sel, body in rules if sel == ".stage > *"]
child_bodies = [body for sel, body in rules if sel == ".preview-stage > *"]
assert child_bodies and any(
re.search(r"grid-area\s*:\s*1\s*/\s*1", body) for body in child_bodies
), (
@@ -431,8 +643,8 @@ class TestStylesheet:
content again.
"""
rules = self._rules()
stage_bodies = [body for sel, body in rules if sel == ".stage"]
assert stage_bodies, ".stage rule missing from app.css"
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
assert stage_bodies, ".preview-stage rule missing from app.css"
assert any(re.search(r"min-height\s*:", body) for body in stage_bodies), (
".stage must keep its floor (min-height)"
)
@@ -460,7 +672,17 @@ class TestAddressGate:
assert client.get("/watch").status_code == 200
@pytest.mark.parametrize(
"path", ["/", "/mirror", "/watch", "/api/streams", "/api/kiosk", "/static/app.css"]
"path",
[
"/",
"/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):
with gated(self.ALLOW) as client:
@@ -527,6 +749,7 @@ class TestAddressGate:
def test_the_real_address_is_in_and_connects(self):
with gated(self.ALLOW, client_address="10.2.1.10") as client:
assert client.get("/watch").status_code == 200
assert client.get("/kiosk").status_code == 200
with client.websocket_connect("/ws") as socket:
assert handshake(socket, "viewer")
@@ -1002,3 +1225,76 @@ class TestSignalingSocket:
assert wait_for(viewer1, "layout")["staged"] is None
assert wait_for(viewer2, "layout")["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