chore(agent): phase roadmap from TODO.md, 3 phases
The one TODO item — a /kiosk endpoint whose state (fullscreen, which mirror) any client can manipulate — becomes three phases, since it is three workstreams: - 07_kiosk_layout_state: the state on the server. `arrange` in, `layout` out (staged/fullscreen/followers), Registry policy with a stale-staged fallback on teardown, `GET /api/kiosk` for curl-level debugging. - 08_kiosk_page: the wall made reusable (`static/wall.js`, behaviour- preserving) and the chrome-free /kiosk page that arranges itself from the state and publishes a local keypress back. - 09_wall_remote_page: /remote, the controller you open on a phone — shows no media and subscribes to nothing, proven by /api/streams' watcher count — plus the security line and the kiosk box's new URL. Locked anchors hold: no third role, state stays in memory (A7), Registry stays pure (A8), no external assets (A10), no remote audio command (SECURITY.md §4). PLAN.md untouched, per the user's decision, so its §7 roadmap table does not list 07-09. LLM-Generated: true
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user