Compare commits

...
4 Commits
Author SHA1 Message Date
ducoterra d0077e0db4 phase: 09_wall_remote_page
Build and Push Containers / test (push) Successful in 45s
Build and Push Containers / build-and-push-app (push) Successful in 13s
**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**

**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.

**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.

**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.

**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.

**Next pending phase** — none; `todo/` held only phase 09.
2026-11-03 23:50:43 -05:00
ducoterra cd29156a7d phase: 08_kiosk_page
Build and Push Containers / test (push) Successful in 42s
Build and Push Containers / build-and-push-app (push) Successful in 9s
**Phase 08 final verification pass — complete, all green.**

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

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

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

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

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

**Next pending phase:** `09_wall_remote_page`.
2026-11-03 22:38:22 -05:00
ducoterra 736da1c1ef phase: 07_kiosk_layout_state
Build and Push Containers / test (push) Successful in 39s
Build and Push Containers / build-and-push-app (push) Successful in 12s
Every AGENTS.md rule claim checks out against the code — no stale statements. All verification is complete.

**Phase 07 — final verification pass report**

- **Verified, not re-implemented:** all 3 tasks already in `complete/` — `Arrange`/`layout` frames + `hello.kiosk` (protocol), `Layout`/`set_layout`/`layout_dto` policy (registry), `arrange` dispatch + `GET /api/kiosk` (app).
- **Live check:** idle `/api/kiosk` → `{"staged": null, "fullscreen": false, "followers": 0}`; drove `arrange` over a real WebSocket (websocat absent → python `websockets` client): state reflected in `/api/kiosk`, kiosk join bumped `followers` to 1 and pushed to other viewers, sharer teardown reset `staged` to `null` in exactly one `layout` frame — no stale id.
- **Gates (all 4):** `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **319 passed, 5 skipped** (realdisplay, by design); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **4 passed, 1 skipped** (documented host has no audio source).
- **Criteria 1/4/5:** targeted suite 154 passed incl. all new layout cases; stale-fallback unit tests cover unpublish, disconnect, and stream-replacement; `git diff` of `src/mirror/static/` and `tests/e2e/` is empty, e2e green within the full run.
- **Criterion 6:** full suite 100% green; coverage 95% total — new/modified code: protocol 100%, registry 99%, app 94% (all >90%).
- **Criterion 7:** `PLAN.md` untouched.
- **Documentation:** README.md reviewed in full and cross-checked against code (routes, CLI flags, config table incl. 25 Mbit/s default, quality-preset table, watch/share key tables, docs references, the phase-07 shared-state paragraph) — consistent, no fixes needed. AGENTS.md — verified, no stale statements, no changes.
- **Note:** `PLAN.md` §6 says the default `MIRROR_MAX_BITRATE` is "12 Mbit/s" but code/README say 25 — pre-existing, in a LOCKED file, left as-is (README is correct).
- **Next pending phase:** `08_kiosk_page`.
2026-11-03 09:53:38 -05:00
ducoterra 1f60a6b40c chore(agent): phase roadmap from TODO.md, 3 phases
The one TODO item — a /kiosk endpoint whose state (fullscreen, which
mirror) any client can manipulate — becomes three phases, since it is
three workstreams:

- 07_kiosk_layout_state: the state on the server. `arrange` in, `layout`
  out (staged/fullscreen/followers), Registry policy with a stale-staged
  fallback on teardown, `GET /api/kiosk` for curl-level debugging.
- 08_kiosk_page: the wall made reusable (`static/wall.js`, behaviour-
  preserving) and the chrome-free /kiosk page that arranges itself from
  the state and publishes a local keypress back.
- 09_wall_remote_page: /remote, the controller you open on a phone —
  shows no media and subscribes to nothing, proven by /api/streams'
  watcher count — plus the security line and the kiosk box's new URL.

Locked anchors hold: no third role, state stays in memory (A7), Registry
stays pure (A8), no external assets (A10), no remote audio command
(SECURITY.md §4). PLAN.md untouched, per the user's decision, so its §7
roadmap table does not list 07-09.

LLM-Generated: true
2026-11-03 09:15:12 -05:00
97 changed files with 6059 additions and 768 deletions
@@ -0,0 +1,113 @@
# Phase 07 — Shared presentation state on the server
**Source:** `TODO.md` L3 — "Add a /kiosk endpoint that is controllable by other clients… There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately…" (the server half of that item)
**Story:** n/a
**Context:** `.agents/PLAN.md` §2 A5 (one WebSocket, JSON, verb in / noun out), A6 (camelCase on the wire), A7 (in-memory state only — no database, no persistence), A8 (`Registry` stays pure and returns `list[Envelope]`), A11/A19 (no accounts; the address is the gate); `src/mirror/protocol.py`, `src/mirror/registry.py`, `src/mirror/app.py`; `tests/test_registry.py`, `tests/test_protocol.py`, `tests/test_app.py`
## Objective
Give the server one piece of presentation state — **which mirror is on stage, and
whether it should be fullscreen** — that any connected peer may change and that is
pushed to everyone the moment it changes. This is the piece the TODO item is
actually short of: `/watch` already stages and fullscreens locally (phase 01), but
that decision lives in one tab and dies there, so an unattended display can never
be driven from elsewhere.
This phase is **server-side only**: no new page, no browser-visible behaviour yet.
It leaves the app exactly as usable as it was, with a wire format and a
`GET /api/kiosk` read surface that phases 08 and 09 build their clients on.
## The contract this phase fixes (phases 08/09 code against it verbatim)
Inbound — verb, like `publish`/`subscribe` (PLAN A5), from **any** peer:
```json
{"type":"arrange","staged":"p1ab2c3d","fullscreen":true}
```
* `staged`: a stream id, or `null` for "back to the wall", or **absent** for "no
opinion". Absent and explicit-`null` are different requests, which is why the
model is read with pydantic's `model_fields_set` rather than with defaults.
* `fullscreen`: `bool`, absent = no opinion.
* A `staged` value that does not name a live mirror is refused with the existing
`unknown_stream` error and changes nothing.
* No audio/mute field. `SECURITY.md` §4: sound must trace back to a gesture on the
device that plays it — a remote hand cannot unmute a stranger's display.
Outbound — noun, to every viewer and back to the sender:
```json
{"type":"layout","staged":"p1ab2c3d","fullscreen":true,"followers":1}
```
* `followers` is how many connected peers announced `hello{"kiosk":true}`, i.e.
how many displays are obeying this. A controller is a plain viewer and is not
counted, which is what makes "nobody is showing a wall" distinguishable from
"the wall will follow".
* Emitted on: any accepted change, a kiosk display joining or leaving, and the
stale fallback when the staged mirror disappears.
* `welcome` carries the same object as its `layout` field, so a page is arranged
before it has asked for anything — the TODO's "navigate to it and it arranges
itself" depends on this.
`GET /api/kiosk` answers the same object, so the state is debuggable with `curl`
exactly like `/api/streams` (AGENTS.md §8).
## What is deliberately *not* here
* **No third role.** `Role` stays `sharer | viewer`; a kiosk is a viewer that
happens to obey the layout. The signal-authorisation matrix and A4 are
untouched.
* **No media coupling.** Staging never subscribes, never negotiates, never touches
an `RTCPeerConnection` — the kiosk already subscribes to every mirror it can
show, so "which one is on stage" is pure presentation.
* **One state per server process.** Not per display, not per room. Two `/kiosk`
pages follow the same state; that is the product's single-wall model (A7).
* **No persistence.** Restart the server and the wall is a wall. Consistent with
A7's reasoning: state that outlives the peers is a lie.
* **No auto-stage-newest.** The state says what is on stage; nothing here invents
a value. (`PLAN.md` §7 item 2, "Wall UX", is where auto-stage belongs.)
## Dependencies
- `06_sharer_quality_presets` (complete) — the current wall/stage/keyboard
behaviour and the green four-gate baseline this phase must not disturb.
(`.agents/phases/todo/` is empty, so this is the phase directly before 07.)
## Tasks
1. `01_arrange_and_layout_frames.md` — the two message shapes in `protocol.py`
(`Arrange` inbound, `layout()` outbound builder) plus the `kiosk` flag on
`hello`, with parse/round-trip tests.
2. `02_registry_layout_policy.md` — `Layout` state, `Registry.set_layout()` and
`Registry.layout_dto()`, the fan-out set, the follower count, and the
stale-staged fallback on teardown; unit-tested with no I/O.
3. `03_dispatch_read_endpoint_and_wire_tests.md` — `Hub._dispatch` handles
`arrange`, `GET /api/kiosk` serves the state, and the whole path is covered
over a real `TestClient` WebSocket.
## Testing & Quality
- **Unit (`tests/test_registry.py`)** owns the policy: partial update vs explicit
clear, refusal of an unknown stream, fan-out to viewers plus the sender,
follower counting, the fallback when the staged mirror goes away, and `welcome`
carrying the state.
- **Wire (`tests/test_app.py`, `tests/test_protocol.py`)** owns the camelCase
round-trip and the socket semantics: an `arrange` frame from a sharer, from a
viewer, with a bad stream id (error frame, socket stays open), and the shape +
`no-store` of `/api/kiosk` behind the address gate.
- Coverage: **>90%** on new/modified code (the project sits ~95%; `registry.py` is
the product and must stay near 100%).
- All four gates from `AGENTS.md` §4, including `-m e2e` and the `realdisplay`
suite: this phase changes frames every browser test consumes, so "nothing in the
browser changed" is itself the assertion.
## Completion Criteria
- [ ] `uv run pytest tests/test_registry.py tests/test_protocol.py tests/test_app.py` green, including the new layout cases
- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` answers `{"staged": null, "fullscreen": false, "followers": 0}` on an idle server, and reflects an `arrange` frame sent with `websocat`
- [ ] `welcome` carries a `layout` object; a second viewer connecting after an `arrange` receives the same state without being asked
- [ ] the staged mirror going away (unpublish, disconnect, eviction) resets `staged` to `null` and pushes one `layout` frame — never a stale id
- [ ] no page, no stylesheet and no browser test changed behaviour (`uv run pytest -m e2e` green with the existing assertions untouched)
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] `PLAN.md` untouched (no anchor moved: A5/A7/A8/A11 all hold, see above)
@@ -0,0 +1,92 @@
# Task 01 — The `arrange` / `layout` frames
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
**Story:** n/a
## Objective
Put both halves of the presentation-state message on the wire: an inbound
`arrange` verb, an outbound `layout` noun, and a `kiosk` flag on `hello` so the
server can tell a display from a controller. No behaviour yet — `Registry` is
wired in task 02, the socket in task 03.
## Work
1. `src/mirror/protocol.py` — add the inbound verb after `Subscribe`/`Unsubscribe`,
in the `_Inbound` house style. Both fields are `| None` **and** optional, because
"leave what I did not mention alone" and "clear it" are different requests:
```python
class Arrange(_Inbound):
"""Ask the wall to show a given mirror. Absent fields are left alone;
``staged: null`` explicitly returns the displays to the wall. The caller
does not have to be the display's operator — see ``SECURITY.md`` §2.
"""
type: Literal["arrange"]
staged: str | None = None
fullscreen: bool | None = None
```
Add `Arrange` to the `ClientMessage` discriminated union and re-export nothing
else — the union is what `Hub._dispatch` switches on.
2. `src/mirror/protocol.py` — `Hello` gains one field, single word so there is no
camelCase alias to get wrong (but round-trip it anyway, A6):
```python
#: true when this peer is a wall display that obeys the shared layout.
kiosk: bool = False
```
Keep `Role = Literal["sharer", "viewer"]` exactly as it is: a kiosk is a viewer
with an attribute, not a third role (see the phase overview).
3. `src/mirror/protocol.py` — add the outbound builder beside `welcome()`:
```python
def layout(
*, staged: str | None, fullscreen: bool, followers: int
) -> dict[str, Any]:
"""The wall's presentation state, pushed to every viewer as it changes."""
return {"type": "layout", "staged": staged, "fullscreen": fullscreen, "followers": followers}
```
Key order and spelling are the contract in `00_phase.md`; the outbound builders
are hand-written dicts, so there is no alias generator to catch a typo here —
the round-trip test in this task is what catches it.
4. `src/mirror/protocol.py` — `welcome()` gains a `layout` parameter and a
`"layout"` key so a page arrives already arranged. Give it a keyword-only
default of `{"staged": None, "fullscreen": False, "followers": 0}`? **No** —
a mutable default is a shared dict. Take it as a required keyword; the one
caller (`Registry.join`) is updated in task 02, so this task must not leave a
call site broken: update `registry.py`'s `welcome(...)` call in the same commit
to pass `layout=protocol.layout(staged=None, fullscreen=False, followers=0)`
as a placeholder that task 02 replaces with the real state.
5. Do **not** touch `Hub`, `create_app`, `Registry` policy or any JS in this task.
## Testing & Quality
- `tests/test_protocol.py` — extend the message-parsing suite:
* `{"type":"arrange"}`, `{"type":"arrange","staged":null}` and
`{"type":"arrange","staged":"p1","fullscreen":true}` all parse, and
`model_fields_set` distinguishes the first two from the third (this is the
whole reason the model is written this way — test it, do not trust it).
* `hello` defaults `kiosk` to `False` and accepts `kiosk: true`.
* `protocol.layout(...)` emits exactly `type/staged/fullscreen/followers` —
assert the key set, which is how the outbound dicts are guarded elsewhere.
- `tests/test_app.py` — `test_an_unknown_message_type_is_reported_but_survivable`
must still pass, and `arrange` must *not* appear as unknown once task 03 lands;
nothing to assert here yet.
- Coverage: **>90%** on this task's new/modified code (the models and the builder
are covered directly by the parse/shape tests above).
## Completion Criteria
- [ ] `uv run pytest tests/test_protocol.py` green with the new `arrange`, `kiosk`
and `layout()` cases
- [ ] `uv run pyright` reports 0 errors (`Arrange` is in the union, so the
`isinstance` chain in `app.py` is exhaustive-checked in task 03)
- [ ] `uv run pytest -m e2e` still green with **no** test file edited — nothing a
browser does has changed
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,118 @@
# Task 02 — `Registry` owns the layout: set, validate, broadcast, fall back
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
**Story:** n/a
## Objective
The state itself, in `Registry`, with the policy that makes it trustworthy: only
live mirrors may be named, the change reaches every viewer in the same call that
produced it, and a mirror disappearing never leaves a display pointing at nothing.
Pure Python, envelopes out — A8 to the letter.
## Work
1. `src/mirror/registry.py` — a sentinel for "the caller did not mention this
field", because `None` already means "clear it":
```python
class _Unset:
"""Marker: the caller expressed no opinion, keep the current value."""
UNSET = _Unset()
```
2. `src/mirror/registry.py` — the state next to `Peer`/`Stream`, with the
dataclass house style (`slots=True`):
```python
@dataclass(slots=True)
class Layout:
"""What the wall displays. One value per server process (A7): there is
one wall, and every display obeying it is showing the same thing."""
staged: str | None = None
fullscreen: bool = False
```
`staged` holds a stream id, which is also a sharer peer id (`streamId ==
sharerId`, PLAN §3) — the fallback in step 6 relies on that identity.
3. `src/mirror/registry.py` — `Peer` gains `kiosk: bool = False`; `Registry.join()`
gains a `kiosk: bool = False` keyword and stores it. The follower count is
derived, never bookkept: `sum(1 for p in self.peers.values() if p.kiosk)`.
4. `src/mirror/registry.py` — `Registry.__init__` creates `self.layout = Layout()`;
`join()` builds its `welcome` with `layout=self.layout_dto()` (replacing task
01's placeholder), so a page is arranged before it asks.
5. `src/mirror/registry.py` — the two public operations:
* `layout_dto() -> dict[str, Any]` — `protocol.layout(staged=…, fullscreen=…,
followers=<derived count>)`. This is the only place the DTO is built, so
`/api/kiosk` (task 03) and the socket can never disagree.
* `set_layout(peer_id, *, staged: Any = UNSET, fullscreen: Any = UNSET)
-> list[Envelope]` —
- `self._peer(peer_id)` first, so a dead peer gets `unknown_peer` like every
other operation;
- `staged` given as a non-`None` string → `self._stream(staged)` (reuses the
existing `unknown_stream` code — no new error code);
- `staged` explicitly `None` → clears; absent → unchanged; same shape for
`fullscreen`;
- if nothing actually changed, return **only** the ack to the sender — no
broadcast storm from a controller that re-sends the same state;
- otherwise return the broadcast: every peer whose role is `viewer`, **plus
the sender** (a sharer that sends an `arrange` still gets its own ack),
each carrying `protocol.layout(**self.layout_dto())`. Deduplicate the
sender into the set so no peer is sent the frame twice.
6. `src/mirror/registry.py` — the fallback, in `_drop_stream()` (the single funnel
for *every* removal path: `unpublish`, disconnect, replaced stream, capacity
eviction): if `self.layout.staged == stream.id`, set it to `None` and append
the layout broadcast to the envelopes it already returns. The comment there
already explains "tell every viewer, not just subscribers" — extend it: a
display aimed at a mirror that vanished must fall back to the wall *in the same
frame set*, because there is no polling to correct it later.
7. `src/mirror/registry.py` — follower joins/leaves must also push state, or a
controller sits on a stale `followers: 0` until someone moves a mirror:
`join()` appends the layout broadcast to the other viewers' envelopes (not to
the joiner — it got the state in its `welcome`), and `leave()` appends the same
broadcast when the departing peer had `kiosk=True`.
8. `src/mirror/registry.py` module docstring — add the invariant to the bulleted
list: *the layout names either nothing or a live mirror; a mirror's removal
clears it in the same envelope set.*
9. Do **not** touch `Hub`, `app.py`, or any JS. Do not add persistence, a layout
per peer, or an audio field (see `00_phase.md`, "What is deliberately not here").
## Testing & Quality
- `tests/test_registry.py` — a new `TestLayout` class covering, in this order:
* default state is the wall, not fullscreen, zero followers;
* `set_layout` with `staged="pX"` broadcasts to viewers **and** the sender, and
no peer receives it twice;
* `staged` absent + `fullscreen=True` changes only fullscreen (the
partial-update rule, asserted on the returned DTO);
* `staged=None` clears an existing selection, and `set_layout` with no fields at
all produces no broadcast;
* an unknown stream id raises `RegistryError` with `code == "unknown_stream"`
and leaves `self.layout` untouched;
* a second `set_layout` with identical values sends the ack and nothing else;
* `unpublish`, `leave()` of the sharer, and a *replaced* stream each reset
`staged` to `None` and emit exactly one layout frame per viewer;
* `followers` counts only `kiosk=True` viewers, and a kiosk joining/leaving
pushes a layout frame whose `followers` is the new count;
* `welcome` of a peer that joins **after** an `arrange` carries that state —
this is "navigate to `/kiosk` and it arranges itself", stated at the layer
where it is decided.
Use the file's existing helpers/idioms (explicit peer ids via `id_factory`, the
`codes_and_targets` style helpers already in the module) rather than a new
harness.
- Coverage: **>90%** on this task's new/modified code — target ~100% of
`registry.py`, which the project treats as the product.
## Completion Criteria
- [ ] `uv run pytest tests/test_registry.py` green, including every `TestLayout` case above
- [ ] `uv run pyright` → 0 errors; `Registry` still imports no socket, no FastAPI, no `await` (A8, AGENTS.md §3)
- [ ] `grep -n "await\|WebSocket\|fastapi" src/mirror/registry.py` finds nothing
- [ ] a layout naming a mirror that has gone is impossible to observe: the frame
every viewer receives after a teardown says `staged: null`
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work (no wire frame any current client sends
or reads changed shape)
@@ -0,0 +1,99 @@
# Task 03 — Socket dispatch, `GET /api/kiosk`, and the wire suite
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "state … that any client can manipulate and will update immediately so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself"
**Story:** n/a
## Objective
Connect the frames to the transport and make the state readable without a
browser: `Hub` accepts `arrange` from any peer and passes the caller's *expressed*
fields to `Registry`, and `GET /api/kiosk` answers the same DTO the socket pushes.
After this task the feature is real and drivable with `websocat`, one step below
the browser.
## Work
1. `src/mirror/app.py` — `Hub._introduce`: pass the flag through,
`self.registry.join(role=hello.role, name=hello.name, device=hello.device,
kiosk=hello.kiosk)`.
2. `src/mirror/app.py` — `Hub._dispatch`, in the existing `isinstance` chain
before the `else` that marks the union exhaustive:
```python
elif isinstance(message, protocol.Arrange):
fields = message.model_fields_set
outbox = self.registry.set_layout(
peer_id,
staged=message.staged if "staged" in fields else UNSET,
fullscreen=message.fullscreen if "fullscreen" in fields else UNSET,
)
```
Import `UNSET` from `.registry`. `model_fields_set` is the one place the
"absent ≠ null" rule survives the trip from JSON to policy; a comment there
should say so, because the next reader will try to simplify it to
`message.staged` and silently lose "back to the wall".
3. `src/mirror/app.py` — the read endpoint, next to `/api/streams`, same
`Cache-Control: no-store` (a roster that is cached is a lie about what is on
the wall right now):
```python
@app.get("/api/kiosk")
async def kiosk_layout() -> JSONResponse:
"""Server truth about the wall display: what is on stage, fullscreen, and
how many displays are obeying it. Read-only by design — the only writer is
the `arrange` frame on /ws, so there is one path that can change the wall."""
return JSONResponse(registry.layout_dto(), headers={"Cache-Control": "no-store"})
```
ASSUMPTION: writes stay on the WebSocket only. An HTTP `POST` would be a second
write path with no authentication (A11) and no same-origin protection to lose,
and the WS already gives any admitted address the same power — so the read-only
endpoint buys the `curl` debugging (AGENTS.md §8) without buying a new surface.
4. `src/mirror/app.py` — **do not** add `/kiosk` to `PAGES` yet: there is no page
until phase 08. `/api/kiosk` is gated like every other route (only `/healthz`
is exempt, AGENTS.md §3), and must not be added to that exemption.
5. No JS in this task.
## Testing & Quality
- `tests/test_app.py` — extend `TestSignalingSocket` (or the class the existing
roster tests live in) with:
* the round-trip: sharer publishes, viewer connects, viewer sends
`{"type":"arrange","staged":<streamId>,"fullscreen":true}` → both the sender
and a second viewer receive `layout` with `staged` and `fullscreen` set, and
`GET /api/kiosk` says the same;
* `followers` — two viewers with `hello{kiosk:true}` and one plain viewer
report `followers: 2`, and closing one of the kiosk sockets pushes a layout
frame that says `1`;
* a bad stream id answers `error{code:"unknown_stream"}` and **does not** close
the socket (send `ping`, get `pong`);
* an `arrange` with only `fullscreen` leaves `staged` alone end to end, and
`arrange{staged:null}` returns the wall;
* a **sharer** that sends `arrange` gets the ack, and a layout frame is not
pushed to other sharers (viewers plus sender only);
* the staged mirror's sharer disconnects → every viewer gets `staged: null`.
- `tests/test_app.py::TestAddressGate` — `/api/kiosk` is refused with the rest of
the app: add it to the parametrised "refused address gets nothing" list and
assert a refused address never learns the layout, plus one test that the real
address gets `200` and `cache-control: no-store`.
- `tests/test_app.py::TestConfigEndpoints`-style shape check: `/api/kiosk` returns
exactly the three keys (`staged`, `fullscreen`, `followers`) so a future field
addition is a decision, not a leak.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] `websocat ws://localhost:8055/ws` can drive the wall by hand: `hello`, then
`{"type":"arrange","staged":"<id from /api/streams>","fullscreen":true}` →
a `layout` frame comes back (AGENTS.md §8 rung 2)
- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` reflects that state
and answers `no-store`
- [ ] the staged mirror's sharer leaving resets `staged` to `null` for every
viewer, observed over the socket
- [ ] `uv run pytest tests/test_app.py` green (new cases + all existing, unedited)
- [ ] all four gates from `AGENTS.md` §4 green, including `-m e2e` and
`MIRROR_REALDISPLAY=1 -m realdisplay` with **no** browser-side change
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] README.md updated for anything user-visible this task changes (a protocol
note is enough here; the pages come in phases 08/09)
@@ -0,0 +1,106 @@
# Phase 08 — The `/kiosk` page: a wall that arranges itself
**Source:** `TODO.md` L3 — "Add a /kiosk endpoint … so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences." (the display-client half of that item)
**Story:** n/a
**Context:** `.agents/PLAN.md` §2 A9 (hand-written ES modules, no framework/bundler), A10 (nothing from outside the origin), A13 (Playwright, `channel="chromium"`); `AGENTS.md` §6 (reuse `net.js` rather than duplicating socket logic; ids must not collide across pages); phase 01 (`01_keyboard_kiosk_control` — the keyboard router, the stage mechanics, the mouseless rule); phase `07_kiosk_layout_state` (the `arrange`/`layout` contract, `hello.kiosk`, `GET /api/kiosk`); `src/mirror/static/watch.js`, `watch.html`, `app.css`, `net.js`; `docs/kiosk-silverblue.md` (the target box: Firefox `--kiosk`, **no keyboard, no mouse, no console**)
## Objective
`GET /kiosk`: a chrome-free wall for an unattended display that subscribes to every
mirror like `/watch` does and then **does what the server state says** — stages the
mirror the state names, fullscreen or not — on arrival and on every change, with no
input from anyone standing at the box.
The phase is deliberately two halves:
1. **Make the wall reusable** (tasks 01–02). `/kiosk` must not fork 400 lines of
tile/WebRTC/stage code (`AGENTS.md` §6: reuse, don't duplicate), so the wall's
core moves out of `watch.js` into `static/wall.js` and both pages consume it.
Those two tasks change **no behaviour** — phases 01–06's suites are the gate.
2. **Build the page on it** (tasks 03–05): the route, the markup, the layout
applier, and the browser proof.
## Rules this page lives by
* **Arrival is arrangement.** `welcome.layout` is applied before the first roster
tile finishes painting. A person who points a kiosk at `/kiosk` must not have to
touch it.
* **The state is the boss, locally too.** A keypress on the kiosk performs the
action *and* publishes it (`arrange`), so the controller and the display cannot
disagree — same as every other shared state: one writer path, everyone re-reads.
* **Fullscreen is best-effort, the stage is the guarantee.** A remote command
carries no user activation, so `requestFullscreen()` may be refused; the in-page
stage fills the viewport anyway and the Silverblue window manager already forces
the window fullscreen (`docs/kiosk-silverblue/window-config.ini`). The page asks
and never blocks on the answer — exactly the stance phase 01 documented for `f`.
* **Never invent state.** No auto-staging of the newest mirror, no remember-last
across a server restart. An empty state means the wall.
* **No audio commands.** The layout carries no sound field (phase 07,
`SECURITY.md` §4); a display never becomes audible because a stranger asked.
* **The chrome stays off the screen.** No header bar, no "Share a screen" button,
no counts in a title bar — a display shows mirrors. The three shared ids
(`net`, `notice`, `toast`) are the only elements `/kiosk` may share with another
page, and `#net` stays because the connection state is the one thing that must be
legible in a screenshot of a wall nobody is looking at.
## Dependencies
- `07_kiosk_layout_state` (todo) — the `arrange`/`layout` frames, `hello.kiosk`,
`welcome.layout`, the `followers` count, `GET /api/kiosk`. Task 01 of this phase
has no dependency on it and may run first, but the phase is not complete until
07 exists.
- `01_keyboard_kiosk_control` (complete) — the router, `stage()`/`unstage()`, the
mouseless rule and the pointer-free suite this phase extracts and extends.
- `06_sharer_quality_presets` (complete) — the current behaviour of the wall.
## Tasks
1. `01_extract_wall_tiles_and_stage.md` — the tile/peer/stage/mute core of
`watch.js` becomes `static/wall.js` (`createWall()`); `app.css` re-keys the
stage rules from `#watch-stage` to a `.stage` class. No behaviour change.
2. `02_extract_wall_key_router.md` — the single `keydown` switch becomes
`installWallKeys()` in `wall.js`, dispatching named commands to the page;
`/watch`'s key map is byte-identical in effect.
3. `03_kiosk_page_and_route.md` — `static/kiosk.html`, `GET /kiosk` in `app.py`
(and in `PAGES` for `no-store`), and every structural guard in `test_app.py`
extended to a third page.
4. `04_kiosk_layout_applier.md` — `static/kiosk.js`: `hello{kiosk:true}`, the wall
from `createWall()`, `applyLayout()` on `welcome`/`layout`, and local keys
published back with `arrange`.
5. `05_kiosk_layout_e2e_and_docs.md` — `tests/e2e/test_kiosk_layout.py`: a real
sharer, two kiosk displays (isolated contexts, hence separate sockets and peer
ids), and the assertion that one display's keypress re-arranges the other,
proved on the second display's decoded pixels; plus the `/kiosk` docs.
## Testing & Quality
- **Refactor gate (tasks 01–02)**: `tests/e2e/test_keyboard_wall.py`,
`test_live_mirror.py`, `test_audio_share.py`, `test_stage_autofade.py`,
`test_share_layout.py` pass **unedited**. A changed assertion in one of them
during a "behaviour-preserving" extraction is a bug report, not a fix.
- **Structure gate (task 03)**: `test_app.py`'s guards extended — page renders,
`no-store`, `node --check`, every `$("#id")` exists, ids do not collide across
*three* pages, `<video>` is `muted playsinline`, nothing from a CDN, the address
gate refuses `/kiosk` like the rest of the app. A new guard: `static/wall.js`
contains **no** `$("#…")` — one module serving two pages takes its elements as
arguments or it will look them up in the wrong document.
- **Behaviour gate (tasks 04–05)**: a dedicated Playwright suite, pointer-free like
phase 01's (a mouse on the kiosk would defeat the point), asserting on the
*second* display's `<video>` (`videoWidth`, distinct frames via the `conftest`
`frames_matching()` helper) and on `data-layout`/`data-fullscreen` — never on
whether a handler ran. Fullscreen is asserted on the stage class, not on
`document.fullscreenElement`, which the browser may legitimately refuse.
- Coverage: **>90%** on new/modified code. The Python surface added here is one
route; the JS is covered by browser assertions, per `AGENTS.md` §4.
## Completion Criteria
- [ ] `uv run pytest -m e2e` green, including the new `test_kiosk_layout.py`, with **no existing e2e assertion edited**
- [ ] pointing a browser at `/kiosk` and sharing a mirror from a second browser process shows that mirror on the wall with no interaction on the first
- [ ] a keypress on one `/kiosk` page re-arranges a second `/kiosk` page (the shared-state property, asserted cross-page, not in one page's DOM)
- [ ] the staged mirror going away returns both displays to the wall (`data-layout="wall"`)
- [ ] `/kiosk` renders no header, no share link, and refuses a blocked address with the same explanation page as `/watch`
- [ ] `uv run pytest tests/test_app.py` green with the guards extended to three pages, and `wall.js` proven free of document-scoped lookups
- [ ] all four gates from `AGENTS.md` §4 green, `MIRROR_REALDISPLAY=1 -m realdisplay` included
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] README.md documents `/kiosk`; `PLAN.md` untouched
@@ -0,0 +1,125 @@
# Task 01 — Extract the wall (tiles, peers, stage) into `static/wall.js`
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) … [that] will automatically arrange itself" (the wall has to exist twice before a page can arrange it)
**Story:** n/a
## Objective
Move the wall's core out of `watch.js` into a reusable `static/wall.js` module that
takes its elements and its socket from the page, and have `watch.js` consume it.
**Pure refactor: `/watch` must behave and look exactly as it did.** The kiosk page is
impossible without this, and forking the tile/WebRTC code instead would violate
`AGENTS.md` §6.
## Work
1. `src/mirror/static/wall.js` (new) — a factory, no document queries, no socket of
its own, no top-level side effects beyond the export:
```js
/**
* 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,
* onChange?: (summary: {mirrors: number, live: number}) => void,
* stats?: boolean}} options
*/
export function createWall(options) { … }
```
Return the surface the pages need — at minimum `absorb(stream)`,
`drop(streamId)`, `onSignal(message)`, `stage(streamId, options)`,
`unstage(options)`, `setMuted(tile, muted)`, `ordered()`, `selectedId()`,
`setSelected(streamId)`, `moveSelection(delta)`, `tile(id)`, `closePeers()`, and
a `stagedId()` accessor. Keep the existing function bodies as the implementation;
the change is *which object owns the state*, not what the code does.
2. `src/mirror/static/wall.js` — move, verbatim except for the `ui.`/`state.` reads
the factory replaces, from `watch.js`: `tiles`, `makeTile`, `paintTile`,
`dropTile`, `closePeer`, `ensurePeer` (with its `ontrack`/`onicecandidate`/
`onconnectionstatechange` handlers and the retry-with-backoff), `onSignal`,
`setMuted`, `subscribe`, `absorb`, `orderedTiles`, `stage`, `unstage`,
`revealStageControls` + `stageRevealTimer` + `STAGE_REVEAL_IDLE_MS`,
`fullscreenExitInFlight`, and the stats ticker. Every comment that explains *why*
(the single-MediaStream-per-tile story, the audio-only-on-stage policy, the
fullscreen-exit race, "never move `srcObject`, move the element") travels with
the code — those comments are why phases 01–06 do not get re-broken.
`renderCounts()` does **not** move: it writes `#counts`/`#empty`, which are
`/watch` elements. The wall calls `onChange({mirrors, live})` wherever
`renderCounts()` is called today, and `watch.js` keeps its own `renderCounts()`
as that callback.
3. `src/mirror/static/wall.js` — the socket is the page's. Take `send(payload)` and
use it wherever the moved code used `state.ws.send(...)`
(`subscribe`, ICE candidates, the retry's re-`subscribe`), and guard on its
truthiness the way the code guards on `state.ws` today. This is what lets a page
whose socket is not open yet build a wall, and what keeps `net.js` as the only
place a socket exists (`AGENTS.md` §6).
4. `src/mirror/static/watch.js` — delete the moved code, import
`{ createWall }` from `./wall.js`, build the wall after `loadConfig()` with the
elements already in its `ui` map (`grid`, `stage`, `template`) and
`send: (payload) => state.ws.send(payload)`, and call `wall.absorb/drop/onSignal`
from the `onMessage` switch, `wall.closePeers()` from `onClose`,
`wall.stage/unstage/…` from the key router (task 02 moves the router itself).
Keep in `watch.js`: the config load, the socket, `renderCounts`, `setNet`, the
help overlay, the `focusin` handler (via `wall.setSelected`), the refusal notice,
the share link.
5. `src/mirror/static/watch.js` — keep the `fullscreenchange` listener **in the
page** (it is a page policy: `/watch` unstages when the browser leaves
fullscreen; the kiosk will do something different, phase 08 task 04), calling
`wall.unstage()`. The `fullscreenExitInFlight` flag it depends on moves into
`wall.js`, so expose the flag's two behaviours through `unstage()`/a small
`handleFullscreenChange(hasFullscreen)` hook — the shape is yours, but the race
documented at watch.js L345–352 must stay closed.
6. `src/mirror/static/app.css` — the stage rules are keyed to the `/watch` id and
would not style a second page's stage. Add `class="stage"` to
`<div id="watch-stage">` in `watch.html` (keep the id — the e2e suites and
`watch.js` both select it) and re-key these selectors from `#watch-stage` to
`.stage`, changing nothing else in the blocks: `#watch-stage { … }` (L565),
`body.staged #watch-stage` (L574), `#watch-stage .tile` (L577),
`#watch-stage .tile video`, `#watch-stage .tile .cap`, and the
`#watch-stage .tile[data-audio="on"|"off"]::after` pair (L471, L477). No new
`display` declarations, no `[hidden]` rules — `TestStylesheet` guards those.
7. `src/mirror/static/watch.js` — the dead `if (ui.unstage) …` branch at L473: it is
unreachable today (`ui` has no `unstage` key) and moving it would launder dead
code into the new module. Delete it and leave a one-line comment saying the exit
control is the per-tile `.exit` button, so nobody re-adds a second exit.
8. Do **not** add `/kiosk`, `kiosk.html` or `kiosk.js` in this task, and do not
change any key binding, DOM structure of the tile, or visual rule beyond the
`#watch-stage` → `.stage` re-key.
## Testing & Quality
- **The gate is the existing suites, unedited** (`AGENTS.md` §4/§5):
`uv run pytest -m e2e -k "keyboard or live_mirror or audio or stage or share_layout"`,
then the full `-m e2e`. These cover tile rendering, the stage transitions, audio
policy, the autofade timing, and the peer-connection probes through
`window.__probe` — i.e. exactly the machinery being moved.
- `tests/test_app.py` — `test_scripts_parse_as_es_modules` gains `"wall.js"`,
`test_static_assets_are_served` gains `"wall.js"`.
`test_every_element_the_script_reaches_for_exists` must still pass for
`watch.js` (the ids it still reaches for exist) — and add the new module rule:
*`wall.js` must contain no `$("#…")`*, because a module shared by pages with
different ids may never look an element up in the document it happens to be
loaded in. Assert it as a source-level guard next to the others, with a comment
naming `test_the_two_pages_share_no_element_ids_but_the_shared_chrome` as the
reason it exists.
- Visual check while working: `/watch` at 1280×800 and 390×844, staged and
unstaged — the stage must be pixel-equivalent (the re-key must not change
specificity in a way that reorders a rule).
- Coverage: **>90%** on new/modified code — the moved code is covered by the e2e
suites that already exercise it; the module has no new logic in this task.
## Completion Criteria
- [ ] `uv run pytest -m e2e` green with **zero assertions changed** in any existing file
- [ ] `uv run pytest tests/test_app.py` green, including `wall.js` in the parse/asset lists and the new "no `$("#…")` in `wall.js`" guard
- [ ] `/watch` looks and behaves as before: grid, click-to-stage, `Esc`/`g`, the autofading overlay, one audible mirror
- [ ] `git diff --stat` shows the moved lines as moves, not rewrites (a reviewer should be able to read the diff as "cut, paste, re-point at `options`")
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,106 @@
# Task 02 — The key router becomes `installWallKeys()` in `wall.js`
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "the kiosk doesn't have a keyboard/mouse plugged in most of the time so I can't full screen/switch views" (the display must accept the same keys *and* report them)
**Story:** n/a
## Objective
Move `/watch`'s single `keydown` switch into `wall.js` as `installWallKeys()`, so
`/kiosk` gets the identical key map for free and — this is the point — can publish
what a key did instead of only performing it. Again **no behaviour change on
`/watch`**: phase 01's pointer-free suite is the gate.
## Work
1. `src/mirror/static/wall.js` — export the router, keeping the invariant phase 01
wrote down (one listener, one switch; every command is a case here, never a new
listener):
```js
/**
* 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).
*
* 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) { … }
```
2. `src/mirror/static/wall.js` — move `onWallKey`'s switch body into the module,
translating each case into a command name, in this order and with these names
(they are the contract `/kiosk` publishes against):
| key(s) | command | detail |
|---|---|---|
| `j` `arrowdown` | `next` | — (module performs `wall.moveSelection(1)` itself, then reports handled) |
| `k` `arrowup` | `previous` | — |
| `enter` | `stage` | — |
| `f` | `fullscreen` | — |
| `g` | `wall` | — |
| `n` / `p` | `step_next` / `step_previous` | — |
| `m` | `mute` | — |
| `?` | `help` | — |
| `escape` | `escape` | — |
| `1`–`9` | `jump` | `index` (0-based) |
Keys are matched case-insensitively exactly as today (a caps-locked kiosk, a
remote hand holding Shift). `next`/`previous` stay module-side because 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.
3. `src/mirror/static/wall.js` — the "any keypress while staged reveals the
overlay" behaviour (today `if (state.staged) revealStageControls();` before the
switch) moves with it, *before* dispatch, and must not `preventDefault` —
an unhandled key keeps flashing the caption, which is the debuggable outcome
phase 01 documented.
4. `src/mirror/static/wall.js` — commands that are pure wall operations
(`stage`, `wall`, `step_*`, `mute`, `jump`) get a **default implementation in the
module** used when the page returns nothing for them, so `onCommand` can be a
thin observer. `stage`/`jump` resolve their target with today's rule (the
selected tile, else the first; `toast` when the wall is empty — `toast` comes
from `net.js`, which `wall.js` may import). `help` and `escape` have **no**
default: they are page policy (`/watch` closes the `?` overlay before leaving the
stage), and a page that ignores them must not break the router.
5. `src/mirror/static/watch.js` — delete `onWallKey`, `moveSelection`,
`stageSelectedOrFirst`, `stepStage`, `toggleHelp`'s key wiring and the
`document.addEventListener("keydown", …)` line; call
`installWallKeys({ wall, onCommand })` instead, where `onCommand` keeps today's
exact behaviour for `help` (toggle `#wall-help`, return whether it changed) and
`escape` (overlay first, then `wall.unstage()`, return whether either happened),
and returns `false`/nothing for the commands the module already performs.
`?`'s "focus is deliberately never touched" comment moves with the behaviour.
6. `src/mirror/static/watch.js` — keep `focusTile`, `setSelected` and the grid's
`focusin` handler as they are now that they call into `wall` (task 01); Tab must
still agree with `j`/`k`.
7. No new page, no route, no kiosk markup in this task.
## Testing & Quality
- **The gate**: `uv run pytest tests/e2e/test_keyboard_wall.py` **unedited** — the
file drives every binding with `page.keyboard.press()` and asserts the DOM result,
and its meta-test forbids the pointer in itself. If a case fails, the router moved
wrong; do not touch the test.
- `tests/test_app.py` — add one source-level guard in the spirit of the existing
ones: `installWallKeys` is defined in `wall.js` and `watch.js` contains **no**
`addEventListener("keydown"` — the "one listener per wall page" rule should fail
the build when someone adds a second one, which was phase 01's whole point.
- `uv run pytest -m e2e -k "keyboard or live_mirror or audio or stage"` green, then
the whole `-m e2e`.
- Coverage: **>90%** on new/modified code (the router is exercised by the browser
suite on every binding).
## Completion Criteria
- [ ] every key in phase 01's key map does on `/watch` exactly what it did before, asserted by `tests/e2e/test_keyboard_wall.py` with no edit
- [ ] `grep -n 'addEventListener("keydown"' src/mirror/static/*.js` shows exactly one hit, inside `wall.js`
- [ ] `g` when already unstaged and `Esc` with nothing open still do not `preventDefault` (browser default preserved)
- [ ] `uv run pytest tests/test_app.py` green including the new single-listener guard
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,118 @@
# Task 03 — `GET /kiosk` and the page that hosts the wall
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "Add a /kiosk endpoint … so I can just navigate to that endpoint on the kiosk"
**Story:** n/a
## Objective
The endpoint and the markup: a chrome-free page whose only job is to hold a wall and
expose the state a test (and a screenshot) can read. No layout behaviour yet —
task 04 writes `kiosk.js` against this skeleton.
## Work
1. `src/mirror/app.py` — the route, beside `/watch`, in the same shape:
```python
@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")
```
Add `"/kiosk"` to the `PAGES` tuple so the middleware keeps sending
`Cache-Control: no-store` on it, like the other pages.
2. `src/mirror/static/kiosk.html` (new) — model it on `watch.html` (same `<head>`,
`/static/app.css`, the same inline `data:` favicon — **no** external asset, A10),
`<title>Wall · mirror</title>` — `docs/kiosk-silverblue/check-kiosk.sh` proves a
client is showing what it should by grepping the served page's `<title>`, so this
string is a contract, not a label (phase 09 task 03 widens that check to accept
it) — and these elements and nothing else:
* `<body class="kiosk" data-layout="wall" data-fullscreen="false">` — the two
layout knobs, mirrored onto the body so a screenshot and a test can read the
state without reaching into JS (`docs/kiosk-silverblue/dim-probe.py` judges a
wall from a screenshot; `data-audio` on a tile exists for the same reason).
* `<main class="grid" id="kiosk-grid" hidden>` and `<div class="empty" id="kiosk-empty">`
— the classes are what the stylesheet and `wall.js` select; the ids are page-unique.
The empty state reads "No mirrors live" plus
`Share from <code id="kiosk-share-url">…</code>` — `kiosk.js` fills that with
`location.origin + "/mirror"`, because nobody is standing at the box to click
anything and the person reading the screen needs a URL to type elsewhere.
* `<div class="stage" id="kiosk-stage" aria-live="polite">` — **`class="stage"`
is mandatory**: task 01 re-keyed the staging rules off `#watch-stage`, and
without the class the kiosk stage would be an unstyled `div`.
* the shared chrome ids and only these: `<span class="net" id="net"
data-state="connecting"><i></i><span>connecting…</span></span>`,
`<div class="notice" id="notice" hidden>`, `<div class="toast" id="toast" hidden>`.
`#net` is not decoration — it is how a wall reports "the server is gone" in a
screenshot, and every e2e `device()` helper waits on it.
* `<template id="kiosk-tile-template">` — a copy of `watch.html`'s
`#tile-template` tile (`article.tile[tabindex=0][data-state=connecting]`,
`<video autoplay playsinline muted>`, `.badge`, `.actions` with `.audio` and
`.expand`, `.cap` with `.title`/`.sub`/`.exit`). Name it
`kiosk-tile-template`, never `tile-template`: 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.
* `<div class="hud" id="kiosk-hud" aria-live="polite">` — one line the script
owns: `N mirrors · wall` / `… · <label> · fullscreen`. It is the wall's
self-description for a room with no keyboard.
* **No** `<header class="bar">`, no counts in a title bar, no "Share a screen"
link: a display shows mirrors.
* `<script type="module" src="/static/kiosk.js">` — task 04 writes it; create a
placeholder `kiosk.js` in this task that only imports `loadConfig` and
`refusalNotice` from `./net.js` so the page parses, serves and shows a refusal
notice, and nothing more.
3. `src/mirror/static/app.css` — the new page's chrome only: `.kiosk` (no header
space, full-bleed grid), `.kiosk .net` (fixed top-right, the same pill), `.hud`
(fixed bottom-left, `--mono`, `--fg-dim`, small, `pointer-events: none`). Do not
duplicate any stage/tile rule — those are `.stage`/`.tile` now. Add no rule whose
selector mentions `[hidden]` and no `display` declaration on a component the
stylesheet already owns (`TestStylesheet` guards both).
4. `src/mirror/static/index.html` — a third `<a class="card" href="/kiosk">`
("GET /kiosk — An unattended wall") so the endpoint is discoverable from `/`.
Cards carry no ids, so the id guard is unaffected.
5. Do **not** touch `/remote` (phase 09), `watch.html`, or the layout applier.
## Testing & Quality
- `tests/test_app.py` — extend the structural guards to the third page; each of
these is an existing test gaining a case, not a new invention:
* `TestPages::test_pages_render` → `("/kiosk", "kiosk-tile-template")`;
* `test_pages_are_not_cacheable` → `client.get("/kiosk")` is `no-store`;
* `test_static_assets_are_served` → `kiosk.js`;
* `test_scripts_parse_as_es_modules` → `"kiosk.js"` (`node --check`);
* `test_every_element_the_script_reaches_for_exists` → `("kiosk.js", "kiosk.html")`;
* `test_both_pages_load_the_stylesheet_and_their_module` → the `kiosk.html` /
`kiosk.js` pair (rename it if the name now lies — the guard matters more than
the name);
* `test_videos_are_muted_and_inline_so_autoplay_is_not_blocked` → `kiosk.html`
(both autoplay rules apply to a kiosk: iOS/Safari blocks the wall otherwise);
* `test_the_two_pages_share_no_element_ids_but_the_shared_chrome` → add
`kiosk.html` to the ids map and assert `kiosk ∩ watch`, `kiosk ∩ mirror`,
`kiosk ∩ index` are all exactly `{"net","notice","toast"}`. Keep the existing
assertions verbatim; `#kiosk-*` prefixes are what make these pass.
* add `#kiosk-grid`, `#kiosk-stage`, `#kiosk-hud` to the "elements a page must
have" style check if the file has one, and assert `class="stage"` appears on
`#kiosk-stage` **and** `#watch-stage` — 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.
- `tests/test_app.py::TestAddressGate` — `/kiosk` joins the parametrised "a refused
address gets no page, no roster, no assets" list, and one test that an admitted
address gets `200`.
- `tests/e2e/test_live_mirror.py` — the refused-address parametr list
`("/mirror", "/watch")` gains `"/kiosk"`, and its
`expect(page.locator("#start, #grid, #notice")).to_have_count(0)` selector list
gains `#kiosk-grid` / `#kiosk-stage` so the assertion actually covers the new page.
- Coverage: **>90%** on new/modified Python (one route — covered by the page tests).
## Completion Criteria
- [ ] `curl -s localhost:8055/kiosk | head -20` serves the page and `curl -sI` shows `cache-control: no-store`
- [ ] opening `/kiosk` in a browser shows an empty-state wall with the share URL in it, no header bar, the net pill top-right, and no console errors
- [ ] `uv run pytest tests/test_app.py` green with every guard above extended to three pages
- [ ] `uv run pytest -m e2e` green (existing suites unedited except the two parametrisations named here)
- [ ] `grep -c 'id="' src/mirror/static/kiosk.html` ids appear in no other page except `net`/`notice`/`toast` — which the extended guard now proves
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,122 @@
# Task 04 — `kiosk.js`: obey the layout, publish the keystrokes
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "state (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences"
**Story:** n/a
## Objective
The behaviour: `/kiosk` opens, subscribes to every mirror, and arranges itself from
`welcome.layout` without being touched; every `layout` frame that follows re-arranges
it; and a keypress on the box — rare, but it happens when a keyboard is plugged in to
diagnose — performs the change **and** publishes it, so the controller and the display
converge instead of disagreeing.
## Work
1. `src/mirror/static/kiosk.js` — replace task 03's placeholder. Module shape, in
the order `watch.js` uses it: `loadConfig()` → `rtcConfig()` → `ui` map →
`createWall()` → `connectSignaling()` → `installWallKeys()`. The `ui` map is
`grid`, `stage`, `template`, `empty`, `shareUrl`, `hud`, `net`, `toast`, `notice`
— every one of them exists in `kiosk.html` (the element guard proves it).
2. `src/mirror/static/kiosk.js` — announce as a display, not a controller:
`state.ws.send({type:"hello", role:"viewer", kiosk:true, name:"", device: deviceLabel()})`.
`deviceLabel()` comes from `net.js`. `kiosk:true` is what feeds `followers`, and a
`/kiosk` page must be counted while a `/remote` page must not.
3. `src/mirror/static/kiosk.js` — the applier, the one function that touches the
stage:
```js
/**
* 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 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) { … }
```
* `next.staged` is a tile the wall has → `wall.stage(id)`; is `null` →
`wall.unstage()`; names something unknown → unstage and remember the intent,
re-applying whenever `absorb()` gains that tile.
* `next.fullscreen` → request on the same target `/watch` uses (the stage element
when staged, `document.documentElement` for the wall), `.catch(() => {})` and
never awaited. A remote command carries no user activation: the request is a
courtesy, the `.stage` CSS is the guarantee, and the Silverblue window is
already fullscreen (`docs/kiosk-silverblue/window-config.ini`). Say exactly that
in a comment — the next reader will otherwise "fix" it by awaiting.
* Mirror both knobs onto the body — `document.body.dataset.layout =
staged ? "stage" : "wall"` and `document.body.dataset.fullscreen = "true"|"false"`,
and set `document.title` to the staged mirror's label (`"mirror — <label>"`, else
`"mirror — wall"`), because a window list and a screenshot are the only views a
headless box offers.
4. `src/mirror/static/kiosk.js` — apply on arrival and on every change:
`welcome` → `applyLayout(message.layout)` (before `renderCounts()`-equivalent work,
so the wall never flashes between the roster and the stage), `layout` →
`applyLayout(message)`. `stream_added`/`stream_updated` → `wall.absorb()` then
`applyLayout(state.layout)` so a remembered intent lands the moment its tile does.
`stream_removed` → `wall.drop()` (the server pushes the cleared layout behind it —
do not "helpfully" unstage locally, or two frames will fight).
5. `src/mirror/static/kiosk.js` — the keys, via `installWallKeys({wall, onCommand})`,
each entry doing the local change **and** publishing the same intent:
| command | local effect | published `arrange` |
|---|---|---|
| `next` / `previous` | (module moves the selection) | none — the cursor is local to this page |
| `stage` (`Enter`) | `wall.stage(cursor ?? first)` | `{staged: <id>}` |
| `wall` (`g`), `escape` | `wall.unstage()` | `{staged: null}` |
| `step_next` / `step_previous` | stage the neighbour | `{staged: <id>}` |
| `jump` (`1`–`9`) | stage the Nth | `{staged: <id>}` |
| `fullscreen` (`f`) | re-request fullscreen | `{fullscreen: true}` |
| `mute` (`m`) | `wall.setMuted(tile, …)` | **none** |
`mute` is never published, and the code must say why in one comment line:
`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. `help`
(`?`) has no target on this page (no overlay markup) and returns not-handled.
6. `src/mirror/static/kiosk.js` — what the page does **not** publish: the browser's
own fullscreen exits. Register no `fullscreenchange` policy that unstages (the WM
dropping fullscreen is not a person asking to leave the stage — on `/watch` that
is a deliberate choice, on a wall it would fight the state) and do not echo
`fullscreen:false` because the browser refused a request; that would make a
controller's "fullscreen" button look broken and would be reporting the browser's
refusal as a user preference.
ASSUMPTION: publish what a person asked for, not what the browser conceded.
7. `src/mirror/static/kiosk.js` — the honest-but-quiet details:
* `onChange({mirrors, live})` → `#kiosk-hud` text (`3 mirrors · wall`,
`3 mirrors · Alice's laptop · fullscreen`) and the `#kiosk-grid`/`#kiosk-empty`
`hidden` toggles `/watch` does in `renderCounts()`;
* `#kiosk-share-url` gets `location.origin + "/mirror"` on load;
* `refusalNotice(config)` → `showNotice(ui.notice, …)` and never open a socket,
exactly as `/watch` does;
* `onClose` → `wall.closePeers()` and mark the tiles stale; the next `welcome`
re-applies the layout, which is what makes a server restart recover on its own.
## Testing & Quality
- `tests/test_app.py` — the element guard from task 03 already forces every
`$("#…")` in `kiosk.js` to exist; add one source-level guard that `kiosk.js`
**never** sends a `mute`/audio field in an `arrange` frame (assert no `arrange`
object literal in the file contains `muted`/`audio`) — the security rule should
fail the build, not live in a comment.
- `uv run pytest -m e2e` — `/watch`'s behaviour must be untouched by this task; the
new page's behaviour is covered in task 05.
- Manual, before task 05 exists: `uv run mirror serve`, two browser windows, one on
`/kiosk`; drive the state with `websocat` (`{"type":"arrange","staged":"<id>"}`) and
watch the page stage it — AGENTS.md §8's "reproduce at the lowest layer" ladder
reversed: prove the layer works before automating it.
- Coverage: **>90%** on new/modified code — the applier is exercised by task 05's
suite; the guards above cover the security rules statically.
## Completion Criteria
- [ ] a `/kiosk` tab opened **after** an `arrange` is already showing that mirror when it finishes loading (no flash of the wall)
- [ ] an `arrange` sent from `websocat` re-arranges an open `/kiosk` tab within one frame
- [ ] a keypress on `/kiosk` (`Enter`, `g`, `n`, `p`, `1`–`9`, `f`) changes `GET /api/kiosk` — the display is a first-class controller
- [ ] `m` on `/kiosk` mutes locally and leaves `/api/kiosk` byte-identical
- [ ] `document.body.dataset.layout` / `dataset.fullscreen` agree with what is on screen at every point a test can observe
- [ ] `uv run pytest tests/test_app.py` and the full `-m e2e` suite green, existing assertions unedited
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,92 @@
# Task 05 — `tests/e2e/test_kiosk_layout.py` and the kiosk docs
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "will update immediately so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences"
**Story:** n/a
## Objective
Prove the two properties the TODO item actually claims — **it arranges itself on
arrival** and **one client's action moves another client's display** — in a browser,
on pixels, with no pointer anywhere near the display. Then write down how to point a
real kiosk box at it.
## Work
1. `tests/e2e/test_kiosk_layout.py` (new) — `pytestmark = [pytest.mark.e2e,
pytest.mark.timeout(180)]`, and the file's own `device()` fixture copied in the
suite's established shape (isolated context per device, `page.on("pageerror")` and
`console` errors collected from the first line of each test — `AGENTS.md` §8),
waiting on `#net` `data-state=online`.
Isolated **contexts** are the right unit here: each is its own socket and peer id,
which is what "another client" means to the server. The media path itself is
already 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.
2. `tests/e2e/test_kiosk_layout.py` — the cases:
* **arrival is arrangement**: sharer on `/mirror` + `start_sharing()`; drive the
state from a driver page (`page.evaluate` over a plain `WebSocket` to `/ws`,
`hello` then `{"type":"arrange","staged":<id>,"fullscreen":true}`); *then* open
`/kiosk`; assert it settles staged — `body[data-layout="stage"]`,
`#kiosk-stage .tile[data-stream="<id>"]`, and
`frames_matching(kiosk, "#kiosk-stage video")` showing `width > 0` and
`distinct >= 2`. The frame assertion is what proves media crossed to the
display, not just a CSS class (`AGENTS.md` §5).
* **immediate update across clients**: two `/kiosk` pages, no input on the second;
press `Enter` on the first (a keypress, so the display stays pointer-free);
assert the second page's `data-layout` flips to `stage`, its staged tile id
matches, and `GET /api/kiosk` reports the same id — one action, two displays,
one source of truth.
* **stepping**: `n` then `p` on page A move page B through the mirrors (start two
sharers; assert the staged `data-stream` on B changes and wraps).
* **teardown is a state change**: `stop_sharing()` on the sharer → both kiosk pages
return to `body[data-layout="wall"]` and `/api/kiosk` reports
`{"staged": null, …}` — no display may sit on a dead tile.
* **followers**: with one `/kiosk` open, `/api/kiosk` says `followers: 1`; open a
`/watch` page (a plain viewer, not a display) and it still says `1`; close the
kiosk and it says `0`.
* **nothing lies**: every case asserts `errors == []`.
3. `tests/e2e/test_kiosk_layout.py` — keep it pointer-free and say so in the module
docstring, in phase 01's words ("a test that reaches for the mouse proves nothing
about a kiosk"); add the same style of self-guard the keyboard suite uses if it
fits (scan this file for `.click(` and fail on it) — the driver's raw `WebSocket`
calls in `page.evaluate` are not a pointer and must be allowed explicitly rather
than by loosening the rule.
4. Do **not** assert on `document.fullscreenElement` anywhere: headless Chromium may
refuse an element fullscreen without user activation and the real kiosk's window
manager forces fullscreen anyway (`docs/kiosk-silverblue/window-config.ini`).
Assert `body[data-fullscreen]` and the `.stage` state, which are what the app
controls. A comment must say this, or the next reader will "strengthen" the test
into a flake.
5. `docs/kiosk-silverblue.md` — a section, *"Two endpoints: `/watch` and `/kiosk`"*:
`/watch` is a person operating a wall, `/kiosk` is a display obeying a wall, the
keys still work on `/kiosk` for the moment a keyboard is plugged in, and the
controller side arrives in phase 09 (`/remote`). Point out the migration rule
honestly: existing clients keep their own `MIRROR_KIOSK_URL`, so nothing changes
under a box until its `kiosk.conf` is re-pointed and `gdm` restarted. Do **not**
change `install-kiosk.sh`, `kiosk.conf`, `gnome-kiosk-script` or `check-kiosk.sh`
in this phase — the default URL moves in phase 09, when there is a way to drive it.
6. `README.md` — the page table gains the `/kiosk` row; the "Keys" section gains one
sentence: the wall keys work on `/kiosk` too, and a change made there is what every
other display follows.
## Testing & Quality
- `uv run pytest tests/e2e/test_kiosk_layout.py -x` green on its own, then the whole
`-m e2e` (the suites share one session-scoped server, so a leaked peer in the new
file will show up as a tile count in someone else's test — clean up contexts via
the fixture's finalizers, as the existing suites do).
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` stays green: `/kiosk` must not
have changed what a real capture looks like to a viewer.
- Coverage: this task adds no product code; it is the **behavioural** coverage the
phase's JS relies on (the project's rule — media and page behaviour are asserted in
a browser, not simulated in Python).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_kiosk_layout.py` green, including the arrival case and the two-display case
- [ ] the two-display test passes with the second display never receiving a `click()`, `focus()` or `hover()` from the test
- [ ] `stop_sharing()` returns both kiosk pages to `data-layout="wall"` within the assertion timeout (no stale tile, no dead `<video>`)
- [ ] `/api/kiosk`'s `followers` counts displays only, asserted over a real socket lifecycle
- [ ] `uv run pytest -m e2e` and `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` green
- [ ] full test suite **100% passing**
- [ ] README.md and `docs/kiosk-silverblue.md` describe what exists after this phase, and nothing that arrives in phase 09
- [ ] no behavior change in completed work
@@ -0,0 +1,90 @@
# Phase 09 — `/remote`: the hand that drives the wall
**Source:** `TODO.md` L3 — "…controllable by other clients. Problem is the kiosk doesn't have a keyboard/mouse plugged in most of the time so I can't full screen/switch views… that any client can manipulate" (the controller half of that item)
**Story:** n/a
**Context:** `.agents/PLAN.md` §2 A9 (no framework, no bundler), A10 (no external assets), A11/A19 (no accounts; the address gate is the access control); `SECURITY.md` §2/§3/§4; phases `07_kiosk_layout_state` (the contract) and `08_kiosk_page` (the display that obeys it); `src/mirror/app.py`, `src/mirror/static/{net.js,app.css}`, `docs/kiosk-silverblue/`
## Objective
`GET /remote`: a page you open on the laptop or phone in your pocket. It lists the
live mirrors and the wall's current state and offers three decisions — *show this,
back to the wall, full screen* — each one an `arrange` frame the display obeys
instantly. Stepping (`n`/`p`) stays on the wall pages: on a controller the direct
choice is a row and a button, and a second copy of the wall's key map would be a
second thing to keep in sync. It shows **no video and subscribes to nothing**: a
controller must not consume a sharer's upstream just to know what to point at
(`PLAN.md` §6, "Mesh upload = N × bitrate per sharer").
`/remote` is its own page rather than a panel on `/watch` because the two questions
are different — "what do *I* want to look at" and "what should the *room* see" — and
collapsing them would either hijack every viewer's clicks or hide the controls behind
a wall of tiles. `/watch` keeps its local staging exactly as phases 01–06 defined it.
## Rules this page lives by
* **Read the state, never own it.** Every control reflects `layout` as the server
pushed it, so two controllers, a kiosk's own keystrokes and a page reload all agree.
A control never paints a result locally and hopes the server agrees.
* **Media-free, and provably so.** No `RTCPeerConnection`, no `subscribe` frame, no
`<video>`. The proof is a test on the server's own numbers: open `/remote` and
`/api/streams` must still report zero watchers.
* **Only buttons and text, focusable.** No custom key map here: `/watch`'s keys belong
to a wall, and a second copy of them on a controller page is a second thing to keep
in sync. Native `<button>`s are keyboard- and screen-reader-reachable and work on a
phone, which is the device this page is for.
* **Fullscreen is reported, not promised.** The page says what the state asks for and
that a browser may refuse it; it never implies it verified another machine's
fullscreen.
* **No audio, ever.** Same rule as the display (`SECURITY.md` §4): the layout has no
sound field and `/remote` does not invent one. A controller that could make a
stranger's display talk is not a controller, it is a prank.
## Dependencies
- `07_kiosk_layout_state` (todo) — `arrange`/`layout`, `followers`, `GET /api/kiosk`.
- `08_kiosk_page` (todo) — the `/kiosk` display that makes a controller meaningful,
and the e2e scaffolding (`device()`-style fixture, frame probe) this suite reuses.
- `01_keyboard_kiosk_control` (complete) — the key map this page deliberately does
**not** copy, and the mouseless rationale it serves.
## Tasks
1. `01_remote_route_and_page.md` — `GET /remote` in `app.py` (+ `PAGES`),
`static/remote.html` with the control cluster and status line, `app.css` for the
list, entry links from `/watch` and `/`, and the `test_app.py` structural guards
extended to a fourth page.
2. `02_remote_controller_logic.md` — `static/remote.js`: the roster as a list (no
media), the live layout readout with the `followers` count, the buttons that send
`arrange`, and the convergence behaviour when someone else changes the state.
3. `03_remote_e2e_docs_and_kiosk_url.md` — `tests/e2e/test_remote_control.py`
(controller → display, asserted on the display's DOM and on `/api/streams`'
watcher count), README + `SECURITY.md`, and the kiosk box's default URL moving to
`/kiosk`.
## Testing & Quality
- **Structure**: every `test_app.py` guard gains the `/remote` page — renders,
`no-store`, `node --check` of `remote.js`, element-existence, the cross-page id
rule now over four pages (`{"net","notice","toast"}` is still the only overlap),
nothing from a CDN, the address gate refusing `/remote` like the rest.
- **The media-free guarantee** is asserted twice, from both ends: a source-level
guard that `remote.js` contains no `RTCPeerConnection` and no `subscribe` frame, and
a browser test that the server's `watching`/`viewers` counters stay at zero with
`/remote` open. One of them will catch a well-meant future "preview thumbnail".
- **Behaviour**: `tests/e2e/test_remote_control.py` drives the controller with real
clicks (a person with a mouse driving a machine that has none — that is the whole
feature) and asserts on the *display's* DOM and on `/api/kiosk`, never on the
controller's optimism.
- Coverage: **>90%** on new/modified code; the Python surface is one route.
## Completion Criteria
- [ ] with a `/kiosk` display open, clicking "Show on wall" in `/remote` puts that mirror on the display within one frame, asserted in the browser
- [ ] "Back to the wall" and "Fullscreen" do the same, and a second `/remote` page updates without being touched
- [ ] `/api/streams` reports zero watchers while only `/remote` is open — driving the wall costs a sharer nothing
- [ ] `/remote` counts one follower per `/kiosk` and never counts itself
- [ ] a controller keeps working when there is no display at all (`/api/kiosk` `followers: 0`, the controls are enabled, the state is still stored)
- [ ] `uv run pytest tests/e2e/test_remote_control.py` green, then the full `-m e2e` and the `realdisplay` suite
- [ ] README + `SECURITY.md` describe what a visitor can now do to someone else's wall; the Silverblue box's default URL is `/kiosk`
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] `PLAN.md` untouched; `AGENTS.md` touched only if a statement in it became stale
@@ -0,0 +1,109 @@
# Task 01 — `GET /remote` and the controller page
**Phase:** `09_wall_remote_page` · **Source:** `TODO.md:3` — "controllable by other clients … so I can't full screen/switch views"
**Story:** n/a
## Objective
The endpoint and the markup for the hand: a list of mirrors, a line that says what the
wall is doing, and the buttons that change it. `remote.js` becomes real in task 02;
here it is a stub that loads config and reports a refusal, so the page serves and the
guards can be written first.
## Work
1. `src/mirror/app.py` — the route beside `/kiosk`:
```python
@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")
```
Add `"/remote"` to the `PAGES` tuple so it is served `no-store` like the others.
2. `src/mirror/static/remote.html` (new) — same `<head>` conventions as the other
pages (app.css, inline `data:` favicon, no external asset), `<title>Remote ·
mirror</title>`, and these elements only:
* `<header class="bar">` with the brand and `<span class="net" id="net"
data-state="connecting">…</span>` (the shared chrome).
* `<div class="notice" id="notice" hidden>` and `<div class="toast" id="toast"
hidden>` — the other two shared ids, nothing else may overlap with another page.
* `<section class="wall-state" id="remote-state" aria-live="polite">` — the readout:
`Wall: <label>` / `Wall: no mirror`, a fullscreen marker, and a follower clause
`· 1 display following` / `· no display following`. Give the pieces stable hooks
(`<span id="remote-staged">`, `<span id="remote-fullscreen">`,
`<span id="remote-followers">`) so the text and the tests never have to parse a
sentence.
* `<div class="remote-controls">` with `<button class="btn" id="remote-back">Back
to the wall</button>` and `<button class="btn ghost" id="remote-fullscreen"
aria-pressed="false">Full screen</button>`.
* `<ul class="mirror-list" id="remote-list">` — the mirror rows live in
`<template id="remote-row-template">`: `<li class="mirror-row" data-stream>`,
`.title`, `.sub`, `<button class="btn ghost show">Show on wall</button>`.
* `<div class="empty" id="remote-empty">` for "No mirrors live" with the same
`Share from <code id="remote-share-url">…</code>` line the kiosk page uses —
the controller is usually the device that goes and asks somebody to share.
3. `src/mirror/static/remote.js` (new, stub) — import `loadConfig`, `refusalNotice`,
`showNotice`, `$` from `./net.js`; set `#remote-share-url`; open the socket enough
to drive `#net` to `online` (the e2e `device()` helper waits on it) and say hello
with `role:"viewer"` and **no `kiosk` flag**. Task 02 fills in the rest.
ASSUMPTION: a controller is a plain viewer in the protocol; `followers` counts
displays only, and that asymmetry is what makes "no display following" mean what it
says.
4. `src/mirror/static/app.css` — `.remote` page styles: `.wall-state` (a strip under
the bar, `--mono` for the readout, `aria-live` friendly), `.mirror-list` /
`.mirror-row` (one row per mirror, title + sub + button on one line, works down to
320 px — the share page's narrow-viewport rules are the model),
`.remote-controls` (button row, wraps). `[aria-pressed="true"]` on
`#remote-fullscreen` gets the same accent treatment the tile audio button uses, so
"on" looks the same everywhere in this app. No rule whose selector mentions
`[hidden]`; no new `display` on an existing component (`TestStylesheet` guards).
5. Entry points — the endpoint has to be findable from the pages a person already
opens:
* `src/mirror/static/watch.html` — `<a class="btn ghost" id="remote-link"
href="/remote">Drive the wall</a>` in the header, before `#share-link`. `watch.js`
needs no change for it (it is a plain link; the element guard only inspects ids
the script reaches for).
* `src/mirror/static/index.html` — a fourth `<a class="card" href="/remote">`
("GET /remote — Point the wall from another device"). Cards carry no ids.
6. `src/mirror/static/kiosk.html` — nothing. The display must not link its way out of
itself; a wall shows mirrors and no navigation.
## Testing & Quality
- `tests/test_app.py` — extend the guards to the fourth page, same list as phase 08
task 03: `test_pages_render` (`("/remote", "remote-row-template")`),
`test_pages_are_not_cacheable`, `test_static_assets_are_served` (`remote.js`),
`test_scripts_parse_as_es_modules` (`remote.js`),
`test_every_element_the_script_reaches_for_exists` (`("remote.js", "remote.html")`),
the stylesheet-and-module pair test, and
`test_the_two_pages_share_no_element_ids_but_the_shared_chrome` with `remote.html`
in the map — `remote ∩ watch`, `remote ∩ mirror`, `remote ∩ index`, `remote ∩ kiosk`
all exactly `{"net","notice","toast"}`.
- New guards for this page's one hard promise, next to the other source-level rules:
* `remote.js` contains no `RTCPeerConnection` and no
`type: "subscribe"`/`type:'subscribe'` string, and `remote.html` contains no
`<video>` — a controller that streams is a bug, and it must fail the build, not
get caught in review (the reason is stated in the test's docstring: `PLAN.md` §6's
mesh cost).
* `remote.html` declares `id="remote-state"`, `#remote-back`,
`#remote-fullscreen`, `#remote-list` — the element guard already covers this once
`remote.js` looks them up in task 02, but assert them here so task 01's stub and
task 02's page cannot drift.
- `tests/test_app.py::TestAddressGate` — `/remote` joins the refused-address list.
- Coverage: **>90%** on new/modified Python (one route).
## Completion Criteria
- [ ] `curl -s localhost:8055/remote` serves the page; `-I` shows `cache-control: no-store`
- [ ] opening `/remote` shows the header with the net pill online, an empty "No mirrors live" state with the share URL, and no console errors
- [ ] `/watch`'s header and `/`'s cards link to `/remote`, and `/kiosk` links to nothing
- [ ] `uv run pytest tests/test_app.py` green with the guards above extended to four pages, including the "no media in a controller" rules
- [ ] at a 320 px viewport the control row wraps and every button is reachable, no horizontal scroll
- [ ] `uv run pytest -m e2e` green (existing assertions unedited)
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,117 @@
# Task 02 — `remote.js`: read the state, send the intent
**Phase:** `09_wall_remote_page` · **Source:** `TODO.md:3` — "state (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
**Story:** n/a
## Objective
Make the controller real: a list of mirrors built from the roster, a readout built
from the server's `layout`, and buttons that change the wall by sending `arrange` —
never by painting a result and hoping. The page's correctness rests on one rule: **the
only thing it displays about the wall is what the server last told it.**
## Work
1. `src/mirror/static/remote.js` — replace the stub's innards, keeping the `ui` map,
the config/refusal path and the socket from task 01. Module-local state:
```js
/** id -> {id, name, label, displaySurface, width, height, audio}, in server
* roster order (welcome.streams is oldest-first, stream_added appends). The
* controller keeps a list, not tiles: no <video>, no MediaStream, no
* RTCPeerConnection — see the "no media in a controller" guard in test_app.py. */
const mirrors = new Map();
let layout = { staged: null, fullscreen: false, followers: 0 };
```
2. `src/mirror/static/remote.js` — roster handling: `welcome` seeds `mirrors`,
`stream_added`/`stream_updated` upsert, `stream_removed` deletes, and each change
re-renders the list and the readout (a staged mirror's label appears in the
readout, so an update can change the readout without changing the list).
`signal` frames are ignored — this page never negotiates, and it should still be
true if a sharer misbehaves.
3. `src/mirror/static/remote.js` — `renderMirrors()`: one `li.mirror-row[data-stream]`
per mirror from `#remote-row-template`, title = `label || name || "Shared screen"`,
sub = `name · surface · W×H · audio|silent` (reuse the same words `watch.js`'s
`paintTile` prints, so the two pages describe a mirror identically), the row's
`.show` button, and `data-on=""` on the row whose id is `layout.staged` with the
button reading `On the wall` (plus `aria-current="true"`). Empty list →
`#remote-empty` shown, list `hidden`.
4. `src/mirror/static/remote.js` — `renderState()`: the three spans
(`#remote-staged`, `#remote-fullscreen`, `#remote-followers`) are filled from
`layout` and nothing else:
* staged → the mirror's title, or `"(gone)"` for the frame-in-transit case where
the server has not yet cleared it; `null` → `no mirror`;
* fullscreen → `on` / `off`, and `#remote-fullscreen`'s `aria-pressed` and label
(`Full screen: on`) follow the **server's** value;
* followers → `N displays following` / `1 display following` / `no display
following`. When it reads `no display following` the controls stay enabled and a
one-line hint appears under them: the state is still stored, so pointing a kiosk
at `/kiosk` immediately picks it up — that is the TODO's "navigate to it and it
arranges itself", seen from the other end.
5. `src/mirror/static/remote.js` — the three writers, each of which sends and then
**waits**:
```js
/**
* Send an intent and let the server's `layout` frame paint it. Never update
* `layout` here: with two controllers open, an optimistic local paint is a lie
* the moment the other one acts, and a lie in the readout is worse than a
* 40 ms delay.
*/
function arrange(patch) {
if (!state.ws.send({ type: "arrange", ...patch })) toast("not connected — nothing sent");
}
```
* row `.show` → `arrange({ staged: id })`;
* `#remote-back` → `arrange({ staged: null })`;
* `#remote-fullscreen` → `arrange({ fullscreen: !layout.fullscreen })` — toggling
from the server-reported value, never from the button's own `aria-pressed`.
Wire them once on the list container (event delegation on `#remote-list`, matching
how `watch.js` handles per-tile buttons) so a re-render cannot orphan a listener.
6. `src/mirror/static/remote.js` — the frames: `welcome` → seed `mirrors`, set
`layout = message.layout`, render both; `layout` → set `layout`, `renderState()`
**and** `renderMirrors()` (the `data-on` marker moved); `error` → `toast` the
message (an `unknown_stream` from a stale row is exactly the case where silence
would look like a broken button).
7. `src/mirror/static/remote.js` — the honest edge cases:
* a mirror that was on the wall disappears → the server's `layout` arrives with
`staged: null`; say so once (`toast("<label> ended — back to the wall")`) because
on a controller the change is invisible otherwise;
* socket closed → mark the readout stale (`state.ws.open === false`):
`#remote-staged` gets a `data-stale=""` and the hint reads "offline — the wall is
not following you"; on reopen, `welcome` re-syncs everything and clears it;
* the page never sends `subscribe`, and never reads `navigator.mediaDevices`.
8. Do not add a key map here (`AGENTS.md` §6, and the rationale in `00_phase.md`): the
buttons are native, focusable and phone-tappable. Do not touch `watch.js`,
`kiosk.js` or `wall.js` — the shared state is the only coupling between pages,
which is the point of the whole feature.
## Testing & Quality
- `tests/test_app.py` — source guards alongside the existing ones:
* `remote.js` sends exactly one message type other than `hello`: assert the file
contains `type: "arrange"` and contains no `type: "subscribe"`;
* `remote.js` contains no `RTCPeerConnection`, no `MediaStream` and no
`navigator.mediaDevices` (the task-01 guard, extended — a controller growing a
"preview" is the regression most likely to arrive with good intentions);
* the element guard (`("remote.js", "remote.html")`) now covers every `$("#…")`
the real script reaches for.
- `tests/test_app.py` — one protocol-level test that a `/remote`-shaped peer
(`hello{role:"viewer"}` with no `kiosk`) drives the layout and is **not** counted in
`followers`, and one that `arrange`ing a mirror a *second* viewer is watching
changes nobody's subscription (`/api/streams` `viewers` unchanged) — driving the
wall must not disturb the people watching it.
- Coverage: **>90%** on new/modified code; the browser behaviour lands in task 03.
## Completion Criteria
- [ ] with a `/kiosk` display open, `/remote`'s row for the mirror on the wall reads `On the wall` and carries `data-on`
- [ ] pressing "Back to the wall" leaves the button state and the readout to the server's frame: two `/remote` tabs show the same thing 100 ms after either one acts
- [ ] `#remote-fullscreen` toggles from the server-reported value (press it twice in two tabs and the two tabs never disagree)
- [ ] `/remote` with `followers: 0` still stores the state and says no display is following
- [ ] `uv run pytest tests/test_app.py` green, including the three new source guards and the two protocol tests
- [ ] `uv run pytest -m e2e` green with existing assertions unedited
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,119 @@
# Task 03 — Controller→display E2E, the security line, and the box's new URL
**Phase:** `09_wall_remote_page` · **Source:** `TODO.md:3` — "controllable by other clients … so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences"
**Story:** n/a
## Objective
Prove the whole loop in a browser — a hand on a laptop, a mirror on the wall, a box
across the room with no keyboard — then write down what it means for the threat model
and point the Silverblue client's default at `/kiosk`.
## Work
1. `tests/e2e/test_remote_control.py` (new) — `pytestmark = [pytest.mark.e2e,
pytest.mark.timeout(180)]`, the suite's own `device()` fixture (isolated context
per device, `pageerror`/`console` collected from line one, wait on `#net`
`data-state=online`) exactly as `test_kiosk_layout.py` sets it up. Unlike the kiosk
suite, **this one clicks** — a person with a mouse driving a machine that has none
is the entire feature.
2. `tests/e2e/test_remote_control.py` — the cases:
* **the loop**: sharer `/mirror` + `start_sharing()`; a `/kiosk` display; a
`/remote` controller. Assert the row appears, click its `.show`; assert on the
**display** — `body[data-layout="stage"]` and
`#kiosk-stage .tile[data-stream="<id>"]` — and on `GET /api/kiosk`. Then click
`#remote-back` and assert the display is back on the wall.
* **pixels, not classes**: with the mirror on the display's stage,
`frames_matching(kiosk, "#kiosk-stage video")` reports `width > 0` and
`distinct >= 2` — the controller moved real media, per `AGENTS.md` §5.
* **full screen**: click `#remote-fullscreen` → `/api/kiosk` says
`fullscreen: true` and the display's `body[data-fullscreen="true"]`. Never assert
`document.fullscreenElement` (headless may refuse; the WM forces fullscreen on the
real box — say so in a comment so nobody re-adds the flake).
* **two controllers converge**: open a second `/remote`; act in the first; the
second's readout and `data-on` row update with no interaction.
* **a controller costs a sharer nothing**: with only `/remote` open,
`GET /api/streams` reports `viewers: 0` and `stats.watching: 0`; open a `/watch`
and the numbers move — the guard that keeps `/remote` from becoming a stealth
watcher.
* **followers**: `/api/kiosk` and `/remote`'s `#remote-followers` agree at 0, at 1
with a display open, and at 1 with a second `/remote` added (a controller is not
a display).
* **the display is a controller too**: press `n` on the `/kiosk` page and assert
the `/remote` readout follows it — the TODO's "any client can manipulate", in the
direction that would otherwise be forgotten.
* **gone is a state**: `stop_sharing()` → the display unstages, the controller's
row disappears and its readout says no mirror.
* every case asserts `errors == []`.
3. `SECURITY.md` — this is a new capability over other people's machines, so it goes
in the model, not just the README:
* §2 "What an unauthenticated visitor can do" gains a bullet: **rearrange anybody's
wall display** — choose which mirror is on stage and whether it is fullscreen, on
every `/kiosk` client, immediately; reachable by anyone the address gate admits
(`/remote`, or one `arrange` frame on `/ws`). Say plainly that this is the same
trust decision as "watch anything anyone shares", and that `MIRROR_ALLOW_CIDRS`
is what bounds it.
* §2 also gains the thing that is *not* possible: a visitor cannot make a display
audible, and why (`§4`'s gesture rule).
* §3's table gains a row: *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 — enforced by the layout model plus the `kiosk.js`/`remote.js`
source guards, tested by
`test_kiosk_mute_is_local_and_never_published`-style tests (name the real ones
from phases 08/09 in the row).
4. `README.md`:
* the page table gains `/remote` (and confirms the `/kiosk` row phase 08 added);
* a short section, *"Driving an unattended wall"*: point the box at `/kiosk`, open
`/remote` on your phone, press "Show on wall"; the wall keys still work on the
box when a keyboard is plugged in and a key there is seen by every controller;
fullscreen is requested, not promised; state lives in the server's memory and a
restart returns the wall to a wall;
* the Quickstart's URL list gains both endpoints.
5. The Silverblue client now defaults to the controllable wall. Change all four places
in one commit so a box installed from this tree cannot get a half-migrated URL:
* `docs/kiosk-silverblue/install-kiosk.sh` — `KIOSK_URL_DEFAULT` →
`https://mirror.reeseapps.com/kiosk`, and the comment above it explains that
`/kiosk` is the display endpoint while `/watch` is the operator's page;
* `docs/kiosk-silverblue/kiosk.conf` — `MIRROR_KIOSK_URL` → the `/kiosk` URL, with
its comment rewritten (it currently argues for `/watch` on the grounds that `/`
needs a tap; the new argument is that a display must be arrangeable from another
device);
* `docs/kiosk-silverblue/gnome-kiosk-script` — the built-in fallback URL (L28)
moves too, since a client with an unreadable conf file silently uses it;
* `docs/kiosk-silverblue/check-kiosk.sh` — the URL sanity check must accept both
walls: the `case "$URL"` branch that warns unless the URL ends in `/watch`
accepts `/kiosk` as equally right, and the body check that greps
`"Watch · mirror"` also accepts `"Wall · mirror"` (that string is the
`<title>` phase 08 gave `kiosk.html`). Keep the warning wording useful for the
genuine third case: "this is neither wall, check DNS and the nginx vhost".
* `docs/kiosk-silverblue.md` — the top-of-file description and the
`KIOSK_URL=…` example move to `/kiosk`, and state the migration rule in one
sentence: existing clients keep their own `kiosk.conf` until an operator
re-points it and restarts `gdm`, because the installer deliberately preserves a
client's URL.
6. Verify the shell changes without a box: `sh -n` on each script you touched, and run
`docs/kiosk-silverblue/check-kiosk.sh` logic in your head for a `/kiosk` URL — it
must report `ok`, not `warn`. These scripts are the only way anyone reaches a kiosk,
and a syntax error in one is discovered on a machine with no console.
## Testing & Quality
- `uv run pytest tests/e2e/test_remote_control.py -x` green alone, then the full
`-m e2e` (shared session server: close contexts via the fixture's finalizers or the
next suite sees extra tiles), then `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`.
- `sh -n docs/kiosk-silverblue/*.sh docs/kiosk-silverblue/gnome-kiosk-script` → clean.
- `uv run pytest tests/test_app.py` still green — the `/remote` guards from tasks 01/02
must not be relaxed to make anything here pass.
- Coverage: **>90%** on new/modified code; this task's product changes are docs and
shell, its tests are the browser suite.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_remote_control.py` green, including the loop, the pixels, the two-controller convergence and the zero-watcher assertions
- [ ] the TODO item's sentence is now true end to end: navigate a browser to `/kiosk`, and from another device open `/remote` and arrange it — no keyboard, no mouse, at the box
- [ ] `SECURITY.md` §2/§3 describe the new capability and the audio limit, naming the tests that enforce them
- [ ] `README.md` documents `/kiosk` and `/remote`, including that fullscreen is best-effort and state is in-memory
- [ ] a fresh `sudo sh install-kiosk.sh` would point a box at `/kiosk`, and `check-kiosk.sh` reports `ok` for a `/kiosk` URL and for an existing `/watch` one
- [ ] `uv run pytest -m e2e`, `-m realdisplay`, `ruff`, `pyright` all green
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,13 @@
Every AGENTS.md rule claim checks out against the code — no stale statements. All verification is complete.
**Phase 07 — final verification pass report**
- **Verified, not re-implemented:** all 3 tasks already in `complete/` — `Arrange`/`layout` frames + `hello.kiosk` (protocol), `Layout`/`set_layout`/`layout_dto` policy (registry), `arrange` dispatch + `GET /api/kiosk` (app).
- **Live check:** idle `/api/kiosk` → `{"staged": null, "fullscreen": false, "followers": 0}`; drove `arrange` over a real WebSocket (websocat absent → python `websockets` client): state reflected in `/api/kiosk`, kiosk join bumped `followers` to 1 and pushed to other viewers, sharer teardown reset `staged` to `null` in exactly one `layout` frame — no stale id.
- **Gates (all 4):** `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **319 passed, 5 skipped** (realdisplay, by design); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **4 passed, 1 skipped** (documented host has no audio source).
- **Criteria 1/4/5:** targeted suite 154 passed incl. all new layout cases; stale-fallback unit tests cover unpublish, disconnect, and stream-replacement; `git diff` of `src/mirror/static/` and `tests/e2e/` is empty, e2e green within the full run.
- **Criterion 6:** full suite 100% green; coverage 95% total — new/modified code: protocol 100%, registry 99%, app 94% (all >90%).
- **Criterion 7:** `PLAN.md` untouched.
- **Documentation:** README.md reviewed in full and cross-checked against code (routes, CLI flags, config table incl. 25 Mbit/s default, quality-preset table, watch/share key tables, docs references, the phase-07 shared-state paragraph) — consistent, no fixes needed. AGENTS.md — verified, no stale statements, no changes.
- **Note:** `PLAN.md` §6 says the default `MIRROR_MAX_BITRATE` is "12 Mbit/s" but code/README say 25 — pre-existing, in a LOCKED file, left as-is (README is correct).
- **Next pending phase:** `08_kiosk_page`.
@@ -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
319 passed, 5 skipped, 1 warning in 99.46s (0:01:39)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,16 @@
All four gates green. Task 01 complete.
**Report**
- **Implemented (protocol-only, per task scope):**
- `Arrange` inbound model in `src/mirror/protocol.py` (`staged: str | None`, `fullscreen: bool | None`, both optional so `model_fields_set` can tell "absent" from "clear"); added to the `ClientMessage` discriminated union
- `Hello.kiosk: bool = False` (single word, so no alias to get wrong; `Role` untouched — no third role)
- `layout()` outbound builder emitting exactly `type/staged/fullscreen/followers`
- `welcome()` gains required keyword-only `layout` (no mutable default); `Registry.join` updated in the same change to pass the placeholder `layout(staged=None, fullscreen=False, followers=0)` for task 02
- No changes to `Hub`, `create_app`, Registry policy, or any JS
- **Tests:** extended `tests/test_protocol.py` — all three `arrange` shapes parse, `model_fields_set` distinguishes absent vs explicit-`null` vs set (incl. explicit `false`), `hello` kiosk default/true, `layout()` key-set + null stage, `welcome` carries its layout, and `layout` added to the type-advertisement list
- **Gates:** `ruff check` + `ruff format --check` clean · `pyright` 0 errors · `uv run pytest` **293 passed, 5 skipped** (realdisplay-gated), e2e green with zero e2e test files edited · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` **4 passed, 1 skipped** (host has no audio source — documented module skip)
- **Coverage:** `protocol.py` 100%; new `registry.py` line covered (its 3 uncovered lines are pre-existing guard clauses)
- **Docs:** no doc impact (no setup/command/config/API change; README does not enumerate wire frames; AGENTS.md not stale)
- **Deviation:** none material — `registry.py` imports the `layout` builder by name (matching its import style) rather than `protocol.layout(...)`
- **Next pending task:** `02_registry_layout_policy.md`
@@ -0,0 +1,36 @@
........................................................................ [ 24%]
...............................sssss.................................... [ 48%]
........................................................................ [ 72%]
........................................................................ [ 96%]
.......... [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 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
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 191 3 98% 176, 190, 365
------------------------------------------------------
TOTAL 660 33 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
293 passed, 5 skipped, 1 warning in 97.57s (0:01:37)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,24 @@
All green. Final state verified — diff is clean, task 01's protocol work plus my registry work coexist.
## Report
**Implemented (task 02 — `Registry` layout policy, `src/mirror/registry.py` only):**
- `UNSET` sentinel (`_Unset`) distinguishing "no opinion" from explicit `null`
- `Layout` dataclass (`staged`, `fullscreen`); `Registry.layout` created in `__init__`; `welcome` now carries `self.layout_dto()` (placeholder from task 01 replaced)
- `Peer.kiosk` + `Registry.join(kiosk=…)`; follower count derived, never bookkept
- `layout_dto()` (single DTO source), `set_layout()` (unknown-peer / unknown-stream refusal reusing existing codes, partial-update rule, no-change → ack-only, change → viewers + sender deduped)
- Stale-staged fallback in `_drop_stream()` (single funnel for all removal paths); kiosk join/leave pushes layout to other viewers; double-frame guard in `leave()`
- New `TestLayout` class: 16 cases in the task's order (defaults, fan-out without doubling, partial update, null-clear + no-op ack, unknown-stream refusal, repeat ack, unpublish/disconnect/replaced-stream fallbacks, follower counting + join/leave pushes, late `welcome` carrying state)
**Gates:**
- `uv run ruff check . && uv run ruff format --check .` → clean
- `uv run pyright` → 0 errors; `grep -n "await\|WebSocket\|fastapi" src/mirror/registry.py` → nothing (A8 holds)
- `uv run pytest` → **309 passed, 5 skipped** (skips = realdisplay-gated)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (known host audio constraint)
- Coverage: `registry.py` 99% (3 pre-existing defensive guards uncovered; all task-02 code covered)
**Docs:** no doc impact (no endpoint/env/CLI change — those land in task 03; README/AGENTS.md untouched)
**Decisions:** `set_layout` validates `staged` before mutating (refusal leaves state untouched); kiosk-leave broadcast in `leave()` is skipped when `_drop_stream` already pushed the identical frame (degenerate kiosk-sharer case).
**Next pending task:** `.agents/phases/todo/07_kiosk_layout_state/03_dispatch_read_endpoint_and_wire_tests.md`
@@ -0,0 +1,36 @@
........................................................................ [ 22%]
...............................sssss.................................... [ 45%]
........................................................................ [ 68%]
........................................................................ [ 91%]
.......................... [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 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
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 697 33 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
309 passed, 5 skipped, 1 warning in 96.60s (0:01:36)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,20 @@
Task 03 complete.
**Implemented**
- `Hub._introduce` passes `kiosk=hello.kiosk` into `registry.join`
- `Hub._dispatch` handles `arrange` via `model_fields_set` (absent = `UNSET`, explicit null = clear), with a comment pinning the rule
- `GET /api/kiosk` serves `registry.layout_dto()` with `Cache-Control: no-store`, gated like every route; no `/kiosk` page added (phase 08); no JS touched
- `tests/test_app.py`: 5 new socket tests in `TestSignalingSocket` (round-trip to sender/second-viewer/endpoint + late-joiner welcome, follower counting and re-count on leave, `unknown_stream` refusal with socket alive, `fullscreen`-only vs `staged:null`, sharer sender ack without fan-out to other sharers, staged-sharer-leaves reset), `/api/kiosk` in the refused-address parametrize + refused/real-address gate tests, exact-DTO shape test
- Manual drive verified on a live server: `hello` → `arrange` → `layout` frame, `curl /api/kiosk` reflects state + `no-store`, sharer exit resets `staged: null`
**Gates**
- `uv run ruff check . && uv run ruff format --check .` — clean
- `uv run pyright` — 0 errors
- `uv run pytest` — 319 passed, 5 skipped (realdisplay-gated), coverage 96% (new code fully covered; all uncovered `app.py` lines pre-existing defensive branches)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented no-audio-source host constraint)
**Docs** — README: "The wall has one shared state" protocol note in *How it works*; SECURITY.md §2: `arrange` capability + no-audio-field guarantee. AGENTS.md: no stale facts, unchanged.
**Notable decision** — the phase's curl example shows 3 keys, but the endpoint returns the socket's exact DTO (task's literal code + task 02's single-DTO invariant), i.e. 4 keys including `"type":"layout"`; the shape test pins all four.
**Next pending task** — `08_kiosk_page/01_extract_wall_tiles_and_stage.md` (phase 07 is complete).
@@ -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
319 passed, 5 skipped, 1 warning in 97.82s (0:01:37)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,22 @@
**Phase 08 final verification pass — complete, all green.**
**Verified (all 5 tasks already implemented):**
- `static/wall.js` (`createWall` + `installWallKeys`), `kiosk.html`/`kiosk.js`, `GET /kiosk` route — guards: no `$("#…")` in wall.js, one keydown listener, kiosk ids no-collision across 3 pages, no audio field in `arrange`, no header/share link in markup, address gate covers `/kiosk`
- Cross-page e2e: arrival-arrangement, keypress-on-one-display moves the other (asserted on the second display's decoded moving pixels), dead mirror → `data-layout="wall"` on both, `followers` count, pointer-free meta-test
**Gates (exact outcomes):**
- `uv run ruff check . && uv run ruff format --check .` — passed
- `uv run pyright` — 0 errors
- `uv run pytest` — 341 passed, 5 skipped (realdisplay-gated), 96s
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented: host has no audio source for real capture)
- Coverage 96% (>90%); only warning is a starlette/httpx third-party deprecation
**Completion criteria:** all 9 ✅ — e2e green incl. `test_kiosk_layout.py` (the one e2e diff is the phase-mandated address-gate extension to `/kiosk`; assertion logic untouched), full suite 100% passing, README documents `/kiosk`, PLAN.md untouched.
**Documentation:**
- README: fixed the Kiosk-client section that implied the display box is permanently pointed at `/watch` — now states the display page is `/kiosk` with the runbook's one-line re-point (installer default still `/watch` this phase, per docs). Rest of README verified against code (routes, config table, CLI flag, presets, key table) — consistent.
- AGENTS.md: fixed 2 stale statements — CDN test moved to `TestPages` (was `TestClientScripts`), suite runtime ~50s → ~95s. No content added.
**Notable:** PLAN.md §6 says "per-leg `maxBitrate` cap (default 12 Mbit/s)" but code/README default is 25 Mbit/s — pre-existing, left untouched (locked file; flagged for a user decision).
**Next pending phase:** `09_wall_remote_page`.
@@ -0,0 +1,36 @@
........................................................................ [ 20%]
......................................sssss............................. [ 41%]
........................................................................ [ 62%]
........................................................................ [ 83%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 194 10 95% 108, 113, 117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 706 31 96%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
341 passed, 5 skipped, 1 warning in 96.44s (0:01:36)
All checks passed!
33 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,16 @@
Task 01 complete — all gates green.
**Implemented**
- New `src/mirror/static/wall.js`: `createWall({grid, stageEl, template, send, rtc, onChange, stats})` owning tiles, peers, stage, selection, mute policy, autofade, fullscreen-exit race, stats ticker, `closePeers()`; returns the task's surface plus `selectedTile()`, `revealStageControls()`, `handleFullscreenChange()`. Zero document queries; socket via page's `send` (guarded on `state.ws` in the page closure, so the wall builds before the socket opens).
- `watch.js` now a thin page: socket, `renderCounts` as the `onChange` callback, `fullscreenchange`/pointer/key bindings, help, refusal, share link. Dead `ui.unstage` branch deleted with a comment pointing at the per-tile `.exit` button.
- `app.css`: `#watch-stage` → `.stage` (all 10 rules, comments updated to class-scoped/source-order rationale); `watch.html` stage gains `class="stage"`.
- **Collision found & fixed:** the share page already used `class="stage"` — the literal re-key would have made `#preview-stage` inherit `position: fixed; inset: 0; display: none` (same specificity, later rule wins). Renamed the share preview to `.preview-stage` (matching its id) so `.stage` is wall-only; `test_share_layout`/`test_live_mirror` pass unedited, proving the share page is unchanged.
- `test_app.py`: `wall.js` in parse/asset lists; new `test_wall_never_looks_up_elements_in_the_document` (cites the id-collision test as its reason); `test_the_two_pages_use_the_media_apis_they_claim` re-pointed at `wall.js` (RTCPeerConnection moved); the two TestStylesheet tests mechanically re-pointed to `.preview-stage`.
**Gates** — `ruff check` + `format --check` clean; `pyright` 0 errors; targeted e2e (`-k "keyboard or live_mirror or audio or stage or share_layout"`) 92 passed **unedited**; full e2e 103 passed; full `uv run pytest` 322 passed / 5 skipped (realdisplay-marked); `MIRROR_REALDISPLAY=1 pytest -m realdisplay` 4 passed / 1 platform-documented skip. No e2e file touched.
**Docs** — no doc impact (pure refactor; README/AGENTS.md state nothing this changed).
**Decisions** — `.preview-stage` rename (above); `rtc` added to the wall options (peers moved into the wall, config stays page-owned); redundant `renderCounts()` in the `welcome` case dropped (per-`absorb` `onChange` covers it, identical final state).
**Next pending task** — `02_extract_wall_key_router.md` (phase 08).
@@ -0,0 +1,36 @@
........................................................................ [ 22%]
...............................sssss.................................... [ 44%]
........................................................................ [ 66%]
........................................................................ [ 88%]
....................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 191 11 94% 108, 113, 116-117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 703 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
322 passed, 5 skipped, 1 warning in 98.55s (0:01:38)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,23 @@
All done. Final state verified — here's the report:
## Task 02 report — `installWallKeys()` in `wall.js`
**Implemented**
- `wall.js`: new `installWallKeys({ wall, onCommand, root? })` — the one `keydown` listener: case-insensitive key→command switch (`next`, `previous`, `stage`, `fullscreen`, `wall`, `step_next`, `step_previous`, `mute`, `jump` w/ 0-based `index`, `help`, `escape`), chord/text-field bail-outs, staged-reveal before dispatch (never preventDefaults), and `preventDefault()` on exactly the keys that changed something. Wall commands carry module defaults (moved verbatim from `watch.js`, with their "why" comments); `help`/`escape` have no default (page policy).
- `wall.js`: `createWall` surface gains `reRequestFullscreen()` so the `f`-when-staged default can re-request on the stage element the wall owns (same element `/watch` used via `ui.stage`).
- `watch.js`: deleted `onWallKey`, `stageSelectedOrFirst`, `stepStage`, `toggleHelp` and the `document.addEventListener("keydown", …)` line; now `installWallKeys({ wall, onCommand })` with `onCommand` answering only `help` (toggle `#wall-help`, always handled) and `escape` (overlay first, then `wall.unstage()`, handled iff either happened) — the `?` focus comment moved with the behaviour.
- `test_app.py`: new guard — `installWallKeys` defined in `wall.js`, `wall.js` holds exactly one keydown listener, and no wall-page module (watch.js, future kiosk/remote) adds its own.
**Results (all gates)**
- `uv run ruff check . && uv run ruff format --check .` → clean
- `uv run pyright` → 0 errors
- `uv run pytest tests/e2e/test_keyboard_wall.py` **unedited** → 35/35 passed
- `uv run pytest -m e2e` → 103 passed, 5 skipped (realdisplay-gated)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (no audio source on host — pre-existing env skip)
- `uv run pytest` (full) → **323 passed, 5 skipped**, 0 failed; coverage ~95% (JS router covered by browser assertions, per AGENTS.md §4)
**Docs:** no doc impact (pure refactor, no user-visible change; README/AGENTS.md unaffected).
**Notable decision:** `grep 'addEventListener("keydown"' static/*.js` shows the one `wall.js` hit *plus* `share.js`'s pre-existing `/mirror` s/x/Esc listener (share-page policy, not a wall page — untouched, out of scope); the test guard encodes the "one listener per wall page" rule exactly. `fullscreen` got a module default (not listed in the task's default list, but required for item 5's thin-observer `/watch` and for `/kiosk` to publish while the module performs).
**Next pending task:** `08_kiosk_page/03_kiosk_page_and_route.md`
@@ -0,0 +1,36 @@
........................................................................ [ 21%]
...............................sssss.................................... [ 43%]
........................................................................ [ 65%]
........................................................................ [ 87%]
........................................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 191 11 94% 108, 113, 116-117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 703 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
323 passed, 5 skipped, 1 warning in 92.90s (0:01:32)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,19 @@
All done. Final state is 100% green across all four gates.
**Report — phase 08, task 03 (`GET /kiosk` + page skeleton)**
**Implemented**
- `GET /kiosk` route in `app.py` (beside `/watch`), `/kiosk` added to `PAGES` (no-store); fixed the stale "Two pages" module docstring
- `static/kiosk.html`: chrome-free wall — `body.kiosk[data-layout="wall"][data-fullscreen="false"]`, `#kiosk-grid`, `#kiosk-empty` + `#kiosk-share-url`, `#kiosk-stage` with mandatory `class="stage"`, `#kiosk-hud`, `#kiosk-tile-template`, shared `net`/`notice`/`toast` only, no header, `<title>Wall · mirror</title>` (the check-kiosk.sh contract)
- Placeholder `static/kiosk.js` (serves, fills the share URL, shows refusal notice; wall/layout are task 04's)
- `app.css`: `.kiosk .grid` full-bleed, `.kiosk .net` fixed top-right (z 60, above stage), `.hud` fixed bottom-left (mono, dim, pointer-events none)
- `index.html`: third card "GET /kiosk — An unattended wall"
- `test_app.py` guards extended to the third page (render, no-store×5, asset, `node --check`, element-reaches-for, stylesheet+module — renamed, video muted/playsinline, id-collision incl. `kiosk` with `#kiosk-*` prefix, new stage-class + structural-element guard, address-gate refused + admitted 200); `test_live_mirror.py` refused-address list + `#kiosk-grid`/`#kiosk-stage` selectors
**Gates** — `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` **334 passed**, 5 skipped (realdisplay marker) ✅ · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` 4 passed, 1 skipped (host has no audio source — the test's own environmental skip, pre-existing) ✅ · curl: `GET /kiosk` 200 + `no-store`; browser check: empty-state wall, share URL filled, no header, net pill fixed top-right, zero console errors
**Docs** — README: `/kiosk` in the page table, quickstart, and the shared-state paragraph (replaced "arrives in a later release"); AGENTS.md: "Three pages" → "Four pages" (stale count)
**Decisions** — placeholder also imports `showNotice` (the only display path in `net.js`; "show a refusal notice" is impossible with only the two named imports); `kiosk ∩ index` id assertion is `<= shared` (index.html has no `notice`/`toast` by structure — same shape as the existing index assertion); id-guard test kept its original name (AGENTS.md and `wall.js` reference it)
**Next task** — `08_kiosk_page/04_kiosk_layout_applier.md`
@@ -0,0 +1,36 @@
........................................................................ [ 21%]
................................sssss................................... [ 42%]
........................................................................ [ 63%]
........................................................................ [ 84%]
................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 194 11 94% 108, 113, 116-117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 706 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
334 passed, 5 skipped, 1 warning in 98.11s (0:01:38)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,11 @@
All done. Final report:
**Phase 08, Task 04 — `kiosk.js`: obey the layout, publish the keystrokes — complete**
- Verified the prior partial run's `kiosk.js` against the task spec (module shape, `hello{kiosk:true}`, idempotent `applyLayout()`, remembered-intent retry, best-effort never-awaited fullscreen with the required comment, body `data-layout`/`data-fullscreen` + title mirroring, per-command `arrange` publishing, `m` local-only with the SECURITY.md §4 comment, no `fullscreenchange` policy) — spec-compliant; fixed one broken indentation block in the `onMessage` switch
- Added the missing `test_app.py` source-level guard: `test_kiosk_never_publishes_an_audio_field` — brace-matches every `type:"arrange"` object literal in `kiosk.js` (member-order independent), asserts none carries `muted:`/`audio:`, and asserts at least one `arrange` frame exists; negative controls verified to be caught
- Verified every completion criterion with a throwaway real-browser/real-server script (deleted after): arrive-after-arrange shows the staged mirror with no wall flash (grid never paints a tile) and moving pixels on the stage; raw-socket (websocat) `arrange` re-arranges an open tab; `Enter`/`g`/`n`/`p`/`1`/`f` change `GET /api/kiosk`; `j`/`k` publish nothing; `m` flips `video.muted` with `/api/kiosk` byte-identical; a second kiosk display follows the first and teardown returns both to the wall; `followers` counts both kiosks only; zero page errors
- Gates: `uv run ruff check . && uv run ruff format --check .` → all checks passed, 32 files formatted; `uv run pyright` → 0 errors; `uv run pytest` → 335 passed, 5 skipped (realdisplay-gated), 96% coverage; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host audio-source limitation)
- README.md: one sentence added — kiosk keypresses publish the same `arrange` intent (controller/display converge) and `m` is local-only; no AGENTS.md changes needed for this task (the "four pages" edit was task 03's and is correct)
- Deviation note: task 04's kiosk.js was already written by the aborted prior run; my work was full spec verification, the indentation fix, and the missing guard — no behavior code was needed
- Next pending task: `.agents/phases/todo/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md` (the permanent `test_kiosk_layout.py` suite + docs)
@@ -0,0 +1,36 @@
........................................................................ [ 21%]
................................sssss................................... [ 42%]
........................................................................ [ 63%]
........................................................................ [ 84%]
.................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 194 10 95% 108, 113, 117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 706 31 96%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
335 passed, 5 skipped, 1 warning in 92.04s (0:01:32)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,21 @@
Everything is in place and verified. All gates were run after the final code/doc state (the last edit was a cosmetic link-label fix in the docs).
## Report
**Implemented**
- `tests/e2e/test_kiosk_layout.py`: 5 behavioural tests + meta-guard — arrival-is-arrangement (driver `arrange` over a raw `page.evaluate` WebSocket *before* `/kiosk` opens; asserts `body[data-layout="stage"]`, `data-fullscreen="true"`, staged tile, decoded moving frames on `#kiosk-stage video`), one keypress on one display re-arranging a second pointer-free display (asserted on the second's decoded pixels + `/api/kiosk`), `n`/`p` stepping with wrap, teardown returning both displays to `data-layout="wall"` with no dead tiles, and `followers` counting displays only over a real socket lifecycle. Established `device()` shape (isolated contexts, finalizers, `#net` online wait, pageerror+console from the first line); the self-guard (same style as `test_keyboard_wall.py`, literals built from parts) bans pointer calls and explicitly allows the wire-level driver; the `document.fullscreenElement` non-assertion is commented as required.
- `docs/kiosk-silverblue.md`: new "Two endpoints: `/watch` and `/kiosk`" section (operator vs. display, keys still work on `/kiosk`, controller arrives in phase 09 `/remote`, honest `MIRROR_KIOSK_URL` migration rule with re-point + `gdm` restart; no changes to `install-kiosk.sh`/`kiosk.conf`/`gnome-kiosk-script`/`check-kiosk.sh`).
- `README.md`: one sentence in the Keys section (wall keys work on `/kiosk`; a change made there is what every other display follows). The `/kiosk` page-table row already existed from task 03.
**Gates (all green)**
- `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors
- `uv run pytest tests/e2e/test_kiosk_layout.py -x` — 6/6 passed
- `uv run pytest -m e2e` — 110 passed, 5 skipped (realdisplay-only)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented host-has-no-audio-source)
- `uv run pytest` — 341 passed, 5 skipped, 0 failed; coverage 95% (unchanged; task adds no product code)
**Docs** — `docs/kiosk-silverblue.md` + `README.md` updated as above; `AGENTS.md` no stale facts → untouched.
**Decisions** — driver closes its socket after the server's `layout` ack (state is process-level, so no `window` global / no leaked peer); mid-test context close in the followers test is guarded so the finalizer can't double-close.
**Next pending task** — none in `todo/` (phase 08 is complete; phase 09 `/remote` is not yet authored).
@@ -0,0 +1,36 @@
........................................................................ [ 20%]
......................................sssss............................. [ 41%]
........................................................................ [ 62%]
........................................................................ [ 83%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 194 11 94% 108, 113, 116-117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 706 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
341 passed, 5 skipped, 1 warning in 96.45s (0:01:36)
All checks passed!
33 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,11 @@
**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**
**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.
**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.
**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.
**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.
**Next pending phase** — none; `todo/` held only phase 09.
@@ -0,0 +1,37 @@
........................................................................ [ 19%]
..............................................sssss..................... [ 39%]
........................................................................ [ 59%]
........................................................................ [ 78%]
........................................................................ [ 98%]
..... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 197 10 95% 108, 113, 117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 709 31 96%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
360 passed, 5 skipped, 1 warning in 102.18s (0:01:42)
All checks passed!
34 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,9 @@
All gates green. Task complete.
**Report**
- **Implemented:** `GET /remote` route in `src/mirror/app.py` (+ `PAGES` no-store); `static/remote.html` (bar + net pill, readout `#remote-state` with stable hooks, `#remote-back`/`#remote-fullscreen` controls, `#remote-list` + row template, empty state with share URL); `static/remote.js` stub (config, refusal notice, socket hello as plain `role:"viewer"` with no `kiosk` flag, net pill to online); `app.css` `.remote` styles (mono readout strip, wrapping control row, 320 px-safe rows, `#remote-fullscreen[aria-pressed="true"]` accent = tile audio button treatment); entry links in `watch.html` (`#remote-link`) and `index.html` (4th card); `/kiosk` untouched; `test_app.py` guards extended to the fourth page (render/no-store/asset/node-check/element-exists/stylesheet-module/id-collision over 5 pages with `{"net","notice","toast"}` as the only overlap, address gate, plus new `test_a_controller_is_media_free` and `test_the_remote_page_declares_its_control_hooks`).
- **Results:** `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — 349 passed, 5 skipped (realdisplay opt-in), 100% green; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 documented platform skip; `uv run pytest tests/test_app.py` — 104 passed; coverage 95% overall, new route fully covered. Live checks: `curl -s/-I localhost:8055/remote` serves the page with `cache-control: no-store`; browser at 320 px: net pill online, empty state + share URL, zero console errors, both buttons reachable, no horizontal scroll.
- **Docs:** README — `/remote` row in the page table + quickstart URL line (SECURITY.md/full controller docs left to task 03 per the phase plan). AGENTS.md — one stale fact only: "Four pages" → "Five pages".
- **Deviation:** the task text names both the readout marker span and the Full screen button `remote-fullscreen` (a duplicate id that would fail the page's own uniqueness guard). The button keeps `#remote-fullscreen` (the `aria-pressed` CSS rule and the task's guard target it); the readout marker is `#remote-fullscreen-mark`, noted in the markup and asserted by the new hook guard.
- **Next pending task:** `.agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md`
@@ -0,0 +1,36 @@
........................................................................ [ 20%]
......................................sssss............................. [ 40%]
........................................................................ [ 61%]
........................................................................ [ 81%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 197 11 94% 108, 113, 116-117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 709 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
349 passed, 5 skipped, 1 warning in 95.96s (0:01:35)
All checks passed!
33 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,14 @@
All green. Final report:
**Implemented (task 02 — `remote.js` controller logic):**
- `src/mirror/static/remote.js`: `mirrors` Map + `layout` state; roster frames (`welcome` seeds, added/updated upsert, removed deletes, `signal` ignored); `renderMirrors()` (template rows, `data-on`/`aria-current`/`On the wall`, empty state); `renderState()` (staged title/`(gone)`/`no mirror`, fullscreen on/off on marker + button `aria-pressed`/label, followers plural handling); one-line hint (`no display following` / `offline`); `arrange()` writers with event delegation on `#remote-list`; one-time "…ended — back to the wall" toast; stale readout on socket close, re-sync on `welcome`.
- `remote.html`: added `#remote-hint`; `app.css`: `.remote-hint` + dimmed `[data-stale]` readout.
- `tests/test_app.py`: media-free guard extended (`MediaStream`, `navigator.mediaDevices`), new `test_a_controller_sends_exactly_one_frame_type_besides_hello`, hook list gains `remote-hint`, 2 protocol tests (controller drives layout, not counted in `followers`; `arrange` changes no subscriptions/audience).
**Gates (all 4, exact commands):** ruff check+format clean · pyright 0 errors · `uv run pytest` **352 passed, 5 skipped** (documented realdisplay skips) · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` **4 passed, 1 documented skip** · coverage 95%, byte-identical to verified baseline (no `src/` Python touched).
**Docs:** no impact — README already describes `/remote` accurately (task 01); deep docs are task 03's scope; no stale AGENTS.md statements.
**Notable:** spec's "three spans" named `#remote-fullscreen`, but task 01's built HTML (locked contract, asserted in tests) uses `#remote-fullscreen-mark` for the readout span and `#remote-fullscreen` for the button — followed the built HTML (marker carries on/off, button carries label/aria). Pre-existing, verified in a reconstructed baseline: the non-gate combined run `MIRROR_REALDISPLAY=1 uv run pytest` errors 5 x11 fixture setups (Playwright sync-API double context); both official invocations are green, left untouched.
**Next pending task:** `03_remote_e2e_docs_and_kiosk_url.md`.
@@ -0,0 +1,36 @@
........................................................................ [ 20%]
......................................sssss............................. [ 40%]
........................................................................ [ 60%]
........................................................................ [ 80%]
..................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 197 11 94% 108, 113, 116-117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 709 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
352 passed, 5 skipped, 1 warning in 96.76s (0:01:36)
All checks passed!
33 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,12 @@
All changes verified. The AGENTS.md diff ("Five pages") was from task 01 — I left AGENTS.md untouched (no stale statement in it). Everything is complete and green.
**Report**
- **New `tests/e2e/test_remote_control.py`** (8 tests, `e2e` + `timeout(180)`, same `device()`/`open_device` setup as the kiosk suite): the click loop (`.show` → display `data-layout="stage"` + staged tile + `/api/kiosk`, `#remote-back` → wall), pixels-not-classes (`frames_matching` width>0, distinct≥2 on the display), fullscreen (server state + `body[data-fullscreen]`, `document.fullscreenElement` never asserted, comment says why), two-controller convergence, controller-costs-nothing (stream `viewers: 0` / `stats.watching: 0` with only `/remote`, moves when `/watch` joins), followers agreement (0/1/1 — a controller is not a display), kiosk `n` keypress moving the remote readout, teardown (display unstages, row/readout clear), `errors == []` in every case.
- **Fix found while testing**: the kiosk suite leaves `fullscreen: true` on the shared session server (teardown resets `staged` only), which broke the toggle-based fullscreen test; the test now normalizes the shared state through the product at start (documented in the test).
- **SECURITY.md**: §2 gains the "rearrange anybody's wall display" bullet (names `/remote`, same trust decision as watching, bounded by `MIRROR_ALLOW_CIDRS`) and the not-possible audio bullet; §3 gains the "No remote audio" row naming the real enforcing tests.
- **README**: new "Driving an unattended wall" section (keys on the box visible to controllers, fullscreen requested-not-promised, in-memory state); Kiosk-client paragraph updated for the new default. Page table/Quickstart already had both endpoints.
- **Silverblue tree → `/kiosk` in one change**: `install-kiosk.sh` default + comment, `kiosk.conf` URL + comment, `gnome-kiosk-script` fallback, `check-kiosk.sh` (accepts both walls, warning kept for the third case), `kiosk-silverblue.md` (top description, `KIOSK_URL` example, controller/migration/title paragraphs de-staled). `sh -n` clean on all scripts; checker logic verified via harness: `/kiosk` and `/watch` → ok, other → warn.
- **Gates**: `uv run pytest tests/e2e/test_remote_control.py` → 8 passed; `uv run pytest -m e2e` → 118 passed; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed (1 documented platform skip); full `uv run pytest` → **360 passed, 5 skipped, 0 failed**; `ruff check`/`ruff format --check`/`pyright` all clean.
- **Note**: one one-off `test_kiosk_layout.py` tile-id flake in a single full run (not reproduced in two subsequent full runs; my suite runs after it in file order, so it cannot be the cause). No doc impact on AGENTS.md.
- **Next pending task**: none — task 03 is the last task of phase 09.
@@ -0,0 +1,37 @@
........................................................................ [ 19%]
..............................................sssss..................... [ 39%]
........................................................................ [ 59%]
........................................................................ [ 78%]
........................................................................ [ 98%]
..... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 197 10 95% 108, 113, 117, 182-183, 212-216
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 66 0 100%
src/mirror/registry.py 228 3 99% 208, 226, 460
------------------------------------------------------
TOTAL 709 31 96%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
360 passed, 5 skipped, 1 warning in 102.09s (0:01:42)
All checks passed!
34 files already formatted
0 errors, 0 warnings, 0 informations
+3 -3
View File
@@ -40,8 +40,8 @@ is a roster and a signaling relay. Linux is the reference platform.
| Rule | Why it exists |
|---|---|
| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestClientScripts::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
| **No framework, no bundler** in `src/mirror/static/` | Three pages and one WebSocket. `node --check` is the entire frontend toolchain |
| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestPages::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
| **No framework, no bundler** in `src/mirror/static/` | Five pages and one WebSocket. `node --check` is the entire frontend toolchain |
| **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 |
| **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage |
| **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test |
@@ -57,7 +57,7 @@ is a roster and a signaling relay. Linux is the reference platform.
```bash
uv run ruff check . && uv run ruff format --check .
uv run pyright # must be 0 errors
uv run pytest # unit + API + browser E2E (~50s)
uv run pytest # unit + API + browser E2E (~95s)
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture
```
+43 -2
View File
@@ -9,6 +9,8 @@ your network:
| ------------ | ------------------ | ----------------------------------------------------------------------- |
| **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
| **Kiosk** | `/kiosk` | A chrome-free wall for an unattended display: no header, no counts — the net pill is the only chrome, and the shared layout state (below) arranges it. |
| **Remote** | `/remote` | A controller for the shared wall, from a laptop or a phone: lists the live mirrors and the wall's state and sends the decisions (show this, back to the wall, full screen) — no video, no bandwidth. |
| **Index** | `/` | Signpost + live count of mirrors. |
Any number of people can mirror at the same time and any number of people can
@@ -30,6 +32,8 @@ Then, from another machine on the LAN:
```
http://<this-machine>:8055/mirror # to share
http://<this-machine>:8055/watch # to watch
http://<this-machine>:8055/kiosk # the unattended wall (kiosk display)
http://<this-machine>:8055/remote # drive the wall from this device
```
Linux is the primary target. On Linux the native picker is X11/Wayland's own
@@ -59,6 +63,37 @@ roster deltas to viewers.
└────── WebRTC (SRTP, LAN, p2p) ─────────┴─────────────────┘
```
**The wall has one shared state.** The server keeps which mirror is on stage, whether
it is fullscreen, and how many displays are obeying that (`followers` — peers whose
`hello` said `"kiosk": true`). Any connected peer may change it with an `arrange`
frame on `/ws` (`{"type":"arrange","staged":"<id>","fullscreen":true}`); an absent
field is "no opinion", `"staged": null` is "back to the wall". A change is pushed
immediately as a `layout` frame to every viewer (and back to the sender), and the
same object is readable at `GET /api/kiosk` — `curl`-debuggable like `/api/streams`,
`Cache-Control: no-store`. The state has no audio: sound always traces back to a
gesture on the device that plays it (see [SECURITY.md](SECURITY.md) §4). The
`/kiosk` page is the display that follows this state: a chrome-free wall that
subscribes to every mirror like `/watch` and does what the state says —
`Cache-Control: no-store` on the read and on the page itself. If a keyboard is
plugged in, the same keys as `/watch` work, and every keypress publishes the
same `arrange` intent, so a controller and a display converge on one state
(`m` is local-only: a display never becomes audible because another machine
asked).
### Driving an unattended wall
The shared state exists for a box with no keyboard: point the box at `/kiosk`,
open `/remote` on a phone, and press **Show on wall** — the box stages that
mirror, and **Back to the wall** and **Full screen** do the same for their
decisions. When a keyboard is plugged into the box, the wall keys still work
there, and a keypress performs the change *and* publishes the same `arrange`
intent, so a key on the box is seen by every controller — a controller and a
display converge on one state. Fullscreen is **requested, not promised**: a
remote command carries no user activation, so the browser may refuse the
request; the in-page stage fills the viewport anyway (and the kiosk box's
window manager forces the window fullscreen). The state lives in the server's
memory only: a restart returns the wall to a wall.
## Configuration
Environment variables (all optional; a `.env` file is loaded if present):
@@ -160,8 +195,10 @@ from this side:
A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk
session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at
`/watch`. It is set up and repaired entirely over SSH — no keyboard, no console,
no monitor. A brand-new box layers the kiosk packages once first
the wall. It is set up and repaired entirely over SSH — no keyboard, no console,
no monitor. The display page is `/kiosk`, and it is the installer default
too; an existing `kiosk.conf` URL wins over the default, so moving a box is
the one-line re-point in [the runbook](docs/kiosk-silverblue.md#two-endpoints-watch-and-kiosk). A brand-new box layers the kiosk packages once first
(`rpm-ostree install gnome-kiosk gnome-kiosk-script-session`, then a reboot);
after that these two commands are both the setup and the repair path:
@@ -200,6 +237,10 @@ in. On `/watch`:
| `?` | Show / hide this list on screen. |
| `Esc` | Close the key list, then leave the stage. |
The same wall keys work on `/kiosk` too — the unattended display — and a
change made there (a keypress stages the mirror *and* publishes the same
`arrange` intent) is what every other display follows.
A staged mirror's caption bar and control cluster are **hidden at rest**, so
the shared video — a terminal's bottom line included — is never covered by
them. A pointer move over the stage (a tap, for touch) or any key press
+11
View File
@@ -31,6 +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).
* **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)
@@ -41,6 +51,7 @@ limiting, no accounts, and no audit trail.
| **No identity spoofing of the roster.** A viewer cannot publish a mirror; a peer cannot claim another's stream | role checks (`not_sharer`, `not_viewer`), stream ids are server-assigned | `test_one_peer_cannot_impersonate_another`, `test_a_viewer_cannot_publish` |
| **No oversized signaling.** Frames above 64 KiB are dropped before parsing | `MAX_MESSAGE_BYTES` | `test_an_oversized_frame_is_refused` |
| **No protocol fuzzing into a crash.** Bad JSON, non-object frames, unknown types and unknown roles produce an error frame; only fatal ones close the socket | `Hub.serve`/`_dispatch` | `TestSignalingSocket` |
| **No remote audio, no remote mute override.** The layout carries no sound field, and every audible state traces to a gesture on the device that plays it | the layout model has no audio field, plus the `kiosk.js`/`remote.js` source guards | `test_kiosk_never_publishes_an_audio_field`, `test_a_controller_is_media_free`, `test_m_flips_the_staged_videos_muted_flag`, `test_only_the_staged_mirror_can_be_unmuted` |
| **No leaked peer connections.** Every teardown path notifies every viewer | `Registry._drop_stream`, `leave()` | `test_sharer_disconnect_removes_the_mirror`, `test_unpublish_clears_the_tile_for_every_viewer` |
| **Nothing leaves the network.** No CDN, no remote font, no analytics, no STUN by default | A10/A4 + a build-failing test | `test_nothing_is_loaded_from_a_cdn`, `test_config_does_not_leak_server_secrets` |
| **The server cannot see your screen.** Media is SRTP between peers; the server relays JSON only | A3 | Architectural: no media code exists in Python |
+56 -3
View File
@@ -1,7 +1,7 @@
# Kiosk client — Fedora Silverblue
The target client for a wall display is **Fedora Silverblue running GNOME Kiosk,
auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/watch`
auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/kiosk`
fullscreen in Firefox.** One box takes about five minutes over SSH and needs no
keyboard, no monitor access, and no console.
@@ -56,7 +56,7 @@ be skipped when the packages are already layered. To point a client somewhere
other than the production wall, pass the URL:
```bash
sudo KIOSK_URL=https://mirror.reeseapps.com/watch sh install-kiosk.sh
sudo KIOSK_URL=https://mirror.reeseapps.com/kiosk sh install-kiosk.sh
```
Only `kiosk.conf` differs between clients; every other file stays byte-identical
@@ -64,6 +64,59 @@ so the fleet is auditable.
---
## Two endpoints: `/watch` and `/kiosk`
The wall has two client pages, and they are not the same page twice:
* **`/watch` is a wall a person operates.** The operator's page: the grid,
the keys, and the chrome a person standing in front of it needs — header,
share link, counts.
* **`/kiosk` is a display that obeys a wall.** No chrome at all: no header,
no counts, no share button — the net pill is the only thing on screen that
is not a mirror. It subscribes to every mirror like `/watch` does and then
does what the shared layout state says — on arrival *and* on every change —
with no input from anyone standing at the box. Point the box at it and it
arranges itself: whichever mirror is on stage, fullscreen or not, is
applied before the first tile finishes painting, so a person who points a
kiosk at `/kiosk` never has to touch it. When a staged mirror goes away,
every display falls back to the wall in the same frame set; no display sits
on a dead tile.
The state is the boss, locally too: **the wall keys still work on `/kiosk`**
for the moment a keyboard is plugged in (see [the key table](#operating-the-wall-keys)),
and a keypress there performs the change *and* publishes the same `arrange`
intent, so a controller and a display converge on one state instead of
disagreeing (`m` is local-only — a display never becomes audible because
another machine asked). The **controller side** is `/remote`: a page a person
on another machine (a phone) opens to list the live mirrors, read the wall's
state, and send the decisions — show this, back to the wall, full screen. Any
client that can send an `arrange` frame on `/ws` is a controller too,
`websocat` included. `GET /api/kiosk` is the state's read surface: what is on
stage, whether it is fullscreen, and how many displays (`followers`) are
obeying it.
**The migration rule, stated honestly.** Existing clients keep their own
`MIRROR_KIOSK_URL` until an operator re-points it and restarts `gdm`, because
the installer deliberately preserves a client's URL: an existing `kiosk.conf`
URL wins over the built-in default (which now points at `/kiosk`). So
**nothing changes under a box until its `kiosk.conf` is re-pointed and `gdm`
restarted**:
```bash
sudo sed -i 's|^MIRROR_KIOSK_URL=.*|MIRROR_KIOSK_URL="https://…/kiosk"|' \
/var/home/kiosk/.config/mirror/kiosk.conf
sudo systemctl restart gdm
```
(`pkill -u kiosk firefox` is the shorter path — the loop re-sources
`kiosk.conf` on every relaunch, see [the section below](#the-two-facts-that-make-this-work) —
`gdm` is the clean restart when you want the whole session back.) Both walls
are served by the same tree; `check-kiosk.sh` accepts both titles
(`<title>Watch · mirror</title>` and `<title>Wall · mirror</title>`) and
warns for anything else.
---
## The two facts that make this work
**Silverblue's mutable seams.** `/usr` is read-only and belongs to the OSTree
@@ -297,7 +350,7 @@ The console is not an interface. Every claim below is checkable over SSH.
| The page loaded | the **server's** `GET /api/streams` shows the kiosk in `viewers` and its socket in `ss -tn` |
| Media really flows | the sharer's own page renders each viewer's `RTCPeerConnection.connectionState`; `connected` = ICE+DTLS+SRTP up |
| It is truly fullscreen | `dim-probe.py`: `viewport == window == screen == available` |
| The URL is serving *mirror* | `check-kiosk.sh` greps the page for `<title>Watch · mirror` |
| The URL is serving *mirror* | `check-kiosk.sh` greps the page for the wall title — `<title>Watch · mirror` or `<title>Wall · mirror` |
| The kiosk holds the screen | `/sys/class/tty/tty0/active` equals the kiosk session's TTY and the session is `Active` — the one check that catches a covered wall |
| No greeter is fighting it | `journalctl -b \| grep -c "New session.*gdm-greeter"` is `0`; any non-zero number is a login screen on your wall |
| It survives a power cut | `sudo systemctl reboot`, then re-run `check-kiosk.sh` |
+5 -3
View File
@@ -64,9 +64,9 @@ if [ -n "$HOME_DIR" ]; then
"$HOME_DIR/.config/mirror/kiosk.conf" 2>/dev/null | head -1)
ok "kiosk URL: ${URL:-<empty>}"
case "$URL" in
*/watch) : ;;
*/watch|*/kiosk) : ;;
"") bad "kiosk URL is empty" ;;
*) warn "kiosk URL is not the /watch wall: $URL" ;;
*) warn "kiosk URL is neither wall (/watch or /kiosk): $URL — check DNS and the nginx vhost" ;;
esac
else
bad "kiosk config $HOME_DIR/.config/mirror/kiosk.conf is missing"
@@ -213,8 +213,10 @@ if [ -n "$URL" ]; then
CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 "$URL" 2>/dev/null)
if printf '%s' "$BODY" | grep -q "Watch · mirror"; then
ok "$URL serves the mirror watch page (HTTP $CODE)"
elif printf '%s' "$BODY" | grep -q "Wall · mirror"; then
ok "$URL serves the mirror kiosk page (HTTP $CODE)"
else
warn "$URL answered HTTP $CODE but it is NOT the mirror watch page — the kiosk"
warn "$URL answered HTTP $CODE but it is NOT a mirror wall — the kiosk"
warn "will happily display whatever this is. Check DNS and the nginx vhost."
info "title: $(printf '%s' "$BODY" | sed -n 's/.*<title>\(.*\)<\/title>.*/\1/p' | head -1)"
fi
+1 -1
View File
@@ -25,7 +25,7 @@ if [ -r "$CONF" ]; then
. "$CONF"
fi
URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/watch}"
URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/kiosk}"
PROFILE="${MIRROR_KIOSK_PROFILE:-$HOME/.mirror-kiosk}"
LOG="${MIRROR_KIOSK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/mirror-kiosk.log}"
POLL="${MIRROR_KIOSK_POLL:-5}"
+4 -1
View File
@@ -17,7 +17,10 @@ set -eu
KIOSK_USER="${KIOSK_USER:-kiosk}"
# The default is for a NEW client only. An existing client keeps its own URL
# unless the operator names one — see "the URL is the one per-client value" below.
KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/watch"
# The default is the DISPLAY endpoint: /kiosk obeys the shared wall state, so
# a device with a keyboard (the /remote page) can arrange a box that has none.
# /watch is the operator's page — a wall a person stands at.
KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/kiosk"
KIOSK_URL="${KIOSK_URL:-}"
KIOSK_SESSION="gnome-kiosk-script-wayland"
HERE=$(cd "$(dirname "$0")" && pwd)
+6 -3
View File
@@ -7,9 +7,12 @@
# After editing: `sudo systemctl restart gdm` (or log the kiosk user out) to
# re-read it. The browser is restarted by the kiosk script's own loop.
# The wall of every live mirror. `/` is the Share/Watch chooser and needs a
# tap to get anywhere, which is wrong for a display nobody is standing at.
MIRROR_KIOSK_URL="https://mirror.reeseapps.com/watch"
# The controllable wall. `/kiosk` is the display endpoint: it obeys the shared
# layout state, so a device with a keyboard (the /remote page, or any
# `arrange` frame on /ws) decides what is on stage and whether it is
# fullscreen — a box that has no keyboard can still be arranged. `/watch` is
# the operator's page: a wall a person stands at.
MIRROR_KIOSK_URL="https://mirror.reeseapps.com/kiosk"
# Optional overrides (uncomment to change):
+37 -5
View File
@@ -1,7 +1,7 @@
"""The HTTP + signaling server.
Two pages and one WebSocket. Media flows browser-to-browser; the server only
shuffles JSON describing who is mirroring what.
A handful of pages and one WebSocket. Media flows browser-to-browser; the
server only shuffles JSON describing who is mirroring what.
"""
from __future__ import annotations
@@ -21,7 +21,7 @@ from starlette.websockets import WebSocketDisconnected
from . import __version__, protocol
from .access import POLICY_VIOLATION, Gate, format_cidrs
from .config import Settings
from .registry import Envelope, Registry, RegistryError
from .registry import UNSET, Envelope, Registry, RegistryError
STATIC_DIR = Path(__file__).parent / "static"
@@ -197,7 +197,7 @@ class Hub:
try:
hello = protocol.parse_client_message(decoded)
peer_id, outbox = self.registry.join(
role=hello.role, name=hello.name, device=hello.device
role=hello.role, name=hello.name, device=hello.device, kiosk=hello.kiosk
)
except ValidationError as exc:
code = "bad_hello"
@@ -245,6 +245,18 @@ class Hub:
outbox = self.registry.unsubscribe(peer_id, message.stream_id)
elif isinstance(message, protocol.Signal):
outbox = self.registry.signal(peer_id, message.to, message.payload)
elif isinstance(message, protocol.Arrange):
# `model_fields_set` is the one place the "absent ≠ null" rule
# survives the trip from JSON to policy: an *absent* field is
# UNSET ("no opinion, leave it"), an explicit null clears it.
# Do not simplify this to `message.staged` — the model defaults
# to None, so that would turn "absent" into "back to the wall".
fields = message.model_fields_set
outbox = self.registry.set_layout(
peer_id,
staged=message.staged if "staged" in fields else UNSET,
fullscreen=message.fullscreen if "fullscreen" in fields else UNSET,
)
elif isinstance(message, protocol.Ping):
outbox = [(peer_id, protocol.pong())]
elif isinstance(message, protocol.Hello):
@@ -298,6 +310,19 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"""The page that shows every live mirror and can stage one fullscreen."""
return _page("watch.html")
@app.get("/kiosk", response_class=HTMLResponse, include_in_schema=False)
async def kiosk_page() -> FileResponse:
"""A wall for an unattended display: subscribes like /watch and arranges
itself from the shared layout (phase 07/08)."""
return _page("kiosk.html")
@app.get("/remote", response_class=HTMLResponse, include_in_schema=False)
async def remote_page() -> FileResponse:
"""The controller: lists mirrors and drives the shared layout. Shows no
media and subscribes to nothing — a controller must not cost a sharer
bandwidth (PLAN.md §6)."""
return _page("remote.html")
@app.get("/api/config")
async def client_config() -> JSONResponse:
return JSONResponse(
@@ -312,6 +337,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
headers={"Cache-Control": "no-store"},
)
@app.get("/api/kiosk")
async def kiosk_layout() -> JSONResponse:
"""Server truth about the wall display: what is on stage, fullscreen, and
how many displays are obeying it. Read-only by design — the only writer is
the `arrange` frame on /ws, so there is one path that can change the wall."""
return JSONResponse(registry.layout_dto(), headers={"Cache-Control": "no-store"})
@app.get("/healthz")
async def healthz() -> JSONResponse:
return JSONResponse({"status": "ok", "version": __version__, **registry.stats()})
@@ -331,7 +363,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
return
await hub.serve(websocket)
PAGES = ("/", "/mirror", "/share", "/watch")
PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk", "/remote")
@app.middleware("http")
async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
+46 -3
View File
@@ -44,6 +44,8 @@ class Hello(_Inbound):
role: Role = "viewer"
name: str = ""
device: str = ""
#: true when this peer is a wall display that obeys the shared layout.
kiosk: bool = False
class Publish(_Inbound):
@@ -73,6 +75,18 @@ class Unsubscribe(_Inbound):
stream_id: str
class Arrange(_Inbound):
"""Ask the wall to show a given mirror. Absent fields are left alone;
``staged: null`` explicitly returns the displays to the wall. The caller
does not have to be the display's operator — see ``SECURITY.md`` §2.
"""
type: Literal["arrange"]
staged: str | None = None
fullscreen: bool | None = None
class Signal(_Inbound):
"""An opaque WebRTC blob (SDP or ICE candidate) addressed to one peer."""
@@ -86,7 +100,7 @@ class Ping(_Inbound):
ClientMessage = Annotated[
Hello | Publish | Unpublish | Subscribe | Unsubscribe | Signal | Ping,
Hello | Publish | Unpublish | Subscribe | Unsubscribe | Arrange | Signal | Ping,
Field(discriminator="type"),
]
@@ -131,8 +145,37 @@ def stream_dto(
}
def welcome(*, peer_id: str, role: Role, streams: list[dict[str, Any]]) -> dict[str, Any]:
return {"type": "welcome", "peerId": peer_id, "role": role, "streams": streams}
def welcome(
*,
peer_id: str,
role: Role,
streams: list[dict[str, Any]],
layout: dict[str, Any],
) -> dict[str, Any]:
"""The first server frame of a connection; the page is already arranged."""
return {
"type": "welcome",
"peerId": peer_id,
"role": role,
"streams": streams,
"layout": layout,
}
def layout(*, staged: str | None, fullscreen: bool, followers: int) -> dict[str, Any]:
"""The wall's presentation state, pushed to every viewer as it changes.
Key spelling and order are the phase-07 contract (``00_phase.md``) — the
outbound
builders are hand-written dicts, so there is no alias generator to catch a
typo here; the round-trip test in ``tests/test_protocol.py`` is what does.
"""
return {
"type": "layout",
"staged": staged,
"fullscreen": fullscreen,
"followers": followers,
}
def stream_added(*, stream: dict[str, Any]) -> dict[str, Any]:
+109 -4
View File
@@ -12,6 +12,8 @@ Invariants enforced here:
* Every teardown path (explicit, disconnect, replaced stream) emits the same
``peer_left`` / ``stream_removed`` notifications, so browsers never leak a
``RTCPeerConnection``.
* The layout names either nothing or a live mirror; a mirror's removal clears
it in the same envelope set, so no display is left pointing at nothing.
"""
from __future__ import annotations
@@ -25,6 +27,7 @@ from typing import Any
from .protocol import (
MAX_LABEL_LEN,
MAX_NAME_LEN,
layout,
peer_joined,
peer_left,
relayed_signal,
@@ -37,6 +40,13 @@ from .protocol import (
Envelope = tuple[str, dict[str, Any]]
class _Unset:
"""Marker: the caller expressed no opinion, keep the current value."""
UNSET = _Unset()
class RegistryError(Exception):
"""A policy violation. Carries a stable ``code`` for the client."""
@@ -53,9 +63,19 @@ class Peer:
role: str
name: str
device: str = ""
kiosk: bool = False
joined_at: float = field(default_factory=time.time)
@dataclass(slots=True)
class Layout:
"""What the wall displays. One value per server process (A7): there is
one wall, and every display obeying it is showing the same thing."""
staged: str | None = None
fullscreen: bool = False
@dataclass(slots=True)
class Stream:
"""One live capture, published by exactly one sharer peer."""
@@ -103,6 +123,7 @@ class Registry:
self._max_streams = max_streams
self.peers: dict[str, Peer] = {}
self.streams: dict[str, Stream] = {}
self.layout = Layout()
#: stream_id -> set of viewer peer ids that currently receive it
self._subscriptions_by_viewer: dict[str, set[str]] = {}
@@ -132,7 +153,14 @@ class Registry:
# ------------------------------------------------------------ operations
def join(self, *, role: str, name: str = "", device: str = "") -> tuple[str, list[Envelope]]:
def join(
self,
*,
role: str,
name: str = "",
device: str = "",
kiosk: bool = False,
) -> tuple[str, list[Envelope]]:
"""Register a freshly connected socket. Returns ``(peer_id, outbox)``."""
if role not in ("sharer", "viewer"):
raise RegistryError("bad_role", f"Unknown role {role!r}.", fatal=True)
@@ -142,12 +170,26 @@ class Registry:
role=role,
name=_clean(name, MAX_NAME_LEN) or ("This device" if role == "sharer" else "Viewer"),
device=_clean(device, MAX_NAME_LEN),
kiosk=kiosk,
)
self.peers[peer_id] = peer
self._subscriptions_by_viewer[peer_id] = set()
outbox: list[Envelope] = [
(peer_id, welcome(peer_id=peer_id, role=role, streams=self.roster())) # type: ignore[arg-type]
(
peer_id,
welcome(
peer_id=peer_id,
role=role, # type: ignore[arg-type]
streams=self.roster(),
layout=self.layout_dto(),
),
)
]
# A new follower changes what the layout frame says; the other viewers
# are told now, because nothing else would update them (the joiner got
# the state in its welcome).
if kiosk:
outbox.extend(self._layout_broadcast(exclude=peer_id))
return peer_id, outbox
def leave(self, peer_id: str) -> list[Envelope]:
@@ -167,6 +209,10 @@ class Registry:
stream.viewers.discard(peer_id)
outbox.append((stream.sharer_id, peer_left(stream_id=stream.id, viewer_id=peer_id)))
outbox.extend(self._broadcast_update(stream))
# A follower leaving changes what the layout frame says. Skip it when a
# dropped stream already pushed the (identical) frame in this outbox.
if peer.kiosk and not any(message["type"] == "layout" for _, message in outbox):
outbox.extend(self._layout_broadcast())
return outbox
def rename(self, peer_id: str, *, name: str = "", device: str = "") -> list[Envelope]:
@@ -309,6 +355,57 @@ class Registry:
"""The stream ids this viewer is currently receiving."""
return set(self._subscriptions_by_viewer.get(viewer_id, set()))
# -------------------------------------------------------------- layout
def layout_dto(self) -> dict[str, Any]:
"""The wall's state as the wire object, with the follower count
derived from the roster. The only place the DTO is built, so the
socket and any read endpoint can never disagree about it.
"""
return layout(
staged=self.layout.staged,
fullscreen=self.layout.fullscreen,
followers=sum(1 for p in self.peers.values() if p.kiosk),
)
def set_layout(
self,
peer_id: str,
*,
staged: Any = UNSET,
fullscreen: Any = UNSET,
) -> list[Envelope]:
"""Change the wall's presentation state on behalf of ``peer_id``.
``UNSET`` means the caller had no opinion (leave the value); an explicit
``None`` for ``staged`` clears it. A ``staged`` value that does not
name a live mirror is refused with ``unknown_stream`` and changes
nothing. An unchanged state replies with the ack to the sender only;
a real change is pushed to every viewer plus the sender.
"""
self._peer(peer_id)
if staged is not UNSET and staged is not None:
self._stream(staged)
changed = False
if staged is not UNSET:
changed = changed or staged != self.layout.staged
self.layout.staged = staged
if fullscreen is not UNSET:
changed = changed or fullscreen != self.layout.fullscreen
self.layout.fullscreen = fullscreen
frame = self.layout_dto()
if not changed:
return [(peer_id, frame)]
targets = {p.id for p in self._viewers()} | {peer_id}
return [(target, frame) for target in sorted(targets)]
def _layout_broadcast(self, *, exclude: str | None = None) -> list[Envelope]:
"""The current layout frame, pushed to every viewer (minus ``exclude``)."""
frame = self.layout_dto()
return [
(p.id, frame) for p in sorted(self._viewers(), key=lambda p: p.id) if p.id != exclude
]
# -------------------------------------------------------------- read-only
def roster(self) -> list[dict[str, Any]]:
@@ -337,13 +434,21 @@ class Registry:
Every viewer is told, not only the ones with a live subscription: a tile
is drawn as soon as ``stream_added`` lands, so a viewer that has not got
round to subscribing still has something on screen to clear.
round to subscribing still has something on screen to clear. A display
aimed at this mirror falls back to the wall in the *same* frame set —
nothing polls the layout, so the correction must ride along.
"""
self.streams.pop(stream.id, None)
for subs in self._subscriptions_by_viewer.values():
subs.discard(stream.id)
message = stream_removed(stream_id=stream.id)
return [(viewer.id, message) for viewer in sorted(self._viewers(), key=lambda p: p.id)]
outbox: list[Envelope] = [
(viewer.id, message) for viewer in sorted(self._viewers(), key=lambda p: p.id)
]
if self.layout.staged == stream.id:
self.layout.staged = None
outbox.extend(self._layout_broadcast())
return outbox
def _broadcast_add(self, stream: Stream) -> list[Envelope]:
name = self.peers[stream.sharer_id].name
+174 -29
View File
@@ -225,15 +225,19 @@ a { color: var(--accent); }
align-items: start;
}
/* The stage is ONE cell. Its children — the <video>, the idle placeholder,
the LIVE badge — share that cell, so the placeholder overlays the video the
way the markup intends instead of stacking into a row below it.
/* The preview stage is ONE cell. Its children — the <video>, the idle
placeholder, the LIVE badge — share that cell, so the placeholder overlays
the video the way the markup intends instead of stacking into a row below
it. It is `preview-stage`, not `stage`: the wall's stage (the container a
staged tile moves into on /watch and /kiosk) is the only thing that may
carry `class="stage"` in this stylesheet — sharing the class would let the
two stages inherit each other's rules (fixed overlay vs inline preview).
Invariant for the next reader: a child added to #preview-stage shares that
cell and must never push another child out of view. `overflow: hidden` is
there to clip the live video to the rounded corners; a box whose content no
longer fits a fixed-aspect stage is the size rule's job, not this one's
(the floor/ceiling below). */
.stage {
.preview-stage {
position: relative;
/* Definite (100%), not stretched-auto: with an aspect-ratio, a stretched
item whose height is clamped by max-height gets its *width* recomputed
@@ -271,22 +275,22 @@ a { color: var(--accent); }
overlap instead of stacking. DOM order paints the placeholder above the video
(no z-index); .livetag stays position: absolute, so grid-area is inert for
it. */
.stage > * { grid-area: 1 / 1; }
.preview-stage > * { grid-area: 1 / 1; }
.stage video { width: 100%; height: 100%; object-fit: contain; display: block; }
.preview-stage video { width: 100%; height: 100%; object-fit: contain; display: block; }
/* Escape hatch: the placeholder may never be taller than the stage; if its
content outgrows it, it scrolls. Paired with `safe center` above, content
that is too tall is always reachable, whichever way the geometry falls. */
.stage .placeholder {
.preview-stage .placeholder {
text-align: center;
padding: 24px;
color: var(--fg-dim);
max-height: 100%;
overflow: auto;
}
.stage .placeholder h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; }
.stage .placeholder p { margin: 0 0 18px; font-size: 14px; }
.preview-stage .placeholder h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; }
.preview-stage .placeholder p { margin: 0 0 18px; font-size: 14px; }
/* Narrow-width shape rules come *after* the base rules above: same
specificity, so source order decides, and a phone must not be left with
@@ -296,15 +300,15 @@ a { color: var(--accent); }
the box from looking like a letterbox. */
@media (max-width: 900px) {
.share { grid-template-columns: 1fr; }
.stage { aspect-ratio: 4 / 3; }
.preview-stage { aspect-ratio: 4 / 3; }
}
/* A phone (≤480px) gets a stage as tall as its content rather than any
letterbox, and the placeholder's padding buys back 16px. min-height still
does the real work in both cases. */
@media (max-width: 480px) {
.stage { aspect-ratio: auto; }
.stage .placeholder { padding: 16px; }
.preview-stage { aspect-ratio: auto; }
.preview-stage .placeholder { padding: 16px; }
}
.livetag {
@@ -468,13 +472,13 @@ ul.audience .empty { border: 1px dashed var(--line); color: var(--fg-dim); justi
/* The staged tile: the mark shows the live state — sound on, or muted with
audio available (dimmed, so a staged-but-quiet mirror is still readable). */
#watch-stage .tile[data-audio="on"]::after {
.stage .tile[data-audio="on"]::after {
content: "🔊";
font-size: 18px;
line-height: 1;
}
#watch-stage .tile[data-audio="off"]::after {
.stage .tile[data-audio="off"]::after {
content: "🔇";
font-size: 18px;
line-height: 1;
@@ -562,7 +566,9 @@ kbd {
/* Staging: one mirror fills the viewport; the grid is hidden behind it. */
body.staged .grid, body.staged .empty { display: none; }
#watch-stage {
/* The wall's stage (class-keyed so /watch and /kiosk share the rules; the
share page's preview is `preview-stage`, never `stage`). */
.stage {
position: fixed;
inset: 0;
z-index: 50;
@@ -570,11 +576,11 @@ body.staged .grid, body.staged .empty { display: none; }
display: none;
}
body.staged #watch-stage { display: block; }
body.staged .stage { display: block; }
/* The staged tile *is* the stage: the element itself moves here, so its
RTCPeerConnection and <video> never have to be torn down. */
#watch-stage .tile {
.stage .tile {
width: 100%;
height: 100%;
border: 0;
@@ -584,14 +590,14 @@ body.staged #watch-stage { display: block; }
flex-direction: column;
}
#watch-stage .tile video {
.stage .tile video {
flex: 1 1 auto;
min-height: 0;
height: auto;
aspect-ratio: auto;
}
#watch-stage .tile .cap {
.stage .tile .cap {
position: absolute;
left: 0;
right: 0;
@@ -607,17 +613,19 @@ body.staged #watch-stage { display: block; }
bottom line included — is never covered. `data-reveal` (pointer activity
or a wall keypress) is the only staged reveal: it shows the caption and
the control cluster briefly and fades them back on idle. The small
`::after` audio mark is a separate rule and is untouched. The id-scoped
selectors outrank the grid rules (`.tile[data-selected] .actions`,
`::after` audio mark is a separate rule and is untouched. The stage-scoped
selectors win over the grid rules (`.tile[data-selected] .actions`,
`.tile:focus-within .actions`, the `hover: none` media rule) which also
match the staged tile — it keeps `data-selected` and holds focus. */
#watch-stage .tile .actions { opacity: 0; }
match the staged tile: they tie on specificity, source order decides, and
these rules sit after the grid rules. The staged tile keeps `data-selected`
and holds focus. */
.stage .tile .actions { opacity: 0; }
/* A `pointer-events: none` container does not shield children whose default
is `auto`: scope it to the container *and its children* so the hidden
Exit/mute buttons are not live dead zones over the video. */
#watch-stage .tile .cap, #watch-stage .tile .cap *,
#watch-stage .tile .actions, #watch-stage .tile .actions * { pointer-events: none; }
.stage .tile .cap, .stage .tile .cap *,
.stage .tile .actions, .stage .tile .actions * { pointer-events: none; }
/* `data-reveal` (pointer activity on the stage, or a wall keypress while
staged) is the only staged reveal: it restores opacity and makes the
@@ -626,14 +634,151 @@ body.staged #watch-stage { display: block; }
shields an `auto` child (setting `opacity: 1` on the children themselves
is harmless). The transitions on the base rules give the fade in and
out; under `prefers-reduced-motion` the global transition-kill makes the
reveal instant, which is the correct degraded behaviour. The id-scoped
selectors outrank every grid rule that also matches the staged tile. */
#watch-stage .tile[data-reveal] .cap, #watch-stage .tile[data-reveal] .cap *,
#watch-stage .tile[data-reveal] .actions, #watch-stage .tile[data-reveal] .actions * {
reveal instant, which is the correct degraded behaviour. The stage-scoped
selectors win over every grid rule that also matches the staged tile
(tied on specificity, source order decides). */
.stage .tile[data-reveal] .cap, .stage .tile[data-reveal] .cap *,
.stage .tile[data-reveal] .actions, .stage .tile[data-reveal] .actions * {
opacity: 1;
pointer-events: auto;
}
/* --------------------------------------------------------------- kiosk */
/* /kiosk is a display, not a page: no header bar, no counts, no share
button. The grid is the whole viewport — there is no chrome above it to
clear — and it scrolls when the wall outgrows the room. */
.kiosk .grid {
height: 100%;
overflow-y: auto;
}
/* The shared net pill pinned where a screenshot must find it. The kiosk has
no header to hold it, so it fixes itself top-right: the same pill, the
same states (.net owns its look) plus a backdrop so it stays legible over
the wall. It sits above the stage (z-index 60 over 50) on purpose: "the
server is gone" must be readable even with a tile on stage. */
.kiosk .net {
position: fixed;
top: 14px;
right: 16px;
z-index: 60;
padding: 6px 12px;
background: rgba(11, 13, 16, 0.88);
border: 1px solid var(--line);
border-radius: 999px;
}
/* The wall's self-description for a room with no keyboard: one fixed line
bottom-left (kiosk.js owns it — "N mirrors · wall", or
"… · <label> · fullscreen"), never interactive. */
.hud {
position: fixed;
left: 16px;
bottom: 12px;
z-index: 40;
font-family: var(--mono);
font-size: 12px;
color: var(--fg-dim);
pointer-events: none;
}
/* --------------------------------------------------------------- remote */
/* /remote is a controller: a readout, the decision buttons and a list of
rows — no video, no tiles (PLAN.md §6). It is the phone page of the app,
so it must stay usable down to 320 px, where the share page's narrow-
viewport rules are the model: wrap, ellipsis, never run off the edge. */
/* The readout: one strip under the bar in the monospace face — what the
server says the wall is doing, verbatim. `aria-live` on the section
(markup) is what makes it speak to a screen reader; the CSS only styles. */
.wall-state {
margin: 14px 16px 0;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--bg-2);
font-family: var(--mono);
font-size: 13px;
color: var(--fg-dim);
}
.wall-state #remote-staged { color: var(--fg); }
/* The decision buttons: one row that wraps, so at 320 px they stack and
stay reachable instead of running off the edge. */
.remote-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 14px 16px 0;
}
/* The "on" state of the Full screen button: the same accent treatment the
tile's audio button gets when its aria-pressed is true, so "on" looks
the same everywhere in this app. */
#remote-fullscreen[aria-pressed="true"] {
border-color: var(--accent);
color: var(--accent);
}
/* The one-line hint under the controls: "no display following yet" (the
state is stored — a display pointed at /kiosk picks it up) and
"offline". The global `[hidden]` rule is what hides it; no rule of its
own may fight that. */
.remote-hint {
margin: 10px 16px 0;
font-family: var(--mono);
font-size: 12px;
color: var(--fg-dim);
}
/* A readout that is a snapshot, not a statement: the socket dropped, so the
last staged label is shown dimmed until the next welcome re-syncs it. */
.wall-state #remote-staged[data-stale] {
opacity: 0.55;
}
/* One row per mirror: title + sub + the decision. `flex-wrap` is the 320 px
escape hatch (the button drops under the title rather than clipping), and
`min-width: 0` on the grow is what lets the title ellipsis instead of
pushing the button out. */
.mirror-list {
list-style: none;
margin: 14px 16px 0;
padding: 0;
display: grid;
gap: 10px;
}
.mirror-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 12px;
}
.mirror-row .grow { flex: 1; min-width: 0; }
.mirror-row .title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mirror-row .sub {
color: var(--fg-dim);
font-family: var(--mono);
font-size: 11.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
+16
View File
@@ -43,6 +43,22 @@
jump to it.
</p>
</a>
<a class="card" href="/kiosk">
<div class="kicker">GET /kiosk</div>
<h2>An unattended wall</h2>
<p>
A chrome-free wall for a kiosk display: it shows every live mirror and arranges
itself from the shared layout. No pointer, no keyboard, nothing to set up at the box.
</p>
</a>
<a class="card" href="/remote">
<div class="kicker">GET /remote</div>
<h2>Point the wall from another device</h2>
<p>
Open it on a laptop or a phone and drive the shared wall: list the live mirrors,
show one on the display, back to the wall, full screen — no video, no bandwidth.
</p>
</a>
</div>
<div class="livebar" id="livebar">Checking for mirrors…</div>
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<!-- The title is a contract, not a label: docs/kiosk-silverblue/check-kiosk.sh
proves a client is showing what it should by grepping the served page's
<title>. -->
<title>Wall · mirror</title>
<link rel="stylesheet" href="/static/app.css" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='12' fill='%234fd1c5'/%3E%3C/svg%3E"
/>
</head>
<!-- The two layout knobs, mirrored onto the body so a screenshot and a test
can read the state without reaching into JS (the tile's data-audio is
seeded for the same reason). The layout applier (kiosk.js, phase 08
task 04) is the only writer. -->
<body class="kiosk" data-layout="wall" data-fullscreen="false">
<!-- No header bar, no counts, no "Share a screen" link: a display shows
mirrors. The net pill is the one chrome a wall keeps — it is how a
wall reports "the server is gone" in a screenshot, and every e2e
device() helper waits on it. -->
<span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span>
<div class="notice" id="notice" hidden></div>
<main class="grid" id="kiosk-grid" hidden></main>
<div class="empty" id="kiosk-empty">
<h2>No mirrors live</h2>
<p>
Share from <code id="kiosk-share-url">…</code>
</p>
</div>
<!-- A staged tile is moved into this container, so its video never
restarts. class="stage" is mandatory: the stage rules are
class-keyed (phase 08 task 01), and a stage without the class renders
a full-size unstyled video. -->
<div class="stage" id="kiosk-stage" aria-live="polite"></div>
<!-- The wall's self-description for a room with no keyboard: one line the
script owns — "N mirrors · wall", or "… · <label> · fullscreen". -->
<div class="hud" id="kiosk-hud" aria-live="polite"></div>
<div class="toast" id="toast" hidden></div>
<!-- A copy of watch.html's #tile-template, renamed: the id-collision guard
is the reason two pages may not share an id, and relaxing it for
convenience is how the #stage bug happened. -->
<template id="kiosk-tile-template">
<article class="tile" tabindex="0" data-state="connecting">
<video autoplay playsinline muted></video>
<div class="badge">connecting…</div>
<div class="actions">
<button class="icon-btn audio" aria-pressed="false" title="Unmute" hidden>🔇</button>
<button class="icon-btn expand" title="Full screen">⤢</button>
</div>
<div class="cap">
<div class="grow">
<div class="title">Shared screen</div>
<div class="sub"></div>
</div>
<button class="btn ghost exit" hidden>Exit</button>
</div>
</article>
</template>
<script type="module" src="/static/kiosk.js"></script>
</body>
</html>
+363
View File
@@ -0,0 +1,363 @@
/* The kiosk page: a chrome-free wall for an unattended display.
It is a display, not a controller: it announces `kiosk: true` in its hello
(so the layout frame's `followers` counts it), subscribes to every mirror
like /watch, and does what the shared layout says — on arrival
(`welcome.layout`) and on every `layout` frame after. A keypress on the box
(rare, but it happens when a keyboard is plugged in to diagnose) performs
the change AND publishes the same intent with an `arrange` frame, so the
controller and the display converge instead of disagreeing: one writer path,
everyone re-reads.
What this page never does:
* stage or unstage on its own — the state is the boss, locally too; an empty
state means the wall, and nothing auto-stages (never invent state);
* publish anything about sound — the layout has no audio field
(SECURITY.md §4), so `m` is local-only and a display must not become
audible because another machine asked;
* react to the browser's own fullscreen exits — the window manager dropping
fullscreen is not a person asking to leave the stage (on /watch it is a
deliberate policy; on a wall it would fight the state), and a refused
request is the browser's answer, not a preference to echo.
*/
import {
$,
connectSignaling,
deviceLabel,
loadConfig,
refusalNotice,
rtcConfig,
showNotice,
toast,
} from "./net.js";
import { createWall, installWallKeys } from "./wall.js";
const config = await loadConfig();
const rtc = rtcConfig(config);
const ui = {
grid: $("#kiosk-grid"),
stage: $("#kiosk-stage"),
template: $("#kiosk-tile-template"),
empty: $("#kiosk-empty"),
shareUrl: $("#kiosk-share-url"),
hud: $("#kiosk-hud"),
net: $("#net"),
toast: $("#toast"),
notice: $("#notice"),
};
// Nobody is standing at the box to click anything, so the person reading the
// screen needs a URL to type elsewhere.
if (ui.shareUrl) ui.shareUrl.textContent = `${location.origin}/mirror`;
const state = {
ws: null,
// The last layout frame: the single source of truth this page arranges from.
layout: null,
// What the page last applied — the applier's idempotency memory.
applied: null,
mirrors: 0,
};
/* -------------------------------------------------------------- rendering */
function setNet(next, detail) {
if (!ui.net) return;
ui.net.dataset.state = next;
const label = ui.net.querySelector("span");
if (label) label.textContent = detail || next;
}
function renderHud() {
// The same hidden toggles /watch does in renderCounts(): the grid shows when
// there is something to show, the empty state when there is not.
ui.grid.hidden = state.mirrors === 0;
ui.empty.hidden = state.mirrors > 0;
const n = state.mirrors;
const words = `${n} mirror${n === 1 ? "" : "s"}`;
const stagedId = wall.stagedId();
const tile = stagedId ? wall.tile(stagedId) : null;
let text = tile ? `${words} · ${tile.title.textContent}` : `${words} · wall`;
// The wall's self-description for a room with no keyboard: it names the state
// knob, not the browser's concession — a refused request never reads as
// "not fullscreen" here.
if (state.layout && state.layout.fullscreen) text += " · fullscreen";
ui.hud.textContent = text;
}
function onChange({ mirrors }) {
state.mirrors = mirrors;
renderHud();
}
/* ---------------------------------------------------------------- applier */
/**
* Make the page match the server's layout. Idempotent: a frame that repeats
* what is already applied changes nothing, so a controller that re-sends (or a
* follower join that re-broadcasts) cannot re-request fullscreen forever.
* The idempotency check has two halves: the frame matches what was last
* applied, and the wall is actually in the state the frame names. The second
* half is what keeps a *remembered* intent alive — a frame that named a mirror
* not in the wall yet (it died on the way here, or has not arrived) leaves the
* page on the wall, so the wall does not match the frame, and the retry (the
* next roster frame re-runs this on `state.layout`) stages the tile the moment
* it lands.
*
* The caller is never trusted to have waited: `staged` names a live mirror when
* the server accepts it, and may have died on the way here, so an unknown id
* keeps the wall and is retried by the next roster frame.
*/
function applyLayout(next) {
if (!next) return;
const wants = next.staged || null;
const fullscreen = !!next.fullscreen;
const settled =
state.applied !== null &&
state.applied.staged === wants &&
state.applied.fullscreen === fullscreen &&
(wants === null || wall.stagedId() === wants);
if (settled) return;
state.applied = { staged: wants, fullscreen };
if (wants) {
const tile = wall.tile(wants);
if (tile) {
// fullscreen: false — the one fullscreen request for this frame is the
// one below, aimed at the target the page is actually showing.
wall.stage(wants, { fullscreen: false });
} else {
// The frame named a mirror the wall does not have: it died on the way
// here. Back to the wall; `state.layout` keeps the intent, and the next
// roster frame re-applies it.
wall.unstage();
}
} else {
wall.unstage();
}
if (fullscreen) {
// A remote command carries no user activation, so requestFullscreen() may
// be refused. The request is a courtesy, never awaited, and its refusal is
// never echoed back: the .stage CSS fills the viewport anyway (the stage
// is the guarantee) and the Silverblue window is already fullscreen
// (docs/kiosk-silverblue/window-config.ini). Do not "fix" this by
// awaiting or by publishing the refusal — the next reader will be tempted
// to.
const target = wall.stagedId() ? ui.stage : document.documentElement;
if (target.requestFullscreen) {
target.requestFullscreen({ navigationUI: "hide" }).catch(() => {
/* the in-page stage is the guarantee; the fullscreen is a bonus */
});
}
}
// Mirror both knobs onto the body (a screenshot and a test read them without
// reaching into JS) and set the title: a window list and a screenshot are
// the only views a headless box offers.
const stagedId = wall.stagedId();
document.body.dataset.layout = stagedId ? "stage" : "wall";
document.body.dataset.fullscreen = fullscreen ? "true" : "false";
const tile = stagedId ? wall.tile(stagedId) : null;
document.title = tile ? `mirror — ${tile.title.textContent}` : "mirror — wall";
renderHud();
}
/* ------------------------------------------------------------------ wall */
const wall = createWall({
grid: ui.grid,
stageEl: ui.stage,
template: ui.template,
rtc,
// Guarded on the socket the way watch.js guards it, so the wall can be
// built before the socket opens (net.js stays the only place a socket
// exists, AGENTS.md §6).
send: (payload) => (state.ws ? state.ws.send(payload) : false),
onChange,
});
/* ------------------------------------------------------------------ keys */
// One writer path: the display performs the keypress and publishes the same
// intent it performed, so a controller and a display converge on one state.
function publish(frame) {
if (state.ws) state.ws.send(frame);
}
function onCommand(command, detail) {
switch (command) {
case "stage": {
// Same target the wall's default would stage — the cursor, else the
// first tile — same no-silent-no-op toasts.
const tile = wall.selectedTile() || wall.ordered()[0];
if (!tile) {
toast("no mirrors to stage");
return true;
}
wall.stage(tile.id);
publish({ type: "arrange", staged: tile.id });
return true;
}
case "wall":
case "escape": {
// Both leave the stage locally and publish the same intent. Like on
// /watch, a key that changed nothing is not "handled": the browser keeps
// its default (on a fullscreen wall that is the browser's own exit, and
// work item six says the page does not publish those).
const wasStaged = wall.stagedId() !== null;
wall.unstage();
if (wasStaged) publish({ type: "arrange", staged: null });
return wasStaged;
}
case "step_next":
case "step_previous": {
const ordered = wall.ordered();
if (ordered.length === 0) {
toast("no mirrors to switch to");
return true;
}
const selected = wall.selectedId();
const delta = command === "step_next" ? 1 : -1;
let index = selected ? ordered.findIndex((t) => t.id === selected) : -1;
if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n first, p last
const next = ordered[(index + delta + ordered.length) % ordered.length];
wall.stage(next.id);
publish({ type: "arrange", staged: next.id });
return true;
}
case "jump": {
const tile = wall.ordered()[detail.index];
if (!tile) {
toast(`no mirror ${detail.index + 1}`);
return true;
}
wall.stage(tile.id);
publish({ type: "arrange", staged: tile.id });
return true;
}
case "fullscreen": {
// The local effect is the *request*; the state's `fullscreen` knob is
// what the frame publishes. On the wall the target is the document, and
// pressing `f` there must not stage: that would invent a `staged` the
// frame does not carry.
if (wall.stagedId()) {
wall.reRequestFullscreen();
} else if (document.documentElement.requestFullscreen) {
document.documentElement
.requestFullscreen({ navigationUI: "hide" })
.catch(() => {
/* no user activation on a kiosk: the .stage CSS fills the
viewport anyway; the window manager already forces fullscreen */
});
}
publish({ type: "arrange", fullscreen: true });
return true;
}
case "mute": {
// Never published: SECURITY.md §4 — sound only ever starts from a
// gesture on the device that plays it, so a display must not become
// audible because another machine asked.
const stagedId = wall.stagedId();
const tile = (stagedId && wall.tile(stagedId)) || wall.selectedTile();
if (!tile) {
toast("no mirror to mute");
} else {
wall.setMuted(tile, !tile.video.muted);
if (tile.audioBtn.hidden) toast("this mirror has no audio");
}
return true;
}
case "next":
case "previous":
// The cursor is local to this page: the module moves the selection and
// nothing is published (there is no cursor in the shared state).
return false;
case "help":
// This page has no key-list markup: `?` does nothing and keeps its
// browser default.
return false;
default:
return false;
}
}
/* ------------------------------------------------------------------ socket */
state.ws = connectSignaling({
// A refused address never opens a socket (net.js says so via the stub);
// the notice is the only thing the page owes the person reading the screen.
refused: !!config.refused,
refusedReason: config.refusedMessage,
onOpen() {
// A display, not a controller: `kiosk: true` is what feeds `followers`,
// and a /kiosk page must be counted while a /remote page must not.
state.ws.send({ type: "hello", role: "viewer", kiosk: true, name: "", device: deviceLabel() });
},
onStatus(next, detail) {
setNet(next, detail);
},
onClose() {
// The roster is invalid the moment the socket drops: every leg is closed
// and its tile marked stale/reconnecting. The fresh `welcome` after
// reconnect re-applies the layout, which is what makes a server restart
// recover on its own.
wall.closePeers();
},
onMessage(message) {
switch (message.type) {
case "welcome": {
// Arrival is arrangement: the layout is applied before the roster
// paints, so the wall never flashes between the roster and the stage.
const layout = message.layout || { staged: null, fullscreen: false };
state.layout = layout;
applyLayout(layout);
for (const stream of message.streams || []) wall.absorb(stream);
// A layout that named a mirror the wall did not have yet (the roster
// arrives in the same frame) lands now that the tile does.
applyLayout(state.layout);
break;
}
case "layout":
state.layout = message;
applyLayout(message);
break;
case "stream_added":
case "stream_updated":
wall.absorb(message.stream);
// A remembered intent (a layout naming a mirror that was not in the
// wall yet) lands the moment its tile does.
applyLayout(state.layout);
break;
case "stream_removed":
// The wall unstages its own dropped tile, and the server pushes the
// cleared layout behind this frame — do not "helpfully" unstage here,
// or two frames will fight.
wall.drop(message.streamId);
break;
case "signal":
wall.onSignal(message);
break;
case "error":
console.warn("server:", message.code, message.message);
if (message.code === "capacity") toast(message.message);
break;
default:
break;
}
},
});
installWallKeys({ wall, onCommand });
/* -------------------------------------------------------------------- misc */
// Same rule as the share and watch pages: if the server refused this address,
// say so rather than showing an empty wall that will never fill.
const refusal = refusalNotice(config);
if (refusal) showNotice(ui.notice, refusal);
// The settled starting line: "0 mirrors · wall", grid hidden, empty shown.
renderHud();
+5 -1
View File
@@ -22,7 +22,11 @@
<div class="notice" id="notice" hidden></div>
<main class="share">
<section class="stage" id="preview-stage">
<!-- `preview-stage`, not `stage`: the wall's stage (the container a
staged tile moves into, /watch and /kiosk) is the only thing that
may carry `class="stage"` in this stylesheet — sharing the class
would let the two stages inherit each other's rules. -->
<section class="preview-stage" id="preview-stage">
<video id="preview" autoplay playsinline muted></video>
<div class="placeholder" id="placeholder">
<h2>Nothing is being shared</h2>
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>Remote · mirror</title>
<link rel="stylesheet" href="/static/app.css" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='12' fill='%234fd1c5'/%3E%3C/svg%3E"
/>
</head>
<!-- A controller, not a viewer: this page shows no media and subscribes to
nothing (PLAN.md §6 — the mesh uploads N × bitrate per sharer, so a
controller that streamed would cost every sharer a leg). It lists the
live mirrors and the wall's state, and sends the decisions as
`arrange` frames. On the wire it is a plain viewer — no `kiosk`
flag — so `followers` counts displays only, which is what makes
"no display following" mean what it says. -->
<body class="remote">
<header class="bar">
<div class="brand"><span class="glyph"></span> mirror</div>
<div class="spacer"></div>
<span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span>
</header>
<div class="notice" id="notice" hidden></div>
<!-- The readout: what the server says the wall is doing, verbatim —
"Wall: <label>" (or "Wall: no mirror"), a fullscreen marker, and the
follower clause. Each piece has a stable hook so the script and the
tests never have to parse a sentence. The marker span is
`remote-fullscreen-mark` (not `remote-fullscreen`): the Full screen
*button* below owns that id, and an id declared twice on one page
fails the uniqueness guard. -->
<section class="wall-state" id="remote-state" aria-live="polite">
Wall: <span id="remote-staged">no mirror</span
><span id="remote-fullscreen-mark" hidden> · fullscreen</span
><span id="remote-followers"> · no display following</span>
</section>
<!-- The three decisions. Task 02 makes them send `arrange` frames and
reflects the server's state; they never paint a result locally. -->
<div class="remote-controls">
<button class="btn" id="remote-back">Back to the wall</button>
<button class="btn ghost" id="remote-fullscreen" aria-pressed="false">Full screen</button>
</div>
<!-- The one-line hint under the controls. It appears when the readout
says "no display following" (the state is still stored, so a display
pointed at /kiosk picks it up immediately) and when the socket is
offline. It never disables the controls: the layout is server-side.
The hint is hidden by the global `[hidden]` rule, as everything else. -->
<p class="remote-hint" id="remote-hint" hidden></p>
<ul class="mirror-list" id="remote-list" hidden></ul>
<div class="empty" id="remote-empty">
<h2>No mirrors live</h2>
<p>
Share from <code id="remote-share-url">…</code>
</p>
</div>
<!-- One row per mirror: title + sub + the decision. No media. -->
<template id="remote-row-template">
<li class="mirror-row" data-stream="">
<div class="grow">
<div class="title">Shared screen</div>
<div class="sub"></div>
</div>
<button class="btn ghost show">Show on wall</button>
</li>
</template>
<div class="toast" id="toast" hidden></div>
<script type="module" src="/static/remote.js"></script>
</body>
</html>
+308
View File
@@ -0,0 +1,308 @@
/* The remote page: the hand that drives the wall.
It lists the live mirrors and the wall's current state, and offers the
three decisions (show this, back to the wall, full screen) as `arrange`
frames the display obeys. The page's correctness rests on one rule: the
only thing it displays about the wall is what the server last told it.
A control never paints a result locally and hopes the server agrees —
with two controllers open, an optimistic paint is a lie the moment the
other one acts, and a lie in the readout is worse than a 40 ms delay.
What this page never does: show media, or ask for a stream's track —
the mesh uploads N × bitrate per sharer, so a controller that consumed
a leg just to know what to point at would cost everyone (PLAN.md §6).
It keeps a list of mirrors, not tiles: no <video> element, no media
connection, no read of the screen-capture APIs — the "no media in a
controller" guards in test_app.py enforce it. On the wire it is a plain
viewer: no `kiosk` flag in its hello, which is what makes `followers`
count displays only, and "no display following" mean what it says.
There is no key map on this page (AGENTS.md §6): the buttons are native,
focusable and phone-tappable, and a second copy of the wall's key map
would be a second thing to keep in sync. */
import {
$,
connectSignaling,
deviceLabel,
loadConfig,
refusalNotice,
showNotice,
toast,
} from "./net.js";
const config = await loadConfig();
// The element-existence guard (test_every_element_the_script_reaches_for_exists)
// enforces that every one of these lives in remote.html; the control hooks
// are additionally asserted on the markup (test_the_remote_page_declares_its_control_hooks)
// so this page and its markup cannot drift.
const ui = {
state: $("#remote-state"),
staged: $("#remote-staged"),
fullscreenMark: $("#remote-fullscreen-mark"),
followers: $("#remote-followers"),
back: $("#remote-back"),
fullscreen: $("#remote-fullscreen"),
list: $("#remote-list"),
template: $("#remote-row-template"),
empty: $("#remote-empty"),
shareUrl: $("#remote-share-url"),
hint: $("#remote-hint"),
net: $("#net"),
notice: $("#notice"),
};
// The controller is usually the device that goes and asks somebody to share.
if (ui.shareUrl) ui.shareUrl.textContent = `${location.origin}/mirror`;
function setNet(next, detail) {
if (!ui.net) return;
ui.net.dataset.state = next;
const label = ui.net.querySelector("span");
if (label) label.textContent = detail || next;
}
const state = {
ws: null,
// The socket dropped: the readout is a snapshot of the last layout frame,
// not a statement about the wall, until the next welcome re-syncs it.
offline: false,
// A layout frame has landed: the follower clause may only be reported as
// fact after the server has actually said it.
synced: false,
// The mirror that was on the wall died: its label, held so the layout
// frame that clears the stage can say "<label> ended — back to the wall"
// exactly once.
endedStagedLabel: null,
};
/** id -> {id, name, label, displaySurface, width, height, audio}, in server
* roster order (welcome.streams is oldest-first, stream_added appends). The
* controller keeps a list, not tiles: no <video>, no media stream, no
* media connection — see the "no media in a controller" guard in
* test_app.py. */
const mirrors = new Map();
let layout = { staged: null, fullscreen: false, followers: 0 };
/* -------------------------------------------------------------- rendering */
function titleOf(stream) {
return stream.label || stream.name || "Shared screen";
}
function surfaceShort(setting) {
return { window: "window", monitor: "screen", browser: "tab" }[setting] || setting || "";
}
// The same words wall.js's paintTile() prints, so the two pages describe a
// mirror identically: `name · surface · W×H · audio|silent`.
function subOf(stream) {
const dims = stream.width && stream.height ? `${stream.width}×${stream.height}` : "";
const parts = [surfaceShort(stream.displaySurface), dims, stream.audio ? "audio" : "silent"].filter(
Boolean,
);
return `${stream.name || "Unknown device"}${parts.length ? ` · ${parts.join(" · ")}` : ""}`;
}
function renderMirrors() {
const list = ui.list;
list.innerHTML = "";
for (const [id, stream] of mirrors) {
const row = ui.template.content.firstElementChild.cloneNode(true);
row.dataset.stream = id;
row.querySelector(".title").textContent = titleOf(stream);
row.querySelector(".sub").textContent = subOf(stream);
const show = row.querySelector(".show");
if (layout.staged === id) {
row.dataset.on = "";
show.textContent = "On the wall";
show.setAttribute("aria-current", "true");
} else {
show.textContent = "Show on wall";
}
list.appendChild(row);
}
const any = mirrors.size > 0;
ui.empty.hidden = any;
list.hidden = !any;
}
function renderHint() {
// The controls stay enabled in every state: the layout is stored
// server-side, so a display pointed at /kiosk picks it up the moment it
// connects — the hint says that, it never disables anything.
if (state.offline) {
ui.hint.textContent = "offline — the wall is not following you";
ui.hint.hidden = false;
return;
}
if (state.synced && (layout.followers || 0) === 0) {
ui.hint.textContent =
"no display following yet — the state is stored, so a display pointed at /kiosk picks it up";
ui.hint.hidden = false;
} else {
ui.hint.hidden = true;
}
}
function renderState() {
// The readout is filled from the last layout frame and nothing else.
const stagedId = layout.staged;
if (stagedId === null) {
ui.staged.textContent = "no mirror";
} else if (mirrors.has(stagedId)) {
ui.staged.textContent = titleOf(mirrors.get(stagedId));
} else {
// The frame in transit: the mirror is gone and the server has not
// cleared the stage yet.
ui.staged.textContent = "(gone)";
}
// Fullscreen is reported, not promised: the marker and the button both
// say what the state asks for, and only the state.
const full = layout.fullscreen === true;
ui.fullscreenMark.hidden = false;
ui.fullscreenMark.textContent = full ? " · fullscreen: on" : " · fullscreen: off";
ui.fullscreen.setAttribute("aria-pressed", String(full));
ui.fullscreen.textContent = full ? "Full screen: on" : "Full screen: off";
const n = layout.followers || 0;
ui.followers.textContent =
n === 0 ? " · no display following" : ` · ${n} display${n === 1 ? "" : "s"} following`;
renderHint();
}
/* ------------------------------------------------------------------ intent */
/**
* Send an intent and let the server's `layout` frame paint it. Never update
* `layout` here: with two controllers open, an optimistic local paint is a
* lie the moment the other one acts, and a lie in the readout is worse
* than a 40 ms delay.
*/
function arrange(patch) {
if (!state.ws.send({ type: "arrange", ...patch })) {
toast("not connected — nothing sent");
}
}
// One listener on the list container (event delegation, the way watch.js
// handles per-tile buttons): the rows are re-created on every roster frame,
// so a per-row listener would be orphaned by the very render it served.
ui.list.addEventListener("click", (event) => {
const button = event.target instanceof Element ? event.target.closest(".show") : null;
const row = button && button.closest(".mirror-row");
if (!row || !row.dataset.stream) return;
arrange({ staged: row.dataset.stream });
});
// The two page-level buttons live for the page's whole life: one listener
// each, wired once.
ui.back.addEventListener("click", () => arrange({ staged: null }));
ui.fullscreen.addEventListener("click", () => {
// Toggle from the server-reported value, never from the button's own
// aria-pressed: with two controllers open, the button may be mid-paint.
arrange({ fullscreen: !layout.fullscreen });
});
/* ------------------------------------------------------------------ socket */
state.ws = connectSignaling({
// A refused address never opens a socket (net.js says so via the stub);
// the notice is the one thing the page owes the person holding the phone.
refused: !!config.refused,
refusedReason: config.refusedMessage,
onOpen() {
// A plain viewer on the wire: role "viewer" and NO `kiosk` flag, so the
// layout's `followers` count (displays only) never includes this page.
state.ws.send({ type: "hello", role: "viewer", name: "", device: deviceLabel() });
},
onStatus(next, detail) {
setNet(next, detail);
},
onClose() {
// The readout is a snapshot now, not a statement: mark it stale and say
// the wall is no longer following. A fresh welcome after reconnect
// re-syncs everything and clears the mark.
state.offline = true;
ui.staged.setAttribute("data-stale", "");
renderHint();
},
onMessage(message) {
switch (message.type) {
case "welcome": {
// Arrival is a full re-sync: the roster and the layout arrive in
// one frame, so a reload (or a reconnect) lands already arranged.
mirrors.clear();
for (const stream of message.streams || []) mirrors.set(stream.id, stream);
layout = message.layout || { staged: null, fullscreen: false, followers: 0 };
state.offline = false;
state.synced = true;
state.endedStagedLabel = null;
ui.staged.removeAttribute("data-stale");
renderMirrors();
renderState();
break;
}
case "stream_added":
case "stream_updated": {
const stream = message.stream;
if (stream && stream.id) mirrors.set(stream.id, stream);
// An update can change the readout without changing the list: the
// staged mirror's label sits in it.
renderMirrors();
renderState();
break;
}
case "stream_removed": {
const id = message.streamId;
const stream = mirrors.get(id);
if (stream && layout.staged === id) {
// The mirror that was on the wall died: hold its label so the
// layout frame that clears the stage can say so.
state.endedStagedLabel = titleOf(stream);
}
mirrors.delete(id);
renderMirrors();
renderState();
break;
}
case "layout": {
layout = message;
state.synced = true;
if (layout.staged === null && state.endedStagedLabel) {
// On a controller the change is invisible otherwise: the row is
// already gone, and nothing is "on the wall" now.
toast(`${state.endedStagedLabel} ended — back to the wall`);
state.endedStagedLabel = null;
}
renderState();
renderMirrors();
break;
}
case "signal":
// This page never negotiates: a signal is somebody else's
// conversation, and it stays ignored even when a sharer misbehaves.
break;
case "error":
// An `unknown_stream` from a stale row is exactly the case where
// silence would look like a broken button.
toast(message.message || message.code);
break;
default:
break;
}
},
});
// Same rule as the other pages: if the server refused this address, say so
// rather than showing a controller that will never reach the wall.
const refusal = refusalNotice(config);
if (refusal) showNotice(ui.notice, refusal);
// The settled starting line: the empty roster and the idle layout, so the
// first paint agrees with the markup before any frame has landed.
renderMirrors();
renderState();
+847
View File
@@ -0,0 +1,847 @@
/* The wall: one tile per live mirror, one RTCPeerConnection per tile, and a
* stage the staged tile's own element moves into (so its <video> and its
* connection never restart — AGENTS.md §6).
*
* Nothing here queries the document: the page hands over the elements it owns,
* because one module now serves /watch, /kiosk and (phase 09) /remote, whose
* ids must not collide (test_the_two_pages_share_no_element_ids…).
*
* @param {{grid: Element, stageEl: Element, template: HTMLTemplateElement,
* send: (payload: object) => boolean,
* rtc?: object,
* onChange?: (summary: {mirrors: number, live: number}) => void,
* stats?: boolean}} options
* @returns the surface the pages drive the wall with: absorb, drop, onSignal,
* stage, unstage, reRequestFullscreen, setMuted, ordered, stagedId,
* selectedId, selectedTile, setSelected, moveSelection, tile, closePeers,
* revealStageControls, handleFullscreenChange
*
* The module's second export, installWallKeys(), is the one keydown listener
* a wall page gets: it maps keys to named commands, hands them to the page's
* onCommand, and performs the wall commands itself when the page does not.
*/
import { bitrateFormat, toast } from "./net.js";
export function createWall(options) {
const { grid, stageEl, template, send, rtc } = options;
const onChange = options.onChange || null;
/** streamId -> {id, sharerId, el, video, badge, sub, pc, pending, attempts, stream, stats} */
const tiles = new Map();
const state = {
staged: null,
stageParent: null,
stageNext: null,
cursor: null,
};
/* -------------------------------------------------------------- rendering */
// The count/empty chrome (#counts, #empty) belongs to the page, not the
// wall: the wall reports the numbers wherever renderCounts() ran before the
// extraction, and the page paints them.
function publishCounts() {
if (!onChange) return;
const n = tiles.size;
const live = [...tiles.values()].filter((t) => t.pc && t.pc.connectionState === "connected").length;
onChange({ mirrors: n, live });
}
function surfaceShort(setting) {
return { window: "window", monitor: "screen", browser: "tab" }[setting] || setting || "";
}
function makeTile(stream) {
const node = template.content.firstElementChild.cloneNode(true);
const tile = {
id: stream.id,
sharerId: stream.sharerId,
el: node,
video: node.querySelector("video"),
badge: node.querySelector(".badge"),
title: node.querySelector(".title"),
sub: node.querySelector(".sub"),
audioBtn: node.querySelector(".audio"),
exitBtn: node.querySelector(".exit"),
pc: null,
pending: [],
attempts: 0,
stream: null,
bytes: 0,
at: Date.now(),
name: stream.name || "Unnamed",
};
node.dataset.stream = stream.id;
node.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
if (state.staged === stream.id) return;
stage(stream.id, { fullscreen: true });
});
tile.audioBtn.addEventListener("click", (event) => {
event.stopPropagation();
setMuted(tile, !tile.video.muted);
});
tile.exitBtn.hidden = true;
tile.exitBtn.addEventListener("click", (event) => {
event.stopPropagation();
unstage();
});
node.querySelector(".expand").addEventListener("click", (event) => {
event.stopPropagation();
if (state.staged === stream.id) unstage();
else stage(stream.id, { fullscreen: true });
});
grid.appendChild(node);
tiles.set(stream.id, tile);
return tile;
}
function paintTile(tile, stream) {
tile.name = stream.name || tile.name;
const label = stream.label || stream.name || "Shared screen";
tile.title.textContent = label;
const dims = stream.width && stream.height ? `${stream.width}×${stream.height}` : "";
const parts = [surfaceShort(stream.displaySurface), dims, stream.audio ? "audio" : "silent"].filter(
Boolean,
);
tile.sub.textContent = `${stream.name || "Unknown device"}${parts.length ? ` · ${parts.join(" · ")}` : ""}`;
tile.el.setAttribute("aria-label", `Watch ${label}`);
tile.audioBtn.hidden = !stream.audio;
// The wall's audio state, readable without a pointer: a screenshot from
// another machine (docs/kiosk-silverblue/dim-probe.py) must show what the
// wall is doing, so the state lives on the element, not in the hover-only
// button. paintTile() seeds it from the server's `audio` flag; setMuted()
// keeps it in step with the element's live state.
tile.el.dataset.audio = stream.audio ? (tile.video.muted ? "off" : "on") : "none";
}
function drop(streamId) {
const tile = tiles.get(streamId);
if (!tile) return;
const wasCursor = state.cursor === streamId;
const slot = wasCursor ? ordered().findIndex((t) => t.id === streamId) : -1;
if (state.staged === streamId) unstage();
tiles.delete(streamId);
closePeer(tile);
// closePeer() is a no-op when the leg never negotiated; the stream is
// reset here too so a dropped tile never keeps a dead MediaStream.
tile.stream = null;
tile.el.remove();
publishCounts();
if (wasCursor) {
const remaining = ordered();
if (remaining.length === 0) {
state.cursor = null;
} else {
// The tile that takes over the dropped slot is the nearest; if the
// dropped tile was last, wrap to the first (the cursor wraps too).
setSelected(remaining[slot % remaining.length].id);
}
}
}
function closePeer(tile) {
if (!tile.pc) return;
const pc = tile.pc;
pc.onicecandidate = null;
pc.ontrack = null;
pc.onconnectionstatechange = null;
try {
pc.close();
} catch {
/* already closed */
}
tile.pc = null;
tile.pending = [];
// A retry builds a fresh connection and must not reattach dead tracks.
tile.stream = null;
}
/* ------------------------------------------------------------------ media */
function ensurePeer(tile) {
if (tile.pc) return tile.pc;
const pc = new RTCPeerConnection(rtc);
tile.pc = pc;
pc.onicecandidate = (event) => {
if (event.candidate && send) {
send({ type: "signal", to: tile.sharerId, payload: { candidate: event.candidate } });
}
};
// Do NOT `addTransceiver("audio", { direction: "recvonly" })` here. PLAN
// A4: the viewer only answers, and the sharer owns the track set. An
// answerer-side transceiver adds an m-line the offerer never sent and the
// m-lines must stay in the offerer's order — this is the first thing a
// future reader will be tempted to try when audio misbehaves.
pc.ontrack = (event) => {
// One MediaStream per tile, built by us: Chrome and Firefox disagree
// about whether addTransceiver tracks share a stream id, and the app
// must behave identically on both (the kiosk is Firefox, the sharers
// are usually Chrome). The old code assigned a fresh one-track stream
// per ontrack, so the second track to arrive erased the first: the
// audio callback's stream replaced the video's srcObject, the video
// kept playing from the still-attached transceiver, and the sound
// simply never came.
tile.stream = tile.stream || new MediaStream();
tile.stream.addTrack(event.track);
if (tile.video.srcObject !== tile.stream) {
tile.video.srcObject = tile.stream;
tile.video.play().catch(() => {
/* autoplay is covered by `muted`; a rejection means a gesture is needed */
});
}
// A track that ends is removed from the composed stream, not the
// connection: the video leg keeps working when a source's audio goes
// away (the video-only case), and the mute button tracks the live
// track set, so a source that stops offering audio loses its button.
event.track.addEventListener("ended", () => {
if (!tile.stream) return; // the peer was already closed; nothing to clean up
tile.stream.removeTrack(event.track);
const hasAudio = tile.stream.getAudioTracks().length > 0;
tile.audioBtn.hidden = !hasAudio;
tile.el.dataset.audio = hasAudio ? (tile.video.muted ? "off" : "on") : "none";
// The one tile that may be audible just lost its audio: re-mute it
// (which also resets the button/glyph, so the dead 🔇 cannot sit on
// an unpressed state) and say why the sound went — a silent wall in a
// shared room otherwise looks like a bug.
if (!hasAudio && state.staged === tile.id) {
setMuted(tile, true);
toast("This mirror stopped sharing audio.");
}
});
};
pc.onconnectionstatechange = () => {
tile.el.dataset.state = pc.connectionState === "connected" ? "live" : pc.connectionState;
tile.badge.textContent =
pc.connectionState === "connected"
? "live"
: pc.connectionState === "connecting"
? "connecting…"
: pc.connectionState;
publishCounts();
if (pc.connectionState === "failed") {
if (tile.attempts < 3) {
tile.attempts += 1;
tile.badge.textContent = `retry ${tile.attempts}/3`;
closePeer(tile);
setTimeout(() => {
if (tiles.has(tile.id) && send) send({ type: "subscribe", streamId: tile.id });
}, 700 * tile.attempts);
} else {
tile.badge.textContent = "unreachable";
}
}
};
return pc;
}
async function onSignal(message) {
const tile = [...tiles.values()].find((t) => t.sharerId === message.from);
if (!tile) return;
const pc = ensurePeer(tile);
const payload = message.payload || {};
try {
if (payload.sdp) {
await pc.setRemoteDescription(payload.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
send({ type: "signal", to: message.from, payload: { sdp: pc.localDescription } });
const queued = tile.pending.splice(0);
for (const candidate of queued) await pc.addIceCandidate(candidate).catch(() => {});
} else if (payload.candidate) {
if (pc.remoteDescription && pc.remoteDescription.type) {
await pc.addIceCandidate(payload.candidate);
} else {
tile.pending.push(payload.candidate);
}
}
} catch (err) {
console.warn("viewer signal rejected", err);
}
}
/* ------------------------------------------------------------------- audio */
/**
* The one place mute state changes: the tile's audio button, the `m` key and
* the staged-only audio policy all go through here, so the video's
* `muted`/`volume`, the button text, `aria-pressed` and `title` cannot drift
* apart. Kept as `(tile, muted)` on purpose.
*
* The policy: **only the staged mirror may be audible.** Unmuting a tile in
* the grid stages it first — a wall of twenty mirrors must not become twenty
* sound sources, and staging is what a person who wants a mirror's audio was
* about to do anyway — and every other tile is muted through this same
* helper, so the button, the key and the policy can never disagree. Unmuting
* never happens on its own: every path that calls `setMuted(tile, false)`
* starts from a click or a keypress (SECURITY.md §4), and `stage()` and
* `unstage()` never change mute state on their own — joining or leaving the
* stage is never a sound source, and audio is never restored after an
* unstage or a reconnect.
*/
function setMuted(tile, muted) {
if (!muted && state.staged !== tile.id) {
stage(tile.id, { fullscreen: true }); // a grid tile may not become audible in the grid
}
tile.video.muted = muted;
tile.video.volume = 1;
if (!muted) {
// Exactly one tile may be audible: mute every other tile first, then
// this one, so there is never a frame in which two mirrors play.
for (const other of tiles.values()) {
if (other !== tile) setMuted(other, true);
}
tile.video.play().catch(() => {}); // a muted play may be suspended
}
tile.audioBtn.textContent = muted ? "🔇" : "🔊";
tile.audioBtn.setAttribute("aria-pressed", String(!muted));
tile.audioBtn.title = muted ? "Unmute" : "Mute";
// The button tracks the live track set (paintTile and the `ended` handler
// keep it honest), so it is the one source of truth for "does this mirror
// have audio at all" — a toggle on a silent mirror is "none", never "on".
const hasAudio = !tile.audioBtn.hidden;
tile.el.dataset.audio = hasAudio ? (muted ? "off" : "on") : "none";
}
/* ------------------------------------------------------------------ stage */
/* The staged overlay (caption bar + control cluster) is hidden at rest so a
shared terminal's bottom line is never covered. Pointer activity on the
stage, and any wall keypress while staged (the kiosk has no mouse),
reveal it via `data-reveal`; this timeout is what fades it back, so a
parked pointer or a one-time keypress never pins it up. */
const STAGE_REVEAL_IDLE_MS = 3000;
let stageRevealTimer = 0;
function revealStageControls() {
if (!state.staged) return;
const tile = tiles.get(state.staged);
if (!tile) return;
tile.el.dataset.reveal = "";
clearTimeout(stageRevealTimer);
stageRevealTimer = setTimeout(() => {
stageRevealTimer = 0;
// The tile may have been unstaged or replaced (n/p, 1-9, a click)
// before the timer fires; only the tile still on stage loses the
// attribute. (unstage() clears the timer, so this guard also covers
// a replacement whose keepFullscreen unstage ran in between.)
if (state.staged === tile.id) delete tile.el.dataset.reveal;
}, STAGE_REVEAL_IDLE_MS);
}
/* A `document.exitFullscreen()` that `unstage()` started is in flight.
The exit settles asynchronously, and a keypress in that window (g followed
by an immediate Enter) stages a new tile before the exit's
`fullscreenchange` event lands: `state.staged` is then the NEW tile, and
the handler would tear the fresh stage down with the old exit. While the
flag is set, an exit event belongs to the unstage that already tore the
stage down synchronously, so it is ignored; the flag is cleared when the
document becomes fullscreen again (a re-stage's request won over the exit)
or when the exit settles. */
let fullscreenExitInFlight = false;
function stage(streamId, options = {}) {
const tile = tiles.get(streamId);
if (!tile) return;
if (state.staged && state.staged !== streamId) {
// Replacing the tile on stage: keep the stage's fullscreen. The fullscreen
// element is the stage container itself, so the replacement moves into a
// stage that is already fullscreen. Exiting first would deliver a
// fullscreenchange event after state.staged is already the new tile, and
// the handler would unstage the replacement — n/p stepping, 1-9 jumps and
// clicking another tile all go through this path on a kiosk.
unstage({ keepFullscreen: true });
}
state.stageParent = tile.el.parentNode;
state.stageNext = tile.el.nextSibling;
stageEl.appendChild(tile.el);
document.body.classList.add("staged");
state.staged = streamId;
// The cursor sits on whatever is on stage, so the selection stays coherent
// while the wall is hidden behind it.
state.cursor = streamId;
tile.el.dataset.selected = "";
tile.exitBtn.hidden = false;
// The cursor moves with the stage: focus the staged tile so Esc/m/g keep
// working no matter what held focus before (the grid is hidden behind it).
tile.el.focus({ preventScroll: true });
if (options.fullscreen !== false) {
const target = stageEl;
if (target.requestFullscreen) {
target.requestFullscreen({ navigationUI: "hide" }).catch(() => {
/* in-page stage still covers the viewport; fullscreen is a bonus */
});
}
}
tile.video.play().catch(() => {});
}
function unstage(options = {}) {
if (!state.staged) return;
const tile = state.staged ? tiles.get(state.staged) : null;
state.staged = null;
document.body.classList.remove("staged");
// The reveal belongs to the stage: every exit (Exit click, `g`, Esc, or a
// replacement's keepFullscreen) clears the pending timeout and the
// departing tile's `data-reveal`, so a stale reveal can never leak back
// into the grid — grid tiles never read the attribute, and re-staging
// must start hidden.
clearTimeout(stageRevealTimer);
stageRevealTimer = 0;
if (tile) delete tile.el.dataset.reveal;
if (tile) {
tile.exitBtn.hidden = true;
if (state.stageParent) {
state.stageParent.insertBefore(tile.el, state.stageNext);
} else {
grid.appendChild(tile.el);
}
}
state.stageParent = null;
state.stageNext = null;
if (tile) {
// Walking back to the wall is always silent: the kiosk in a shared room
// depends on it, and on a replacement (keepFullscreen) the departing tile
// goes quiet the same way. Re-staging the tile does not restore sound —
// every audible state must trace back to a gesture (SECURITY.md §4).
setMuted(tile, true);
}
const restoreFocus = () => {
// The tile is back in the grid at its old position; the cursor goes with
// it. The grid's focusin handler then agrees the selection with focus —
// focusTile() declines when focus is already on the tile, so this cannot
// loop. If a different tile was staged in the meantime (the exit is
// asynchronous), that tile already holds the cursor, so don't steal it.
if (tile && state.staged !== tile.id) tile.el.focus({ preventScroll: true });
};
// keepFullscreen: the caller is re-staging into the same stage container in
// the same tick, so its fullscreen (if granted) is left in place and its
// focus() lands the cursor on the new tile.
if (!options.keepFullscreen && document.fullscreenElement && document.exitFullscreen) {
// Exiting fullscreen makes the browser re-validate focus and drops it to
// <body>, which would cancel a focus() made earlier in this tick — so
// the cursor is handed back to the tile only after the exit has settled.
// The in-flight flag keeps the exit's late `fullscreenchange` from
// tearing down a stage re-entered in the meantime (see the handler).
fullscreenExitInFlight = true;
document.exitFullscreen().then(restoreFocus).catch(() => {
// A rejected exit never settles: if the document is still fullscreen
// the exit was pre-empted (or lost user activation) and the flag is
// stale — drop it so a genuine later exit still unstages.
if (document.fullscreenElement) fullscreenExitInFlight = false;
restoreFocus();
});
} else {
restoreFocus();
}
}
/* `f` while staged: re-request fullscreen on the stage container. The
in-page stage fills the viewport anyway, so the request is best-effort
and a refusal (a remote command has no user activation) is not an error —
the stage is the guarantee, the fullscreen is a bonus (the same stance
installWallKeys documents for the whole key map). */
function reRequestFullscreen() {
if (stageEl.requestFullscreen) {
stageEl.requestFullscreen({ navigationUI: "hide" }).catch(() => {
/* in-page stage still covers the viewport; fullscreen is a bonus */
});
}
}
/* The page owns the `fullscreenchange` listener — what leaving fullscreen
means is page policy (/watch unstages; the kiosk will do something else,
phase 08 task 04). This hook owns the in-flight flag and the two
behaviours it implements. */
function handleFullscreenChange(hasFullscreen) {
if (hasFullscreen) {
// (Re-)entered fullscreen — a re-stage's request won over our exit, so
// the in-flight flag is stale from this point on.
fullscreenExitInFlight = false;
return;
}
if (fullscreenExitInFlight) {
// Our own unstage's exit settling: the stage was already torn down
// synchronously, and a keypress in the exit window may have re-staged —
// the event must not take that new stage down with it.
fullscreenExitInFlight = false;
return;
}
// The browser (or the kiosk's window manager) left fullscreen on its own;
// the in-page stage is what the operator asked to leave.
if (state.staged) unstage();
}
/* ------------------------------------------------------------------ socket */
function subscribe(streamId) {
if (send) send({ type: "subscribe", streamId });
}
function absorb(stream) {
if (!stream || !stream.id) return;
const tile = tiles.get(stream.id) || makeTile(stream);
paintTile(tile, stream);
publishCounts();
if (!tile.pc) subscribe(stream.id);
}
/* The socket drop is the page's event; the wall owns what the drop means
for its tiles: every leg is closed and marked stale/reconnecting. */
function closePeers() {
for (const streamId of [...tiles.keys()]) {
const tile = tiles.get(streamId);
closePeer(tile);
tile.el.dataset.state = "stale";
tile.badge.textContent = "reconnecting…";
}
}
/* ----------------------------------------------------------------- ticker */
if (options.stats) {
setInterval(async () => {
for (const tile of tiles.values()) {
if (!tile.pc) continue;
try {
const report = await tile.pc.getStats();
let fps = 0;
let bytes = 0;
let w = 0;
let h = 0;
report.forEach((entry) => {
if (entry.type === "inbound-rtp" && entry.kind === "video") {
fps = entry.framesPerSecond || 0;
bytes = entry.bytesReceived || 0;
w = entry.frameWidth || w;
h = entry.frameHeight || h;
}
});
const now = Date.now();
const dt = Math.max(0.25, (now - tile.at) / 1000);
const bps = ((bytes - tile.bytes) / dt) * 8;
tile.bytes = bytes;
tile.at = now;
if (w && h) {
const dims = `${w}×${h}`;
const head = tile.sub.textContent.split(" · ").slice(0, 1);
tile.sub.textContent = [...head, dims, bitrateFormat(bps), `${Math.round(fps)} fps`].join(
" · ",
);
}
} catch {
/* stats are advisory */
}
}
}, 1500);
}
/* --------------------------------------------------------- wall accessors */
function ordered() {
// DOM order == insertion order, which is the grid order the page's count
// renderer uses.
return [...tiles.values()];
}
function stagedId() {
return state.staged;
}
function selectedId() {
return state.cursor;
}
function selectedTile() {
return state.cursor ? tiles.get(state.cursor) || null : null;
}
function tile(id) {
return tiles.get(id);
}
function focusTile(tile) {
// Focus follows selection (tiles are tabindex="0"). preventScroll keeps the
// grid from jumping while a remote hand walks it. If focus is already inside
// this tile — Tab reached one of its buttons — the selection already agrees,
// so don't steal focus back off the control the operator is on.
const active = document.activeElement;
if (active === tile.el || (active && tile.el.contains(active))) return;
tile.el.focus({ preventScroll: true });
}
function setSelected(streamId) {
const tile = tiles.get(streamId);
if (!tile) return;
const previous = selectedTile();
if (previous && previous !== tile) delete previous.el.dataset.selected;
tile.el.dataset.selected = "";
state.cursor = streamId;
focusTile(tile);
}
function moveSelection(delta) {
const list = ordered();
const n = list.length;
if (n === 0) return;
const index = state.cursor ? list.findIndex((tile) => tile.id === state.cursor) : -1;
if (index === -1) {
// No live cursor yet: the first j lands on the first tile, the first k on
// the last. A wall with twenty mirrors is navigated in a loop from a
// remote, so the ends wrap.
setSelected(list[delta > 0 ? 0 : n - 1].id);
return;
}
setSelected(list[(index + delta + n) % n].id);
}
return {
absorb,
drop,
onSignal,
stage,
unstage,
reRequestFullscreen,
setMuted,
ordered,
stagedId,
selectedId,
selectedTile,
setSelected,
moveSelection,
tile,
closePeers,
revealStageControls,
handleFullscreenChange,
};
}
/* ----------------------------------------------------------------- keys */
/**
* The one keydown listener for a wall page. It maps keys to named commands and
* hands them to the page; it never decides what a command does to shared state.
*
* A page returns `true` only when the key actually changed something — the
* router calls `preventDefault()` on exactly those, which is today's discipline
* on /watch (`g` unstaged and `Esc` with nothing open are not "handled" and
* keep their browser default).
*
* The wall commands (`next`, `previous`, `stage`, `fullscreen`, `wall`,
* `step_next`, `step_previous`, `mute`, `jump`) have a default implementation
* here, used when the page returns nothing for them, so `onCommand` can be a
* thin observer: /watch answers only `help` and `escape`, and a page like
* /kiosk can publish what a key did (`arrange`) while the module performs it.
* `help` and `escape` have **no** default: they are page policy, and a page
* that ignores them must not break the router.
*
* `next`/`previous` run in the module on purpose: moving the selection is wall
* state, not page policy. The page still sees the command, so a page that
* wants to publish the new cursor can.
*
* Any keypress while staged reveals the overlay first, handled or not: a key on
* a mouseless kiosk is the only way to read the caption and reach the controls
* (a silent no-op key flashing the caption is the debuggable outcome). The idle
* timeout fades it again, and the reveal never preventDefaults, so unhandled
* keys keep their defaults.
*
* Never intercepted: any chord with Ctrl/Cmd/Alt (back, reload, copy belong to
* the browser) and every key inside an <input>/<textarea>/<select>/contenteditable.
*
* @param {{wall: object, onCommand: (command: string, detail: {key: string, index?: number, event: KeyboardEvent}) => boolean, root?: Document}} options
*/
export function installWallKeys(options) {
const wall = options.wall;
const onCommand = options.onCommand || null;
const root = options.root || document;
/* ------------------------------------------------- default wall commands */
function defaultStage() {
// Enter and `f` (when unstaged) share a target: the selected tile, or the
// first tile when nothing is selected yet. With nothing on the wall there
// is nothing to show, so say why instead of staying silent.
const tile = wall.selectedTile() || wall.ordered()[0] || null;
if (!tile) {
toast("no mirrors to stage");
return true;
}
wall.stage(tile.id, { fullscreen: true });
return true;
}
function defaultFullscreen() {
if (wall.stagedId()) {
// The kiosk's window manager can drop the *window* out of fullscreen
// without mirror noticing (docs/kiosk-silverblue.md: window-config.ini
// forces the window fullscreen; an element requestFullscreen() on top
// of that is allowed and simply no-ops if the browser refuses). So
// `f` re-requests rather than tracking state.
wall.reRequestFullscreen();
} else {
// When unstaged it stages like Enter.
defaultStage();
}
return true;
}
function defaultWall() {
const wasStaged = wall.stagedId() !== null;
wall.unstage();
// When already unstaged the wall *is* the visible result "back to the
// wall", so there is nothing to toast; the key was only handled if it
// changed state.
return wasStaged;
}
function stepStage(delta) {
// `n`/`p` *stage* the neighbour, not just move the selection: a remote
// operator cannot reach an Enter afterwards, so the step is the switch.
// The cursor sits on the staged tile while staged (stage() moves it), so
// stepping from the stage walks the wall; stage() unstages the previous
// tile, and unstaging moves — never recreates — the tile element.
const ordered = wall.ordered();
if (ordered.length === 0) {
toast("no mirrors to switch to");
return true;
}
const selectedId = wall.selectedId();
let index = selectedId ? ordered.findIndex((tile) => tile.id === selectedId) : -1;
if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n lands first, p wraps to last
const next = ordered[(index + delta + ordered.length) % ordered.length];
wall.stage(next.id, { fullscreen: true });
return true;
}
function defaultMute() {
const stagedId = wall.stagedId();
const tile = (stagedId && wall.tile(stagedId)) || wall.selectedTile();
if (!tile) {
toast("no mirror to mute");
} else {
wall.setMuted(tile, !tile.video.muted);
// A stream with no audio track still toggles the element's `muted`,
// but "I muted a silent mirror" and "the keyboard is broken" look
// identical without a word.
if (tile.audioBtn.hidden) toast("this mirror has no audio");
}
return true;
}
function defaultJump(detail) {
// 1-9: jump to the Nth mirror in grid order. A no-op gets a toast — a
// silent no-op is the one outcome a keyboard-only operator cannot debug.
const tile = wall.ordered()[detail.index];
if (tile) {
wall.stage(tile.id, { fullscreen: true });
} else {
toast(`no mirror ${detail.index + 1}`);
}
return true;
}
const defaults = {
next: () => {
wall.moveSelection(1);
return true;
},
previous: () => {
wall.moveSelection(-1);
return true;
},
stage: defaultStage,
fullscreen: defaultFullscreen,
wall: defaultWall,
step_next: () => stepStage(1),
step_previous: () => stepStage(-1),
mute: defaultMute,
jump: defaultJump,
};
function onKey(event) {
if (event.defaultPrevented) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
return; // a key typed in a text field is text, not a command
}
// Any wall keypress while staged reveals the overlay, handled or not —
// before dispatch, so an unhandled key still gets the flash. (See the
// docstring: this never preventDefaults.)
if (wall.stagedId()) wall.revealStageControls();
// Match case-insensitively: a caps-locked kiosk keyboard, or a remote hand
// holding Shift, must drive the wall exactly the same. "?" is the only key
// that needs Shift; it lowercases to itself, so the same match covers it.
const key = event.key.toLowerCase();
let command = null;
const detail = { key, event };
switch (key) {
case "j":
case "arrowdown":
command = "next";
break;
case "k":
case "arrowup":
command = "previous";
break;
case "enter":
command = "stage";
break;
case "f":
command = "fullscreen";
break;
case "g":
command = "wall";
break;
case "n":
command = "step_next";
break;
case "p":
command = "step_previous";
break;
case "m":
command = "mute";
break;
case "?":
command = "help";
break;
case "escape":
command = "escape";
break;
default:
if (/^[1-9]$/.test(key)) {
command = "jump";
detail.index = Number(key) - 1; // 0-based
}
break;
}
if (!command) return;
// The page speaks first: when it does not handle the key (returns nothing
// or false), the module performs the wall command. A page that returns true
// has handled it itself — for `help`/`escape` that is the only way.
let handled = onCommand ? onCommand(command, detail) === true : false;
if (!handled && defaults[command]) {
handled = defaults[command](detail) === true;
}
if (handled) event.preventDefault();
}
root.addEventListener("keydown", onKey);
}
+6 -2
View File
@@ -17,6 +17,7 @@
<span class="meta" id="counts">no mirrors</span>
<div class="spacer"></div>
<span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span>
<a class="btn ghost" id="remote-link" href="/remote">Drive the wall</a>
<a class="btn ghost" id="share-link" href="/mirror">Share a screen</a>
</header>
@@ -31,8 +32,11 @@
</p>
</div>
<!-- A staged tile is moved into this container, so its video never restarts. -->
<div id="watch-stage" aria-live="polite"></div>
<!-- A staged tile is moved into this container, so its video never restarts.
The stage is class-keyed (not id-keyed) so a second page's stage (the
/kiosk wall, phase 08) picks up the same rules; #watch-stage stays so
the e2e suites and wall's selectors keep one anchor. -->
<div id="watch-stage" class="stage" aria-live="polite"></div>
<!-- The key list: on a mouseless kiosk `?` is the only way to see the key
map, so it lives in the page (static markup) where a translator can
+73 -685
View File
@@ -1,8 +1,12 @@
/* The watch page: a grid of every live mirror. Click one and it takes the
* stage — the tile element itself moves into the stage container, so the
* RTCPeerConnection and the <video> keep running through the transition. */
* RTCPeerConnection and the <video> keep running through the transition.
* The tile/peer/stage machinery and the one keydown listener live in wall.js
* (createWall, installWallKeys); this page owns the socket, the page chrome
* (counts, net, help, share link) and the key policy (help/escape). */
import { $, bitrateFormat, connectSignaling, loadConfig, refusalNotice, rtcConfig, showNotice, toast } from "./net.js";
import { $, connectSignaling, loadConfig, refusalNotice, rtcConfig, showNotice, toast } from "./net.js";
import { createWall, installWallKeys } from "./wall.js";
const ui = {
grid: $("#grid"),
@@ -22,17 +26,22 @@ const ui = {
const config = await loadConfig();
const rtc = rtcConfig(config);
/** streamId -> {id, sharerId, el, video, badge, sub, pc, pending, attempts, stream, stats} */
const tiles = new Map();
const state = { ws: null };
const state = {
ws: null,
staged: null,
stageParent: null,
stageNext: null,
ticker: 0,
cursor: null,
};
/* The wall owns the tiles, the peers and the stage; the page hands it its
elements and the socket's send — guarded on the socket the way the moved
code guarded on state.ws, so the wall can be built before the socket opens
(net.js stays the only place a socket exists, AGENTS.md §6) — and learns
the counts back through renderCounts(). */
const wall = createWall({
grid: ui.grid,
stageEl: ui.stage,
template: ui.template,
rtc,
send: (payload) => (state.ws ? state.ws.send(payload) : false),
onChange: renderCounts,
stats: true,
});
/* -------------------------------------------------------------- rendering */
@@ -43,456 +52,37 @@ function setNet(next, detail) {
if (label) label.textContent = detail || next;
}
function renderCounts() {
const n = tiles.size;
const live = [...tiles.values()].filter((t) => t.pc && t.pc.connectionState === "connected").length;
function renderCounts({ mirrors, live }) {
const n = mirrors;
ui.counts.textContent =
n === 0 ? "no mirrors" : `${n} mirror${n === 1 ? "" : "s"}${live ? ` · ${live} live` : ""}`;
ui.empty.hidden = n > 0;
ui.grid.hidden = n === 0;
}
function surfaceShort(setting) {
return { window: "window", monitor: "screen", browser: "tab" }[setting] || setting || "";
}
function makeTile(stream) {
const node = ui.template.content.firstElementChild.cloneNode(true);
const tile = {
id: stream.id,
sharerId: stream.sharerId,
el: node,
video: node.querySelector("video"),
badge: node.querySelector(".badge"),
title: node.querySelector(".title"),
sub: node.querySelector(".sub"),
audioBtn: node.querySelector(".audio"),
exitBtn: node.querySelector(".exit"),
pc: null,
pending: [],
attempts: 0,
stream: null,
bytes: 0,
at: Date.now(),
name: stream.name || "Unnamed",
};
node.dataset.stream = stream.id;
node.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
if (state.staged === stream.id) return;
stage(stream.id, { fullscreen: true });
});
tile.audioBtn.addEventListener("click", (event) => {
event.stopPropagation();
setMuted(tile, !tile.video.muted);
});
tile.exitBtn.hidden = true;
tile.exitBtn.addEventListener("click", (event) => {
event.stopPropagation();
unstage();
});
node.querySelector(".expand").addEventListener("click", (event) => {
event.stopPropagation();
if (state.staged === stream.id) unstage();
else stage(stream.id, { fullscreen: true });
});
ui.grid.appendChild(node);
tiles.set(stream.id, tile);
return tile;
}
function paintTile(tile, stream) {
tile.name = stream.name || tile.name;
const label = stream.label || stream.name || "Shared screen";
tile.title.textContent = label;
const dims = stream.width && stream.height ? `${stream.width}×${stream.height}` : "";
const parts = [surfaceShort(stream.displaySurface), dims, stream.audio ? "audio" : "silent"].filter(
Boolean,
);
tile.sub.textContent = `${stream.name || "Unknown device"}${parts.length ? ` · ${parts.join(" · ")}` : ""}`;
tile.el.setAttribute("aria-label", `Watch ${label}`);
tile.audioBtn.hidden = !stream.audio;
// The wall's audio state, readable without a pointer: a screenshot from
// another machine (docs/kiosk-silverblue/dim-probe.py) must show what the
// wall is doing, so the state lives on the element, not in the hover-only
// button. paintTile() seeds it from the server's `audio` flag; setMuted()
// keeps it in step with the element's live state.
tile.el.dataset.audio = stream.audio ? (tile.video.muted ? "off" : "on") : "none";
}
function dropTile(streamId) {
const tile = tiles.get(streamId);
if (!tile) return;
const wasCursor = state.cursor === streamId;
const slot = wasCursor ? orderedTiles().findIndex((t) => t.id === streamId) : -1;
if (state.staged === streamId) unstage();
tiles.delete(streamId);
closePeer(tile);
// closePeer() is a no-op when the leg never negotiated; the stream is
// reset here too so a dropped tile never keeps a dead MediaStream.
tile.stream = null;
tile.el.remove();
renderCounts();
if (wasCursor) {
const remaining = orderedTiles();
if (remaining.length === 0) {
state.cursor = null;
} else {
// The tile that takes over the dropped slot is the nearest; if the
// dropped tile was last, wrap to the first (the cursor wraps too).
setSelected(remaining[slot % remaining.length].id);
}
}
}
function closePeer(tile) {
if (!tile.pc) return;
const pc = tile.pc;
pc.onicecandidate = null;
pc.ontrack = null;
pc.onconnectionstatechange = null;
try {
pc.close();
} catch {
/* already closed */
}
tile.pc = null;
tile.pending = [];
// A retry builds a fresh connection and must not reattach dead tracks.
tile.stream = null;
}
/* ------------------------------------------------------------------ media */
function ensurePeer(tile) {
if (tile.pc) return tile.pc;
const pc = new RTCPeerConnection(rtc);
tile.pc = pc;
pc.onicecandidate = (event) => {
if (event.candidate && state.ws) {
state.ws.send({ type: "signal", to: tile.sharerId, payload: { candidate: event.candidate } });
}
};
// Do NOT `addTransceiver("audio", { direction: "recvonly" })` here. PLAN
// A4: the viewer only answers, and the sharer owns the track set. An
// answerer-side transceiver adds an m-line the offerer never sent and the
// m-lines must stay in the offerer's order — this is the first thing a
// future reader will be tempted to try when audio misbehaves.
pc.ontrack = (event) => {
// One MediaStream per tile, built by us: Chrome and Firefox disagree
// about whether addTransceiver tracks share a stream id, and the app
// must behave identically on both (the kiosk is Firefox, the sharers
// are usually Chrome). The old code assigned a fresh one-track stream
// per ontrack, so the second track to arrive erased the first: the
// audio callback's stream replaced the video's srcObject, the video
// kept playing from the still-attached transceiver, and the sound
// simply never came.
tile.stream = tile.stream || new MediaStream();
tile.stream.addTrack(event.track);
if (tile.video.srcObject !== tile.stream) {
tile.video.srcObject = tile.stream;
tile.video.play().catch(() => {
/* autoplay is covered by `muted`; a rejection means a gesture is needed */
});
}
// A track that ends is removed from the composed stream, not the
// connection: the video leg keeps working when a source's audio goes
// away (the video-only case), and the mute button tracks the live
// track set, so a source that stops offering audio loses its button.
event.track.addEventListener("ended", () => {
if (!tile.stream) return; // the peer was already closed; nothing to clean up
tile.stream.removeTrack(event.track);
const hasAudio = tile.stream.getAudioTracks().length > 0;
tile.audioBtn.hidden = !hasAudio;
tile.el.dataset.audio = hasAudio ? (tile.video.muted ? "off" : "on") : "none";
// The one tile that may be audible just lost its audio: re-mute it
// (which also resets the button/glyph, so the dead 🔇 cannot sit on
// an unpressed state) and say why the sound went — a silent wall in a
// shared room otherwise looks like a bug.
if (!hasAudio && state.staged === tile.id) {
setMuted(tile, true);
toast("This mirror stopped sharing audio.");
}
});
};
pc.onconnectionstatechange = () => {
tile.el.dataset.state = pc.connectionState === "connected" ? "live" : pc.connectionState;
tile.badge.textContent =
pc.connectionState === "connected"
? "live"
: pc.connectionState === "connecting"
? "connecting…"
: pc.connectionState;
renderCounts();
if (pc.connectionState === "failed") {
if (tile.attempts < 3) {
tile.attempts += 1;
tile.badge.textContent = `retry ${tile.attempts}/3`;
closePeer(tile);
setTimeout(() => {
if (tiles.has(tile.id) && state.ws) state.ws.send({ type: "subscribe", streamId: tile.id });
}, 700 * tile.attempts);
} else {
tile.badge.textContent = "unreachable";
}
}
};
return pc;
}
async function onSignal(message) {
const tile = [...tiles.values()].find((t) => t.sharerId === message.from);
if (!tile) return;
const pc = ensurePeer(tile);
const payload = message.payload || {};
try {
if (payload.sdp) {
await pc.setRemoteDescription(payload.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
state.ws.send({ type: "signal", to: message.from, payload: { sdp: pc.localDescription } });
const queued = tile.pending.splice(0);
for (const candidate of queued) await pc.addIceCandidate(candidate).catch(() => {});
} else if (payload.candidate) {
if (pc.remoteDescription && pc.remoteDescription.type) {
await pc.addIceCandidate(payload.candidate);
} else {
tile.pending.push(payload.candidate);
}
}
} catch (err) {
console.warn("viewer signal rejected", err);
}
}
/* ------------------------------------------------------------------- audio */
/**
* The one place mute state changes: the tile's audio button, the `m` key and
* the staged-only audio policy all go through here, so the video's
* `muted`/`volume`, the button text, `aria-pressed` and `title` cannot drift
* apart. Kept as `(tile, muted)` on purpose.
*
* The policy: **only the staged mirror may be audible.** Unmuting a tile in
* the grid stages it first — a wall of twenty mirrors must not become twenty
* sound sources, and staging is what a person who wants a mirror's audio was
* about to do anyway — and every other tile is muted through this same
* helper, so the button, the key and the policy can never disagree. Unmuting
* never happens on its own: every path that calls `setMuted(tile, false)`
* starts from a click or a keypress (SECURITY.md §4), and `stage()` and
* `unstage()` never change mute state on their own — joining or leaving the
* stage is never a sound source, and audio is never restored after an
* unstage or a reconnect.
*/
function setMuted(tile, muted) {
if (!muted && state.staged !== tile.id) {
stage(tile.id, { fullscreen: true }); // a grid tile may not become audible in the grid
}
tile.video.muted = muted;
tile.video.volume = 1;
if (!muted) {
// Exactly one tile may be audible: mute every other tile first, then
// this one, so there is never a frame in which two mirrors play.
for (const other of tiles.values()) {
if (other !== tile) setMuted(other, true);
}
tile.video.play().catch(() => {}); // a muted play may be suspended
}
tile.audioBtn.textContent = muted ? "🔇" : "🔊";
tile.audioBtn.setAttribute("aria-pressed", String(!muted));
tile.audioBtn.title = muted ? "Unmute" : "Mute";
// The button tracks the live track set (paintTile and the `ended` handler
// keep it honest), so it is the one source of truth for "does this mirror
// have audio at all" — a toggle on a silent mirror is "none", never "on".
const hasAudio = !tile.audioBtn.hidden;
tile.el.dataset.audio = hasAudio ? (muted ? "off" : "on") : "none";
}
/* ------------------------------------------------------------------ stage */
/* The staged overlay (caption bar + control cluster) is hidden at rest so a
shared terminal's bottom line is never covered. Pointer activity on the
stage, and any wall keypress while staged (the kiosk has no mouse),
reveal it via `data-reveal`; this timeout is what fades it back, so a
parked pointer or a one-time keypress never pins it up. */
const STAGE_REVEAL_IDLE_MS = 3000;
let stageRevealTimer = 0;
function revealStageControls() {
if (!state.staged) return;
const tile = tiles.get(state.staged);
if (!tile) return;
tile.el.dataset.reveal = "";
clearTimeout(stageRevealTimer);
stageRevealTimer = setTimeout(() => {
stageRevealTimer = 0;
// The tile may have been unstaged or replaced (n/p, 1-9, a click)
// before the timer fires; only the tile still on stage loses the
// attribute. (unstage() clears the timer, so this guard also covers
// a replacement whose keepFullscreen unstage ran in between.)
if (state.staged === tile.id) delete tile.el.dataset.reveal;
}, STAGE_REVEAL_IDLE_MS);
}
/* A `document.exitFullscreen()` that `unstage()` started is in flight.
The exit settles asynchronously, and a keypress in that window (g followed
by an immediate Enter) stages a new tile before the exit's
`fullscreenchange` event lands: `state.staged` is then the NEW tile, and
the handler would tear the fresh stage down with the old exit. While the
flag is set, an exit event belongs to the unstage that already tore the
stage down synchronously, so it is ignored; the flag is cleared when the
document becomes fullscreen again (a re-stage's request won over the exit)
or when the exit settles. */
let fullscreenExitInFlight = false;
export function stage(streamId, options = {}) {
const tile = tiles.get(streamId);
if (!tile) return;
if (state.staged && state.staged !== streamId) {
// Replacing the tile on stage: keep the stage's fullscreen. The fullscreen
// element is the stage container itself, so the replacement moves into a
// stage that is already fullscreen. Exiting first would deliver a
// fullscreenchange event after state.staged is already the new tile, and
// the handler would unstage the replacement — n/p stepping, 1-9 jumps and
// clicking another tile all go through this path on a kiosk.
unstage({ keepFullscreen: true });
}
state.stageParent = tile.el.parentNode;
state.stageNext = tile.el.nextSibling;
ui.stage.appendChild(tile.el);
document.body.classList.add("staged");
state.staged = streamId;
// The cursor sits on whatever is on stage, so the selection stays coherent
// while the wall is hidden behind it.
state.cursor = streamId;
tile.el.dataset.selected = "";
tile.exitBtn.hidden = false;
// The cursor moves with the stage: focus the staged tile so Esc/m/g keep
// working no matter what held focus before (the grid is hidden behind it).
tile.el.focus({ preventScroll: true });
if (options.fullscreen !== false) {
const target = ui.stage;
if (target.requestFullscreen) {
target.requestFullscreen({ navigationUI: "hide" }).catch(() => {
/* in-page stage still covers the viewport; fullscreen is a bonus */
});
}
}
tile.video.play().catch(() => {});
}
export function unstage(options = {}) {
if (!state.staged) return;
const tile = state.staged ? tiles.get(state.staged) : null;
state.staged = null;
document.body.classList.remove("staged");
// The reveal belongs to the stage: every exit (Exit click, `g`, Esc, or a
// replacement's keepFullscreen) clears the pending timeout and the
// departing tile's `data-reveal`, so a stale reveal can never leak back
// into the grid — grid tiles never read the attribute, and re-staging
// must start hidden.
clearTimeout(stageRevealTimer);
stageRevealTimer = 0;
if (tile) delete tile.el.dataset.reveal;
if (tile) {
tile.exitBtn.hidden = true;
if (state.stageParent) {
state.stageParent.insertBefore(tile.el, state.stageNext);
} else {
ui.grid.appendChild(tile.el);
}
}
state.stageParent = null;
state.stageNext = null;
if (tile) {
// Walking back to the wall is always silent: the kiosk in a shared room
// depends on it, and on a replacement (keepFullscreen) the departing tile
// goes quiet the same way. Re-staging the tile does not restore sound —
// every audible state must trace back to a gesture (SECURITY.md §4).
setMuted(tile, true);
}
const restoreFocus = () => {
// The tile is back in the grid at its old position; the cursor goes with
// it. The grid's focusin handler then agrees the selection with focus —
// focusTile() declines when focus is already on the tile, so this cannot
// loop. If a different tile was staged in the meantime (the exit is
// asynchronous), that tile already holds the cursor, so don't steal it.
if (tile && state.staged !== tile.id) tile.el.focus({ preventScroll: true });
};
// keepFullscreen: the caller is re-staging into the same stage container in
// the same tick, so its fullscreen (if granted) is left in place and its
// focus() lands the cursor on the new tile.
if (!options.keepFullscreen && document.fullscreenElement && document.exitFullscreen) {
// Exiting fullscreen makes the browser re-validate focus and drops it to
// <body>, which would cancel a focus() made earlier in this tick — so
// the cursor is handed back to the tile only after the exit has settled.
// The in-flight flag keeps the exit's late `fullscreenchange` from
// tearing down a stage re-entered in the meantime (see the handler).
fullscreenExitInFlight = true;
document.exitFullscreen().then(restoreFocus).catch(() => {
// A rejected exit never settles: if the document is still fullscreen
// the exit was pre-empted (or lost user activation) and the flag is
// stale — drop it so a genuine later exit still unstages.
if (document.fullscreenElement) fullscreenExitInFlight = false;
restoreFocus();
});
} else {
restoreFocus();
}
}
// The browser's fullscreen state is page policy: /watch unstages when the
// browser (or the kiosk's window manager) leaves fullscreen on its own. The
// exit-race flag and its two behaviours live in wall.js (the
// handleFullscreenChange hook); the listener stays here so the kiosk page can
// observe the same event without the /watch policy (phase 08 task 04).
document.addEventListener("fullscreenchange", () => {
if (document.fullscreenElement) {
// (Re-)entered fullscreen — a re-stage's request won over our exit, so
// the in-flight flag is stale from this point on.
fullscreenExitInFlight = false;
return;
}
if (fullscreenExitInFlight) {
// Our own unstage's exit settling: the stage was already torn down
// synchronously, and a keypress in the exit window may have re-staged —
// the event must not take that new stage down with it.
fullscreenExitInFlight = false;
return;
}
// The browser (or the kiosk's window manager) left fullscreen on its own;
// the in-page stage is what the operator asked to leave.
if (state.staged) unstage();
wall.handleFullscreenChange(document.fullscreenElement !== null);
});
if (ui.unstage) ui.unstage.addEventListener("click", unstage);
// Pointer activity on the stage reveals the overlay. `pointerdown` — not
// just `pointermove` — is a deliberate touch tap, so a phone watcher can
// reach the controls without a hover; the idle timeout is what fades them
// again, so a parked pointer never pins the overlay up.
ui.stage.addEventListener("pointermove", revealStageControls);
ui.stage.addEventListener("pointerdown", revealStageControls);
ui.stage.addEventListener("pointermove", wall.revealStageControls);
ui.stage.addEventListener("pointerdown", wall.revealStageControls);
// The only stage-exit control is the per-tile `.exit` button: there is no
// page-level unstage control, and there must not be a second one.
/* ------------------------------------------------------------------ socket */
function subscribe(streamId) {
if (state.ws) state.ws.send({ type: "subscribe", streamId });
}
function absorb(stream) {
if (!stream || !stream.id) return;
const tile = tiles.get(stream.id) || makeTile(stream);
paintTile(tile, stream);
renderCounts();
if (!tile.pc) subscribe(stream.id);
}
state.ws = connectSignaling({
refused: !!config.refused,
refusedReason: config.refusedMessage,
@@ -505,30 +95,24 @@ state.ws = connectSignaling({
onClose() {
// The roster is invalid the moment the socket drops; the fresh `welcome`
// after reconnect rebuilds it.
for (const streamId of [...tiles.keys()]) {
const tile = tiles.get(streamId);
closePeer(tile);
tile.el.dataset.state = "stale";
tile.badge.textContent = "reconnecting…";
}
wall.closePeers();
},
onMessage(message) {
switch (message.type) {
case "welcome":
for (const stream of message.streams || []) absorb(stream);
renderCounts();
for (const stream of message.streams || []) wall.absorb(stream);
break;
case "stream_added":
absorb(message.stream);
wall.absorb(message.stream);
break;
case "stream_updated":
absorb(message.stream);
wall.absorb(message.stream);
break;
case "stream_removed":
dropTile(message.streamId);
wall.drop(message.streamId);
break;
case "signal":
onSignal(message);
wall.onSignal(message);
break;
case "error":
console.warn("server:", message.code, message.message);
@@ -540,248 +124,52 @@ state.ws = connectSignaling({
},
});
/* ----------------------------------------------------------------- ticker */
state.ticker = setInterval(async () => {
for (const tile of tiles.values()) {
if (!tile.pc) continue;
try {
const report = await tile.pc.getStats();
let fps = 0;
let bytes = 0;
let w = 0;
let h = 0;
report.forEach((entry) => {
if (entry.type === "inbound-rtp" && entry.kind === "video") {
fps = entry.framesPerSecond || 0;
bytes = entry.bytesReceived || 0;
w = entry.frameWidth || w;
h = entry.frameHeight || h;
}
});
const now = Date.now();
const dt = Math.max(0.25, (now - tile.at) / 1000);
const bps = ((bytes - tile.bytes) / dt) * 8;
tile.bytes = bytes;
tile.at = now;
if (w && h) {
const dims = `${w}×${h}`;
const head = tile.sub.textContent.split(" · ").slice(0, 1);
tile.sub.textContent = [...head, dims, bitrateFormat(bps), `${Math.round(fps)} fps`].join(" · ");
}
} catch {
/* stats are advisory */
}
}
}, 1500);
/* -------------------------------------------------------- keyboard router */
/* The kiosk rule this page lives by: no command may require a pointer, and no
pointer affordance may be the only way to see state. This is the ONE place
a keypress on /watch is read — one listener, one switch. Every command in the
key map (j/k/arrows, Enter/f, g, n/p, 1-9, m, ?, Esc) is a case here, never
a new listener. Chords with Ctrl/Cmd/Alt belong to the browser (back, reload,
copy), and a key in a text field is text, not a command, so both bail out
before the switch. Only keys this router actually handled are
preventDefault'ed. */
pointer affordance may be the only way to see state. The one keydown
listener is installed by wall.js (installWallKeys): one listener, one
switch. Every command in the key map (j/k/arrows, Enter/f, g, n/p, 1-9, m,
?, Esc) is a case there, never a new listener. Chords with Ctrl/Cmd/Alt
belong to the browser (back, reload, copy), and a key in a text field is
text, not a command, so both bail out before the switch. Only keys the
router actually handled are preventDefault'ed.
function orderedTiles() {
// DOM order == insertion order, which is the grid order renderCounts() uses.
return [...tiles.values()];
}
/watch's onCommand is a thin observer: the wall commands (j/k, Enter, f, g,
n/p, m, 1-9) run in wall.js's defaults, and the page answers only `help`
and `escape` — page policy, no default. */
function selectedTile() {
return state.cursor ? tiles.get(state.cursor) || null : null;
}
function focusTile(tile) {
// Focus follows selection (tiles are tabindex="0"). preventScroll keeps the
// grid from jumping while a remote hand walks it. If focus is already inside
// this tile — Tab reached one of its buttons — the selection already agrees,
// so don't steal focus back off the control the operator is on.
const active = document.activeElement;
if (active === tile.el || (active && tile.el.contains(active))) return;
tile.el.focus({ preventScroll: true });
}
function setSelected(streamId) {
const tile = tiles.get(streamId);
if (!tile) return;
const previous = selectedTile();
if (previous && previous !== tile) delete previous.el.dataset.selected;
tile.el.dataset.selected = "";
state.cursor = streamId;
focusTile(tile);
}
function moveSelection(delta) {
const ordered = orderedTiles();
const n = ordered.length;
if (n === 0) return;
const index = state.cursor ? ordered.findIndex((tile) => tile.id === state.cursor) : -1;
if (index === -1) {
// No live cursor yet: the first j lands on the first tile, the first k on
// the last. A wall with twenty mirrors is navigated in a loop from a
// remote, so the ends wrap.
setSelected(ordered[delta > 0 ? 0 : n - 1].id);
return;
}
setSelected(ordered[(index + delta + n) % n].id);
}
/**
* `?` shows and hides the key list. Focus is deliberately never touched: the
* selected tile holds it throughout, so `?` → `?` → `Esc` lands the operator
* exactly where they were — stage included.
*/
function toggleHelp() {
ui.help.hidden = !ui.help.hidden;
}
function stageSelectedOrFirst() {
// Enter and `f` (when unstaged) share a target: the selected tile, or the
// first tile when nothing is selected yet. With nothing on the wall there
// is nothing to show, so say why instead of staying silent.
const tile = selectedTile() || orderedTiles()[0] || null;
if (!tile) {
toast("no mirrors to stage");
return;
}
stage(tile.id, { fullscreen: true });
}
function stepStage(delta) {
// `n`/`p` *stage* the neighbour, not just move the selection: a remote
// operator cannot reach an Enter afterwards, so the step is the switch.
// The cursor sits on the staged tile while staged (stage() moves it), so
// stepping from the stage walks the wall; stage() unstages the previous
// tile, and unstaging moves — never recreates — the tile element.
const ordered = orderedTiles();
if (ordered.length === 0) {
toast("no mirrors to switch to");
return;
}
let index = state.cursor ? ordered.findIndex((tile) => tile.id === state.cursor) : -1;
if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n lands first, p wraps to last
const next = ordered[(index + delta + ordered.length) % ordered.length];
stage(next.id, { fullscreen: true });
}
function onWallKey(event) {
if (event.defaultPrevented) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
return; // a key typed in a text field is text, not a command
}
// Any wall keypress while staged reveals the overlay, handled or not: a
// key in a mouseless kiosk is the only way to read the caption and reach
// the controls (a silent no-op key flashing the caption is the
// debuggable outcome). The idle timeout fades it again, and this call
// never preventDefaults, so unhandled keys keep their defaults.
if (state.staged) revealStageControls();
// Match case-insensitively: a caps-locked kiosk keyboard, or a remote hand
// holding Shift, must drive the wall exactly the same. "?" is the only key
// that needs Shift; it lowercases to itself, so the same match covers it.
const key = event.key.toLowerCase();
switch (key) {
case "j":
case "arrowdown":
moveSelection(1);
event.preventDefault();
break;
case "k":
case "arrowup":
moveSelection(-1);
event.preventDefault();
break;
case "enter":
stageSelectedOrFirst();
event.preventDefault(); // the default would click a focused button instead
break;
case "f":
if (state.staged) {
// The kiosk's window manager can drop the *window* out of fullscreen
// without mirror noticing (docs/kiosk-silverblue.md: window-config.ini
// forces the window fullscreen; an element requestFullscreen() on top
// of that is allowed and simply no-ops if the browser refuses). So
// `f` re-requests rather than tracking state. When unstaged it stages
// like Enter.
if (ui.stage.requestFullscreen) {
ui.stage.requestFullscreen({ navigationUI: "hide" }).catch(() => {});
}
} else {
stageSelectedOrFirst();
}
event.preventDefault();
break;
case "g": {
const wasStaged = state.staged !== null;
unstage();
// When already unstaged the wall *is* the visible result "back to the
// wall", so there is nothing to toast; the key was only handled if it
// changed state.
if (wasStaged) event.preventDefault();
break;
}
case "n":
case "p":
stepStage(key === "n" ? 1 : -1);
event.preventDefault();
break;
case "m": {
const tile = (state.staged && tiles.get(state.staged)) || selectedTile();
if (!tile) {
toast("no mirror to mute");
} else {
setMuted(tile, !tile.video.muted);
// A stream with no audio track still toggles the element's `muted`,
// but "I muted a silent mirror" and "the keyboard is broken" look
// identical without a word.
if (tile.audioBtn.hidden) toast("this mirror has no audio");
}
event.preventDefault();
break;
}
case "?":
toggleHelp();
event.preventDefault();
break;
function onCommand(command, detail) {
switch (command) {
case "help":
// `?` shows and hides the key list. Focus is deliberately never touched:
// the selected tile holds it throughout, so `?` → `?` → `Esc` lands the
// operator exactly where they were — stage included. The toggle always
// flips, so the key was always handled.
ui.help.hidden = !ui.help.hidden;
return true;
case "escape":
// The overlay sits above the stage (z-index 70 over 50), so Esc closes
// it FIRST and only leaves the stage when it was not open: ? over a
// staged mirror is two Escapes out of the wall, in that order.
if (!ui.help.hidden) {
ui.help.hidden = true;
event.preventDefault();
} else if (state.staged) {
unstage();
event.preventDefault();
return true;
}
break;
if (wall.stagedId()) {
wall.unstage();
return true;
}
// Nothing open: not handled — the browser keeps its default.
return false;
default:
// 1-9: jump to the Nth mirror in grid order. A no-op gets a toast — a
// silent no-op is the one outcome a keyboard-only operator cannot debug.
if (/^[1-9]$/.test(key)) {
const tile = orderedTiles()[Number(key) - 1];
if (tile) {
stage(tile.id, { fullscreen: true });
} else {
toast(`no mirror ${key}`);
}
event.preventDefault();
}
break;
// j/k, Enter, f, g, n/p, m, 1-9 are wall commands: the defaults in
// wall.js perform them; /watch has no policy of its own for them.
return false;
}
}
document.addEventListener("keydown", onWallKey);
installWallKeys({ wall, onCommand });
// Tab is the browser's own cursor, and it must agree with j/k: when a tile
// (or a control inside one) receives focus, the selection moves with it.
@@ -790,8 +178,8 @@ document.addEventListener("keydown", onWallKey);
// to a tile's own button is not stolen back to the tile.
ui.grid.addEventListener("focusin", (event) => {
const node = event.target instanceof Element ? event.target.closest(".tile") : null;
if (node && node.dataset.stream && tiles.has(node.dataset.stream)) {
setSelected(node.dataset.stream);
if (node && node.dataset.stream && wall.tile(node.dataset.stream)) {
wall.setSelected(node.dataset.stream);
}
});
@@ -801,4 +189,4 @@ const refusal = refusalNotice(config);
if (refusal) showNotice(ui.notice, refusal);
if (ui.share) ui.share.href = `${location.origin}/mirror`;
renderCounts();
renderCounts({ mirrors: 0, live: 0 });
+360
View File
@@ -0,0 +1,360 @@
"""End-to-end: the /kiosk display arranges itself, and obeys one client over another.
Phase 08, task 05: the two properties the TODO item claims — **it arranges
itself on arrival** (point a display at /kiosk and it does what the shared
layout already says, with nobody standing at the box) and **one client's
action moves another client's display** (a keypress on one kiosk re-arranges
the other, asserted on the second display's decoded pixels) — plus the
``followers`` count (displays only, not plain viewers) and the teardown
fallback (a dead mirror returns every display to the wall in the same frame
set; no display may sit on a dead tile).
Isolated contexts are the unit of "a client": each is its own socket and its
own peer id, which is exactly what "another client" means to the server. The
media path itself is proven by ``test_live_mirror.py``; this suite proves the
*state* path and still asserts on real decoded frames, so nothing is taken on
trust.
The suite is pointer-free: a test that reaches for the mouse proves nothing
about a kiosk. The only input a display receives here is a keypress; the
server state is driven by raw ``WebSocket`` frames in ``page.evaluate`` — a
wire-level client, not a pointer. That is what the meta-test at the bottom
permits: it bans pointer calls, not the driver's socket.
Fullscreen is asserted on the page's own knobs — ``body[data-fullscreen]``
and the staged state — and **never on ``document.fullscreenElement``**:
headless Chromium may refuse an element ``requestFullscreen()`` without user
activation, and the real kiosk's window manager forces the window fullscreen
anyway (``docs/kiosk-silverblue/window-config.ini``). The knobs are what the
app controls (the same stance ``kiosk.js`` documents for its best-effort
request); a "strengthened" assertion on ``fullscreenElement`` turns this
suite into a flake.
"""
from __future__ import annotations
import time
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import expect
from .conftest import frames_matching, start_sharing, stop_sharing
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
@pytest.fixture
def device(base_url, browser, request):
"""Open a page as its own device (isolated context, fresh peer id).
Uncaught page errors and console errors are captured from the first line
of the test and surfaced through the ``errors == []`` assertion
(AGENTS.md §8). Contexts are closed by finalizers, so a leaked peer here
cannot surface as a tile count in another suite's test (they share one
session-scoped server).
"""
opened = []
def _open(path: str = "/", errors: list | None = None):
context, page = open_device(base_url, browser, request, path, errors)
opened.append(context)
return page
return _open
def open_device(base_url, browser, request, path: str, errors: list | None = None):
"""Open one device; return ``(context, page)``.
The context is closed exactly once — by a finalizer at test end, or by
the test itself (the guard flag is what makes a finalizer after an
explicit close a no-op).
"""
context = browser.new_context(viewport={"width": 1100, "height": 720})
closed = [False]
def _finalize() -> None:
if not closed[0]:
closed[0] = True
context.close()
request.addfinalizer(_finalize)
page = context.new_page()
if errors is not None:
page.on("pageerror", lambda error: errors.append(f"pageerror: {error}"))
page.on(
"console",
lambda message: (
errors.append(f"console: {message.text}") if message.type == "error" else None
),
)
page.goto(f"{base_url}{path}", wait_until="domcontentloaded")
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
return context, page
def mirror_ids(base_url, count: int, timeout: float = 15.0) -> list[str]:
"""The server's truth about what is live (AGENTS.md §8, rung 1)."""
deadline = time.time() + timeout
last: list[str] = []
while time.time() < deadline:
payload = httpx.get(f"{base_url}/api/streams", timeout=5).json()
last = [mirror["id"] for mirror in payload["mirrors"]]
if len(last) == count:
return last
time.sleep(0.2)
raise AssertionError(f"expected {count} mirror(s) on /api/streams, saw {last}")
def kiosk_state(base_url) -> dict:
return httpx.get(f"{base_url}/api/kiosk", timeout=5).json()
def wait_for_kiosk(base_url, predicate, what: str, timeout: float = 15.0) -> dict:
deadline = time.time() + timeout
last: dict = {}
while time.time() < deadline:
last = kiosk_state(base_url)
if predicate(last):
return last
time.sleep(0.2)
raise AssertionError(f"{what}; last /api/kiosk was {last}")
# The driver: a plain WebSocket client, opened from inside a page so the
# handshake runs in a real browser. It is wire-level state, not a pointer —
# the meta-test below allows it on exactly that ground. It closes the socket
# once the server has acknowledged the change: the layout is process state,
# not the driver's, so the kiosk's `welcome` (which carries it) cannot race
# the driver's exit.
DRIVER = """
async ([url, staged, fullscreen]) => {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = () => reject(new Error('driver socket did not open'));
});
const nextFrame = (type) => new Promise((resolve, reject) => {
const timer = setTimeout(
() => { ws.close(); reject(new Error(`driver timed out waiting for ${type}`)); },
10_000);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === type) { clearTimeout(timer); resolve(message); }
};
});
ws.send(JSON.stringify({ type: 'hello', role: 'viewer', name: '', device: 'e2e layout driver' }));
const welcome = await nextFrame('welcome');
ws.send(JSON.stringify({ type: 'arrange', staged, fullscreen }));
const layout = await nextFrame('layout');
ws.close();
return { peerId: welcome.peerId, layout };
}
"""
def drive_layout(page, base_url, staged: str | None, fullscreen: bool) -> dict:
"""Send one `arrange` frame over a raw socket; return the server's ack."""
ws_url = base_url.replace("http://", "ws://") + "/ws"
return page.evaluate(DRIVER, [ws_url, staged, fullscreen])
def expect_staged(page, stream_id: str, timeout: int = 15_000) -> None:
"""The display's stage holds exactly the tile the layout names."""
expect(page.locator("#kiosk-stage .tile")).to_have_count(1, timeout=timeout)
assert page.locator("#kiosk-stage .tile").get_attribute("data-stream") == stream_id
class TestArrivalIsArrangement:
def test_a_display_opened_after_an_arrange_shows_it_without_input(self, device, base_url):
"""Arrival is arrangement: the state is applied before the first tile
paints, so the person pointing a kiosk at /kiosk touches nothing."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
# The controller acts first, over a raw socket: stage + fullscreen, and
# the server's own ack says it took.
driver = device("/watch", errors)
ack = drive_layout(driver, base_url, stream_id, True)
assert ack["layout"]["staged"] == stream_id
assert ack["layout"]["fullscreen"] is True
# The display opens *after* the arrange. No key, no pointer: it must
# settle on the stage.
kiosk = device("/kiosk", errors)
expect(kiosk.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
# The page's own knob, not document.fullscreenElement: headless may
# refuse the request and the window manager's window-fullscreen is the
# guarantee, so the knob (what the app controls) is the assertion.
expect(kiosk.locator("body")).to_have_attribute("data-fullscreen", "true", timeout=15_000)
expect_staged(kiosk, stream_id)
# The frame assertion is what proves media crossed to the display, not
# just a CSS class (AGENTS.md §5): decoded, *changing* pixels on the
# staged <video>.
expect(kiosk.locator("#kiosk-stage .tile[data-state='live']")).to_have_count(
1, timeout=25_000
)
frames = frames_matching(kiosk, "#kiosk-stage video")
assert frames["width"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["height"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["distinct"] >= 2, f"the staged video is frozen: {frames}"
# One source of truth: the server says what the pixels show.
state = kiosk_state(base_url)
assert state["staged"] == stream_id
assert state["fullscreen"] is True
# The display is counted; the driver (a plain viewer) is not.
assert state["followers"] == 1, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestOneClientMovesAnother:
def test_a_keypress_on_one_display_rearranges_the_other(self, device, base_url):
"""One action, two displays, one source of truth — and the second
display never receives a pointer call (or any input at all)."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
first = device("/kiosk", errors)
second = device("/kiosk", errors) # nothing is ever typed or pointed here
expect(first.locator("body")).to_have_attribute("data-layout", "wall")
expect(second.locator("body")).to_have_attribute("data-layout", "wall")
# The only input in this test: one keypress on the first display.
first.keyboard.press("Enter")
expect(first.locator("body")).to_have_attribute("data-layout", "stage", timeout=5_000)
expect(second.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect_staged(second, stream_id)
# The display is showing it, not merely labelled: a live, moving leg.
expect(second.locator("#kiosk-stage .tile[data-state='live']")).to_have_count(
1, timeout=25_000
)
frames = frames_matching(second, "#kiosk-stage video")
assert frames["width"] > 0, f"no decoded frame on the second display: {frames}"
assert frames["distinct"] >= 2, f"the second display's video is frozen: {frames}"
# The server agrees with both displays.
state = kiosk_state(base_url)
assert state["staged"] == stream_id, state
assert errors == [], f"uncaught browser errors: {errors}"
def test_n_and_p_on_one_display_walk_the_other_through_the_mirrors(self, device, base_url):
errors: list = []
for _ in range(2):
start_sharing(device("/mirror", errors))
first_id, second_id = mirror_ids(base_url, 2)
first = device("/kiosk", errors)
second = device("/kiosk", errors)
expect(second.locator("body")).to_have_attribute("data-layout", "wall")
expect(second.locator(".tile")).to_have_count(2, timeout=15_000)
# n: no cursor yet, so it lands on the first mirror…
first.keyboard.press("n")
expect_staged(second, first_id)
# …p walks to the second…
first.keyboard.press("p")
expect_staged(second, second_id)
# …and wraps back to the first.
first.keyboard.press("p")
expect_staged(second, first_id)
# The server walked the same way (checked at the wrap, where a stale
# broadcast would still show the second mirror).
state = kiosk_state(base_url)
assert state["staged"] == first_id, state
# Both mirrors survived the walking: the display's wall holds both.
expect(second.locator(".tile")).to_have_count(2)
expect(second.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_dead_mirror_returns_every_display_to_the_wall(self, device, base_url):
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
first = device("/kiosk", errors)
second = device("/kiosk", errors)
first.keyboard.press("Enter")
expect(first.locator("body")).to_have_attribute("data-layout", "stage", timeout=5_000)
expect_staged(second, stream_id)
stop_sharing(sharer)
# No display may sit on a dead tile: both back on the wall, in time.
expect(first.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(second.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(first.locator("#kiosk-stage .tile")).to_have_count(0)
expect(second.locator("#kiosk-stage .tile")).to_have_count(0)
# And the wall is actually empty, not just unstaged: the dead tile is
# gone, so there is no stale <video> to clear.
expect(first.locator(".tile")).to_have_count(0, timeout=10_000)
expect(second.locator(".tile")).to_have_count(0, timeout=10_000)
state = wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the layout cleared")
assert state["followers"] == 2, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestFollowers:
def test_followers_counts_displays_only_over_a_real_socket_lifecycle(
self, base_url, browser, request
):
"""`followers` is how many *displays* are obeying the wall — a plain
viewer (a /watch tab) is not one, and a display leaving is noticed
without any state to poll: the count is derived from the roster."""
errors: list = []
assert kiosk_state(base_url)["followers"] == 0, "the suite must start with no displays"
context, _kiosk = open_device(base_url, browser, request, "/kiosk", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the display counted")
# A plain viewer joins: the wall follows the same state, but the count
# is about displays, not about who is reading the state.
open_device(base_url, browser, request, "/watch", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the viewer not counted")
# The display leaves (its socket closes through a real context close)
# and the count falls back with it.
context.close()
wait_for_kiosk(base_url, lambda s: s["followers"] == 0, "the display counted out")
assert errors == [], f"uncaught browser errors: {errors}"
class TestSuiteIsPointerFree:
"""The kiosk guarantee, enforced on this file itself.
A test that reaches for the mouse proves nothing about a kiosk. The
guarantee lives in the code, not in reviewer attention, so this meta-test
bans pointer calls on this file and fails the suite the moment one slips
back in. The driver's raw ``WebSocket`` frames (``page.evaluate``) are
wire-level, not a pointer, and are allowed *explicitly* on that ground —
this guard bans the two pointer calls (click and hover) anywhere in this
file, nothing else, and does not loosen to skip the driver's block: a
pointer call anywhere in this file is a failure.
"""
def test_the_suite_never_reaches_for_the_pointer(self):
source = Path(__file__).read_text()
# Built from parts so this very assertion does not contain the banned
# literals (a plain string here would trip the check on itself).
assert ("cli" + "ck(") not in source, "a pointer call slipped into the kiosk suite"
assert ("hover" + "(") not in source, "a pointer call slipped into the kiosk suite"
+4 -2
View File
@@ -544,7 +544,7 @@ class TestAddressGate:
like a bug in the tool.
"""
@pytest.mark.parametrize("path", ["/mirror", "/watch"])
@pytest.mark.parametrize("path", ["/mirror", "/watch", "/kiosk"])
def test_a_refused_address_sees_why_and_none_of_the_app(self, browser, restricted_url, path):
context = browser.new_context(viewport={"width": 1100, "height": 720})
page = context.new_page()
@@ -557,7 +557,9 @@ class TestAddressGate:
# front of a proxy is undiagnosable.
expect(page.locator("body")).to_contain_text("127.0.0.1")
# None of the app shell leaked past the gate.
expect(page.locator("#start, #grid, #notice")).to_have_count(0)
expect(page.locator("#start, #grid, #notice, #kiosk-grid, #kiosk-stage")).to_have_count(
0
)
finally:
context.close()
+423
View File
@@ -0,0 +1,423 @@
"""End-to-end: a hand on another machine drives the /kiosk display.
Phase 09, task 03: the whole loop the TODO item claims — a mirror on the wall,
a box across the room with no keyboard, and a person on a laptop or phone who
can put a mirror on the stage, send it fullscreen, or put the wall back. The
controller is asserted *on the display's DOM and on the server's numbers*,
never on the controller's optimism (a page that paints a result and hopes is
the regression this suite exists to catch), and the media is asserted as
decoded, changing pixels on the display's staged ``<video>`` (AGENTS.md §5).
Unlike the kiosk suite, this one **clicks**: a person with a mouse driving a
machine that has none is the entire feature. Isolated contexts are the unit
of "a device": each is its own socket and its own peer id, which is exactly
what "another client" means to the server. Uncaught page errors and console
errors are captured from the first line of every test and surfaced through
the ``errors == []`` assertion.
Two invariants are asserted here because they are the product's, not the
browser's:
* **A controller costs a sharer nothing** — with only ``/remote`` open, the
stream reports ``viewers: 0`` and the server's ``watching`` count is 0: the
controller never subscribes and never opens a media leg (PLAN.md §6's mesh
cost). Opening a real ``/watch`` is what moves those numbers.
* **``followers`` counts displays, never controllers** — a second ``/remote``
does not raise the count, so "no display following" keeps meaning what it
says.
Fullscreen is asserted on the page's own knobs — ``body[data-fullscreen]``
and the server's state — and **never on ``document.fullscreenElement``**:
headless Chromium may refuse an element ``requestFullscreen()`` without user
activation, and the real kiosk's window manager forces the window fullscreen
anyway (``docs/kiosk-silverblue/window-config.ini``). Asserting on the
browser's concession would turn this suite into a flake.
"""
from __future__ import annotations
import time
import httpx
import pytest
from playwright.sync_api import expect
from .conftest import frames_matching, start_sharing, stop_sharing
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
@pytest.fixture
def device(base_url, browser, request):
"""Open a page as its own device (isolated context, fresh peer id).
Uncaught page errors and console errors are captured from the first line
of the test and surfaced through the ``errors == []`` assertion
(AGENTS.md §8). Contexts are closed by finalizers, so a leaked peer here
cannot surface as a tile count in another suite's test (they share one
session-scoped server).
"""
opened = []
def _open(path: str = "/", errors: list | None = None):
context, page = open_device(base_url, browser, request, path, errors)
opened.append(context)
return page
return _open
def open_device(base_url, browser, request, path: str, errors: list | None = None):
"""Open one device; return ``(context, page)``.
The context is closed exactly once — by a finalizer at test end, or by
the test itself (the guard flag is what makes a finalizer after an
explicit close a no-op).
"""
context = browser.new_context(viewport={"width": 1100, "height": 720})
closed = [False]
def _finalize() -> None:
if not closed[0]:
closed[0] = True
context.close()
request.addfinalizer(_finalize)
page = context.new_page()
if errors is not None:
page.on("pageerror", lambda error: errors.append(f"pageerror: {error}"))
page.on(
"console",
lambda message: (
errors.append(f"console: {message.text}") if message.type == "error" else None
),
)
page.goto(f"{base_url}{path}", wait_until="domcontentloaded")
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
return context, page
def mirror_ids(base_url, count: int, timeout: float = 15.0) -> list[str]:
"""The server's truth about what is live (AGENTS.md §8, rung 1)."""
deadline = time.time() + timeout
last: list[str] = []
while time.time() < deadline:
payload = httpx.get(f"{base_url}/api/streams", timeout=5).json()
last = [mirror["id"] for mirror in payload["mirrors"]]
if len(last) == count:
return last
time.sleep(0.2)
raise AssertionError(f"expected {count} mirror(s) on /api/streams, saw {last}")
def streams_api(base_url) -> dict:
return httpx.get(f"{base_url}/api/streams", timeout=5).json()
def kiosk_state(base_url) -> dict:
return httpx.get(f"{base_url}/api/kiosk", timeout=5).json()
def wait_for_kiosk(base_url, predicate, what: str, timeout: float = 15.0) -> dict:
deadline = time.time() + timeout
last: dict = {}
while time.time() < deadline:
last = kiosk_state(base_url)
if predicate(last):
return last
time.sleep(0.2)
raise AssertionError(f"{what}; last /api/kiosk was {last}")
def wait_for_streams(base_url, predicate, what: str, timeout: float = 15.0) -> dict:
deadline = time.time() + timeout
last: dict = {}
while time.time() < deadline:
last = streams_api(base_url)
if predicate(last):
return last
time.sleep(0.2)
raise AssertionError(f"{what}; last /api/streams was {last}")
def wait_row(remote, timeout: int = 15_000) -> None:
"""The controller's list holds one row: `welcome` has landed."""
expect(remote.locator("#remote-list .mirror-row")).to_have_count(1, timeout=timeout)
class TestTheLoop:
def test_show_on_wall_and_back_to_the_wall(self, device, base_url):
"""The whole loop: a click on the controller arranges the display, and
"back to the wall" undoes it — asserted on the display, not the click."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
expect(display.locator("body")).to_have_attribute("data-layout", "wall")
controller = device("/remote", errors)
wait_row(controller)
# The row the click lands on names the live mirror.
assert controller.locator(".mirror-row").get_attribute("data-stream") == stream_id
# One click: "Show on wall". The display must obey, on the server's
# numbers as well as its own DOM.
controller.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect(display.locator(f"#kiosk-stage .tile[data-stream='{stream_id}']")).to_have_count(
1, timeout=15_000
)
state = wait_for_kiosk(base_url, lambda s: s["staged"] == stream_id, "the display staged")
assert state["staged"] == stream_id, state
# The controller reflects the server's state, not its own click: the
# row says the mirror is on the wall.
expect(controller.locator(".mirror-row[data-on]")).to_have_count(1, timeout=15_000)
controller.locator("#remote-back").click()
expect(display.locator("body")).to_have_attribute("data-layout", "wall", timeout=15_000)
expect(display.locator("#kiosk-stage .tile")).to_have_count(0)
wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the display unstaged")
assert errors == [], f"uncaught browser errors: {errors}"
def test_the_controller_moves_real_media_to_the_display(self, device, base_url):
"""Pixels, not classes: the assertion is decoded, *changing* frames on
the display's staged <video> (AGENTS.md §5) — a CSS class would prove
nothing crossed the wire."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
controller = device("/remote", errors)
wait_row(controller)
controller.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect(
display.locator(f"#kiosk-stage .tile[data-stream='{stream_id}'][data-state='live']")
).to_have_count(1, timeout=25_000)
frames = frames_matching(display, "#kiosk-stage video")
assert frames["width"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["height"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["distinct"] >= 2, f"the staged video is frozen: {frames}"
assert errors == [], f"uncaught browser errors: {errors}"
class TestFullscreen:
def test_fullscreen_is_reported_not_promised(self, device, base_url):
"""The controller requests fullscreen; the display obeys in its state.
Never assert ``document.fullscreenElement`` here: headless Chromium
may refuse an element ``requestFullscreen()`` without user activation,
and the real box's window manager forces the window fullscreen anyway
(``docs/kiosk-silverblue/window-config.ini``). The page's own knob and
the server's state are what the app controls; the browser's
concession is a bonus, not the guarantee."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
mirror_ids(base_url, 1)
display = device("/kiosk", errors)
controller = device("/remote", errors)
wait_row(controller)
# Normalize the shared server state: the suites share one session
# server, and the kiosk suite's first test leaves `fullscreen: true`
# behind (a teardown resets `staged`, never `fullscreen`). The button
# toggles from the server's value, so a leaked true would make this
# click flip it to false — reset it through the product, the way a
# controller would.
if kiosk_state(base_url)["fullscreen"]:
controller.locator("#remote-fullscreen").click()
wait_for_kiosk(base_url, lambda s: s["fullscreen"] is False, "the state normalized")
controller.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
controller.locator("#remote-fullscreen").click()
state = wait_for_kiosk(
base_url, lambda s: s["fullscreen"] is True, "the server's state went fullscreen"
)
assert state["fullscreen"] is True, state
expect(display.locator("body")).to_have_attribute("data-fullscreen", "true", timeout=15_000)
# The controller's button reads the server's value, not its own click.
expect(controller.locator("#remote-fullscreen")).to_have_attribute("aria-pressed", "true")
assert errors == [], f"uncaught browser errors: {errors}"
class TestConvergence:
def test_two_controllers_converge_without_touching_the_second(self, device, base_url):
"""One acts, the other was never touched: its readout and the
``data-on`` row update from the server's frame, so two open
controllers can never disagree (task 02's no-optimistic-paint rule,
proven from the outside)."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
first = device("/remote", errors)
second = device("/remote", errors) # nothing is ever clicked here
wait_row(first)
wait_row(second)
first.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
# The untouched controller: its row carries the marker and the button
# reads the server's state.
row = second.locator(".mirror-row[data-on]")
expect(row).to_have_count(1, timeout=15_000)
assert row.get_attribute("data-stream") == stream_id
expect(row.locator(".show")).to_have_text("On the wall")
# Its readout agrees too: the staged mirror's title is the row's title.
title = row.locator(".title").inner_text()
expect(second.locator("#remote-staged")).to_have_text(title, timeout=15_000)
# And the server, which the display obeys, names the same mirror.
state = kiosk_state(base_url)
assert state["staged"] == stream_id, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestACostsNothing:
def test_a_controller_costs_the_sharer_nothing(self, device, base_url):
"""The guard that keeps /remote from becoming a stealth watcher:
the controller never subscribes, so the sharer uploads nothing for
it — the per-stream ``viewers`` and the server's ``watching`` count
stay at zero until a real ``/watch`` joins (PLAN.md §6)."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
def only(stream_id):
def check(payload):
mirror = next(m for m in payload["mirrors"] if m["id"] == stream_id)
return mirror["viewers"] == 0 and payload["stats"]["watching"] == 0
return check
# The baseline: nothing is watching before anyone opens a page.
wait_for_streams(base_url, only(stream_id), "the idle baseline")
controller = device("/remote", errors)
wait_row(controller) # the page is connected and rendered…
# …and still consumes nothing: no subscription, no leg.
wait_for_streams(base_url, only(stream_id), "the controller not counted")
device("/watch", errors)
wait_for_streams(
base_url,
lambda payload: (
next(m for m in payload["mirrors"] if m["id"] == stream_id)["viewers"] == 1
and payload["stats"]["watching"] == 1
),
"the real watcher counted",
)
assert errors == [], f"uncaught browser errors: {errors}"
class TestFollowers:
def test_followers_and_the_readout_agree_and_a_controller_is_not_a_display(
self, device, base_url
):
"""`/api/kiosk` and the controller's `#remote-followers` read the same
number at 0, at 1 with a display open, and still at 1 with a second
/remote added — a controller is not a display, so "1 display
following" keeps meaning what it says."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
assert kiosk_state(base_url)["followers"] == 0, "the suite must start with no displays"
controller = device("/remote", errors)
wait_row(controller) # welcome has landed: the readout is the server's
assert kiosk_state(base_url)["followers"] == 0
expect(controller.locator("#remote-followers")).to_contain_text("no display following")
device("/kiosk", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the display counted")
expect(controller.locator("#remote-followers")).to_contain_text(
"1 display following", timeout=15_000
)
second = device("/remote", errors)
wait_row(second)
assert kiosk_state(base_url)["followers"] == 1, "a controller is not a display"
expect(second.locator("#remote-followers")).to_contain_text(
"1 display following", timeout=15_000
)
assert errors == [], f"uncaught browser errors: {errors}"
class TestTheDisplayIsAControllerToo:
def test_a_keypress_on_the_display_moves_the_remote_readout(self, device, base_url):
"""The TODO's "any client can manipulate", in the direction that would
otherwise be forgotten: with a keyboard plugged in to the box, `n`
stages a mirror *and* publishes the intent, so the controller — which
receives no keypress — follows it."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
controller = device("/remote", errors)
wait_row(controller)
expect(controller.locator("#remote-staged")).to_have_text("no mirror")
# The one input in this test: a keypress on the display.
display.keyboard.press("n")
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
# The untouched controller follows: its readout names the staged
# mirror and its row carries the marker.
row = controller.locator(".mirror-row")
title = row.locator(".title").inner_text()
expect(controller.locator("#remote-staged")).to_have_text(title, timeout=15_000)
expect(row).to_have_count(1)
expect(controller.locator(".mirror-row[data-on]")).to_have_count(1, timeout=15_000)
assert row.get_attribute("data-stream") == stream_id
assert errors == [], f"uncaught browser errors: {errors}"
class TestGoneIsAState:
def test_an_ended_mirror_unstages_the_display_and_clears_the_controller(self, device, base_url):
"""Gone is a state: when the mirror ends, the display falls back to
the wall and the controller's row and readout clear — no stale tile,
no stale readout, no stale server state."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
display = device("/kiosk", errors)
controller = device("/remote", errors)
wait_row(controller)
controller.locator(".mirror-row .show").click()
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect(display.locator(f"#kiosk-stage .tile[data-stream='{stream_id}']")).to_have_count(1)
stop_sharing(sharer)
# No display sits on a dead tile.
expect(display.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(display.locator("#kiosk-stage .tile")).to_have_count(0)
# The controller: the row is gone and the readout says no mirror.
expect(controller.locator("#remote-list .mirror-row")).to_have_count(0, timeout=10_000)
expect(controller.locator("#remote-staged")).to_have_text("no mirror", timeout=10_000)
# And the server cleared the stage.
wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the layout cleared")
assert errors == [], f"uncaught browser errors: {errors}"
+502 -20
View File
@@ -80,6 +80,8 @@ class TestPages:
("/mirror", "Share my screen"),
("/share", "Share my screen"),
("/watch", "tile-template"),
("/kiosk", "kiosk-tile-template"),
("/remote", "remote-row-template"),
],
)
def test_pages_render(self, client, path, marker):
@@ -88,14 +90,17 @@ class TestPages:
assert response.headers["content-type"].startswith("text/html")
assert marker in response.text
def test_pages_are_not_cacheable(self, client):
assert client.get("/watch").headers["cache-control"] == "no-store"
@pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk", "/remote"])
def test_pages_are_not_cacheable(self, client, path):
assert client.get(path).headers["cache-control"] == "no-store"
def test_the_two_pages_use_the_media_apis_they_claim(self):
# The watch page's media path lives in wall.js (phase 08): the page
# builds the wall, and the wall owns the RTCPeerConnection.
share = (STATIC / "share.js").read_text()
watch = (STATIC / "watch.js").read_text()
wall = (STATIC / "wall.js").read_text()
assert "getDisplayMedia" in share
assert "RTCPeerConnection" in share and "RTCPeerConnection" in watch
assert "RTCPeerConnection" in share and "RTCPeerConnection" in wall
assert "new WebSocket" in (STATIC / "net.js").read_text()
def test_nothing_is_loaded_from_a_cdn(self):
@@ -109,7 +114,10 @@ class TestPages:
offenders.append(f"{file.name} (@import)")
assert offenders == []
@pytest.mark.parametrize("asset", ["app.css", "net.js", "share.js", "watch.js"])
@pytest.mark.parametrize(
"asset",
["app.css", "net.js", "share.js", "watch.js", "wall.js", "kiosk.js", "remote.js"],
)
def test_static_assets_are_served(self, client, asset):
assert client.get(f"/static/{asset}").status_code == 200
@@ -124,7 +132,9 @@ class TestClientScripts:
NODE = shutil.which("node")
@pytest.mark.skipif(NODE is None, reason="node is not installed")
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.js"])
@pytest.mark.parametrize(
"script", ["net.js", "share.js", "watch.js", "wall.js", "kiosk.js", "remote.js"]
)
def test_scripts_parse_as_es_modules(self, script, tmp_path):
copy = tmp_path / "subject.mjs"
copy.write_text((STATIC / script).read_text())
@@ -133,7 +143,12 @@ class TestClientScripts:
@pytest.mark.parametrize(
("script", "page"),
[("share.js", "mirror.html"), ("watch.js", "watch.html")],
[
("share.js", "mirror.html"),
("watch.js", "watch.html"),
("kiosk.js", "kiosk.html"),
("remote.js", "remote.html"),
],
)
def test_every_element_the_script_reaches_for_exists(self, script, page):
html = (STATIC / page).read_text()
@@ -141,8 +156,168 @@ class TestClientScripts:
missing = {name for name in wanted if f'id="{name}"' not in html}
assert not missing, f"{script} looks up {sorted(missing)} which {page} does not render"
def test_both_pages_load_the_stylesheet_and_their_module(self):
for page, script in (("mirror.html", "share.js"), ("watch.html", "watch.js")):
def test_wall_never_looks_up_elements_in_the_document(self):
"""wall.js serves /watch and /kiosk (and phase 09's /remote) from one
module; an element lookup would resolve in whichever page happens to
load it. The elements come from the page instead, which is why the
element ids must not collide across pages (see
test_the_two_pages_share_no_element_ids_but_the_shared_chrome) — so
the module is guarded at the source level, here, next to the other
client-script guards."""
source = (STATIC / "wall.js").read_text()
offenders = re.findall(r"""\$\(['\"]#([A-Za-z0-9_-]+)['\"]\)""", source)
assert not offenders, f"wall.js must not query the document; found {offenders}"
def test_the_wall_key_router_is_one_listener_in_wall_js(self):
"""One listener, one switch, per wall page — phase 01's whole point.
`installWallKeys` (wall.js) is the single keydown listener a wall page
gets (/watch, /kiosk, phase 09's /remote): a second listener on the
same document would run the same key twice (j would move the cursor
twice) or race the first. The share page is not a wall page — its
s/x/Esc listener lives in share.js and is page policy — but a wall
page module must never add a keydown listener of its own, so a second
`addEventListener("keydown"` in a wall page module fails the build.
"""
wall = (STATIC / "wall.js").read_text()
assert "export function installWallKeys" in wall, (
"installWallKeys must be defined in wall.js (the one keydown listener)"
)
assert len(re.findall(r'addEventListener\(\s*["\']keydown["\']', wall)) == 1, (
"wall.js must hold exactly one keydown listener (the router)"
)
offenders = [
file.name
for file in sorted(STATIC.glob("*.js"))
if file.name not in ("share.js", "wall.js")
and re.search(r'addEventListener\(\s*["\']keydown["\']', file.read_text())
]
assert offenders == [], (
"a wall page module added its own keydown listener; the one listener "
f"per wall page lives in wall.js: {offenders}"
)
@staticmethod
def _object_literal_around(source, pos):
"""The object literal containing ``source[pos:``.
``pos`` points at a ``type: "arrange"`` key. Walking back, the nearest
``{`` that is not inside a deeper ``{...}`` span is the literal's start
— the literal itself when ``type`` is its first member, the enclosing
object when it is not. Forward brace matching then closes it.
"""
depth = 0
start = None
for i in range(pos - 1, -1, -1):
if source[i] == "}":
depth += 1
elif source[i] == "{":
if depth == 0:
start = i
break
depth -= 1
assert start is not None, "no object literal before the arrange key"
depth = 0
for i in range(start, len(source)):
if source[i] == "{":
depth += 1
elif source[i] == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise AssertionError("unbalanced braces around the arrange frame")
def test_kiosk_never_publishes_an_audio_field(self):
"""The layout has no audio field (SECURITY.md §4): a display must not
become audible because another machine asked, so ``kiosk.js`` may never
put a ``muted``/``audio`` field in an ``arrange`` frame. The rule is a
security rule, so it fails the build here instead of living in a
comment (phase 08 task 04). At least one ``arrange`` frame must exist:
the display is a first-class controller, and this guard must not pass
on a file that simply stopped publishing."""
source = (STATIC / "kiosk.js").read_text()
matches = list(re.finditer(r"type\s*:\s*[\"\']arrange[\"\']", source))
assert matches, "kiosk.js publishes no arrange frames at all"
for match in matches:
literal = self._object_literal_around(source, match.start())
assert not re.search(r"\b(muted|audio)\s*:", literal), (
f"an arrange frame carries an audio field: {literal}"
)
def test_a_controller_is_media_free(self):
"""The /remote page shows no video and subscribes to nothing (PLAN.md §6).
The mesh gives every sharer one upload per connected leg, so a controller
that consumed a leg just to know what to point at would add an upload to
every sharer for every controller open. The promise is enforced at the
source level so a well-meant future "preview thumbnail" fails the build
instead of being caught in review: `remote.js` may never construct a media
connection or send a subscribe frame, may never hold a media stream or
read the screen-capture APIs, and `remote.html` may never embed a
`<video>`.
"""
source = (STATIC / "remote.js").read_text()
assert "RTCPeerConnection" not in source, (
"remote.js must not open a media connection: a controller that streams "
"costs every sharer a leg (PLAN.md §6)"
)
assert "MediaStream" not in source, (
"remote.js must not hold a media stream: the controller keeps a list, "
"not tiles — a preview is the regression this guard exists for"
)
assert "navigator.mediaDevices" not in source, (
"remote.js must not read the screen-capture APIs: the controller never "
"asks the browser for a surface"
)
assert 'type: "subscribe"' not in source and "type:'subscribe'" not in source, (
"remote.js must not send a subscribe frame: a controller subscribes to nothing"
)
html = (STATIC / "remote.html").read_text()
assert "<video" not in html, (
"remote.html must not embed a <video>: the controller shows no media"
)
def test_a_controller_sends_exactly_one_frame_type_besides_hello(self):
"""The controller's whole job is the `arrange` frame (plus the hello that
introduces it): the file must actually send one, and must not send any
other roster frame. The presence half matters: a guard that only bans
strings would pass on a controller whose buttons had gone dead."""
source = (STATIC / "remote.js").read_text()
assert 'type: "arrange"' in source, (
"remote.js sends no arrange frame: the controller's buttons are dead"
)
assert 'type: "subscribe"' not in source and "type:'subscribe'" not in source, (
"remote.js must not send a subscribe frame: a controller subscribes to nothing"
)
def test_the_remote_page_declares_its_control_hooks(self):
"""The readout, the buttons and the list are the contract task 02 and the
e2e suite code against; assert them on the markup so task 01's stub and
task 02's page cannot drift (the element guard covers the script→page
direction once remote.js looks them up)."""
html = (STATIC / "remote.html").read_text()
for element_id in (
"remote-state",
"remote-staged",
"remote-fullscreen-mark",
"remote-followers",
"remote-back",
"remote-fullscreen",
"remote-list",
"remote-row-template",
"remote-empty",
"remote-share-url",
"remote-hint",
):
assert f'id="{element_id}"' in html, f"remote.html is missing #{element_id}"
def test_the_pages_load_the_stylesheet_and_their_module(self):
for page, script in (
("mirror.html", "share.js"),
("watch.html", "watch.js"),
("kiosk.html", "kiosk.js"),
("remote.html", "remote.js"),
):
html = (STATIC / page).read_text()
assert 'href="/static/app.css"' in html
assert f'src="/static/{script}"' in html
@@ -289,23 +464,59 @@ class TestClientScripts:
)
def test_the_two_pages_share_no_element_ids_but_the_shared_chrome(self):
"""Both pages consume one stylesheet, so a duplicated id means a colliding rule.
"""The pages consume one stylesheet, so a duplicated id means a colliding rule.
This is not hypothetical: a `#stage` on both pages once made the share
page's preview inherit `display: none` from the watch page's overlay.
The kiosk page's ids carry the `kiosk-` prefix and the remote page's the
`remote-` prefix for exactly this reason; the only ids a page may share
with another are the shared chrome (net, notice, toast).
"""
shared = {"net", "notice", "toast"}
ids = {
page: set(re.findall(r'id="([A-Za-z0-9_-]+)"', (STATIC / page).read_text()))
for page in ("mirror.html", "watch.html", "index.html")
for page in ("mirror.html", "watch.html", "index.html", "kiosk.html", "remote.html")
}
assert ids["mirror.html"] & ids["watch.html"] == shared
assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"}
assert ids["kiosk.html"] & ids["watch.html"] == shared
assert ids["kiosk.html"] & ids["mirror.html"] == shared
assert ids["remote.html"] & ids["watch.html"] == shared
assert ids["remote.html"] & ids["mirror.html"] == shared
assert ids["remote.html"] & ids["kiosk.html"] == shared
# index.html has no notice/toast (a signpost, not an app page), so its
# intersection with an app page is a subset of the shared chrome — the
# same shape as the index/mirror assertion above.
assert ids["kiosk.html"] & ids["index.html"] <= shared
assert ids["remote.html"] & ids["index.html"] <= shared
for page, found in ids.items():
assert len(found) == len(set(found)), f"{page} declares an id twice"
def test_the_wall_pages_render_their_stage_classed(self):
"""The stage rules are class-keyed (`.stage`, phase 08 task 01): a page
whose stage lost the class renders a full-size unstyled video, which is
exactly the class of bug the stylesheet guards exist for. Both wall
pages keep it, and the kiosk's structural elements (grid, stage, hud)
exist for the layout applier to take them. (There was no
elements-a-page-must-have check before; this is the one.)
"""
kiosk = (STATIC / "kiosk.html").read_text()
for element_id in ("kiosk-grid", "kiosk-stage", "kiosk-hud"):
assert f'id="{element_id}"' in kiosk, f"kiosk.html is missing #{element_id}"
for page, stage_id in (("watch.html", "watch-stage"), ("kiosk.html", "kiosk-stage")):
stage_tags = [
tag
for tag in re.findall(r"<div[^>]*>", (STATIC / page).read_text())
if f'id="{stage_id}"' in tag
]
assert stage_tags, f'{page} is missing <div id="{stage_id}">'
assert 'class="stage"' in stage_tags[0], (
f'{page}#{stage_id} lost class="stage": the stage rules are '
"class-keyed, so an unclassed stage renders a full-size unstyled video"
)
def test_videos_are_muted_and_inline_so_autoplay_is_not_blocked(self):
for page in ("mirror.html", "watch.html"):
for page in ("mirror.html", "watch.html", "kiosk.html"):
for tag in re.findall(r"<video[^>]*>", (STATIC / page).read_text()):
assert "muted" in tag, f"{page}: {tag} would be blocked by autoplay policy"
assert "playsinline" in tag, f"{page}: {tag} would hijack iOS into fullscreen"
@@ -378,8 +589,9 @@ class TestStylesheet:
The flat ``[^{}]+\{[^{}]*\}`` pass treats an ``@media`` header as a
selector and the first child's header as its body; the child rules
that follow still come back as their own pairs, which is all these
guards need — every assertion is about a top-level block (``.stage``,
``.stage > *``) or about properties that must sit in the *same* block
guards need — every assertion is about a top-level block
(``.preview-stage``, ``.preview-stage > *``) or about properties that
must sit in the *same* block
(``overflow``/``aspect-ratio``/``min-height`` together).
"""
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
@@ -396,8 +608,8 @@ class TestStylesheet:
without the other stacks the rows back.
"""
rules = self._rules()
stage_bodies = [body for sel, body in rules if sel == ".stage"]
assert stage_bodies, ".stage rule missing from app.css"
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
assert stage_bodies, ".preview-stage rule missing from app.css"
assert any(
re.search(r"grid-template\s*:\s*1fr\s*/\s*1fr", body)
or (
@@ -409,7 +621,7 @@ class TestStylesheet:
".stage must declare an explicit single cell (grid-template: 1fr / 1fr, "
"or grid-template-rows/columns of 1fr) — otherwise its children stack into rows"
)
child_bodies = [body for sel, body in rules if sel == ".stage > *"]
child_bodies = [body for sel, body in rules if sel == ".preview-stage > *"]
assert child_bodies and any(
re.search(r"grid-area\s*:\s*1\s*/\s*1", body) for body in child_bodies
), (
@@ -431,8 +643,8 @@ class TestStylesheet:
content again.
"""
rules = self._rules()
stage_bodies = [body for sel, body in rules if sel == ".stage"]
assert stage_bodies, ".stage rule missing from app.css"
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
assert stage_bodies, ".preview-stage rule missing from app.css"
assert any(re.search(r"min-height\s*:", body) for body in stage_bodies), (
".stage must keep its floor (min-height)"
)
@@ -459,7 +671,19 @@ class TestAddressGate:
with gated(client_address="203.0.113.9") as client:
assert client.get("/watch").status_code == 200
@pytest.mark.parametrize("path", ["/", "/mirror", "/watch", "/api/streams", "/static/app.css"])
@pytest.mark.parametrize(
"path",
[
"/",
"/mirror",
"/watch",
"/kiosk",
"/remote",
"/api/streams",
"/api/kiosk",
"/static/app.css",
],
)
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
with gated(self.ALLOW) as client:
assert client.get(path).status_code == 403
@@ -502,6 +726,18 @@ class TestAddressGate:
response = client.get("/api/config", headers=headers)
assert response.headers["cache-control"] == "no-store"
def test_a_refused_address_never_learns_the_layout(self):
with gated(self.ALLOW) as client:
response = client.get("/api/kiosk")
assert response.status_code == 403
assert "staged" not in response.text
def test_the_real_address_gets_the_layout_read_with_no_store(self):
with gated(self.ALLOW, client_address="10.2.1.10") as client:
response = client.get("/api/kiosk")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
def test_the_socket_closes_before_it_accepts(self):
with (
gated(self.ALLOW) as client,
@@ -513,6 +749,7 @@ class TestAddressGate:
def test_the_real_address_is_in_and_connects(self):
with gated(self.ALLOW, client_address="10.2.1.10") as client:
assert client.get("/watch").status_code == 200
assert client.get("/kiosk").status_code == 200
with client.websocket_connect("/ws") as socket:
assert handshake(socket, "viewer")
@@ -562,6 +799,17 @@ class TestJsonApi:
def test_healthz(self, client):
assert client.get("/healthz").json()["status"] == "ok"
def test_kiosk_answers_exactly_the_layout_dto(self, client):
"""Shape guard: the read endpoint is the socket's `layout` DTO, nothing more.
It answers the same object the socket pushes (task 02's single-DTO
invariant), so the key set — including the `type` tag — is pinned: a new
key here is a decision (00_phase.md), not a leak.
"""
body = client.get("/api/kiosk").json()
assert set(body) == {"type", "staged", "fullscreen", "followers"}
assert body == {"type": "layout", "staged": None, "fullscreen": False, "followers": 0}
class TestSignalingSocket:
def test_hello_must_come_first(self, client):
@@ -816,3 +1064,237 @@ class TestSignalingSocket:
assert wait_for(viewer, "error")["code"] == "not_sharer"
assert client.get("/api/streams").json()["stats"]["mirrors"] == 1
assert sharer_id and viewer
def test_arrange_roundtrips_to_viewers_the_sender_and_the_read_endpoint(self, client):
"""The whole path: publish, arrange, and every observer — the sender, the
other viewers, and `GET /api/kiosk` — says the same thing."""
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as viewer,
client.websocket_connect("/ws") as second,
):
sharer_id = handshake(sharer, "sharer", "A")
handshake(viewer, "viewer", "V1")
handshake(second, "viewer", "V2")
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(viewer, "stream_added")
wait_for(second, "stream_added")
viewer.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
sent = wait_for(viewer, "layout")
assert sent["staged"] == sharer_id
assert sent["fullscreen"] is True
assert wait_for(second, "layout") == sent
body = client.get("/api/kiosk").json()
assert (body["staged"], body["fullscreen"], body["followers"]) == (sharer_id, True, 0)
# A peer that joins after the arrange is already arranged: its
# welcome carries the state, so a page can navigate and obey
# without asking. (Joins while the wall still exists — closing the
# sharer's socket would legitimately reset the stage.)
with client.websocket_connect("/ws") as late:
late.send_json({"type": "hello", "role": "viewer", "name": "late"})
welcome = late.receive_json()
assert welcome["layout"]["staged"] == sharer_id
assert welcome["layout"]["fullscreen"] is True
def test_kiosk_followers_are_counted_and_recounted_on_leave(self, client):
with (
client.websocket_connect("/ws") as plain,
client.websocket_connect("/ws") as kiosk1,
client.websocket_connect("/ws") as kiosk2,
):
handshake(plain, "viewer", "plain")
kiosk1.send_json({"type": "hello", "role": "viewer", "name": "K1", "kiosk": True})
assert kiosk1.receive_json()["type"] == "welcome"
kiosk2.send_json({"type": "hello", "role": "viewer", "name": "K2", "kiosk": True})
# The joiner counts itself: it is connected and it declared kiosk.
assert kiosk2.receive_json()["layout"]["followers"] == 2
# The plain viewer was told by every kiosk join, in order.
assert wait_for(plain, "layout")["followers"] == 1
assert wait_for(plain, "layout")["followers"] == 2
assert wait_for(kiosk1, "layout")["followers"] == 2
kiosk1.send_json({"type": "arrange", "fullscreen": True})
sent = wait_for(kiosk1, "layout")
assert sent["fullscreen"] is True
assert sent["followers"] == 2
assert wait_for(plain, "layout")["followers"] == 2
assert wait_for(kiosk2, "layout")["followers"] == 2
kiosk1.close()
# A follower leaving re-counts: the remaining viewers hear `1`.
assert wait_for(kiosk2, "layout")["followers"] == 1
assert wait_for(plain, "layout")["followers"] == 1
assert client.get("/api/kiosk").json()["followers"] == 1
def test_arranging_a_mirror_that_is_not_there_is_refused_without_closing(self, client):
with client.websocket_connect("/ws") as viewer:
handshake(viewer, "viewer")
viewer.send_json({"type": "arrange", "staged": "p404"})
error = wait_for(viewer, "error")
assert error["code"] == "unknown_stream"
assert error["fatal"] is False
# The socket is still up, and nothing changed.
viewer.send_json({"type": "ping"})
assert viewer.receive_json() == {"type": "pong"}
assert client.get("/api/kiosk").json()["staged"] is None
def test_a_fullscreen_only_arrange_leaves_the_stage_and_null_returns_the_wall(self, client):
"""End to end: absent ≠ null. A `fullscreen`-only frame keeps the staged
mirror; an explicit `staged: null` is the only way back to the wall."""
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as viewer,
):
sharer_id = handshake(sharer, "sharer")
handshake(viewer, "viewer")
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(viewer, "stream_added")
viewer.send_json({"type": "arrange", "staged": sharer_id})
wait_for(viewer, "layout")
viewer.send_json({"type": "arrange", "fullscreen": True})
frame = wait_for(viewer, "layout")
assert frame["staged"] == sharer_id # absent field left alone
assert frame["fullscreen"] is True
viewer.send_json({"type": "arrange", "staged": None})
frame = wait_for(viewer, "layout")
assert frame["staged"] is None # explicit null returns the wall
assert frame["fullscreen"] is True # and touches nothing else
body = client.get("/api/kiosk").json()
assert (body["staged"], body["fullscreen"]) == (None, True)
def test_a_sharer_may_arrange_but_only_viewers_and_the_sender_are_told(self, client):
with (
client.websocket_connect("/ws") as sharer_a,
client.websocket_connect("/ws") as sharer_b,
client.websocket_connect("/ws") as viewer,
):
a_id = handshake(sharer_a, "sharer", "A")
b_id = handshake(sharer_b, "sharer", "B")
handshake(viewer, "viewer")
sharer_a.send_json({"type": "publish", "label": "A"})
wait_for(viewer, "stream_added")
sharer_b.send_json({"type": "publish", "label": "B"})
wait_for(viewer, "stream_added")
viewer.send_json({"type": "subscribe", "streamId": a_id})
wait_for(sharer_a, "peer_joined")
wait_for(sharer_a, "stream_updated")
viewer.send_json({"type": "subscribe", "streamId": b_id})
wait_for(sharer_b, "peer_joined")
wait_for(sharer_b, "stream_updated")
for _ in range(2):
wait_for(viewer, "stream_updated")
sharer_a.send_json({"type": "arrange", "staged": a_id, "fullscreen": True})
sent = wait_for(sharer_a, "layout") # a sharer-sender gets its ack
assert sent["staged"] == a_id
assert wait_for(viewer, "layout")["fullscreen"] is True
# sharer_b is neither the sender nor a viewer: its next frame must be
# the unsubscribe below, not a layout — a fanned-out layout would
# have arrived first and broken this.
viewer.send_json({"type": "unsubscribe", "streamId": b_id})
message = sharer_b.receive_json()
assert message["type"] == "peer_left", (
f"a non-viewing sharer received {message!r}: layout may not fan out to sharers"
)
def test_the_staged_mirror_going_away_resets_the_stage_for_every_viewer(self, client):
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as viewer1,
client.websocket_connect("/ws") as viewer2,
):
sharer_id = handshake(sharer, "sharer")
handshake(viewer1, "viewer")
handshake(viewer2, "viewer")
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(viewer1, "stream_added")
wait_for(viewer2, "stream_added")
viewer1.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
wait_for(viewer1, "layout")
wait_for(viewer2, "layout")
sharer.close()
assert wait_for(viewer1, "layout")["staged"] is None
assert wait_for(viewer2, "layout")["staged"] is None
assert client.get("/api/kiosk").json()["staged"] is None
def test_a_controller_peer_drives_the_layout_without_counting_in_followers(self, client):
"""A /remote-shaped peer — `hello{role:"viewer"}` with no `kiosk` flag —
may drive the wall, but must not be counted in it: `followers` is the
display count, and the phone that holds the wall is not a display. The
read endpoint and the frames must agree on the same number."""
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as controller,
client.websocket_connect("/ws") as display,
):
sharer_id = handshake(sharer, "sharer", "A")
handshake(controller, "viewer", "phone") # no `kiosk` flag
display.send_json({"type": "hello", "role": "viewer", "name": "K", "kiosk": True})
assert display.receive_json()["type"] == "welcome"
# The display's join is reported to the other viewers; the controller
# is one of them and hears the count of one.
assert wait_for(controller, "layout")["followers"] == 1
assert client.get("/api/kiosk").json()["followers"] == 1
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(controller, "stream_added")
# The controller drives the wall...
controller.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
sent = wait_for(controller, "layout")
assert sent["staged"] == sharer_id
assert sent["fullscreen"] is True
# ...the display obeys it...
assert wait_for(display, "layout")["staged"] == sharer_id
# ...and the read endpoint agrees with both — the controller still
# does not count: the wall is driven by one, obeyed by one, and the
# count says one.
body = client.get("/api/kiosk").json()
assert (body["staged"], body["fullscreen"], body["followers"]) == (sharer_id, True, 1)
assert sent["followers"] == 1
def test_arranging_a_mirror_a_viewer_is_watching_changes_no_subscriptions(self, client):
"""Driving the wall must not disturb the people watching it: an
`arrange` for a mirror a second viewer is receiving touches no
subscription — /api/streams reports the same audience before and after,
and the controller's exit removes nothing, because it never watched."""
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as viewer,
client.websocket_connect("/ws") as controller,
):
sharer_id = handshake(sharer, "sharer", "A")
handshake(viewer, "viewer", "V")
handshake(controller, "viewer", "phone") # no `kiosk` flag
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(viewer, "stream_added")
viewer.send_json({"type": "subscribe", "streamId": sharer_id})
wait_for(sharer, "peer_joined")
before = client.get("/api/streams").json()
assert before["stats"]["watching"] == 1
assert before["mirrors"][0]["viewers"] == 1
controller.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
sent = wait_for(controller, "layout")
assert sent["staged"] == sharer_id
after = client.get("/api/streams").json()
assert after["stats"]["watching"] == 1
assert after["mirrors"][0]["viewers"] == 1
# The controller leaving removes no subscription: it never watched,
# so its exit touches the audience not at all.
controller.close()
final = client.get("/api/streams").json()
assert final["stats"]["watching"] == 1
assert final["mirrors"][0]["viewers"] == 1
+71 -1
View File
@@ -48,6 +48,49 @@ class TestInbound:
with pytest.raises(ValidationError):
protocol.parse_client_message({"type": "subscribe"})
def test_arrange_with_no_fields_changes_nothing(self):
message = protocol.parse_client_message({"type": "arrange"})
assert message.staged is None
assert message.fullscreen is None
# Nothing was mentioned, so nothing may be applied: this is the whole
# reason the fields are read with ``model_fields_set`` rather than
# defaults.
assert "staged" not in message.model_fields_set
assert "fullscreen" not in message.model_fields_set
def test_arrange_with_an_explicit_null_staged_returns_the_displays_to_the_wall(self):
message = protocol.parse_client_message({"type": "arrange", "staged": None})
assert message.staged is None
# Explicit ``null`` is a different request from "absent": it says
# "clear the stage", so ``staged`` must be in ``model_fields_set``.
assert "staged" in message.model_fields_set
assert "fullscreen" not in message.model_fields_set
def test_arrange_carries_its_values_and_marks_only_what_was_mentioned(self):
message = protocol.parse_client_message(
{"type": "arrange", "staged": "p1", "fullscreen": True}
)
assert message.staged == "p1"
assert message.fullscreen is True
assert message.model_fields_set == {"type", "staged", "fullscreen"}
def test_arrange_accepts_an_explicit_false_fullscreen(self):
message = protocol.parse_client_message(
{"type": "arrange", "staged": "p1", "fullscreen": False}
)
assert message.fullscreen is False
assert "fullscreen" in message.model_fields_set
def test_hello_defaults_kiosk_to_false(self):
message = protocol.parse_client_message({"type": "hello", "role": "viewer"})
assert message.kiosk is False
def test_hello_accepts_a_kiosk_display(self):
message = protocol.parse_client_message(
{"type": "hello", "role": "viewer", "name": "Wall", "kiosk": True}
)
assert message.kiosk is True
def test_dimensions_are_bounded(self):
for bad in (-1, 16385, "wide"):
with pytest.raises(ValidationError):
@@ -93,9 +136,35 @@ class TestOutbound:
}
assert dto["displaySurface"] == "window"
def test_the_layout_frame_has_exactly_the_contract_keys(self):
frame = protocol.layout(staged="p1", fullscreen=True, followers=2)
# The outbound dicts are hand-written, so there is no alias generator to
# catch a typo here; the key set is the guard.
assert set(frame) == {"type", "staged", "fullscreen", "followers"}
assert frame["type"] == "layout"
assert frame["staged"] == "p1"
assert frame["fullscreen"] is True
assert frame["followers"] == 2
def test_a_layout_with_no_stage_says_null_not_absent(self):
frame = protocol.layout(staged=None, fullscreen=False, followers=0)
assert frame["staged"] is None
def test_welcome_carries_the_layout_it_was_given(self):
state = protocol.layout(staged="p1", fullscreen=True, followers=3)
frame = protocol.welcome(peer_id="p1", role="viewer", streams=[], layout=state)
assert frame["layout"] == state
assert set(frame) == {"type", "peerId", "role", "streams", "layout"}
def test_every_server_message_advertises_its_type(self):
messages = [
protocol.welcome(peer_id="p1", role="viewer", streams=[]),
protocol.welcome(
peer_id="p1",
role="viewer",
streams=[],
layout=protocol.layout(staged=None, fullscreen=False, followers=0),
),
protocol.layout(staged=None, fullscreen=False, followers=0),
protocol.stream_added(stream={"id": "p1"}),
protocol.stream_updated(stream={"id": "p1"}),
protocol.stream_removed(stream_id="p1"),
@@ -107,6 +176,7 @@ class TestOutbound:
]
assert [m["type"] for m in messages] == [
"welcome",
"layout",
"stream_added",
"stream_updated",
"stream_removed",
+154
View File
@@ -315,6 +315,160 @@ class TestTeardown:
registry.signal(v1, sharer, {})
class TestLayout:
def test_the_default_is_the_wall_not_fullscreen_with_zero_followers(self, registry):
registry.join(role="viewer")
assert registry.layout.staged is None
assert registry.layout.fullscreen is False
assert registry.layout_dto() == {
"type": "layout",
"staged": None,
"fullscreen": False,
"followers": 0,
}
def test_staging_broadcasts_to_viewers_and_the_sender_without_doubling(self, mesh):
sharer, v1, v2, registry = mesh
outbox = registry.set_layout(sharer, staged=sharer)
assert {t for t, _ in outbox} == {sharer, v1, v2}
for peer_id in (sharer, v1, v2):
assert types_for(outbox, peer_id) == ["layout"]
expected = {"type": "layout", "staged": sharer, "fullscreen": False, "followers": 0}
assert all(m == expected for _t, m in outbox)
assert len(outbox) == 3 # one frame per target; nobody receives it twice
def test_a_partial_update_touches_only_the_named_fields(self, mesh):
sharer, _v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
outbox = registry.set_layout(sharer, fullscreen=True)
dto = message_for(outbox, sharer, "layout")
assert registry.layout.staged == sharer # absent field left alone
assert registry.layout.fullscreen is True
assert dto == {"type": "layout", "staged": sharer, "fullscreen": True, "followers": 0}
def test_an_explicit_null_clears_and_an_opinionless_arrange_is_ack_only(self, mesh):
sharer, v1, v2, registry = mesh
registry.set_layout(sharer, staged=sharer, fullscreen=True)
outbox = registry.set_layout(v1, staged=None)
assert registry.layout.staged is None
assert registry.layout.fullscreen is True # absent field left alone
# The clearing is a real change: viewers plus the sender (deduped) are told.
assert {t for t, _ in outbox} == {v1, v2}
# No opinion at all: the sender is acked and nobody else hears anything.
outbox = registry.set_layout(v2)
assert outbox == [
(v2, {"type": "layout", "staged": None, "fullscreen": True, "followers": 0})
]
def test_an_unknown_staged_mirror_is_refused_and_changes_nothing(self, mesh):
sharer, _v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer, fullscreen=True)
with pytest.raises(RegistryError) as excinfo:
registry.set_layout(sharer, staged="ghost", fullscreen=False)
assert excinfo.value.code == "unknown_stream"
assert registry.layout.staged == sharer
assert registry.layout.fullscreen is True
def test_repeating_the_same_state_replies_with_the_ack_only(self, mesh):
sharer, _v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
outbox = registry.set_layout(sharer, staged=sharer, fullscreen=False)
assert outbox == [
(sharer, {"type": "layout", "staged": sharer, "fullscreen": False, "followers": 0})
]
def test_unpublishing_the_staged_mirror_falls_back_to_the_wall(self, mesh):
sharer, v1, v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
outbox = registry.unpublish(sharer)
for peer_id in (v1, v2):
# Exactly one layout frame, riding the same envelope set as the removal.
assert types_for(outbox, peer_id) == ["stream_removed", "layout"]
assert message_for(outbox, peer_id, "layout")["staged"] is None
assert registry.layout.staged is None
def test_a_sharer_disconnecting_falls_back_to_the_wall(self, mesh):
sharer, v1, v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
outbox = registry.leave(sharer)
for peer_id in (v1, v2):
assert types_for(outbox, peer_id) == ["stream_removed", "layout"]
assert message_for(outbox, peer_id, "layout")["staged"] is None
assert registry.layout.staged is None
def test_a_replaced_stream_leaves_the_stage_cleared(self, mesh):
sharer, v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
registry.leave(sharer) # the old mirror goes with its socket
fresh, _ = registry.join(role="sharer", name="Linux Box")
outbox = registry.publish(fresh, label="Terminal", width=1280, height=720)
# The replacement has a new id; the stage must not still point at the
# old one, and nothing auto-stages the newcomer.
assert fresh != sharer
assert registry.layout.staged is None
assert "layout" not in types_for(outbox, v1)
assert [s["id"] for s in registry.roster()] == [fresh]
def test_the_stage_is_said_once_when_a_kiosk_sharer_leaves(self, registry):
kiosk_sharer, _ = registry.join(role="sharer", kiosk=True)
viewer, _ = registry.join(role="viewer")
registry.publish(kiosk_sharer, label="Terminal")
registry.set_layout(kiosk_sharer, staged=kiosk_sharer)
outbox = registry.leave(kiosk_sharer)
# The fallback and the follower change must not double the layout frame.
assert types_for(outbox, viewer) == ["stream_removed", "layout"]
def test_followers_counts_only_kiosk_peers(self, registry):
registry.join(role="viewer")
registry.join(role="viewer")
registry.join(role="viewer", kiosk=True)
registry.join(role="sharer")
assert registry.layout_dto()["followers"] == 1
def test_a_kiosk_joining_pushes_the_new_follower_count(self, mesh):
_sharer, v1, v2, registry = mesh
_id, outbox = registry.join(role="viewer", kiosk=True)
# Told to the other viewers; the joiner got the state in its welcome,
# not as a layout frame.
assert {t for t, m in outbox if m["type"] == "layout"} == {v1, v2}
assert types_for(outbox, _id) == ["welcome"]
assert message_for(outbox, v1, "layout")["followers"] == 1
assert message_for(outbox, v1, "layout") == message_for(outbox, v2, "layout")
assert registry.layout_dto()["followers"] == 1
def test_a_non_kiosk_join_says_nothing_about_the_layout(self, mesh):
_sharer, v1, _v2, registry = mesh
_id, outbox = registry.join(role="viewer")
assert "layout" not in types_for(outbox, v1)
def test_a_kiosk_leaving_pushes_the_new_follower_count(self, mesh):
_sharer, v1, v2, registry = mesh
kiosk, _ = registry.join(role="viewer", kiosk=True)
assert registry.layout_dto()["followers"] == 1
outbox = registry.leave(kiosk)
assert {t for t, _ in outbox} == {v1, v2}
assert message_for(outbox, v1, "layout")["followers"] == 0
assert registry.layout_dto()["followers"] == 0
def test_a_peer_joined_after_an_arrange_arrives_already_arranged(self, mesh):
sharer, _v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer, fullscreen=True)
late, outbox = registry.join(role="viewer", kiosk=True)
welcome = message_for(outbox, late, "welcome")
assert welcome["layout"] == {
"type": "layout",
"staged": sharer,
"fullscreen": True,
"followers": 1,
}
def test_a_dead_peer_cannot_arrange_the_wall(self, mesh):
_sharer, _v1, _v2, registry = mesh
with pytest.raises(RegistryError) as excinfo:
registry.set_layout("ghost", staged="ghost")
assert excinfo.value.code == "unknown_peer"
class TestRenameAndStats:
def test_rename_refreshes_the_mirrors_that_show_it(self, mesh):
sharer, v1, _v2, registry = mesh