**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`.
18 KiB
Kiosk client — Fedora Silverblue
The target client for a wall display is Fedora Silverblue running GNOME Kiosk,
auto-logged in as kiosk, showing https://mirror.reeselink.com/watch
fullscreen in Firefox. One box takes about five minutes over SSH and needs no
keyboard, no monitor access, and no console.
Everything is in this directory and is idempotent — re-run it to repair a kiosk or to change its URL.
| File | Lands at | Why |
|---|---|---|
install-kiosk.sh |
run on the client as root | the whole install, one pass |
check-kiosk.sh |
run on the client as root | read-only report; nothing needs the console |
gnome-kiosk-script |
~kiosk/.local/bin/gnome-kiosk-script |
the kiosk application + watchdog loop |
kiosk.conf |
~kiosk/.config/mirror/kiosk.conf |
the one per-client value: the URL |
policies.json |
/etc/firefox/policies/policies.json |
no updates, accounts, telemetry, first-run pages |
window-config.ini |
/usr/local/share/gnome-kiosk/window-config.ini |
force the Firefox window fullscreen |
gnome-kiosk-script-wayland.desktop |
/usr/local/share/wayland-sessions/ |
stops GDM covering the wall with a greeter |
mirror-kiosk-console-guard |
/usr/local/libexec/ |
reclaims the console if the wall loses it |
mirror-kiosk-console-guard.{service,timer} |
/etc/systemd/system/ |
runs that check every minute |
dim-probe.py |
run on any reachable host | measures what the kiosk actually renders |
Add a client
# 1. A vanilla Silverblue install. Enable Settings → Sharing → Remote Login.
# Create an admin user (the first one you make) — that is your SSH account.
# 2. Layer the kiosk packages. This is the only step that needs a reboot.
ssh client 'sudo rpm-ostree install gnome-kiosk gnome-kiosk-script-session && sudo reboot'
# 3. Wait ~60s, then install everything else. Stage it in /var/tmp, not /tmp:
# /tmp is wiped at every boot, so a checker staged there is gone exactly when
# you want it. The installer keeps a permanent copy of the payload in
# /usr/local/share/mirror-kiosk.
scp -r docs/kiosk-silverblue client:/var/tmp/ && ssh client \
'cd /var/tmp/kiosk-silverblue && sudo sh install-kiosk.sh'
# 4. Hand the console to the kiosk.
ssh client 'sudo systemctl restart gdm'
# 5. Prove it, without touching the machine.
ssh client 'sudo sh /usr/local/share/mirror-kiosk/check-kiosk.sh'
install-kiosk.sh creates the kiosk user if it does not exist, so step 2 can
be skipped when the packages are already layered. To point a client somewhere
other than the production wall, pass the URL:
sudo KIOSK_URL=https://mirror.reeselink.com/watch sh install-kiosk.sh
Only kiosk.conf differs between clients; every other file stays byte-identical
so the fleet is auditable.
The two facts that make this work
Silverblue's mutable seams. /usr is read-only and belongs to the OSTree
commit, but /etc, /var and /usr/local (which is /var/usrlocal) are
writable and survive upgrades. That is why the payload goes to /etc/firefox,
/usr/local/share/gnome-kiosk and /var/home/kiosk and nowhere else. Never
ostree admin unlock to shove files into /usr — the next upgrade deletes them.
GNOME Kiosk's script session. gnome-kiosk-script-session starts a locked
down mutter session (no top bar, no activities, no dock) and runs
~/.local/bin/gnome-kiosk-script as the kiosk application. Two consequences
drive the design of that file:
- It re-executes itself forever, so an unguarded launch opens a new window
every few seconds. The script checks
pgrep -u $(id -u) -x firefoxfirst. - It must never exit.
org.gnome.Kiosk.Script.servicecarriesOnSuccess=gnome-session-shutdown.target, so the script returning logs the kiosk out and the screen falls back to the GDM greeter. Exiting is the failure mode, not crashing — which is exactly why a browser that dies is a feature: the loop comes back and starts it again.
The loop also re-sources kiosk.conf on every relaunch, so changing the URL
is:
sudo sed -i 's|^MIRROR_KIOSK_URL=.*|MIRROR_KIOSK_URL="https://…/watch"|' \
/var/home/kiosk/.config/mirror/kiosk.conf
sudo pkill -u kiosk firefox # loop notices within 5s and relaunches
Operating the wall: keys
The whole wall works from a keyboard — and the box needs no keyboard attached
to be operated or debugged: everything about it is checkable over SSH (see
Verifying a kiosk you cannot see). 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) |
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 the key 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, so on a layout where ? is a
dead key the list is not reachable — keep this table to hand.
Why each piece is where it is
Auto-login — /etc/gdm/custom.conf
[daemon]
AutomaticLoginEnable=True
AutomaticLogin=kiosk
TimedLoginEnable=False
TimedLoginEnable=False matters: a timed login would hand the console to the
greeter on its own schedule and you get a login form on your wall.
Default session — /var/lib/AccountsService/users/kiosk
[User]
Session=gnome-kiosk-script-wayland
This is what GDM logs the kiosk user into. Without it auto-login drops the
user into a normal GNOME session with a top bar. The installer restarts
accounts-daemon afterwards, because the daemon caches these files and happily
writes the stale value back over your edit.
gsettings — applied inside the session, not at install time
The screen must never blank or lock. This cannot be done from your SSH session:
gsettings only commits when it can reach a D-Bus session bus, and over SSH it
prints Cannot autolaunch D-Bus without X11 and silently leaves the default. It
also cannot live in /etc/dconf/db/local.d, because /usr/bin/gnome-kiosk sets
DCONF_PROFILE=gnomekiosk and that profile is user-db:user plus the shipped
compiled defaults — it never reads a system db.
So the kiosk script applies them itself on first run each boot, stamped by a
file in $XDG_RUNTIME_DIR (which is per-login, so it re-applies if someone
changes a setting by hand):
gsettings set org.gnome.desktop.session idle-delay 0
gsettings set org.gnome.desktop.screensaver lock-enabled false
gsettings set org.gnome.desktop.interface enable-animations false
Firefox window rules — /usr/local/share/gnome-kiosk/window-config.ini
gnome-kiosk reads the first window-config.ini it finds in
$XDG_CONFIG_HOME/gnome-kiosk, then /var/lib/flatpak/exports/share,
/usr/local/share, /usr/share. Because ours wins, the shipped file is not
read at all — anything it defines is lost unless repeated. Ours therefore copies
the shipped [notifications] section verbatim and adds:
[mirror-browser]
match-class=org.mozilla.*
set-fullscreen=true
firefox --kiosk already goes fullscreen; this is the belt so a window that
comes back from a resume still fills the output.
Firefox policy — /etc/firefox/policies/policies.json
No update prompts, no Firefox Account or Sync, no studies, no telemetry, no
first-run/restore pages, no default bookmarks, no saved-password prompts.
/usr/lib64/firefox/distribution/ is the other documented location and it is
inside the read-only /usr.
Verified on this box: the file installs, is world-readable with a sane SELinux label, and Firefox runs happily with it. What is not verified headlessly is that each policy is applied — Firefox's policy engine does not initialise in the
--headless --screenshot/--dump-dommodes, so a probe there proves nothing either way. Openabout:policieson a real kiosk display to confirm; it lists every policy Firefox read and any it rejected.
Initial-setup guard — ~kiosk/.config/gnome-initial-setup-done
Without it the GNOME "welcome" wizard claims the session on first login and the
kiosk never starts. The journal confirms the guard working:
gnome-initial-setup-first-login.service … skipped, unmet condition check ConditionPathExists=!/var/home/kiosk/.config/gnome-initial-setup-done.
The greeter that eats the wall
The shipped gnome-kiosk-script-wayland.desktop declares:
X-GDM-SessionRegisters=false
That is true of the session itself — gnome-kiosk is a bare mutter kiosk, so nothing ever registers with GDM. But GDM reads "never registered" as "this display is still mine", and about ten seconds after a perfectly successful auto-login it autospawns a full greeter session on the same GPU. A login screen covers the wall.
The part that makes this maddening: the kiosk keeps running underneath. The
session stays Active, Firefox keeps streaming, the mirror roster keeps
counting the wall as a viewer, loginctl reports the kiosk on seat0 — every
remote check says nothing is wrong while a password prompt is on the physical
screen. This is what "it didn't auto-login" actually looks like.
From a real boot, journalctl -b:
19:07:28 PAM:authentication grantors=pam_permit acct="kiosk" ... res=success
19:07:28 New session '1' of user 'kiosk' with class 'user' and type 'wayland'
19:07:30 Started org.gnome.Kiosk@wayland.service ← the wall is up
19:07:38 New session 'c1' of user 'gdm-greeter' with class 'greeter'
19:07:39 gnome-shell: Registering session with GDM ← the greeter eats it
19:08:46 Stopped org.gnome.Shell@gdm.service ← 68s of login screen
The fix is the local override of that one file, changing only
X-GDM-SessionRegisters to true, so GDM treats the kiosk session as the owner
of the display and never brings up a greeter. Two things about it are traps:
- Keep the file name and session id identical to the shipped one. Inventing
mirror-kiosk.desktopand pointing AccountsService at it does not work — GDM silently falls back to the plain GNOME session, and you get a normal desktop with a top bar instead of a kiosk. Verified the hard way. - A normal GNOME session already sets
true, which is why this bug only ever shows up on the kiosk.
After the override, the same boot looks like this: gdm-greeter appears zero
times, the session registers, and the kiosk compositor and Firefox come up
undisturbed.
And if the wall loses the console anyway
Because the failure above is invisible from the outside, the install also drops
in a guard: once a minute it checks that the kiosk's seat0 session is still the
foreground VT, and if not it runs loginctl activate on it and writes one line
to the journal.
It is deliberately timid — it never restarts gdm and never logs anyone out, and if there is no kiosk session at all it only reports that, because a display manager is a bad thing to fight automatically. To stop it fighting you while you debug the console by hand:
sudo systemctl stop mirror-kiosk-console-guard.timer
sudo systemctl start mirror-kiosk-console-guard.timer # when finished
sudo journalctl -u mirror-kiosk-console-guard.service # what it has done
Tested by stealing the console with chvt 1; the wall came back on its own:
mirror-kiosk-guard[4598]: kiosk session 1 brought back to the foreground
Verifying a kiosk you cannot see
The console is not an interface. Every claim below is checkable over SSH.
| Claim | Evidence |
|---|---|
| Auto-login works | loginctl list-sessions → a seat0 session whose user is kiosk |
| The right session | loginctl show-session N -p Type → wayland; pgrep -u kiosk -a gnome-kiosk |
| The browser is up in kiosk mode | pgrep -u kiosk -a firefox → --kiosk … <url> |
| It restarted after a crash | ~kiosk/.local/state/mirror-kiosk.log shows exited rc=143 then launching: |
| The page loaded | the server's GET /api/streams shows the kiosk in viewers and its socket in ss -tn |
| Media really flows | the sharer's own page renders each viewer's RTCPeerConnection.connectionState; connected = ICE+DTLS+SRTP up |
| It is truly fullscreen | dim-probe.py: viewport == window == screen == available |
| The URL is serving mirror | check-kiosk.sh greps the page for <title>Watch · mirror |
| The kiosk holds the screen | /sys/class/tty/tty0/active equals the kiosk session's TTY and the session is Active — the one check that catches a covered wall |
| No greeter is fighting it | journalctl -b | grep -c "New session.*gdm-greeter" is 0; any non-zero number is a login screen on your wall |
| It survives a power cut | sudo systemctl reboot, then re-run check-kiosk.sh |
Measured on the reference client, which is attached to a 4K output:
viewport=3840x2160 window=3840x2160 screen=3840x2160 available=3840x2160
Window equal to viewport means no browser chrome is left over; screen equal to available means no panel or bar took any of it.
After a cold boot the wall re-joins on its own — reference client back up and subscribed to a live mirror in under 60 seconds:
{"mirrors": 1, "sharers": 1, "viewers": 1, "connections": 2, "watching": 1}
Firefox · Linux | Primary Monitor | 2560 x 1600 | viewers 1
One trap worth knowing
/watch works over plain http://<lan-ip>, but /mirror does not:
getDisplayMedia() needs a secure context, so the Share button is disabled
there. Pointing a kiosk at a bare-LAN-IP mirror gives you a wall that watches
fine and cannot share. Production is HTTPS at the edge, so both work — which is
the main practical reason the kiosk URL should be the https:// one and not a
convenient IP.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Login screen on the wall, but every remote check passes | The session is not registered with GDM, so GDM autospawns a greeter over it. See The greeter that eats the wall; confirm with ls /usr/local/share/wayland-sessions/ |
| Wall vanished, console on a blank VT | Someone or something switched VT — the guard reclaims it within a minute; journalctl -u mirror-kiosk-console-guard.service |
Greeter on screen after boot and seat0 is another user |
sudo systemctl restart gdm. GDM reads custom.conf at start only |
| Kiosk logs in to a normal desktop | Session= empty in /var/lib/AccountsService/users/kiosk; re-run the installer (it restarts accounts-daemon) |
| Wall shows a non-mirror page | the URL serves something else. check-kiosk.sh calls this out; a stale vhost/DNS looks like a working kiosk showing a login form |
| Black screen, no browser at all | the URL is unreachable so the loop is waiting. curl -fsSL <url> from the client; then look in ~/.local/state/mirror-kiosk.log |
| A new window every few seconds | someone removed the pgrep guard from the kiosk script |
| Blank on wake from idle | the gsettings did not apply (they must run inside the session) — journalctl _UID=<kiosk uid> | grep -i permission |
Permission denied about keyrings/ICC |
~kiosk/.local is not owned by kiosk. sudo chown -R kiosk:kiosk /var/home/kiosk/.local and re-run the installer |
| Firefox shows a restore-session or first-run page | the policy file is missing or unreadable — sudo cat /etc/firefox/policies/policies.json |
Tile says unreachable |
the sharer and the kiosk could not find each other over ICE. Both must be on a network where they can send UDP directly; mirror uses no STUN/TURN by design |
| Wall shows the "not allowed" page | the kiosk's address is outside MIRROR_ALLOW_CIDRS — this is admission control working, see SECURITY.md |
Debugging rule for this project applies: reproduce at the lowest layer that can
fail. curl the URL from the client, check /api/streams on the server, and
only then reach for the browser.
Deliberately not done here
These are real hardening steps for a kiosk in a public space; each is a decision, not an oversight.
- The
kioskuser has no password. Auto-login does not need one and a password does not stop anyone with a keyboard. If you want to be able to log in askiosk, set one withsudo passwd kiosk. - TTYs are live. Ctrl-Alt-F3 still gets a console, which is your rescue
path.
mask-inggetty@tty3..6is the usual lockdown — do it only once the kiosk is known good, since it removes the way back in without SSH. - No GRUB password, so anyone with physical access can boot something else.
- No automatic OS updates.
rpm-ostreeis transactional and the previous deployment is kept, so a bad upgrade is undone withrpm-ostree rollback(or chosen from the GRUB menu if it will not boot). Unattended upgrades of a box nobody looks at is its own risk — decide it deliberately, viarpm-ostreed-automatic.timer. - SELinux is left enforcing, unmodified. Nothing here needed a policy tweak; treat a new AVC as a bug in this runbook.
- No reboot watchdog. If the wall is remote enough that a hang means driving to it, that is an ops decision (greenboot, or a smart PDU), not a client one.
Naming on the reference network
| Name | Address | What it is |
|---|---|---|
mirror.reeselink.com |
10.1.1.2 | the mirror service (nginx → container), valid TLS cert |
mirror-client.reeselink.com |
10.2.1.183 | the reference kiosk client |
mirror |
— | SSH alias for the kiosk client in ~/.ssh/config |
The client's address is DHCP. A kiosk wants a reservation: the address is
what admits it to the service (MIRROR_ALLOW_CIDRS, nginx allow/deny), so a
renewal into a different range can lock a wall out by itself.