diff --git a/.agents/phases/todo/09_wall_remote_page/00_phase.md b/.agents/phases/complete/09_wall_remote_page/00_phase.md similarity index 100% rename from .agents/phases/todo/09_wall_remote_page/00_phase.md rename to .agents/phases/complete/09_wall_remote_page/00_phase.md diff --git a/.agents/phases/todo/09_wall_remote_page/01_remote_route_and_page.md b/.agents/phases/complete/09_wall_remote_page/01_remote_route_and_page.md similarity index 100% rename from .agents/phases/todo/09_wall_remote_page/01_remote_route_and_page.md rename to .agents/phases/complete/09_wall_remote_page/01_remote_route_and_page.md diff --git a/.agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md b/.agents/phases/complete/09_wall_remote_page/02_remote_controller_logic.md similarity index 100% rename from .agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md rename to .agents/phases/complete/09_wall_remote_page/02_remote_controller_logic.md diff --git a/.agents/phases/todo/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md b/.agents/phases/complete/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md similarity index 100% rename from .agents/phases/todo/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md rename to .agents/phases/complete/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.err b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md new file mode 100644 index 0000000..43fff5f --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md @@ -0,0 +1,11 @@ +**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)** + +**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass. + +**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓. + +**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added. + +**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure. + +**Next pending phase** — none; `todo/` held only phase 09. diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md.status b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.validate b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.validate new file mode 100644 index 0000000..9a2e518 --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.validate @@ -0,0 +1,37 @@ +........................................................................ [ 19%] +..............................................sssss..................... [ 39%] +........................................................................ [ 59%] +........................................................................ [ 78%] +........................................................................ [ 98%] +..... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1 + /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.15.0-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------ +src/mirror/__init__.py 3 0 100% +src/mirror/__main__.py 72 3 96% 129, 145, 156 +src/mirror/access.py 60 0 100% +src/mirror/app.py 197 10 95% 108, 113, 117, 182-183, 212-216 +src/mirror/config.py 68 5 93% 110-111, 118-120 +src/mirror/debug.py 15 10 33% 19-28 +src/mirror/protocol.py 66 0 100% +src/mirror/registry.py 228 3 99% 208, 226, 460 +------------------------------------------------------ +TOTAL 709 31 96% +=========================== short test summary info ============================ +SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +360 passed, 5 skipped, 1 warning in 102.18s (0:01:42) +All checks passed! +34 files already formatted +0 errors, 0 warnings, 0 informations diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.err b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md new file mode 100644 index 0000000..32dc003 --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md @@ -0,0 +1,9 @@ +All gates green. Task complete. + +**Report** + +- **Implemented:** `GET /remote` route in `src/mirror/app.py` (+ `PAGES` no-store); `static/remote.html` (bar + net pill, readout `#remote-state` with stable hooks, `#remote-back`/`#remote-fullscreen` controls, `#remote-list` + row template, empty state with share URL); `static/remote.js` stub (config, refusal notice, socket hello as plain `role:"viewer"` with no `kiosk` flag, net pill to online); `app.css` `.remote` styles (mono readout strip, wrapping control row, 320 px-safe rows, `#remote-fullscreen[aria-pressed="true"]` accent = tile audio button treatment); entry links in `watch.html` (`#remote-link`) and `index.html` (4th card); `/kiosk` untouched; `test_app.py` guards extended to the fourth page (render/no-store/asset/node-check/element-exists/stylesheet-module/id-collision over 5 pages with `{"net","notice","toast"}` as the only overlap, address gate, plus new `test_a_controller_is_media_free` and `test_the_remote_page_declares_its_control_hooks`). +- **Results:** `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — 349 passed, 5 skipped (realdisplay opt-in), 100% green; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 documented platform skip; `uv run pytest tests/test_app.py` — 104 passed; coverage 95% overall, new route fully covered. Live checks: `curl -s/-I localhost:8055/remote` serves the page with `cache-control: no-store`; browser at 320 px: net pill online, empty state + share URL, zero console errors, both buttons reachable, no horizontal scroll. +- **Docs:** README — `/remote` row in the page table + quickstart URL line (SECURITY.md/full controller docs left to task 03 per the phase plan). AGENTS.md — one stale fact only: "Four pages" → "Five pages". +- **Deviation:** the task text names both the readout marker span and the Full screen button `remote-fullscreen` (a duplicate id that would fail the page's own uniqueness guard). The button keeps `#remote-fullscreen` (the `aria-pressed` CSS rule and the task's guard target it); the readout marker is `#remote-fullscreen-mark`, noted in the markup and asserted by the new hook guard. +- **Next pending task:** `.agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md` diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md.status b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.validate b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.validate new file mode 100644 index 0000000..93530b9 --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.validate @@ -0,0 +1,36 @@ +........................................................................ [ 20%] +......................................sssss............................. [ 40%] +........................................................................ [ 61%] +........................................................................ [ 81%] +.................................................................. [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1 + /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.15.0-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------ +src/mirror/__init__.py 3 0 100% +src/mirror/__main__.py 72 3 96% 129, 145, 156 +src/mirror/access.py 60 0 100% +src/mirror/app.py 197 11 94% 108, 113, 116-117, 182-183, 212-216 +src/mirror/config.py 68 5 93% 110-111, 118-120 +src/mirror/debug.py 15 10 33% 19-28 +src/mirror/protocol.py 66 0 100% +src/mirror/registry.py 228 3 99% 208, 226, 460 +------------------------------------------------------ +TOTAL 709 32 95% +=========================== short test summary info ============================ +SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +349 passed, 5 skipped, 1 warning in 95.96s (0:01:35) +All checks passed! +33 files already formatted +0 errors, 0 warnings, 0 informations diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.err b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md new file mode 100644 index 0000000..ec11f1c --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md @@ -0,0 +1,14 @@ +All green. Final report: + +**Implemented (task 02 — `remote.js` controller logic):** +- `src/mirror/static/remote.js`: `mirrors` Map + `layout` state; roster frames (`welcome` seeds, added/updated upsert, removed deletes, `signal` ignored); `renderMirrors()` (template rows, `data-on`/`aria-current`/`On the wall`, empty state); `renderState()` (staged title/`(gone)`/`no mirror`, fullscreen on/off on marker + button `aria-pressed`/label, followers plural handling); one-line hint (`no display following` / `offline`); `arrange()` writers with event delegation on `#remote-list`; one-time "…ended — back to the wall" toast; stale readout on socket close, re-sync on `welcome`. +- `remote.html`: added `#remote-hint`; `app.css`: `.remote-hint` + dimmed `[data-stale]` readout. +- `tests/test_app.py`: media-free guard extended (`MediaStream`, `navigator.mediaDevices`), new `test_a_controller_sends_exactly_one_frame_type_besides_hello`, hook list gains `remote-hint`, 2 protocol tests (controller drives layout, not counted in `followers`; `arrange` changes no subscriptions/audience). + +**Gates (all 4, exact commands):** ruff check+format clean · pyright 0 errors · `uv run pytest` **352 passed, 5 skipped** (documented realdisplay skips) · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` **4 passed, 1 documented skip** · coverage 95%, byte-identical to verified baseline (no `src/` Python touched). + +**Docs:** no impact — README already describes `/remote` accurately (task 01); deep docs are task 03's scope; no stale AGENTS.md statements. + +**Notable:** spec's "three spans" named `#remote-fullscreen`, but task 01's built HTML (locked contract, asserted in tests) uses `#remote-fullscreen-mark` for the readout span and `#remote-fullscreen` for the button — followed the built HTML (marker carries on/off, button carries label/aria). Pre-existing, verified in a reconstructed baseline: the non-gate combined run `MIRROR_REALDISPLAY=1 uv run pytest` errors 5 x11 fixture setups (Playwright sync-API double context); both official invocations are green, left untouched. + +**Next pending task:** `03_remote_e2e_docs_and_kiosk_url.md`. diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md.status b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.validate b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.validate new file mode 100644 index 0000000..62aed55 --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.validate @@ -0,0 +1,36 @@ +........................................................................ [ 20%] +......................................sssss............................. [ 40%] +........................................................................ [ 60%] +........................................................................ [ 80%] +..................................................................... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1 + /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.15.0-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------ +src/mirror/__init__.py 3 0 100% +src/mirror/__main__.py 72 3 96% 129, 145, 156 +src/mirror/access.py 60 0 100% +src/mirror/app.py 197 11 94% 108, 113, 116-117, 182-183, 212-216 +src/mirror/config.py 68 5 93% 110-111, 118-120 +src/mirror/debug.py 15 10 33% 19-28 +src/mirror/protocol.py 66 0 100% +src/mirror/registry.py 228 3 99% 208, 226, 460 +------------------------------------------------------ +TOTAL 709 32 95% +=========================== short test summary info ============================ +SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +352 passed, 5 skipped, 1 warning in 96.76s (0:01:36) +All checks passed! +33 files already formatted +0 errors, 0 warnings, 0 informations diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.err b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md new file mode 100644 index 0000000..59735d4 --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md @@ -0,0 +1,12 @@ +All changes verified. The AGENTS.md diff ("Five pages") was from task 01 — I left AGENTS.md untouched (no stale statement in it). Everything is complete and green. + +**Report** + +- **New `tests/e2e/test_remote_control.py`** (8 tests, `e2e` + `timeout(180)`, same `device()`/`open_device` setup as the kiosk suite): the click loop (`.show` → display `data-layout="stage"` + staged tile + `/api/kiosk`, `#remote-back` → wall), pixels-not-classes (`frames_matching` width>0, distinct≥2 on the display), fullscreen (server state + `body[data-fullscreen]`, `document.fullscreenElement` never asserted, comment says why), two-controller convergence, controller-costs-nothing (stream `viewers: 0` / `stats.watching: 0` with only `/remote`, moves when `/watch` joins), followers agreement (0/1/1 — a controller is not a display), kiosk `n` keypress moving the remote readout, teardown (display unstages, row/readout clear), `errors == []` in every case. +- **Fix found while testing**: the kiosk suite leaves `fullscreen: true` on the shared session server (teardown resets `staged` only), which broke the toggle-based fullscreen test; the test now normalizes the shared state through the product at start (documented in the test). +- **SECURITY.md**: §2 gains the "rearrange anybody's wall display" bullet (names `/remote`, same trust decision as watching, bounded by `MIRROR_ALLOW_CIDRS`) and the not-possible audio bullet; §3 gains the "No remote audio" row naming the real enforcing tests. +- **README**: new "Driving an unattended wall" section (keys on the box visible to controllers, fullscreen requested-not-promised, in-memory state); Kiosk-client paragraph updated for the new default. Page table/Quickstart already had both endpoints. +- **Silverblue tree → `/kiosk` in one change**: `install-kiosk.sh` default + comment, `kiosk.conf` URL + comment, `gnome-kiosk-script` fallback, `check-kiosk.sh` (accepts both walls, warning kept for the third case), `kiosk-silverblue.md` (top description, `KIOSK_URL` example, controller/migration/title paragraphs de-staled). `sh -n` clean on all scripts; checker logic verified via harness: `/kiosk` and `/watch` → ok, other → warn. +- **Gates**: `uv run pytest tests/e2e/test_remote_control.py` → 8 passed; `uv run pytest -m e2e` → 118 passed; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed (1 documented platform skip); full `uv run pytest` → **360 passed, 5 skipped, 0 failed**; `ruff check`/`ruff format --check`/`pyright` all clean. +- **Note**: one one-off `test_kiosk_layout.py` tile-id flake in a single full run (not reproduced in two subsequent full runs; my suite runs after it in file order, so it cannot be the cause). No doc impact on AGENTS.md. +- **Next pending task**: none — task 03 is the last task of phase 09. diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md.status b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.validate b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.validate new file mode 100644 index 0000000..96e3a3e --- /dev/null +++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.validate @@ -0,0 +1,37 @@ +........................................................................ [ 19%] +..............................................sssss..................... [ 39%] +........................................................................ [ 59%] +........................................................................ [ 78%] +........................................................................ [ 98%] +..... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1 + /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.15.0-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------ +src/mirror/__init__.py 3 0 100% +src/mirror/__main__.py 72 3 96% 129, 145, 156 +src/mirror/access.py 60 0 100% +src/mirror/app.py 197 10 95% 108, 113, 117, 182-183, 212-216 +src/mirror/config.py 68 5 93% 110-111, 118-120 +src/mirror/debug.py 15 10 33% 19-28 +src/mirror/protocol.py 66 0 100% +src/mirror/registry.py 228 3 99% 208, 226, 460 +------------------------------------------------------ +TOTAL 709 31 96% +=========================== short test summary info ============================ +SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +360 passed, 5 skipped, 1 warning in 102.09s (0:01:42) +All checks passed! +34 files already formatted +0 errors, 0 warnings, 0 informations diff --git a/AGENTS.md b/AGENTS.md index bef2415..c1782f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ is a roster and a signaling relay. Linux is the reference platform. | Rule | Why it exists | |---|---| | **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestPages::test_nothing_is_loaded_from_a_cdn` fails the build if you add one | -| **No framework, no bundler** in `src/mirror/static/` | Four pages and one WebSocket. `node --check` is the entire frontend toolchain | +| **No framework, no bundler** in `src/mirror/static/` | Five pages and one WebSocket. `node --check` is the entire frontend toolchain | | **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 | | **`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 | diff --git a/README.md b/README.md index d19a9bb..c24a3e2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ your network: | **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. | | **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. | | **Kiosk** | `/kiosk` | A chrome-free wall for an unattended display: no header, no counts — the net pill is the only chrome, and the shared layout state (below) arranges it. | +| **Remote** | `/remote` | A controller for the shared wall, from a laptop or a phone: lists the live mirrors and the wall's state and sends the decisions (show this, back to the wall, full screen) — no video, no bandwidth. | | **Index** | `/` | Signpost + live count of mirrors. | Any number of people can mirror at the same time and any number of people can @@ -32,6 +33,7 @@ Then, from another machine on the LAN: http://:8055/mirror # to share http://:8055/watch # to watch http://:8055/kiosk # the unattended wall (kiosk display) +http://:8055/remote # drive the wall from this device ``` Linux is the primary target. On Linux the native picker is X11/Wayland's own @@ -78,6 +80,20 @@ same `arrange` intent, so a controller and a display converge on one state (`m` is local-only: a display never becomes audible because another machine asked). +### Driving an unattended wall + +The shared state exists for a box with no keyboard: point the box at `/kiosk`, +open `/remote` on a phone, and press **Show on wall** — the box stages that +mirror, and **Back to the wall** and **Full screen** do the same for their +decisions. When a keyboard is plugged into the box, the wall keys still work +there, and a keypress performs the change *and* publishes the same `arrange` +intent, so a key on the box is seen by every controller — a controller and a +display converge on one state. Fullscreen is **requested, not promised**: a +remote command carries no user activation, so the browser may refuse the +request; the in-page stage fills the viewport anyway (and the kiosk box's +window manager forces the window fullscreen). The state lives in the server's +memory only: a restart returns the wall to a wall. + ## Configuration Environment variables (all optional; a `.env` file is loaded if present): @@ -180,9 +196,9 @@ from this side: A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at the wall. It is set up and repaired entirely over SSH — no keyboard, no console, -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 +no monitor. The display page is `/kiosk`, and it is the installer default +too; an existing `kiosk.conf` URL wins over the default, so moving a box is +the one-line re-point in [the runbook](docs/kiosk-silverblue.md#two-endpoints-watch-and-kiosk). A brand-new box layers the kiosk packages once first (`rpm-ostree install gnome-kiosk gnome-kiosk-script-session`, then a reboot); after that these two commands are both the setup and the repair path: diff --git a/SECURITY.md b/SECURITY.md index 8e0b67d..83d53ed 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,11 +31,16 @@ limiting, no accounts, and no audit trail. `MIRROR_MAX_BITRATE`; the sharer's quality preset scales that bound only *down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's ceiling). -* Point the shared wall at any live mirror and toggle its fullscreen state - (the `arrange` frame on `/ws`), which every kiosk display follows. The - state has no audio field: a remote hand can stage and scale, but it can - never make a display *sound* — that still needs a gesture on the device - playing it (see §4 “Audio”). +* **Rearrange anybody's wall display**: choose which mirror is on stage and + whether it is fullscreen, on every `/kiosk` client, immediately — open + `/remote` on a phone, or send one `arrange` frame on `/ws`. This is the + same trust decision as "watch anything anyone shares": it is visible, it is + presentation-only (no sound, see below), and `MIRROR_ALLOW_CIDRS` is what + bounds it. +* What a visitor **cannot** do: make a display audible. The layout carries no + sound field, and every audible state traces to a gesture on the device that + plays it (the §4 “Audio” gesture rule) — a remote hand can stage and scale, + but a stranger's display never sounds because they asked. ## 3. What they cannot do (enforced and tested) @@ -46,6 +51,7 @@ limiting, no accounts, and no audit trail. | **No identity spoofing of the roster.** A viewer cannot publish a mirror; a peer cannot claim another's stream | role checks (`not_sharer`, `not_viewer`), stream ids are server-assigned | `test_one_peer_cannot_impersonate_another`, `test_a_viewer_cannot_publish` | | **No oversized signaling.** Frames above 64 KiB are dropped before parsing | `MAX_MESSAGE_BYTES` | `test_an_oversized_frame_is_refused` | | **No protocol fuzzing into a crash.** Bad JSON, non-object frames, unknown types and unknown roles produce an error frame; only fatal ones close the socket | `Hub.serve`/`_dispatch` | `TestSignalingSocket` | +| **No remote audio, no remote mute override.** The layout carries no sound field, and every audible state traces to a gesture on the device that plays it | the layout model has no audio field, plus the `kiosk.js`/`remote.js` source guards | `test_kiosk_never_publishes_an_audio_field`, `test_a_controller_is_media_free`, `test_m_flips_the_staged_videos_muted_flag`, `test_only_the_staged_mirror_can_be_unmuted` | | **No leaked peer connections.** Every teardown path notifies every viewer | `Registry._drop_stream`, `leave()` | `test_sharer_disconnect_removes_the_mirror`, `test_unpublish_clears_the_tile_for_every_viewer` | | **Nothing leaves the network.** No CDN, no remote font, no analytics, no STUN by default | A10/A4 + a build-failing test | `test_nothing_is_loaded_from_a_cdn`, `test_config_does_not_leak_server_secrets` | | **The server cannot see your screen.** Media is SRTP between peers; the server relays JSON only | A3 | Architectural: no media code exists in Python | diff --git a/docs/kiosk-silverblue.md b/docs/kiosk-silverblue.md index 36ff87a..15fd030 100644 --- a/docs/kiosk-silverblue.md +++ b/docs/kiosk-silverblue.md @@ -1,7 +1,7 @@ # Kiosk client — Fedora Silverblue The target client for a wall display is **Fedora Silverblue running GNOME Kiosk, -auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/watch` +auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/kiosk` fullscreen in Firefox.** One box takes about five minutes over SSH and needs no keyboard, no monitor access, and no console. @@ -56,7 +56,7 @@ be skipped when the packages are already layered. To point a client somewhere other than the production wall, pass the URL: ```bash -sudo KIOSK_URL=https://mirror.reeseapps.com/watch sh install-kiosk.sh +sudo KIOSK_URL=https://mirror.reeseapps.com/kiosk sh install-kiosk.sh ``` Only `kiosk.conf` differs between clients; every other file stays byte-identical @@ -87,17 +87,20 @@ for the moment a keyboard is plugged in (see [the key table](#operating-the-wall and a keypress there performs the change *and* publishes the same `arrange` intent, so a controller and a display converge on one state instead of disagreeing (`m` is local-only — a display never becomes audible because -another machine asked). The **controller side** — a page a person on another -machine drives the wall from — arrives in phase 09 (`/remote`); until then a -controller is any client that can send an `arrange` frame on `/ws`, `websocat` -included. `GET /api/kiosk` is the state's read surface: what is on stage, -whether it is fullscreen, and how many displays (`followers`) are obeying it. +another machine asked). The **controller side** is `/remote`: a page a person +on another machine (a phone) opens to list the live mirrors, read the wall's +state, and send the decisions — show this, back to the wall, full screen. Any +client that can send an `arrange` frame on `/ws` is a controller too, +`websocat` included. `GET /api/kiosk` is the state's read surface: what is on +stage, whether it is fullscreen, and how many displays (`followers`) are +obeying it. **The migration rule, stated honestly.** Existing clients keep their own -`MIRROR_KIOSK_URL`: an existing `kiosk.conf` URL wins over the built-in -default, and the installer's default still points at `/watch` in this phase. -So **nothing changes under a box until its `kiosk.conf` is re-pointed and -`gdm` restarted**: +`MIRROR_KIOSK_URL` until an operator re-points it and restarts `gdm`, because +the installer deliberately preserves a client's URL: an existing `kiosk.conf` +URL wins over the built-in default (which now points at `/kiosk`). So +**nothing changes under a box until its `kiosk.conf` is re-pointed and `gdm` +restarted**: ```bash sudo sed -i 's|^MIRROR_KIOSK_URL=.*|MIRROR_KIOSK_URL="https://…/kiosk"|' \ @@ -107,10 +110,10 @@ sudo systemctl restart gdm (`pkill -u kiosk firefox` is the shorter path — the loop re-sources `kiosk.conf` on every relaunch, see [the section below](#the-two-facts-that-make-this-work) — -`gdm` is the clean restart when you want the whole session back.) The kiosk -page serves `Wall · mirror`; `check-kiosk.sh`, untouched in this -phase, still greps for `<title>Watch · mirror`, and the checker is widened to -accept the kiosk title in phase 09, when the default URL moves with it. +`gdm` is the clean restart when you want the whole session back.) Both walls +are served by the same tree; `check-kiosk.sh` accepts both titles +(`<title>Watch · mirror` and `Wall · mirror`) and +warns for anything else. --- @@ -347,7 +350,7 @@ The console is not an interface. Every claim below is checkable over SSH. | The page loaded | the **server's** `GET /api/streams` shows the kiosk in `viewers` and its socket in `ss -tn` | | Media really flows | the sharer's own page renders each viewer's `RTCPeerConnection.connectionState`; `connected` = ICE+DTLS+SRTP up | | It is truly fullscreen | `dim-probe.py`: `viewport == window == screen == available` | -| The URL is serving *mirror* | `check-kiosk.sh` greps the page for `Watch · mirror` | +| The URL is serving *mirror* | `check-kiosk.sh` greps the page for the wall title — `<title>Watch · mirror` or `<title>Wall · mirror` | | The kiosk holds the screen | `/sys/class/tty/tty0/active` equals the kiosk session's TTY and the session is `Active` — the one check that catches a covered wall | | No greeter is fighting it | `journalctl -b \| grep -c "New session.*gdm-greeter"` is `0`; any non-zero number is a login screen on your wall | | It survives a power cut | `sudo systemctl reboot`, then re-run `check-kiosk.sh` | diff --git a/docs/kiosk-silverblue/check-kiosk.sh b/docs/kiosk-silverblue/check-kiosk.sh index b703074..12808cb 100755 --- a/docs/kiosk-silverblue/check-kiosk.sh +++ b/docs/kiosk-silverblue/check-kiosk.sh @@ -64,9 +64,9 @@ if [ -n "$HOME_DIR" ]; then "$HOME_DIR/.config/mirror/kiosk.conf" 2>/dev/null | head -1) ok "kiosk URL: ${URL:-<empty>}" case "$URL" in - */watch) : ;; + */watch|*/kiosk) : ;; "") bad "kiosk URL is empty" ;; - *) warn "kiosk URL is not the /watch wall: $URL" ;; + *) warn "kiosk URL is neither wall (/watch or /kiosk): $URL — check DNS and the nginx vhost" ;; esac else bad "kiosk config $HOME_DIR/.config/mirror/kiosk.conf is missing" @@ -213,8 +213,10 @@ if [ -n "$URL" ]; then CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 "$URL" 2>/dev/null) if printf '%s' "$BODY" | grep -q "Watch · mirror"; then ok "$URL serves the mirror watch page (HTTP $CODE)" + elif printf '%s' "$BODY" | grep -q "Wall · mirror"; then + ok "$URL serves the mirror kiosk page (HTTP $CODE)" else - warn "$URL answered HTTP $CODE but it is NOT the mirror watch page — the kiosk" + warn "$URL answered HTTP $CODE but it is NOT a mirror wall — the kiosk" warn "will happily display whatever this is. Check DNS and the nginx vhost." info "title: $(printf '%s' "$BODY" | sed -n 's/.*<title>\(.*\)<\/title>.*/\1/p' | head -1)" fi diff --git a/docs/kiosk-silverblue/gnome-kiosk-script b/docs/kiosk-silverblue/gnome-kiosk-script index 85b8c85..bec7164 100755 --- a/docs/kiosk-silverblue/gnome-kiosk-script +++ b/docs/kiosk-silverblue/gnome-kiosk-script @@ -25,7 +25,7 @@ if [ -r "$CONF" ]; then . "$CONF" fi -URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/watch}" +URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/kiosk}" PROFILE="${MIRROR_KIOSK_PROFILE:-$HOME/.mirror-kiosk}" LOG="${MIRROR_KIOSK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/mirror-kiosk.log}" POLL="${MIRROR_KIOSK_POLL:-5}" diff --git a/docs/kiosk-silverblue/install-kiosk.sh b/docs/kiosk-silverblue/install-kiosk.sh index e3b119d..9f8829c 100755 --- a/docs/kiosk-silverblue/install-kiosk.sh +++ b/docs/kiosk-silverblue/install-kiosk.sh @@ -17,7 +17,10 @@ set -eu KIOSK_USER="${KIOSK_USER:-kiosk}" # The default is for a NEW client only. An existing client keeps its own URL # unless the operator names one — see "the URL is the one per-client value" below. -KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/watch" +# The default is the DISPLAY endpoint: /kiosk obeys the shared wall state, so +# a device with a keyboard (the /remote page) can arrange a box that has none. +# /watch is the operator's page — a wall a person stands at. +KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/kiosk" KIOSK_URL="${KIOSK_URL:-}" KIOSK_SESSION="gnome-kiosk-script-wayland" HERE=$(cd "$(dirname "$0")" && pwd) diff --git a/docs/kiosk-silverblue/kiosk.conf b/docs/kiosk-silverblue/kiosk.conf index 8095eed..e58e761 100644 --- a/docs/kiosk-silverblue/kiosk.conf +++ b/docs/kiosk-silverblue/kiosk.conf @@ -7,9 +7,12 @@ # After editing: `sudo systemctl restart gdm` (or log the kiosk user out) to # re-read it. The browser is restarted by the kiosk script's own loop. -# The wall of every live mirror. `/` is the Share/Watch chooser and needs a -# tap to get anywhere, which is wrong for a display nobody is standing at. -MIRROR_KIOSK_URL="https://mirror.reeseapps.com/watch" +# The controllable wall. `/kiosk` is the display endpoint: it obeys the shared +# layout state, so a device with a keyboard (the /remote page, or any +# `arrange` frame on /ws) decides what is on stage and whether it is +# fullscreen — a box that has no keyboard can still be arranged. `/watch` is +# the operator's page: a wall a person stands at. +MIRROR_KIOSK_URL="https://mirror.reeseapps.com/kiosk" # Optional overrides (uncomment to change): diff --git a/src/mirror/app.py b/src/mirror/app.py index da0c2b7..83a2c46 100644 --- a/src/mirror/app.py +++ b/src/mirror/app.py @@ -316,6 +316,13 @@ def create_app(settings: Settings | None = None) -> FastAPI: itself from the shared layout (phase 07/08).""" return _page("kiosk.html") + @app.get("/remote", response_class=HTMLResponse, include_in_schema=False) + async def remote_page() -> FileResponse: + """The controller: lists mirrors and drives the shared layout. Shows no + media and subscribes to nothing — a controller must not cost a sharer + bandwidth (PLAN.md §6).""" + return _page("remote.html") + @app.get("/api/config") async def client_config() -> JSONResponse: return JSONResponse( @@ -356,7 +363,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: return await hub.serve(websocket) - PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk") + PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk", "/remote") @app.middleware("http") async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def] diff --git a/src/mirror/static/app.css b/src/mirror/static/app.css index 4bd2228..3dcabc2 100644 --- a/src/mirror/static/app.css +++ b/src/mirror/static/app.css @@ -683,6 +683,102 @@ body.staged .stage { display: block; } pointer-events: none; } +/* --------------------------------------------------------------- remote */ + +/* /remote is a controller: a readout, the decision buttons and a list of + rows — no video, no tiles (PLAN.md §6). It is the phone page of the app, + so it must stay usable down to 320 px, where the share page's narrow- + viewport rules are the model: wrap, ellipsis, never run off the edge. */ + +/* The readout: one strip under the bar in the monospace face — what the + server says the wall is doing, verbatim. `aria-live` on the section + (markup) is what makes it speak to a screen reader; the CSS only styles. */ +.wall-state { + margin: 14px 16px 0; + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-2); + font-family: var(--mono); + font-size: 13px; + color: var(--fg-dim); +} + +.wall-state #remote-staged { color: var(--fg); } + +/* The decision buttons: one row that wraps, so at 320 px they stack and + stay reachable instead of running off the edge. */ +.remote-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 14px 16px 0; +} + +/* The "on" state of the Full screen button: the same accent treatment the + tile's audio button gets when its aria-pressed is true, so "on" looks + the same everywhere in this app. */ +#remote-fullscreen[aria-pressed="true"] { + border-color: var(--accent); + color: var(--accent); +} + +/* The one-line hint under the controls: "no display following yet" (the + state is stored — a display pointed at /kiosk picks it up) and + "offline". The global `[hidden]` rule is what hides it; no rule of its + own may fight that. */ +.remote-hint { + margin: 10px 16px 0; + font-family: var(--mono); + font-size: 12px; + color: var(--fg-dim); +} + +/* A readout that is a snapshot, not a statement: the socket dropped, so the + last staged label is shown dimmed until the next welcome re-syncs it. */ +.wall-state #remote-staged[data-stale] { + opacity: 0.55; +} + +/* One row per mirror: title + sub + the decision. `flex-wrap` is the 320 px + escape hatch (the button drops under the title rather than clipping), and + `min-width: 0` on the grow is what lets the title ellipsis instead of + pushing the button out. */ +.mirror-list { + list-style: none; + margin: 14px 16px 0; + padding: 0; + display: grid; + gap: 10px; +} + +.mirror-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: 12px; +} + +.mirror-row .grow { flex: 1; min-width: 0; } +.mirror-row .title { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mirror-row .sub { + color: var(--fg-dim); + font-family: var(--mono); + font-size: 11.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } diff --git a/src/mirror/static/index.html b/src/mirror/static/index.html index a99e472..e2a987b 100644 --- a/src/mirror/static/index.html +++ b/src/mirror/static/index.html @@ -51,6 +51,14 @@ itself from the shared layout. No pointer, no keyboard, nothing to set up at the box. </p> </a> + <a class="card" href="/remote"> + <div class="kicker">GET /remote</div> + <h2>Point the wall from another device</h2> + <p> + Open it on a laptop or a phone and drive the shared wall: list the live mirrors, + show one on the display, back to the wall, full screen — no video, no bandwidth. + </p> + </a> </div> <div class="livebar" id="livebar">Checking for mirrors…</div> diff --git a/src/mirror/static/remote.html b/src/mirror/static/remote.html new file mode 100644 index 0000000..7629ac0 --- /dev/null +++ b/src/mirror/static/remote.html @@ -0,0 +1,81 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> + <meta name="color-scheme" content="dark" /> + <title>Remote · mirror + + + + + +
+
mirror
+
+ connecting… +
+ + + + +
+ Wall: no mirror · no display following +
+ + +
+ + +
+ + + + + + +
+

No mirrors live

+

+ Share from … +

+
+ + + + + + + + + diff --git a/src/mirror/static/remote.js b/src/mirror/static/remote.js new file mode 100644 index 0000000..54d4128 --- /dev/null +++ b/src/mirror/static/remote.js @@ -0,0 +1,308 @@ +/* The remote page: the hand that drives the wall. + + It lists the live mirrors and the wall's current state, and offers the + three decisions (show this, back to the wall, full screen) as `arrange` + frames the display obeys. The page's correctness rests on one rule: the + only thing it displays about the wall is what the server last told it. + A control never paints a result locally and hopes the server agrees — + with two controllers open, an optimistic paint is a lie the moment the + other one acts, and a lie in the readout is worse than a 40 ms delay. + + What this page never does: show media, or ask for a stream's track — + the mesh uploads N × bitrate per sharer, so a controller that consumed + a leg just to know what to point at would cost everyone (PLAN.md §6). + It keeps a list of mirrors, not tiles: no