From cd29156a7dde777bc062ff7b6145b05bd75362c6 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 3 Nov 2026 22:38:22 -0500 Subject: [PATCH] phase: 08_kiosk_page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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`. --- .../08_kiosk_page/00_phase.md | 0 .../01_extract_wall_tiles_and_stage.md | 0 .../02_extract_wall_key_router.md | 0 .../08_kiosk_page/03_kiosk_page_and_route.md | 0 .../08_kiosk_page/04_kiosk_layout_applier.md | 0 .../05_kiosk_layout_e2e_and_docs.md | 0 .../08_kiosk_page__00_phase.a1.err | 0 .../08_kiosk_page__00_phase.a1.md | 22 + .../08_kiosk_page__00_phase.a1.md.status | 1 + .../08_kiosk_page__00_phase.a1.validate | 36 + ...ge__01_extract_wall_tiles_and_stage.a1.err | 0 ...age__01_extract_wall_tiles_and_stage.a1.md | 16 + ..._extract_wall_tiles_and_stage.a1.md.status | 1 + ...1_extract_wall_tiles_and_stage.a1.validate | 36 + ...sk_page__02_extract_wall_key_router.a1.err | 0 ...osk_page__02_extract_wall_key_router.a1.md | 23 + ...e__02_extract_wall_key_router.a1.md.status | 1 + ...ge__02_extract_wall_key_router.a1.validate | 36 + ...kiosk_page__03_kiosk_page_and_route.a1.err | 0 ..._kiosk_page__03_kiosk_page_and_route.a1.md | 19 + ...page__03_kiosk_page_and_route.a1.md.status | 1 + ..._page__03_kiosk_page_and_route.a1.validate | 36 + ...kiosk_page__04_kiosk_layout_applier.a1.err | 0 ..._kiosk_page__04_kiosk_layout_applier.a1.md | 11 + ...page__04_kiosk_layout_applier.a1.md.status | 1 + ..._page__04_kiosk_layout_applier.a1.validate | 36 + ..._page__05_kiosk_layout_e2e_and_docs.a1.err | 0 ...k_page__05_kiosk_layout_e2e_and_docs.a1.md | 21 + ..._05_kiosk_layout_e2e_and_docs.a1.md.status | 1 + ...__05_kiosk_layout_e2e_and_docs.a1.validate | 36 + AGENTS.md | 6 +- README.md | 23 +- docs/kiosk-silverblue.md | 50 ++ src/mirror/app.py | 12 +- src/mirror/static/app.css | 107 ++- src/mirror/static/index.html | 8 + src/mirror/static/kiosk.html | 74 ++ src/mirror/static/kiosk.js | 363 ++++++++ src/mirror/static/mirror.html | 6 +- src/mirror/static/wall.js | 847 ++++++++++++++++++ src/mirror/static/watch.html | 7 +- src/mirror/static/watch.js | 758 ++-------------- tests/e2e/test_kiosk_layout.py | 360 ++++++++ tests/e2e/test_live_mirror.py | 6 +- tests/test_app.py | 178 +++- 45 files changed, 2389 insertions(+), 750 deletions(-) rename .agents/phases/{todo => complete}/08_kiosk_page/00_phase.md (100%) rename .agents/phases/{todo => complete}/08_kiosk_page/01_extract_wall_tiles_and_stage.md (100%) rename .agents/phases/{todo => complete}/08_kiosk_page/02_extract_wall_key_router.md (100%) rename .agents/phases/{todo => complete}/08_kiosk_page/03_kiosk_page_and_route.md (100%) rename .agents/phases/{todo => complete}/08_kiosk_page/04_kiosk_layout_applier.md (100%) rename .agents/phases/{todo => complete}/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md (100%) create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.err create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md.status create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.validate create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.err create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md.status create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.validate create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.err create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md.status create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.validate create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.err create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md.status create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.validate create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.err create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md.status create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.validate create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.err create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md.status create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.validate create mode 100644 src/mirror/static/kiosk.html create mode 100644 src/mirror/static/kiosk.js create mode 100644 src/mirror/static/wall.js create mode 100644 tests/e2e/test_kiosk_layout.py diff --git a/.agents/phases/todo/08_kiosk_page/00_phase.md b/.agents/phases/complete/08_kiosk_page/00_phase.md similarity index 100% rename from .agents/phases/todo/08_kiosk_page/00_phase.md rename to .agents/phases/complete/08_kiosk_page/00_phase.md diff --git a/.agents/phases/todo/08_kiosk_page/01_extract_wall_tiles_and_stage.md b/.agents/phases/complete/08_kiosk_page/01_extract_wall_tiles_and_stage.md similarity index 100% rename from .agents/phases/todo/08_kiosk_page/01_extract_wall_tiles_and_stage.md rename to .agents/phases/complete/08_kiosk_page/01_extract_wall_tiles_and_stage.md diff --git a/.agents/phases/todo/08_kiosk_page/02_extract_wall_key_router.md b/.agents/phases/complete/08_kiosk_page/02_extract_wall_key_router.md similarity index 100% rename from .agents/phases/todo/08_kiosk_page/02_extract_wall_key_router.md rename to .agents/phases/complete/08_kiosk_page/02_extract_wall_key_router.md diff --git a/.agents/phases/todo/08_kiosk_page/03_kiosk_page_and_route.md b/.agents/phases/complete/08_kiosk_page/03_kiosk_page_and_route.md similarity index 100% rename from .agents/phases/todo/08_kiosk_page/03_kiosk_page_and_route.md rename to .agents/phases/complete/08_kiosk_page/03_kiosk_page_and_route.md diff --git a/.agents/phases/todo/08_kiosk_page/04_kiosk_layout_applier.md b/.agents/phases/complete/08_kiosk_page/04_kiosk_layout_applier.md similarity index 100% rename from .agents/phases/todo/08_kiosk_page/04_kiosk_layout_applier.md rename to .agents/phases/complete/08_kiosk_page/04_kiosk_layout_applier.md diff --git a/.agents/phases/todo/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md b/.agents/phases/complete/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md similarity index 100% rename from .agents/phases/todo/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md rename to .agents/phases/complete/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md new file mode 100644 index 0000000..a762d21 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md @@ -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`. diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.validate new file mode 100644 index 0000000..e919b32 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md new file mode 100644 index 0000000..739891b --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md @@ -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). diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.validate new file mode 100644 index 0000000..b69116e --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.validate @@ -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 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md new file mode 100644 index 0000000..2008247 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md @@ -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` diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.validate new file mode 100644 index 0000000..73e0789 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.validate @@ -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 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md new file mode 100644 index 0000000..79a957e --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md @@ -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, `Wall · mirror` (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` diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.validate new file mode 100644 index 0000000..9630ad2 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.validate @@ -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 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md new file mode 100644 index 0000000..34272f7 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md @@ -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) diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.validate new file mode 100644 index 0000000..5e4af3c --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.validate @@ -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 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md new file mode 100644 index 0000000..653ed3f --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md @@ -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). diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.validate new file mode 100644 index 0000000..ed51805 --- /dev/null +++ b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.validate @@ -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 diff --git a/AGENTS.md b/AGENTS.md index f964470..bef2415 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` | Four 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 ``` diff --git a/README.md b/README.md index 884776f..d19a9bb 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ 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. | | **Index** | `/` | Signpost + live count of mirrors. | Any number of people can mirror at the same time and any number of people can @@ -30,6 +31,7 @@ Then, from another machine on the LAN: ``` http://:8055/mirror # to share http://:8055/watch # to watch +http://:8055/kiosk # the unattended wall (kiosk display) ``` Linux is the primary target. On Linux the native picker is X11/Wayland's own @@ -67,9 +69,14 @@ 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). ## Configuration @@ -172,8 +179,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`; the installer default still opens +`/watch` and an existing `kiosk.conf` URL wins, 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 +221,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 diff --git a/docs/kiosk-silverblue.md b/docs/kiosk-silverblue.md index 6fefab7..36ff87a 100644 --- a/docs/kiosk-silverblue.md +++ b/docs/kiosk-silverblue.md @@ -64,6 +64,56 @@ 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** — a page a person on another +machine drives the wall from — arrives in phase 09 (`/remote`); until then a +controller is any client that can send an `arrange` frame on `/ws`, `websocat` +included. `GET /api/kiosk` is the state's read surface: what is on stage, +whether it is fullscreen, and how many displays (`followers`) are obeying it. + +**The migration rule, stated honestly.** Existing clients keep their own +`MIRROR_KIOSK_URL`: an existing `kiosk.conf` URL wins over the built-in +default, and the installer's default still points at `/watch` in this phase. +So **nothing changes under a box until its `kiosk.conf` is re-pointed and +`gdm` restarted**: + +```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.) The kiosk +page serves `Wall · mirror`; `check-kiosk.sh`, untouched in this +phase, still greps for `<title>Watch · mirror`, and the checker is widened to +accept the kiosk title in phase 09, when the default URL moves with it. + +--- + ## The two facts that make this work **Silverblue's mutable seams.** `/usr` is read-only and belongs to the OSTree diff --git a/src/mirror/app.py b/src/mirror/app.py index 942f89b..da0c2b7 100644 --- a/src/mirror/app.py +++ b/src/mirror/app.py @@ -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,12 @@ 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("/api/config") async def client_config() -> JSONResponse: return JSONResponse( @@ -350,7 +356,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: return await hub.serve(websocket) - PAGES = ("/", "/mirror", "/share", "/watch") + PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk") @app.middleware("http") async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def] diff --git a/src/mirror/static/app.css b/src/mirror/static/app.css index 1e3226d..4bd2228 100644 --- a/src/mirror/static/app.css +++ b/src/mirror/static/app.css @@ -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,55 @@ 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; +} + @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } diff --git a/src/mirror/static/index.html b/src/mirror/static/index.html index 03d2827..a99e472 100644 --- a/src/mirror/static/index.html +++ b/src/mirror/static/index.html @@ -43,6 +43,14 @@ 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> </div> <div class="livebar" id="livebar">Checking for mirrors…</div> diff --git a/src/mirror/static/kiosk.html b/src/mirror/static/kiosk.html new file mode 100644 index 0000000..4611d11 --- /dev/null +++ b/src/mirror/static/kiosk.html @@ -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 + + + + + + + connecting… + + + +
+ +
+

No mirrors live

+

+ Share from … +

+
+ + +
+ + +
+ + + + + + + + + diff --git a/src/mirror/static/kiosk.js b/src/mirror/static/kiosk.js new file mode 100644 index 0000000..3fe2e40 --- /dev/null +++ b/src/mirror/static/kiosk.js @@ -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(); diff --git a/src/mirror/static/mirror.html b/src/mirror/static/mirror.html index ace546b..99bf21f 100644 --- a/src/mirror/static/mirror.html +++ b/src/mirror/static/mirror.html @@ -22,7 +22,11 @@
-
+ +

Nothing is being shared

diff --git a/src/mirror/static/wall.js b/src/mirror/static/wall.js new file mode 100644 index 0000000..220c5b5 --- /dev/null +++ b/src/mirror/static/wall.js @@ -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