phase: 09_wall_remote_page
**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**
**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.
**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.
**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.
**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.
**Next pending phase** — none; `todo/` held only phase 09.
This commit is contained in:
@@ -1,90 +0,0 @@
|
||||
# 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
|
||||
@@ -1,109 +0,0 @@
|
||||
# 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
|
||||
@@ -1,117 +0,0 @@
|
||||
# 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
|
||||
@@ -1,119 +0,0 @@
|
||||
# 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