phase: 08_kiosk_page
**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`.
This commit is contained in:
@@ -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 @@
|
|||||||
|
ok
|
||||||
@@ -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).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+36
@@ -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 @@
|
|||||||
|
ok
|
||||||
@@ -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 @@
|
|||||||
|
ok
|
||||||
@@ -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 @@
|
|||||||
|
ok
|
||||||
@@ -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 @@
|
|||||||
|
ok
|
||||||
@@ -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
|
||||||
@@ -40,8 +40,8 @@ is a roster and a signaling relay. Linux is the reference platform.
|
|||||||
|
|
||||||
| Rule | Why it exists |
|
| Rule | Why it exists |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestClientScripts::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
|
| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestPages::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
|
||||||
| **No framework, no bundler** in `src/mirror/static/` | Three pages and one WebSocket. `node --check` is the entire frontend toolchain |
|
| **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 |
|
| **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 |
|
||||||
| **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage |
|
| **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage |
|
||||||
| **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test |
|
| **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test |
|
||||||
@@ -57,7 +57,7 @@ is a roster and a signaling relay. Linux is the reference platform.
|
|||||||
```bash
|
```bash
|
||||||
uv run ruff check . && uv run ruff format --check .
|
uv run ruff check . && uv run ruff format --check .
|
||||||
uv run pyright # must be 0 errors
|
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
|
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ your network:
|
|||||||
| ------------ | ------------------ | ----------------------------------------------------------------------- |
|
| ------------ | ------------------ | ----------------------------------------------------------------------- |
|
||||||
| **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
|
| **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
|
||||||
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
|
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
|
||||||
|
| **Kiosk** | `/kiosk` | A chrome-free wall for an unattended display: no header, no counts — the net pill is the only chrome, and the shared layout state (below) arranges it. |
|
||||||
| **Index** | `/` | Signpost + live count of mirrors. |
|
| **Index** | `/` | Signpost + live count of mirrors. |
|
||||||
|
|
||||||
Any number of people can mirror at the same time and any number of people can
|
Any number of people can mirror at the same time and any number of people can
|
||||||
@@ -30,6 +31,7 @@ Then, from another machine on the LAN:
|
|||||||
```
|
```
|
||||||
http://<this-machine>:8055/mirror # to share
|
http://<this-machine>:8055/mirror # to share
|
||||||
http://<this-machine>:8055/watch # to watch
|
http://<this-machine>:8055/watch # to watch
|
||||||
|
http://<this-machine>:8055/kiosk # the unattended wall (kiosk display)
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux is the primary target. On Linux the native picker is X11/Wayland's own
|
Linux is the primary target. On Linux the native picker is X11/Wayland's own
|
||||||
@@ -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
|
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`,
|
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
|
`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
|
gesture on the device that plays it (see [SECURITY.md](SECURITY.md) §4). The
|
||||||
page that follows this state arrives in a later release; the wire and the read
|
`/kiosk` page is the display that follows this state: a chrome-free wall that
|
||||||
surface are what any client builds on.
|
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
|
## Configuration
|
||||||
|
|
||||||
@@ -172,8 +179,10 @@ from this side:
|
|||||||
|
|
||||||
A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk
|
A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk
|
||||||
session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at
|
session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at
|
||||||
`/watch`. It is set up and repaired entirely over SSH — no keyboard, no console,
|
the wall. It is set up and repaired entirely over SSH — no keyboard, no console,
|
||||||
no monitor. A brand-new box layers the kiosk packages once first
|
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);
|
(`rpm-ostree install gnome-kiosk gnome-kiosk-script-session`, then a reboot);
|
||||||
after that these two commands are both the setup and the repair path:
|
after that these two commands are both the setup and the repair path:
|
||||||
|
|
||||||
@@ -212,6 +221,10 @@ in. On `/watch`:
|
|||||||
| `?` | Show / hide this list on screen. |
|
| `?` | Show / hide this list on screen. |
|
||||||
| `Esc` | Close the key list, then leave the stage. |
|
| `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
|
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
|
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
|
them. A pointer move over the stage (a tap, for touch) or any key press
|
||||||
|
|||||||
@@ -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 `<title>Wall · mirror`; `check-kiosk.sh`, untouched in this
|
||||||
|
phase, still greps for `<title>Watch · mirror`, and the checker is widened to
|
||||||
|
accept the kiosk title in phase 09, when the default URL moves with it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## The two facts that make this work
|
## The two facts that make this work
|
||||||
|
|
||||||
**Silverblue's mutable seams.** `/usr` is read-only and belongs to the OSTree
|
**Silverblue's mutable seams.** `/usr` is read-only and belongs to the OSTree
|
||||||
|
|||||||
+9
-3
@@ -1,7 +1,7 @@
|
|||||||
"""The HTTP + signaling server.
|
"""The HTTP + signaling server.
|
||||||
|
|
||||||
Two pages and one WebSocket. Media flows browser-to-browser; the server only
|
A handful of pages and one WebSocket. Media flows browser-to-browser; the
|
||||||
shuffles JSON describing who is mirroring what.
|
server only shuffles JSON describing who is mirroring what.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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."""
|
"""The page that shows every live mirror and can stage one fullscreen."""
|
||||||
return _page("watch.html")
|
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")
|
@app.get("/api/config")
|
||||||
async def client_config() -> JSONResponse:
|
async def client_config() -> JSONResponse:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -350,7 +356,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
return
|
return
|
||||||
await hub.serve(websocket)
|
await hub.serve(websocket)
|
||||||
|
|
||||||
PAGES = ("/", "/mirror", "/share", "/watch")
|
PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk")
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
|
async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
|
||||||
|
|||||||
+78
-29
@@ -225,15 +225,19 @@ a { color: var(--accent); }
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The stage is ONE cell. Its children — the <video>, the idle placeholder,
|
/* The preview stage is ONE cell. Its children — the <video>, the idle
|
||||||
the LIVE badge — share that cell, so the placeholder overlays the video the
|
placeholder, the LIVE badge — share that cell, so the placeholder overlays
|
||||||
way the markup intends instead of stacking into a row below it.
|
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
|
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
|
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
|
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
|
longer fits a fixed-aspect stage is the size rule's job, not this one's
|
||||||
(the floor/ceiling below). */
|
(the floor/ceiling below). */
|
||||||
.stage {
|
.preview-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
/* Definite (100%), not stretched-auto: with an aspect-ratio, a stretched
|
/* Definite (100%), not stretched-auto: with an aspect-ratio, a stretched
|
||||||
item whose height is clamped by max-height gets its *width* recomputed
|
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
|
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
|
(no z-index); .livetag stays position: absolute, so grid-area is inert for
|
||||||
it. */
|
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
|
/* Escape hatch: the placeholder may never be taller than the stage; if its
|
||||||
content outgrows it, it scrolls. Paired with `safe center` above, content
|
content outgrows it, it scrolls. Paired with `safe center` above, content
|
||||||
that is too tall is always reachable, whichever way the geometry falls. */
|
that is too tall is always reachable, whichever way the geometry falls. */
|
||||||
.stage .placeholder {
|
.preview-stage .placeholder {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.stage .placeholder h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; }
|
.preview-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 p { margin: 0 0 18px; font-size: 14px; }
|
||||||
|
|
||||||
/* Narrow-width shape rules come *after* the base rules above: same
|
/* Narrow-width shape rules come *after* the base rules above: same
|
||||||
specificity, so source order decides, and a phone must not be left with
|
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. */
|
the box from looking like a letterbox. */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.share { grid-template-columns: 1fr; }
|
.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
|
/* 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
|
letterbox, and the placeholder's padding buys back 16px. min-height still
|
||||||
does the real work in both cases. */
|
does the real work in both cases. */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.stage { aspect-ratio: auto; }
|
.preview-stage { aspect-ratio: auto; }
|
||||||
.stage .placeholder { padding: 16px; }
|
.preview-stage .placeholder { padding: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.livetag {
|
.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
|
/* 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). */
|
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: "🔊";
|
content: "🔊";
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#watch-stage .tile[data-audio="off"]::after {
|
.stage .tile[data-audio="off"]::after {
|
||||||
content: "🔇";
|
content: "🔇";
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -562,7 +566,9 @@ kbd {
|
|||||||
/* Staging: one mirror fills the viewport; the grid is hidden behind it. */
|
/* Staging: one mirror fills the viewport; the grid is hidden behind it. */
|
||||||
body.staged .grid, body.staged .empty { display: none; }
|
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;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
@@ -570,11 +576,11 @@ body.staged .grid, body.staged .empty { display: none; }
|
|||||||
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
|
/* The staged tile *is* the stage: the element itself moves here, so its
|
||||||
RTCPeerConnection and <video> never have to be torn down. */
|
RTCPeerConnection and <video> never have to be torn down. */
|
||||||
#watch-stage .tile {
|
.stage .tile {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -584,14 +590,14 @@ body.staged #watch-stage { display: block; }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
#watch-stage .tile video {
|
.stage .tile video {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#watch-stage .tile .cap {
|
.stage .tile .cap {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -607,17 +613,19 @@ body.staged #watch-stage { display: block; }
|
|||||||
bottom line included — is never covered. `data-reveal` (pointer activity
|
bottom line included — is never covered. `data-reveal` (pointer activity
|
||||||
or a wall keypress) is the only staged reveal: it shows the caption and
|
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
|
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
|
`::after` audio mark is a separate rule and is untouched. The stage-scoped
|
||||||
selectors outrank the grid rules (`.tile[data-selected] .actions`,
|
selectors win over the grid rules (`.tile[data-selected] .actions`,
|
||||||
`.tile:focus-within .actions`, the `hover: none` media rule) which also
|
`.tile:focus-within .actions`, the `hover: none` media rule) which also
|
||||||
match the staged tile — it keeps `data-selected` and holds focus. */
|
match the staged tile: they tie on specificity, source order decides, and
|
||||||
#watch-stage .tile .actions { opacity: 0; }
|
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
|
/* A `pointer-events: none` container does not shield children whose default
|
||||||
is `auto`: scope it to the container *and its children* so the hidden
|
is `auto`: scope it to the container *and its children* so the hidden
|
||||||
Exit/mute buttons are not live dead zones over the video. */
|
Exit/mute buttons are not live dead zones over the video. */
|
||||||
#watch-stage .tile .cap, #watch-stage .tile .cap *,
|
.stage .tile .cap, .stage .tile .cap *,
|
||||||
#watch-stage .tile .actions, #watch-stage .tile .actions * { pointer-events: none; }
|
.stage .tile .actions, .stage .tile .actions * { pointer-events: none; }
|
||||||
|
|
||||||
/* `data-reveal` (pointer activity on the stage, or a wall keypress while
|
/* `data-reveal` (pointer activity on the stage, or a wall keypress while
|
||||||
staged) is the only staged reveal: it restores opacity and makes the
|
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
|
shields an `auto` child (setting `opacity: 1` on the children themselves
|
||||||
is harmless). The transitions on the base rules give the fade in and
|
is harmless). The transitions on the base rules give the fade in and
|
||||||
out; under `prefers-reduced-motion` the global transition-kill makes the
|
out; under `prefers-reduced-motion` the global transition-kill makes the
|
||||||
reveal instant, which is the correct degraded behaviour. The id-scoped
|
reveal instant, which is the correct degraded behaviour. The stage-scoped
|
||||||
selectors outrank every grid rule that also matches the staged tile. */
|
selectors win over every grid rule that also matches the staged tile
|
||||||
#watch-stage .tile[data-reveal] .cap, #watch-stage .tile[data-reveal] .cap *,
|
(tied on specificity, source order decides). */
|
||||||
#watch-stage .tile[data-reveal] .actions, #watch-stage .tile[data-reveal] .actions * {
|
.stage .tile[data-reveal] .cap, .stage .tile[data-reveal] .cap *,
|
||||||
|
.stage .tile[data-reveal] .actions, .stage .tile[data-reveal] .actions * {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* { animation: none !important; transition: none !important; }
|
* { animation: none !important; transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,14 @@
|
|||||||
jump to it.
|
jump to it.
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</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>
|
||||||
|
|
||||||
<div class="livebar" id="livebar">Checking for mirrors…</div>
|
<div class="livebar" id="livebar">Checking for mirrors…</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -22,7 +22,11 @@
|
|||||||
<div class="notice" id="notice" hidden></div>
|
<div class="notice" id="notice" hidden></div>
|
||||||
|
|
||||||
<main class="share">
|
<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>
|
<video id="preview" autoplay playsinline muted></video>
|
||||||
<div class="placeholder" id="placeholder">
|
<div class="placeholder" id="placeholder">
|
||||||
<h2>Nothing is being shared</h2>
|
<h2>Nothing is being shared</h2>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -31,8 +31,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- A staged tile is moved into this container, so its video never restarts. -->
|
<!-- A staged tile is moved into this container, so its video never restarts.
|
||||||
<div id="watch-stage" aria-live="polite"></div>
|
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
|
<!-- 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
|
map, so it lives in the page (static markup) where a translator can
|
||||||
|
|||||||
+72
-684
@@ -1,8 +1,12 @@
|
|||||||
/* The watch page: a grid of every live mirror. Click one and it takes the
|
/* 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
|
* 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 = {
|
const ui = {
|
||||||
grid: $("#grid"),
|
grid: $("#grid"),
|
||||||
@@ -22,17 +26,22 @@ const ui = {
|
|||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
const rtc = rtcConfig(config);
|
const rtc = rtcConfig(config);
|
||||||
|
|
||||||
/** streamId -> {id, sharerId, el, video, badge, sub, pc, pending, attempts, stream, stats} */
|
const state = { ws: null };
|
||||||
const tiles = new Map();
|
|
||||||
|
|
||||||
const state = {
|
/* The wall owns the tiles, the peers and the stage; the page hands it its
|
||||||
ws: null,
|
elements and the socket's send — guarded on the socket the way the moved
|
||||||
staged: null,
|
code guarded on state.ws, so the wall can be built before the socket opens
|
||||||
stageParent: null,
|
(net.js stays the only place a socket exists, AGENTS.md §6) — and learns
|
||||||
stageNext: null,
|
the counts back through renderCounts(). */
|
||||||
ticker: 0,
|
const wall = createWall({
|
||||||
cursor: null,
|
grid: ui.grid,
|
||||||
};
|
stageEl: ui.stage,
|
||||||
|
template: ui.template,
|
||||||
|
rtc,
|
||||||
|
send: (payload) => (state.ws ? state.ws.send(payload) : false),
|
||||||
|
onChange: renderCounts,
|
||||||
|
stats: true,
|
||||||
|
});
|
||||||
|
|
||||||
/* -------------------------------------------------------------- rendering */
|
/* -------------------------------------------------------------- rendering */
|
||||||
|
|
||||||
@@ -43,456 +52,37 @@ function setNet(next, detail) {
|
|||||||
if (label) label.textContent = detail || next;
|
if (label) label.textContent = detail || next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCounts() {
|
function renderCounts({ mirrors, live }) {
|
||||||
const n = tiles.size;
|
const n = mirrors;
|
||||||
const live = [...tiles.values()].filter((t) => t.pc && t.pc.connectionState === "connected").length;
|
|
||||||
ui.counts.textContent =
|
ui.counts.textContent =
|
||||||
n === 0 ? "no mirrors" : `${n} mirror${n === 1 ? "" : "s"}${live ? ` · ${live} live` : ""}`;
|
n === 0 ? "no mirrors" : `${n} mirror${n === 1 ? "" : "s"}${live ? ` · ${live} live` : ""}`;
|
||||||
ui.empty.hidden = n > 0;
|
ui.empty.hidden = n > 0;
|
||||||
ui.grid.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 */
|
/* ------------------------------------------------------------------ stage */
|
||||||
|
|
||||||
/* The staged overlay (caption bar + control cluster) is hidden at rest so a
|
// The browser's fullscreen state is page policy: /watch unstages when the
|
||||||
shared terminal's bottom line is never covered. Pointer activity on the
|
// browser (or the kiosk's window manager) leaves fullscreen on its own. The
|
||||||
stage, and any wall keypress while staged (the kiosk has no mouse),
|
// exit-race flag and its two behaviours live in wall.js (the
|
||||||
reveal it via `data-reveal`; this timeout is what fades it back, so a
|
// handleFullscreenChange hook); the listener stays here so the kiosk page can
|
||||||
parked pointer or a one-time keypress never pins it up. */
|
// observe the same event without the /watch policy (phase 08 task 04).
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("fullscreenchange", () => {
|
document.addEventListener("fullscreenchange", () => {
|
||||||
if (document.fullscreenElement) {
|
wall.handleFullscreenChange(document.fullscreenElement !== null);
|
||||||
// (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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ui.unstage) ui.unstage.addEventListener("click", unstage);
|
|
||||||
|
|
||||||
// Pointer activity on the stage reveals the overlay. `pointerdown` — not
|
// Pointer activity on the stage reveals the overlay. `pointerdown` — not
|
||||||
// just `pointermove` — is a deliberate touch tap, so a phone watcher can
|
// 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
|
// reach the controls without a hover; the idle timeout is what fades them
|
||||||
// again, so a parked pointer never pins the overlay up.
|
// again, so a parked pointer never pins the overlay up.
|
||||||
ui.stage.addEventListener("pointermove", revealStageControls);
|
ui.stage.addEventListener("pointermove", wall.revealStageControls);
|
||||||
ui.stage.addEventListener("pointerdown", 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 */
|
/* ------------------------------------------------------------------ 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({
|
state.ws = connectSignaling({
|
||||||
refused: !!config.refused,
|
refused: !!config.refused,
|
||||||
refusedReason: config.refusedMessage,
|
refusedReason: config.refusedMessage,
|
||||||
@@ -505,30 +95,24 @@ state.ws = connectSignaling({
|
|||||||
onClose() {
|
onClose() {
|
||||||
// The roster is invalid the moment the socket drops; the fresh `welcome`
|
// The roster is invalid the moment the socket drops; the fresh `welcome`
|
||||||
// after reconnect rebuilds it.
|
// after reconnect rebuilds it.
|
||||||
for (const streamId of [...tiles.keys()]) {
|
wall.closePeers();
|
||||||
const tile = tiles.get(streamId);
|
|
||||||
closePeer(tile);
|
|
||||||
tile.el.dataset.state = "stale";
|
|
||||||
tile.badge.textContent = "reconnecting…";
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onMessage(message) {
|
onMessage(message) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "welcome":
|
case "welcome":
|
||||||
for (const stream of message.streams || []) absorb(stream);
|
for (const stream of message.streams || []) wall.absorb(stream);
|
||||||
renderCounts();
|
|
||||||
break;
|
break;
|
||||||
case "stream_added":
|
case "stream_added":
|
||||||
absorb(message.stream);
|
wall.absorb(message.stream);
|
||||||
break;
|
break;
|
||||||
case "stream_updated":
|
case "stream_updated":
|
||||||
absorb(message.stream);
|
wall.absorb(message.stream);
|
||||||
break;
|
break;
|
||||||
case "stream_removed":
|
case "stream_removed":
|
||||||
dropTile(message.streamId);
|
wall.drop(message.streamId);
|
||||||
break;
|
break;
|
||||||
case "signal":
|
case "signal":
|
||||||
onSignal(message);
|
wall.onSignal(message);
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
console.warn("server:", message.code, message.message);
|
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 */
|
/* -------------------------------------------------------- keyboard router */
|
||||||
|
|
||||||
/* The kiosk rule this page lives by: no command may require a pointer, and no
|
/* 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
|
pointer affordance may be the only way to see state. The one keydown
|
||||||
a keypress on /watch is read — one listener, one switch. Every command in the
|
listener is installed by wall.js (installWallKeys): one listener, one
|
||||||
key map (j/k/arrows, Enter/f, g, n/p, 1-9, m, ?, Esc) is a case here, never
|
switch. Every command in the key map (j/k/arrows, Enter/f, g, n/p, 1-9, m,
|
||||||
a new listener. Chords with Ctrl/Cmd/Alt belong to the browser (back, reload,
|
?, Esc) is a case there, never a new listener. Chords with Ctrl/Cmd/Alt
|
||||||
copy), and a key in a text field is text, not a command, so both bail out
|
belong to the browser (back, reload, copy), and a key in a text field is
|
||||||
before the switch. Only keys this router actually handled are
|
text, not a command, so both bail out before the switch. Only keys the
|
||||||
preventDefault'ed. */
|
router actually handled are preventDefault'ed.
|
||||||
|
|
||||||
function orderedTiles() {
|
/watch's onCommand is a thin observer: the wall commands (j/k, Enter, f, g,
|
||||||
// DOM order == insertion order, which is the grid order renderCounts() uses.
|
n/p, m, 1-9) run in wall.js's defaults, and the page answers only `help`
|
||||||
return [...tiles.values()];
|
and `escape` — page policy, no default. */
|
||||||
}
|
|
||||||
|
|
||||||
function selectedTile() {
|
function onCommand(command, detail) {
|
||||||
return state.cursor ? tiles.get(state.cursor) || null : null;
|
switch (command) {
|
||||||
}
|
case "help":
|
||||||
|
// `?` shows and hides the key list. Focus is deliberately never touched:
|
||||||
function focusTile(tile) {
|
// the selected tile holds it throughout, so `?` → `?` → `Esc` lands the
|
||||||
// Focus follows selection (tiles are tabindex="0"). preventScroll keeps the
|
// operator exactly where they were — stage included. The toggle always
|
||||||
// grid from jumping while a remote hand walks it. If focus is already inside
|
// flips, so the key was always handled.
|
||||||
// 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;
|
ui.help.hidden = !ui.help.hidden;
|
||||||
}
|
return true;
|
||||||
|
|
||||||
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;
|
|
||||||
case "escape":
|
case "escape":
|
||||||
// The overlay sits above the stage (z-index 70 over 50), so Esc closes
|
// 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
|
// 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.
|
// staged mirror is two Escapes out of the wall, in that order.
|
||||||
if (!ui.help.hidden) {
|
if (!ui.help.hidden) {
|
||||||
ui.help.hidden = true;
|
ui.help.hidden = true;
|
||||||
event.preventDefault();
|
return true;
|
||||||
} else if (state.staged) {
|
|
||||||
unstage();
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
}
|
||||||
break;
|
if (wall.stagedId()) {
|
||||||
|
wall.unstage();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Nothing open: not handled — the browser keeps its default.
|
||||||
|
return false;
|
||||||
default:
|
default:
|
||||||
// 1-9: jump to the Nth mirror in grid order. A no-op gets a toast — a
|
// j/k, Enter, f, g, n/p, m, 1-9 are wall commands: the defaults in
|
||||||
// silent no-op is the one outcome a keyboard-only operator cannot debug.
|
// wall.js perform them; /watch has no policy of its own for them.
|
||||||
if (/^[1-9]$/.test(key)) {
|
return false;
|
||||||
const tile = orderedTiles()[Number(key) - 1];
|
|
||||||
if (tile) {
|
|
||||||
stage(tile.id, { fullscreen: true });
|
|
||||||
} else {
|
|
||||||
toast(`no mirror ${key}`);
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", onWallKey);
|
installWallKeys({ wall, onCommand });
|
||||||
|
|
||||||
// Tab is the browser's own cursor, and it must agree with j/k: when a tile
|
// 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.
|
// (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.
|
// to a tile's own button is not stolen back to the tile.
|
||||||
ui.grid.addEventListener("focusin", (event) => {
|
ui.grid.addEventListener("focusin", (event) => {
|
||||||
const node = event.target instanceof Element ? event.target.closest(".tile") : null;
|
const node = event.target instanceof Element ? event.target.closest(".tile") : null;
|
||||||
if (node && node.dataset.stream && tiles.has(node.dataset.stream)) {
|
if (node && node.dataset.stream && wall.tile(node.dataset.stream)) {
|
||||||
setSelected(node.dataset.stream);
|
wall.setSelected(node.dataset.stream);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -801,4 +189,4 @@ const refusal = refusalNotice(config);
|
|||||||
if (refusal) showNotice(ui.notice, refusal);
|
if (refusal) showNotice(ui.notice, refusal);
|
||||||
|
|
||||||
if (ui.share) ui.share.href = `${location.origin}/mirror`;
|
if (ui.share) ui.share.href = `${location.origin}/mirror`;
|
||||||
renderCounts();
|
renderCounts({ mirrors: 0, live: 0 });
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -544,7 +544,7 @@ class TestAddressGate:
|
|||||||
like a bug in the tool.
|
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):
|
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})
|
context = browser.new_context(viewport={"width": 1100, "height": 720})
|
||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
@@ -557,7 +557,9 @@ class TestAddressGate:
|
|||||||
# front of a proxy is undiagnosable.
|
# front of a proxy is undiagnosable.
|
||||||
expect(page.locator("body")).to_contain_text("127.0.0.1")
|
expect(page.locator("body")).to_contain_text("127.0.0.1")
|
||||||
# None of the app shell leaked past the gate.
|
# 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:
|
finally:
|
||||||
context.close()
|
context.close()
|
||||||
|
|
||||||
|
|||||||
+158
-20
@@ -80,6 +80,7 @@ class TestPages:
|
|||||||
("/mirror", "Share my screen"),
|
("/mirror", "Share my screen"),
|
||||||
("/share", "Share my screen"),
|
("/share", "Share my screen"),
|
||||||
("/watch", "tile-template"),
|
("/watch", "tile-template"),
|
||||||
|
("/kiosk", "kiosk-tile-template"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_pages_render(self, client, path, marker):
|
def test_pages_render(self, client, path, marker):
|
||||||
@@ -88,14 +89,17 @@ class TestPages:
|
|||||||
assert response.headers["content-type"].startswith("text/html")
|
assert response.headers["content-type"].startswith("text/html")
|
||||||
assert marker in response.text
|
assert marker in response.text
|
||||||
|
|
||||||
def test_pages_are_not_cacheable(self, client):
|
@pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk"])
|
||||||
assert client.get("/watch").headers["cache-control"] == "no-store"
|
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):
|
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()
|
share = (STATIC / "share.js").read_text()
|
||||||
watch = (STATIC / "watch.js").read_text()
|
wall = (STATIC / "wall.js").read_text()
|
||||||
assert "getDisplayMedia" in share
|
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()
|
assert "new WebSocket" in (STATIC / "net.js").read_text()
|
||||||
|
|
||||||
def test_nothing_is_loaded_from_a_cdn(self):
|
def test_nothing_is_loaded_from_a_cdn(self):
|
||||||
@@ -109,7 +113,10 @@ class TestPages:
|
|||||||
offenders.append(f"{file.name} (@import)")
|
offenders.append(f"{file.name} (@import)")
|
||||||
assert offenders == []
|
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"],
|
||||||
|
)
|
||||||
def test_static_assets_are_served(self, client, asset):
|
def test_static_assets_are_served(self, client, asset):
|
||||||
assert client.get(f"/static/{asset}").status_code == 200
|
assert client.get(f"/static/{asset}").status_code == 200
|
||||||
|
|
||||||
@@ -124,7 +131,7 @@ class TestClientScripts:
|
|||||||
NODE = shutil.which("node")
|
NODE = shutil.which("node")
|
||||||
|
|
||||||
@pytest.mark.skipif(NODE is None, reason="node is not installed")
|
@pytest.mark.skipif(NODE is None, reason="node is not installed")
|
||||||
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.js"])
|
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.js", "wall.js", "kiosk.js"])
|
||||||
def test_scripts_parse_as_es_modules(self, script, tmp_path):
|
def test_scripts_parse_as_es_modules(self, script, tmp_path):
|
||||||
copy = tmp_path / "subject.mjs"
|
copy = tmp_path / "subject.mjs"
|
||||||
copy.write_text((STATIC / script).read_text())
|
copy.write_text((STATIC / script).read_text())
|
||||||
@@ -133,7 +140,11 @@ class TestClientScripts:
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("script", "page"),
|
("script", "page"),
|
||||||
[("share.js", "mirror.html"), ("watch.js", "watch.html")],
|
[
|
||||||
|
("share.js", "mirror.html"),
|
||||||
|
("watch.js", "watch.html"),
|
||||||
|
("kiosk.js", "kiosk.html"),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
def test_every_element_the_script_reaches_for_exists(self, script, page):
|
def test_every_element_the_script_reaches_for_exists(self, script, page):
|
||||||
html = (STATIC / page).read_text()
|
html = (STATIC / page).read_text()
|
||||||
@@ -141,8 +152,100 @@ class TestClientScripts:
|
|||||||
missing = {name for name in wanted if f'id="{name}"' not in html}
|
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"
|
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):
|
def test_wall_never_looks_up_elements_in_the_document(self):
|
||||||
for page, script in (("mirror.html", "share.js"), ("watch.html", "watch.js")):
|
"""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_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"),
|
||||||
|
):
|
||||||
html = (STATIC / page).read_text()
|
html = (STATIC / page).read_text()
|
||||||
assert 'href="/static/app.css"' in html
|
assert 'href="/static/app.css"' in html
|
||||||
assert f'src="/static/{script}"' in html
|
assert f'src="/static/{script}"' in html
|
||||||
@@ -289,23 +392,55 @@ class TestClientScripts:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_the_two_pages_share_no_element_ids_but_the_shared_chrome(self):
|
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
|
This is not hypothetical: a `#stage` on both pages once made the share
|
||||||
page's preview inherit `display: none` from the watch page's overlay.
|
page's preview inherit `display: none` from the watch page's overlay.
|
||||||
|
The kiosk page's ids carry the `kiosk-` prefix for exactly this reason;
|
||||||
|
the only ids it may share with another page are the shared chrome
|
||||||
|
(net, notice, toast).
|
||||||
"""
|
"""
|
||||||
shared = {"net", "notice", "toast"}
|
shared = {"net", "notice", "toast"}
|
||||||
ids = {
|
ids = {
|
||||||
page: set(re.findall(r'id="([A-Za-z0-9_-]+)"', (STATIC / page).read_text()))
|
page: set(re.findall(r'id="([A-Za-z0-9_-]+)"', (STATIC / page).read_text()))
|
||||||
for page in ("mirror.html", "watch.html", "index.html")
|
for page in ("mirror.html", "watch.html", "index.html", "kiosk.html")
|
||||||
}
|
}
|
||||||
assert ids["mirror.html"] & ids["watch.html"] == shared
|
assert ids["mirror.html"] & ids["watch.html"] == shared
|
||||||
assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"}
|
assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"}
|
||||||
|
assert ids["kiosk.html"] & ids["watch.html"] == shared
|
||||||
|
assert ids["kiosk.html"] & ids["mirror.html"] == shared
|
||||||
|
# index.html has no notice/toast (a signpost, not an app page), so its
|
||||||
|
# intersection with the kiosk is a subset of the shared chrome — the
|
||||||
|
# same shape as the index/mirror assertion above.
|
||||||
|
assert ids["kiosk.html"] & ids["index.html"] <= shared
|
||||||
for page, found in ids.items():
|
for page, found in ids.items():
|
||||||
assert len(found) == len(set(found)), f"{page} declares an id twice"
|
assert len(found) == len(set(found)), f"{page} declares an id twice"
|
||||||
|
|
||||||
|
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):
|
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()):
|
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 "muted" in tag, f"{page}: {tag} would be blocked by autoplay policy"
|
||||||
assert "playsinline" in tag, f"{page}: {tag} would hijack iOS into fullscreen"
|
assert "playsinline" in tag, f"{page}: {tag} would hijack iOS into fullscreen"
|
||||||
@@ -378,8 +513,9 @@ class TestStylesheet:
|
|||||||
The flat ``[^{}]+\{[^{}]*\}`` pass treats an ``@media`` header as a
|
The flat ``[^{}]+\{[^{}]*\}`` pass treats an ``@media`` header as a
|
||||||
selector and the first child's header as its body; the child rules
|
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
|
that follow still come back as their own pairs, which is all these
|
||||||
guards need — every assertion is about a top-level block (``.stage``,
|
guards need — every assertion is about a top-level block
|
||||||
``.stage > *``) or about properties that must sit in the *same* block
|
(``.preview-stage``, ``.preview-stage > *``) or about properties that
|
||||||
|
must sit in the *same* block
|
||||||
(``overflow``/``aspect-ratio``/``min-height`` together).
|
(``overflow``/``aspect-ratio``/``min-height`` together).
|
||||||
"""
|
"""
|
||||||
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
|
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
|
||||||
@@ -396,8 +532,8 @@ class TestStylesheet:
|
|||||||
without the other stacks the rows back.
|
without the other stacks the rows back.
|
||||||
"""
|
"""
|
||||||
rules = self._rules()
|
rules = self._rules()
|
||||||
stage_bodies = [body for sel, body in rules if sel == ".stage"]
|
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
|
||||||
assert stage_bodies, ".stage rule missing from app.css"
|
assert stage_bodies, ".preview-stage rule missing from app.css"
|
||||||
assert any(
|
assert any(
|
||||||
re.search(r"grid-template\s*:\s*1fr\s*/\s*1fr", body)
|
re.search(r"grid-template\s*:\s*1fr\s*/\s*1fr", body)
|
||||||
or (
|
or (
|
||||||
@@ -409,7 +545,7 @@ class TestStylesheet:
|
|||||||
".stage must declare an explicit single cell (grid-template: 1fr / 1fr, "
|
".stage must declare an explicit single cell (grid-template: 1fr / 1fr, "
|
||||||
"or grid-template-rows/columns of 1fr) — otherwise its children stack into rows"
|
"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(
|
assert child_bodies and any(
|
||||||
re.search(r"grid-area\s*:\s*1\s*/\s*1", body) for body in child_bodies
|
re.search(r"grid-area\s*:\s*1\s*/\s*1", body) for body in child_bodies
|
||||||
), (
|
), (
|
||||||
@@ -431,8 +567,8 @@ class TestStylesheet:
|
|||||||
content again.
|
content again.
|
||||||
"""
|
"""
|
||||||
rules = self._rules()
|
rules = self._rules()
|
||||||
stage_bodies = [body for sel, body in rules if sel == ".stage"]
|
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
|
||||||
assert stage_bodies, ".stage rule missing from app.css"
|
assert stage_bodies, ".preview-stage rule missing from app.css"
|
||||||
assert any(re.search(r"min-height\s*:", body) for body in stage_bodies), (
|
assert any(re.search(r"min-height\s*:", body) for body in stage_bodies), (
|
||||||
".stage must keep its floor (min-height)"
|
".stage must keep its floor (min-height)"
|
||||||
)
|
)
|
||||||
@@ -460,7 +596,8 @@ class TestAddressGate:
|
|||||||
assert client.get("/watch").status_code == 200
|
assert client.get("/watch").status_code == 200
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"path", ["/", "/mirror", "/watch", "/api/streams", "/api/kiosk", "/static/app.css"]
|
"path",
|
||||||
|
["/", "/mirror", "/watch", "/kiosk", "/api/streams", "/api/kiosk", "/static/app.css"],
|
||||||
)
|
)
|
||||||
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
|
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
|
||||||
with gated(self.ALLOW) as client:
|
with gated(self.ALLOW) as client:
|
||||||
@@ -527,6 +664,7 @@ class TestAddressGate:
|
|||||||
def test_the_real_address_is_in_and_connects(self):
|
def test_the_real_address_is_in_and_connects(self):
|
||||||
with gated(self.ALLOW, client_address="10.2.1.10") as client:
|
with gated(self.ALLOW, client_address="10.2.1.10") as client:
|
||||||
assert client.get("/watch").status_code == 200
|
assert client.get("/watch").status_code == 200
|
||||||
|
assert client.get("/kiosk").status_code == 200
|
||||||
with client.websocket_connect("/ws") as socket:
|
with client.websocket_connect("/ws") as socket:
|
||||||
assert handshake(socket, "viewer")
|
assert handshake(socket, "viewer")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user