**Phase 01 final verification pass — complete, 100% green** - Verified all 4 tasks done (files in `complete/`); implementation review: key router in `watch.js`, share-page router, CSS focus/selection affordances, `?` overlay, pointer-free E2E suite — all sound. - Gates: `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` → 223 passed, 2 skipped (realdisplay), 1 pre-existing FastAPI deprecation warning · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` → 2 passed ✅ · `pytest -m e2e` → 51 passed (incl. `test_keyboard_wall.py`, which a meta-test keeps pointer-free) · `node --check` on all static JS ✅. - Completion criteria: all 6 ✅ (j/k+Enter, f/g/n/p/1–9/m/?/Esc keyboard-only E2E; no hover-only controls via CSS guard + focus-ring E2E; `#name` text-safe; e2e/realdisplay green; key map documented in README "Kiosk client" and `docs/kiosk-silverblue.md`). - README fixed: added missing env vars `MIRROR_MAX_BITRATE`, `MIRROR_PING_INTERVAL`, `DEBUGPY_WAIT` to the config table; added the one-time `rpm-ostree` layering note before the kiosk install snippet. - AGENTS.md fixed: removed stale "roadmap is intentionally empty" parenthetical (3 phases pending); coverage ~94% → ~95%. No content added. - **Unresolved, needs your call:** README/SECURITY.md say `mirror.reeseapps.com`; `docs/kiosk-silverblue.md` (reference-client naming table with real IPs) says `mirror.reeselink.com`. I couldn't verify which is the live deployment domain, so I changed neither — say the word and I'll unify. - Next pending phase: `02_sharp_capture`.
9.0 KiB
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 |
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. |
| 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
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 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) ─────────┴─────────────────┘
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 |
12000000 |
Per-leg WebRTC bitrate cap in bits/s — the mesh uploads N × bitrate per sharer, so this is the ceiling. |
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. |
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.
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
--httpsflag here. That is what makesgetDisplayMedia()available on every device, and it means anUpgradeheader and a long idle timeout on/wsare load-bearing. - The address list is enforced twice — nginx
allow/deny, andMIRROR_ALLOW_CIDRSin 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=trueis 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
/watch. It is set up and repaired entirely over SSH — no keyboard, no console,
no monitor. 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.
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. |
? |
Show / hide this list on screen. |
Esc |
Close the key list, then leave the stage. |
On /mirror: s starts a share, x stops one, 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 (~20s)
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 1280×720 monitor stream, 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.