Files
mirror/README.md
T
ducoterra d0077e0db4
Build and Push Containers / test (push) Successful in 45s
Build and Push Containers / build-and-push-app (push) Successful in 13s
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.
2026-11-03 23:50:43 -05:00

16 KiB
Raw Blame History

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. 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

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.

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 §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 §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. 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:

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/). 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

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:

MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay

See AGENTS.md for the operating rules, .agents/PLAN.md for the locked architectural decisions, and SECURITY.md for why there is no login.