**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.
286 lines
16 KiB
Markdown
286 lines
16 KiB
Markdown
# mirror
|
||
|
||
**Device-agnostic screen mirroring that lives entirely in the browser.**
|
||
|
||
`mirror` runs a small HTTP server on your machine. Open it from any device on
|
||
your network:
|
||
|
||
| Page | URL | What it does |
|
||
| ------------ | ------------------ | ----------------------------------------------------------------------- |
|
||
| **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
|
||
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
|
||
| **Kiosk** | `/kiosk` | A chrome-free wall for an unattended display: no header, no counts — the net pill is the only chrome, and the shared layout state (below) arranges it. |
|
||
| **Remote** | `/remote` | A controller for the shared wall, from a laptop or a phone: lists the live mirrors and the wall's state and sends the decisions (show this, back to the wall, full screen) — no video, no bandwidth. |
|
||
| **Index** | `/` | Signpost + live count of mirrors. |
|
||
|
||
Any number of people can mirror at the same time and any number of people can
|
||
watch. There are **no accounts and no passwords** — this is built for a trusted
|
||
local network, and that assumption is deliberate and documented in
|
||
[SECURITY.md](SECURITY.md). What *does* decide who gets in is the source address:
|
||
by default any address is welcome, and `--allow-cidr` / `MIRROR_ALLOW_CIDRS`
|
||
narrows that to the networks you name.
|
||
|
||
## Quickstart
|
||
|
||
```bash
|
||
uv sync --extra dev
|
||
uv run mirror serve # http://0.0.0.0:8055
|
||
```
|
||
|
||
Then, from another machine on the LAN:
|
||
|
||
```
|
||
http://<this-machine>:8055/mirror # to share
|
||
http://<this-machine>:8055/watch # to watch
|
||
http://<this-machine>:8055/kiosk # the unattended wall (kiosk display)
|
||
http://<this-machine>:8055/remote # drive the wall from this device
|
||
```
|
||
|
||
Linux is the primary target. On Linux the native picker is X11/Wayland's own
|
||
(Chromium uses `xdg-desktop-portal` on Wayland/GNOME/KDE, and its built-in X11
|
||
picker on Xorg), so nothing has to be installed on the client side.
|
||
|
||
The share page works on a phone as well as a desktop: `/mirror` is usable
|
||
down to a 320 px-wide viewport, and the preview keeps its shape but never
|
||
shrinks below the controls it holds, so the Share button is always on screen.
|
||
|
||
The target *client* setup for an always-on display is Fedora Silverblue running
|
||
GNOME Kiosk — see [`docs/kiosk-silverblue.md`](docs/kiosk-silverblue.md).
|
||
|
||
## How it works
|
||
|
||
Media never touches the server. Each mirror is a WebRTC peer connection
|
||
established directly between the sharer's browser and each watcher's browser
|
||
(a "mesh" fan-out). The server is a **signaling relay and roster only**: it
|
||
tracks who is publishing what, forwards SDP/ICE between peers, and pushes
|
||
roster deltas to viewers.
|
||
|
||
```
|
||
sharer browser ──ws──► ┌──────────────────────┐ ◄──ws── watcher browser
|
||
│ │ FastAPI │ ▲
|
||
│ offer/ICE ───►│ /ws (signalling) │◄─── offer/ICE │
|
||
│ │ registry (in-mem) │ │
|
||
└────── WebRTC (SRTP, LAN, p2p) ─────────┴─────────────────┘
|
||
```
|
||
|
||
**The wall has one shared state.** The server keeps which mirror is on stage, whether
|
||
it is fullscreen, and how many displays are obeying that (`followers` — peers whose
|
||
`hello` said `"kiosk": true`). Any connected peer may change it with an `arrange`
|
||
frame on `/ws` (`{"type":"arrange","staged":"<id>","fullscreen":true}`); an absent
|
||
field is "no opinion", `"staged": null` is "back to the wall". A change is pushed
|
||
immediately as a `layout` frame to every viewer (and back to the sender), and the
|
||
same object is readable at `GET /api/kiosk` — `curl`-debuggable like `/api/streams`,
|
||
`Cache-Control: no-store`. The state has no audio: sound always traces back to a
|
||
gesture on the device that plays it (see [SECURITY.md](SECURITY.md) §4). The
|
||
`/kiosk` page is the display that follows this state: a chrome-free wall that
|
||
subscribes to every mirror like `/watch` and does what the state says —
|
||
`Cache-Control: no-store` on the read and on the page itself. If a keyboard is
|
||
plugged in, the same keys as `/watch` work, and every keypress publishes the
|
||
same `arrange` intent, so a controller and a display converge on one state
|
||
(`m` is local-only: a display never becomes audible because another machine
|
||
asked).
|
||
|
||
### Driving an unattended wall
|
||
|
||
The shared state exists for a box with no keyboard: point the box at `/kiosk`,
|
||
open `/remote` on a phone, and press **Show on wall** — the box stages that
|
||
mirror, and **Back to the wall** and **Full screen** do the same for their
|
||
decisions. When a keyboard is plugged into the box, the wall keys still work
|
||
there, and a keypress performs the change *and* publishes the same `arrange`
|
||
intent, so a key on the box is seen by every controller — a controller and a
|
||
display converge on one state. Fullscreen is **requested, not promised**: a
|
||
remote command carries no user activation, so the browser may refuse the
|
||
request; the in-page stage fills the viewport anyway (and the kiosk box's
|
||
window manager forces the window fullscreen). The state lives in the server's
|
||
memory only: a restart returns the wall to a wall.
|
||
|
||
## Configuration
|
||
|
||
Environment variables (all optional; a `.env` file is loaded if present):
|
||
|
||
| Variable | Default | Meaning |
|
||
| ---------------------- | ------- | ---------------------------------------------------- |
|
||
| `MIRROR_HOST` | `0.0.0.0` | Bind address for `mirror serve`. |
|
||
| `MIRROR_PORT` | `8055` | Bind port. |
|
||
| `MIRROR_ICE_SERVERS` | `[]` | JSON array of RTCIceServer objects, if peers cannot reach each other directly. |
|
||
| `MIRROR_MAX_MIRRORS` | `0` | Cap on concurrent mirrors (`0` = unlimited). |
|
||
| `MIRROR_MAX_BITRATE` | `25000000` | Per-leg WebRTC bitrate cap in bits/s — the mesh uploads N × bitrate per sharer, so this is the ceiling. 25 Mbit/s suits a wired LAN at 1440p; a Wi-Fi sharer with many viewers may need less, not more. |
|
||
| `MIRROR_PING_INTERVAL` | `20` | Seconds between socket liveness pings. |
|
||
| `MIRROR_ALLOW_CIDRS` | *(unset)* | Comma-separated source networks allowed to connect, e.g. `10.2.0.0/22,fd00::/64`. Unset means unrestricted. A wall needs this range too, and its address is DHCP — give it a reservation. |
|
||
| `MIRROR_TRUSTED_PROXIES` | `127.0.0.0/8, ::1/128` | Which peers may speak for a client via `X-Forwarded-For`. Only a proxy that overwrites the header belongs here. |
|
||
| `DEBUGPY` | `0` | `1` opens a debugpy listener on `DEBUGPY_PORT`. |
|
||
| `DEBUGPY_PORT` | `5678` | debugpy port. |
|
||
| `DEBUGPY_WAIT` | `0` | `1` blocks the server at start until a debugger attaches. |
|
||
|
||
## Platform notes
|
||
|
||
**Screen capture needs a secure context.** `http://localhost` is one; a plain
|
||
`http://192.168.x.x` opened from another device is not, and the browser hides
|
||
`getDisplayMedia()` completely. The `/mirror` page detects this and prints the
|
||
three ways forward (share from localhost, serve HTTPS, or mark that one origin as
|
||
secure on the sharing device). Watching never needs permission.
|
||
|
||
This is why the fleet deployment terminates TLS in nginx rather than exposing
|
||
`mirror serve` directly — over `https://`, every device on the network can share
|
||
and none of this applies. A bare `mirror serve` is for the machine it runs on and
|
||
for testing.
|
||
|
||
**Linux.** On X11/Xorg Chromium uses its native capturer — the picker appears and
|
||
that is the end of it. On Wayland (GNOME/KDE) Chromium goes through
|
||
`xdg-desktop-portal`, so the compositor shows its **own permission dialog for
|
||
each share**, which mirror neither controls nor tries to bypass. Chrome's picker
|
||
offers whole screens and individual windows; on Wayland what the portal will
|
||
agree to depends on the compositor.
|
||
|
||
**Audio.** A share carries audio when the browser's picker offers it (Chrome's
|
||
"share audio" tickbox, or a tab's own audio); the sharer's panel says
|
||
`shared` / `silent` / `not offered`, and the wall shows an audio button only
|
||
when one exists. On the wall, only the staged mirror can be audible — **press
|
||
`m` on the wall to hear the staged mirror** (unmuting it mutes the others,
|
||
leaving the stage re-mutes it). What *system* audio a browser can capture is
|
||
a browser/platform fact, not a mirror one: Chrome on Linux gets it where
|
||
PipeWire offers it, Firefox's desktop capture does not offer it at all. See
|
||
[SECURITY.md](SECURITY.md) §4 "Audio".
|
||
|
||
**Quality.** The share page has a Quality control — `Sharp` (default),
|
||
`Balanced`, `Light` — because a sharer's machine is a resource the share
|
||
spends, and `MIRROR_MAX_BITRATE` alone cannot express "fewer pixels". Each
|
||
preset is one request to the capture device and one per-viewer encoder
|
||
budget:
|
||
|
||
| Preset | Requested pixel cap | Frame rate | Per-viewer bitrate | `degradationPreference` (what the encoder may sacrifice) |
|
||
| ------------ | ------------------- | ------------ | ------------------------------ | ------------------------------------------------------- |
|
||
| `Sharp` | ≤ 3840×2160 | 30 fps (up to 60) | 100% of `MIRROR_MAX_BITRATE` | `maintain-resolution` — frame rate |
|
||
| `Balanced` | ≤ 1920×1200 | 24 fps (up to 60) | 60% of `MIRROR_MAX_BITRATE` | `balanced` |
|
||
| `Light` | ≤ 960×600 | 15 fps (up to 30) | 35% of `MIRROR_MAX_BITRATE` | `maintain-framerate` — resolution |
|
||
|
||
`Sharp` is the default and is what the sharp-capture work shipped: its
|
||
request, cap and preference are the old hardcoded values verbatim, so nothing
|
||
it proved gets worse — the new presets only ever ask for less, and a preset
|
||
can never raise a leg above the server's ceiling. `MIRROR_MAX_BITRATE` stays
|
||
the ceiling the factor is applied to: a Wi-Fi sharer picks `Light` in the
|
||
page, an operator lowers the ceiling for everyone.
|
||
|
||
Who it is for: the software-encoding sharer. A Firefox/Linux box often has
|
||
no hardware video encoder (VA-API exposes no H.264 encode entrypoint on many
|
||
machines), where 4K at 30 fps burns roughly a whole core — `Light` trades
|
||
pixels and frame rate for a fan that does not spin, and under pressure the
|
||
encoder will in fact sacrifice what its `degradationPreference` allows.
|
||
Changing the preset applies to the share that is already running — the
|
||
per-viewer bitrate and frame rate always, and the capture size where the
|
||
browser allows a live rescale; when it does not, the panel says the size
|
||
lands on the next share instead of pretending it did. `q` cycles the preset.
|
||
The panel also shows the quality budget in force and the codec the legs
|
||
actually negotiated — `—` where a browser does not report it, never a guess.
|
||
|
||
## Deployment
|
||
|
||
The internal deployment is `https://mirror.reeseapps.com`, described and automated
|
||
in the deployments repo under `mirror/` (`app/` quadlet, `nginx/` edge, `certbot/`,
|
||
`route53/`, and a README with the VM SOP). Three things about it are worth knowing
|
||
from this side:
|
||
|
||
- **TLS ends at nginx on the service VM**, not in this process, so there is no
|
||
`--https` flag here. That is what makes `getDisplayMedia()` available on every
|
||
device, and it means an `Upgrade` header and a long idle timeout on `/ws` are
|
||
load-bearing.
|
||
- **The address list is enforced twice** — nginx `allow`/`deny`, and
|
||
`MIRROR_ALLOW_CIDRS` in the container for the path that bypasses nginx. Change
|
||
both in one commit.
|
||
- **The container has no internet egress.** Media goes browser-to-browser and the
|
||
server only relays JSON, so it dials nothing; `Internal=true` is what makes
|
||
that a fact rather than an intention.
|
||
|
||
### Kiosk client
|
||
|
||
A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk
|
||
session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at
|
||
the wall. It is set up and repaired entirely over SSH — no keyboard, no console,
|
||
no monitor. The display page is `/kiosk`, and it is the installer default
|
||
too; an existing `kiosk.conf` URL wins over the default, so moving a box is
|
||
the one-line re-point in [the runbook](docs/kiosk-silverblue.md#two-endpoints-watch-and-kiosk). A brand-new box layers the kiosk packages once first
|
||
(`rpm-ostree install gnome-kiosk gnome-kiosk-script-session`, then a reboot);
|
||
after that these two commands are both the setup and the repair path:
|
||
|
||
```bash
|
||
scp -r docs/kiosk-silverblue client:/var/tmp/ && ssh client \
|
||
'cd /var/tmp/kiosk-silverblue && sudo sh install-kiosk.sh && sudo systemctl restart gdm'
|
||
ssh client 'sudo sh /usr/local/share/mirror-kiosk/check-kiosk.sh'
|
||
```
|
||
|
||
Because nobody is standing at the machine, every claim about it is verified from
|
||
the outside: `loginctl` for the session, the foreground VT for "is the wall
|
||
actually on screen", the server's `/api/streams` for the viewer, the sharer's own
|
||
peer state for the media, and a probe page for the rendered resolution. The
|
||
runbook records why each file lives where it does on an
|
||
otherwise read-only OS.
|
||
|
||
**When it hangs.** A wall is unattended, so a hang has to end without a person:
|
||
the kernel turns lockups and oopses into a reboot, and PID 1 must keep kicking the
|
||
hardware watchdog or the chipset resets the box. Being *quiet* is a different
|
||
failure than being hung, so the server watches `/healthz` for that and pages over
|
||
ntfy ([`docs/wall-watch/`](docs/wall-watch/)). Both are installed by the commands
|
||
above and asserted by `check-kiosk.sh`.
|
||
|
||
**Keys.** The whole wall works from a keyboard — and the box needs no keyboard
|
||
attached to be operated *or* debugged; the keys are there when one is plugged
|
||
in. On `/watch`:
|
||
|
||
| Key | Action |
|
||
| --- | --- |
|
||
| `j` / `↓` · `k` / `↑` | Select the next / previous mirror (wraps). |
|
||
| `Enter` / `f` | Stage the selected mirror fullscreen (`f` re-enters fullscreen when already staged). |
|
||
| `g` | Back to the wall. |
|
||
| `n` / `p` | Stage the next / previous mirror. |
|
||
| `1`–`9` | Stage the Nth mirror. |
|
||
| `m` | Mute / unmute the staged mirror — on the selected mirror it stages it first. |
|
||
| `?` | Show / hide this list on screen. |
|
||
| `Esc` | Close the key list, then leave the stage. |
|
||
|
||
The same wall keys work on `/kiosk` too — the unattended display — and a
|
||
change made there (a keypress stages the mirror *and* publishes the same
|
||
`arrange` intent) is what every other display follows.
|
||
|
||
A staged mirror's caption bar and control cluster are **hidden at rest**, so
|
||
the shared video — a terminal's bottom line included — is never covered by
|
||
them. A pointer move over the stage (a tap, for touch) or any key press
|
||
while staged reveals them briefly; after ~3 s without activity they fade
|
||
back, so a parked pointer or a one-time key press never pins them up.
|
||
|
||
A mirror's audio is only heard while it is on the stage: every mirror starts
|
||
muted, unmuting one (the tile's audio button or `m`) mutes the others, and
|
||
leaving the stage re-mutes it. Sound never starts without a click or a
|
||
keypress.
|
||
|
||
On `/mirror`: `s` starts a share, `x` stops one, `q` cycles the Quality
|
||
preset, and `Esc` stops while live. Keys
|
||
with `Ctrl`/`Cmd`/`Alt` are never intercepted, and a key typed in the display-
|
||
name field is text, not a command. `?` is the only key that needs Shift, and it
|
||
is matched on the character itself — on a layout where `?` is a dead key the
|
||
list is not reachable, so keep this table to hand.
|
||
|
||
## Development
|
||
|
||
```bash
|
||
uv run pytest # unit + API + browser E2E (~95s)
|
||
uv run pytest -m "not e2e" # everything except the browser (~0.5s)
|
||
uv run ruff check . && uv run ruff format --check . && uv run pyright
|
||
```
|
||
|
||
The browser suite drives **real capture**: Chromium's fake media device yields a
|
||
moving monitor stream (1280×720 by default; clamped to the capturing window when
|
||
a size is requested, as `/mirror` now asks), so the assertions are on decoded,
|
||
changing pixels at the watcher — not on log lines.
|
||
|
||
There is a second, opt-in rung that exercises Chromium's **real X11 capture
|
||
backend** on a private Xvfb display, isolated from your own session so it can
|
||
never prompt you or mirror your desktop:
|
||
|
||
```bash
|
||
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay
|
||
```
|
||
|
||
See [AGENTS.md](AGENTS.md) for the operating rules,
|
||
[.agents/PLAN.md](.agents/PLAN.md) for the locked architectural decisions, and
|
||
[SECURITY.md](SECURITY.md) for why there is no login.
|