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
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user