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
111 lines
4.1 KiB
Python
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)
|