Files
mirror/docs/kiosk-silverblue/dim-probe.py
T
ducoterra c5da9461a2 docs: the Silverblue kiosk client, installed and checked over SSH
A wall display is now a target client: Fedora Silverblue running GNOME
Kiosk, auto-logged in as kiosk, showing /watch fullscreen in Firefox.
docs/kiosk-silverblue/ carries the idempotent installer, a read-only
checker and a probe that measures what the browser actually renders, so
a second client is a copy-paste and nothing about it needs a keyboard.

The runbook records what the upstream tutorial leaves out: gsettings
only commits from inside the session (gnome-kiosk sets DCONF_PROFILE,
so a system dconf db is never read at all), window-config.ini wins by
being found first and therefore silently loses the shipped notification
rules, AccountsService caches Session= until its daemon restarts, and
/watch survives a bare LAN IP while /mirror cannot.

Verified on the reference client: cold boot back to a subscribed wall in
under 60s, viewport == window == screen at 3840x2160, and the sharer's
own peer connection to it reports connected.

LLM-Generated: true
2026-11-01 19:11:53 -05:00

111 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""dim-probe.py — measure what a headless kiosk actually renders.
A kiosk has no keyboard, no monitor you can read and (on Silverblue) no
xdotool/grim to ask the compositor. So ask the browser instead: this serves one
page that reports its own viewport and screen size back as a request, which
lands in this server's log.
Run it on a machine the kiosk can reach:
python3 dim-probe.py # listens on 0.0.0.0:8099
then point the kiosk at it for a moment:
sudo KIOSK_URL=http://<this-host>:8099/ sh install-kiosk.sh
sudo pkill -u kiosk firefox # the kiosk loop reloads and relaunches
`innerWidth == screen.width` and `fullscreenElement == null` is the signature of
a real fullscreen window: the page occupies the whole output, so there is no
browser chrome and no panel left over to measure. (Firefox does not report
itself as a document.fullscreenElement when the window is fullscreen at
startup — that API is for in-page requestFullscreen.)
Read-only, no dependencies, ctrl-C to stop.
"""
from __future__ import annotations
import http.server
import socketserver
import sys
import time
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8099
PAGE = b"""<!doctype html><html><head><meta charset="utf-8"><title>dim-probe</title></head>
<body style="margin:0;background:#0a0f14;color:#4fd1c5;font:700 8vmin system-ui">
<div id="t">dim-probe</div>
<script>
function report(tag) {
const q = new URLSearchParams({
tag: tag,
inner: window.innerWidth + "x" + window.innerHeight,
outer: window.outerWidth + "x" + window.outerHeight,
screen: screen.width + "x" + screen.height,
avail: screen.availWidth + "x" + screen.availHeight,
dpr: window.devicePixelRatio,
fsElement: document.fullscreenElement ? document.fullscreenElement.tagName : "null",
ua: encodeURIComponent(navigator.userAgent.slice(0, 40)),
});
new Image().src = "/d?" + q.toString();
}
report("load");
// Two more samples: if the window is still settling, the numbers move.
setTimeout(() => report("t2s"), 2000);
setTimeout(() => report("t6s"), 6000);
setInterval(() => report("poll"), 10000);
</script>
</body></html>
"""
PNG1x1 = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d4944415478da6360000002000100fdff03fd0000000049454e44ae426082"
)
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path.startswith("/d?"):
query = self.path.split("?", 1)[1]
fields = dict(kv.split("=", 1) for kv in query.split("&") if "=" in kv)
print(
f"{time.strftime('%H:%M:%S')} "
f"{self.client_address[0]:<15} "
f"{fields.get('tag', '?'):<5} "
f"viewport={fields.get('inner', '?'):<11} "
f"window={fields.get('outer', '?'):<11} "
f"screen={fields.get('screen', '?'):<11} "
f"available={fields.get('avail', '?'):<11} "
f"fsElement={fields.get('fsElement', '?')}",
flush=True,
)
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(PNG1x1)))
self.end_headers()
self.wfile.write(PNG1x1)
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(PAGE)))
self.end_headers()
self.wfile.write(PAGE)
def log_message(self, *args: object) -> None:
"""Silence the default access log; the /d beacon above is the signal."""
if __name__ == "__main__":
socketserver.TCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer(("0.0.0.0", PORT), Handler) as httpd:
print(f"dim-probe on http://0.0.0.0:{PORT}/ — waiting for the kiosk…", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
sys.exit(0)