Compare commits
2
Commits
3f93481c54
...
736da1c1ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736da1c1ef | ||
|
|
1f60a6b40c |
@@ -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)
|
||||
+99
@@ -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 @@
|
||||
ok
|
||||
@@ -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
|
||||
+16
@@ -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`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+36
@@ -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
|
||||
+24
@@ -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`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+36
@@ -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
|
||||
+20
@@ -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).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
........................................................................ [ 22%]
|
||||
...............................sssss.................................... [ 44%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 88%]
|
||||
.................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------
|
||||
src/mirror/__init__.py 3 0 100%
|
||||
src/mirror/__main__.py 72 3 96% 129, 145, 156
|
||||
src/mirror/access.py 60 0 100%
|
||||
src/mirror/app.py 191 11 94% 108, 113, 116-117, 182-183, 212-216
|
||||
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||
src/mirror/debug.py 15 10 33% 19-28
|
||||
src/mirror/protocol.py 66 0 100%
|
||||
src/mirror/registry.py 228 3 99% 208, 226, 460
|
||||
------------------------------------------------------
|
||||
TOTAL 703 32 95%
|
||||
=========================== short test summary info ============================
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
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
|
||||
@@ -59,6 +59,18 @@ 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 that follows this state arrives in a later release; the wire and the read
|
||||
surface are what any client builds on.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (all optional; a `.env` file is loaded if present):
|
||||
|
||||
@@ -31,6 +31,11 @@ limiting, no accounts, and no audit trail.
|
||||
`MIRROR_MAX_BITRATE`; the sharer's quality preset scales that bound only
|
||||
*down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's
|
||||
ceiling).
|
||||
* Point the shared wall at any live mirror and toggle its fullscreen state
|
||||
(the `arrange` frame on `/ws`), which every kiosk display follows. The
|
||||
state has no audio field: a remote hand can stage and scale, but it can
|
||||
never make a display *sound* — that still needs a gesture on the device
|
||||
playing it (see §4 “Audio”).
|
||||
|
||||
## 3. What they cannot do (enforced and tested)
|
||||
|
||||
|
||||
+21
-2
@@ -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):
|
||||
@@ -312,6 +324,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()})
|
||||
|
||||
+46
-3
@@ -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
@@ -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
|
||||
|
||||
+187
-1
@@ -459,7 +459,9 @@ 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", "/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 +504,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,
|
||||
@@ -562,6 +576,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 +841,164 @@ 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
|
||||
|
||||
+71
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user