Auto-login succeeded and the wall kept streaming, but about ten seconds after the kiosk session came up GDM autospawned a greeter on the same GPU and a login screen covered it. gnome-kiosk ships X-GDM-SessionRegisters=false, so nothing ever registers and GDM concludes the display is still free — which is why every remote check (loginctl, the mirror roster) stayed green while the physical screen showed a password prompt. Override that session file with the flag set to true, keeping the file name and session id identical: inventing an id makes GDM quietly fall back to the plain GNOME session, top bar and all. Add a per-minute guard that reclaims the foreground VT, because this class of failure is invisible from the outside, and have the checker assert both the foreground VT and the greeter-session count. That count had a silent false green: journalctl has no --no-legend, so the check read an empty stream and passed unconditionally. Also keep a copy of the payload in /usr/local/share/mirror-kiosk. /tmp is cleared at every boot, which deletes the checker just after the one event you are most likely to want to re-run it for. Verified on the reference client: a cold boot now creates no greeter session at all, the kiosk compositor and Firefox come up undisturbed, and a console stolen with chvt is taken back within a minute. LLM-Generated: true
154 lines
7.4 KiB
Markdown
154 lines
7.4 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` | 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](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
|
||
```
|
||
|
||
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`](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_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. |
|
||
|
||
## 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
|
||
`--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
|
||
`/watch`. It is set up and repaired entirely over SSH — no keyboard, no console,
|
||
no monitor:
|
||
|
||
```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.
|
||
|
||
## Development
|
||
|
||
```bash
|
||
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:
|
||
|
||
```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.
|