Kiosk start, offline and setup pages in the InfoFlow display style
CI / test-bookworm (push) Failing after 2s
CI / test-latest (push) Successful in 31s

Dark grey background, name top left, date and clock top right, a flat
panel with a blue top edge. The pages show the kiosk link (token masked)
and the management address; the clock code is shared in kiosk.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-04 23:13:03 +01:00
1 parent efc581e0ae
commit 19595aaf39
11 files changed
+169 -89

No files matched your search

+2 -1
View File
@@ -29,7 +29,7 @@ from urllib.parse import urlencode, urlsplit
from . import logutil, sysinfo from . import logutil, sysinfo
from .cdp import CDP, CDPError from .cdp import CDP, CDPError
from .config import load_config from .config import admin_url, load_config
from .paths import Paths, load_paths from .paths import Paths, load_paths
from .validation import mask_url from .validation import mask_url
@@ -139,6 +139,7 @@ class Agent:
def offline_url(self, config: dict[str, Any], reason: str) -> str: def offline_url(self, config: dict[str, Any], reason: str) -> str:
return self.local_url("offline", {"name": config["device_name"], "host": urlsplit(config["url"]).hostname or "", return self.local_url("offline", {"name": config["device_name"], "host": urlsplit(config["url"]).hostname or "",
"url": mask_url(config["url"]), "admin": admin_url(config, self.hostname()),
"reason": reason, "since": int(self.offline_since or self.wall())}) "reason": reason, "since": int(self.offline_since or self.wall())})
def navigate(self, url: str) -> None: def navigate(self, url: str) -> None:
+5 -3
View File
@@ -13,6 +13,7 @@ import logging
import os import os
import re import re
import shutil import shutil
import socket
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -20,9 +21,9 @@ from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from . import logutil, sysinfo from . import logutil, sysinfo
from .config import load_config from .config import admin_url, load_config
from .paths import Paths, load_paths from .paths import Paths, load_paths
from .validation import parse_resolution from .validation import mask_url, parse_resolution
log = logging.getLogger("voxip-kiosk-browser") log = logging.getLogger("voxip-kiosk-browser")
@@ -79,7 +80,8 @@ def build_args(chromium: str, config: dict[str, Any], profile_dir: Path, cache_d
def start_page_url(paths: Paths, config: dict[str, Any]) -> str: def start_page_url(paths: Paths, config: dict[str, Any]) -> str:
query = urlencode({"name": config["device_name"], "next": config["url"]}) query = urlencode({"name": config["device_name"], "next": config["url"], "url": mask_url(config["url"]),
"admin": admin_url(config, socket.gethostname().split(".")[0])})
return f"file://{paths.www_dir}/start.html?{query}" return f"file://{paths.www_dir}/start.html?{query}"
+6
View File
@@ -123,6 +123,12 @@ class ConfigError(Exception):
self.errors = errors self.errors = errors
def admin_url(config: dict[str, Any], hostname: str) -> str:
"""Address of the web interface as shown on the kiosk screen."""
scheme = "https" if config["admin_https"] else "http"
return f"{scheme}://{hostname}.local:{config['admin_port']}"
def validate(updates: dict[str, Any]) -> dict[str, Any]: def validate(updates: dict[str, Any]) -> dict[str, Any]:
"""Validate known keys in ``updates``; raise ConfigError listing every problem.""" """Validate known keys in ``updates``; raise ConfigError listing every problem."""
clean: dict[str, Any] = {} clean: dict[str, Any] = {}
+9
View File
@@ -169,3 +169,12 @@ def test_devtools_down_is_reported(env):
browser.down = True browser.down = True
agent.tick() agent.tick()
assert json.loads(paths.agent_state_file.read_text())["browser"] == "unreachable" assert json.loads(paths.agent_state_file.read_text())["browser"] == "unreachable"
def test_offline_page_never_receives_the_token(env, paths):
agent, browser, probe, _, _ = env
save_config(paths.config_file, {"url": "https://e.org/scherm?token=geheim123456"})
probe.ok, probe.reason = False, "Time-out"
agent.tick()
assert "offline.html" in browser.url
assert "geheim" not in browser.url and "3456" in browser.url
+9 -1
View File
@@ -1,5 +1,6 @@
import json import json
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from voxip_kiosk import browser as b from voxip_kiosk import browser as b
from voxip_kiosk.config import DEFAULTS from voxip_kiosk.config import DEFAULTS
@@ -74,9 +75,16 @@ def test_start_page_url_contains_target(paths):
assert "next=https%3A%2F%2Fe.org%2Fx%3Ftoken%3Dabc" in url and "name=Hal+1" in url assert "next=https%3A%2F%2Fe.org%2Fx%3Ftoken%3Dabc" in url and "name=Hal+1" in url
def test_start_page_shows_only_the_masked_link(paths):
url = b.start_page_url(paths, dict(DEFAULTS, url="https://e.org/x?token=geheim123456"))
query = parse_qs(urlsplit(url).query)
assert query["url"] == ["https://e.org/x?token=…3456"]
assert query["admin"][0].endswith(".local:8080")
def test_local_pages_exist(): def test_local_pages_exist():
www = Path(__file__).resolve().parents[1] / "www" www = Path(__file__).resolve().parents[1] / "www"
for page in ("start.html", "offline.html", "setup.html", "kiosk.css", "logo.svg"): for page in ("start.html", "offline.html", "setup.html", "kiosk.css", "kiosk.js"):
assert (www / page).exists() assert (www / page).exists()
+38 -24
View File
@@ -1,26 +1,40 @@
/* Local pages shown on the kiosk screen itself (file://). Large type, readable from a distance. */ /* Local pages shown on the kiosk screen itself (file://). Same look as the InfoFlow
:root { color-scheme: dark; } display (dark theme): flat dark grey, white type, panels with a blue top edge. */
:root {
color-scheme: dark;
--bg: #111827;
--panel: rgba(0, 0, 0, .2);
--line: rgba(255, 255, 255, .1);
--accent: #2563eb;
--muted: rgba(255, 255, 255, .7);
--faint: rgba(255, 255, 255, .45);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; cursor: none; user-select: none; } html, body { margin: 0; height: 100%; overflow: hidden; cursor: none; user-select: none; }
body { body { font-family: ui-sans-serif, system-ui, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; background: var(--bg); color: #fff; }
font-family: system-ui, "DejaVu Sans", "Liberation Sans", Arial, sans-serif;
background: radial-gradient(ellipse at 30% 20%, #163a6b 0%, #0b2545 45%, #061529 100%); .screen { display: flex; flex-direction: column; height: 100%; padding: 4vmin; transition: transform 2s ease; }
color: #eaf1fb; .top { display: flex; justify-content: space-between; align-items: flex-start; gap: 4vmin; }
display: flex; align-items: center; justify-content: center; .name { font-size: clamp(20px, 3.4vmin, 44px); font-weight: 600; }
} .when { text-align: right; }
.stage { text-align: center; padding: 4vh 6vw; max-width: 1400px; transition: transform 2s ease; } .date { font-size: clamp(18px, 3.6vmin, 44px); color: var(--muted); }
.logo { width: 9vmin; height: 9vmin; min-width: 64px; min-height: 64px; margin-bottom: 3vh; opacity: .95; } .clock { font-size: clamp(36px, 8vmin, 110px); font-weight: 300; line-height: 1.1; font-variant-numeric: tabular-nums; }
h1 { font-size: clamp(28px, 5vmin, 72px); margin: 0 0 2vh; font-weight: 700; letter-spacing: -.01em; }
p { font-size: clamp(16px, 2.6vmin, 36px); margin: 0 0 1.5vh; color: #b9cbe4; } .middle { flex: 1; display: flex; align-items: center; justify-content: center; padding: 3vmin 0; }
.device { font-size: clamp(14px, 2.2vmin, 28px); color: #7f9cc4; text-transform: uppercase; letter-spacing: .12em; margin-bottom: 2vh; } .panel { width: min(100%, 1100px); background: var(--panel); border-top: 4px solid var(--accent); border-radius: 8px; padding: 4.5vmin 5vmin; }
.clock { font-size: clamp(48px, 12vmin, 180px); font-weight: 300; font-variant-numeric: tabular-nums; margin: 3vh 0 1vh; color: #fff; } h1 { font-size: clamp(26px, 4.6vmin, 64px); font-weight: 600; margin: 0 0 1.5vmin; }
.date { font-size: clamp(18px, 3vmin, 40px); color: #b9cbe4; } p { font-size: clamp(16px, 2.6vmin, 34px); color: var(--muted); margin: 0 0 1vmin; }
.status { display: inline-flex; align-items: center; gap: 1.2vmin; margin-top: 4vh; padding: 1.2vmin 2.4vmin; border-radius: 999px; background: rgba(255, 255, 255, .08); font-size: clamp(14px, 2.2vmin, 28px); color: #d6e2f3; } .link { margin-top: 3.5vmin; padding-top: 2.5vmin; border-top: 1px solid var(--line); font-size: clamp(14px, 2.2vmin, 28px); color: var(--muted); overflow-wrap: anywhere; }
.dot { width: 1.6vmin; height: 1.6vmin; min-width: 10px; min-height: 10px; border-radius: 50%; background: #f0a020; animation: pulse 1.6s infinite; } .link .url { font-family: "DejaVu Sans Mono", ui-monospace, monospace; color: #fff; }
.spinner { width: 7vmin; height: 7vmin; margin: 4vh auto 0; border-radius: 50%; border: .8vmin solid rgba(255, 255, 255, .15); border-top-color: #4f9cf9; animation: spin 1s linear infinite; }
.code { display: inline-block; font-family: "DejaVu Sans Mono", ui-monospace, monospace; font-size: clamp(40px, 10vmin, 150px); font-weight: 700; letter-spacing: .12em; padding: 2vh 4vw; margin: 2vh 0 3vh; border-radius: 2vmin; background: rgba(255, 255, 255, .1); border: 2px solid rgba(79, 156, 249, .6); color: #fff; } .bottom { display: flex; justify-content: space-between; gap: 4vmin; font-size: clamp(12px, 1.9vmin, 24px); color: var(--faint); }
.urls { list-style: none; padding: 0; margin: 1vh 0 3vh; }
.urls li { font-family: "DejaVu Sans Mono", ui-monospace, monospace; font-size: clamp(18px, 3.4vmin, 48px); color: #fff; margin: .8vh 0; } .progress { height: 4px; margin-top: 4vmin; background: var(--line); border-radius: 2px; overflow: hidden; }
.hint { font-size: clamp(14px, 2vmin, 26px); color: #7f9cc4; } .progress span { display: block; width: 30%; height: 100%; background: var(--accent); animation: slide 1.8s ease-in-out infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(340%); } }
@keyframes pulse { 50% { opacity: .3; } }
/* setup page */
.urls { list-style: none; padding: 0; margin: 1.5vmin 0 3vmin; }
.urls li { font-family: "DejaVu Sans Mono", ui-monospace, monospace; font-size: clamp(18px, 3.2vmin, 44px); margin: .8vmin 0; }
.code { display: inline-block; font-family: "DejaVu Sans Mono", ui-monospace, monospace; font-size: clamp(40px, 9vmin, 130px); font-weight: 700; letter-spacing: .1em; padding: 1.5vmin 3vmin; margin: 1.5vmin 0 3vmin; border-radius: 8px; background: rgba(255, 255, 255, .06); border: 1px solid var(--line); }
.hint { font-size: clamp(14px, 2vmin, 26px); color: var(--faint); }
+25
View File
@@ -0,0 +1,25 @@
// Shared by the local kiosk pages: device name, clock/date, kiosk link and admin address.
const params = new URLSearchParams(location.search);
const fmtTime = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit" });
const fmtDate = new Intl.DateTimeFormat("nl-NL", { weekday: "long", day: "numeric", month: "long" });
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
setText("name", params.get("name") || "VoxIP Kiosk");
// The link arrives with its token already masked (…abcd).
const link = (params.get("url") || "").replace(/^https:\/\//, "");
if (link) setText("link-url", link);
else document.getElementById("link")?.setAttribute("hidden", "");
setText("admin", params.get("admin") ? "Beheer: " + params.get("admin") : "");
function tickClock() {
const now = new Date();
setText("clock", fmtTime.format(now));
const date = fmtDate.format(now);
setText("date", date.charAt(0).toUpperCase() + date.slice(1));
}
tickClock();
setInterval(tickClock, 1000);
-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="VoxIP Kiosk">
<rect x="2" y="4" width="28" height="19" rx="3" fill="#1d6fd8"/>
<rect x="5" y="7" width="22" height="13" rx="1.5" fill="#0b2545"/>
<path d="M10 10.5l3 6 3-6M18.5 10.5v6M21.5 16.5v-6h2a1.8 1.8 0 010 3.6h-2" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="13" y="23" width="6" height="3" fill="#1d6fd8"/>
<rect x="9" y="26" width="14" height="2.5" rx="1.25" fill="#1d6fd8"/>
</svg>

Before

Width:  |  Height:  |  Size: 543 B

+29 -34
View File
@@ -6,50 +6,45 @@
<link rel="stylesheet" href="kiosk.css"> <link rel="stylesheet" href="kiosk.css">
</head> </head>
<body> <body>
<main class="stage" id="stage"> <div class="screen" id="screen">
<img class="logo" src="logo.svg" alt=""> <header class="top">
<div class="device" id="device"></div> <div class="name" id="name"></div>
<h1>De informatie is tijdelijk niet beschikbaar</h1> <div class="when">
<p id="detail">Er is op dit moment geen verbinding met de server.</p> <div class="date" id="date"></div>
<div class="clock" id="clock">--:--</div> <div class="clock" id="clock">--:--</div>
<div class="date" id="date"></div> </div>
<div class="status"><span class="dot" aria-hidden="true"></span><span id="status">Automatisch opnieuw verbinden…</span></div> </header>
</main> <main class="middle">
<section class="panel">
<h1>Geen verbinding</h1>
<p>De informatie is tijdelijk niet beschikbaar. Het scherm verbindt automatisch opnieuw.</p>
<p id="since" hidden></p>
<div class="link" id="link">Pagina: <span class="url" id="link-url"></span><br><span id="reason"></span></div>
</section>
</main>
<footer class="bottom">
<span id="admin"></span>
<span>VoxIP Kiosk</span>
</footer>
</div>
<script src="kiosk.js"></script>
<script> <script>
const params = new URLSearchParams(location.search);
const fmtTime = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit" });
const fmtDate = new Intl.DateTimeFormat("nl-NL", { weekday: "long", day: "numeric", month: "long", year: "numeric" });
document.getElementById("device").textContent = params.get("name") || "";
const host = params.get("host");
const reason = params.get("reason"); const reason = params.get("reason");
if (host) { setText("reason", reason ? "Reden: " + reason : "");
document.getElementById("detail").textContent =
"Geen verbinding met " + host + (reason ? " (" + reason + ")" : "") + ".";
}
const since = Number(params.get("since")) * 1000; const since = Number(params.get("since")) * 1000;
if (since) {
function tick() { const el = document.getElementById("since");
const now = new Date(); el.textContent = "Offline sinds " + fmtTime.format(new Date(since)) + ".";
document.getElementById("clock").textContent = fmtTime.format(now); el.hidden = false;
const date = fmtDate.format(now);
document.getElementById("date").textContent = date.charAt(0).toUpperCase() + date.slice(1);
if (since) {
const minutes = Math.floor((now - since) / 60000);
document.getElementById("status").textContent = minutes >= 1
? "Offline sinds " + fmtTime.format(new Date(since)) + " — automatisch opnieuw verbinden…"
: "Automatisch opnieuw verbinden…";
}
} }
tick();
setInterval(tick, 1000);
// Shift the content slowly to avoid burn-in on screens that stay offline for long. // Shift everything slowly to avoid burn-in on screens that stay offline for long.
let step = 0; let step = 0;
setInterval(() => { setInterval(() => {
step = (step + 1) % 8; step = (step + 1) % 8;
const dx = [0, 12, 24, 12, 0, -12, -24, -12][step]; const dx = [0, 12, 24, 12, 0, -12, -24, -12][step];
const dy = [0, -8, 0, 8, 0, -8, 0, 8][step]; const dy = [0, -8, 0, 8, 0, -8, 0, 8][step];
document.getElementById("stage").style.transform = "translate(" + dx + "px," + dy + "px)"; document.getElementById("screen").style.transform = "translate(" + dx + "px," + dy + "px)";
}, 60000); }, 60000);
</script> </script>
</body> </body>
+24 -10
View File
@@ -6,17 +6,31 @@
<link rel="stylesheet" href="kiosk.css"> <link rel="stylesheet" href="kiosk.css">
</head> </head>
<body> <body>
<main class="stage"> <div class="screen">
<img class="logo" src="logo.svg" alt=""> <header class="top">
<h1>Welkom bij VoxIP Kiosk OS</h1> <div class="name" id="name"></div>
<p>Open de beheerinterface op een computer, tablet of telefoon in hetzelfde netwerk:</p> <div class="when">
<ul class="urls" id="urls"></ul> <div class="date" id="date"></div>
<p>en voer deze setupcode in om een beheerderswachtwoord in te stellen:</p> <div class="clock" id="clock">--:--</div>
<div class="code" id="code">—</div> </div>
<p class="hint">De code is eenmalig en verdwijnt zodra het wachtwoord is ingesteld.</p> </header>
</main> <main class="middle">
<section class="panel">
<h1>Eerste configuratie</h1>
<p>Open de beheerinterface op een computer, tablet of telefoon in hetzelfde netwerk:</p>
<ul class="urls" id="urls"></ul>
<p>en voer deze setupcode in om een beheerderswachtwoord in te stellen:</p>
<div class="code" id="code">—</div>
<p class="hint">De code is eenmalig en verdwijnt zodra het wachtwoord is ingesteld.</p>
</section>
</main>
<footer class="bottom">
<span></span>
<span>VoxIP Kiosk</span>
</footer>
</div>
<script src="kiosk.js"></script>
<script> <script>
const params = new URLSearchParams(location.search);
const scheme = params.get("scheme") === "https" ? "https" : "http"; const scheme = params.get("scheme") === "https" ? "https" : "http";
const port = params.get("port") || "8080"; const port = params.get("port") || "8080";
const suffix = (scheme === "http" && port === "80") || (scheme === "https" && port === "443") ? "" : ":" + port; const suffix = (scheme === "http" && port === "80") || (scheme === "https" && port === "443") ? "" : ":" + port;
+22 -9
View File
@@ -6,18 +6,31 @@
<link rel="stylesheet" href="kiosk.css"> <link rel="stylesheet" href="kiosk.css">
</head> </head>
<body> <body>
<main class="stage"> <div class="screen">
<img class="logo" src="logo.svg" alt=""> <header class="top">
<div class="device" id="device"></div> <div class="name" id="name"></div>
<h1>Bezig met laden…</h1> <div class="when">
<p>De pagina wordt geopend zodra de verbinding gereed is.</p> <div class="date" id="date"></div>
<div class="spinner" aria-hidden="true"></div> <div class="clock" id="clock">--:--</div>
</main> </div>
</header>
<main class="middle">
<section class="panel">
<h1>Laden…</h1>
<p>Het scherm wordt geopend zodra de verbinding klaar is.</p>
<div class="progress" aria-hidden="true"><span></span></div>
<div class="link" id="link">Pagina: <span class="url" id="link-url"></span></div>
</section>
</main>
<footer class="bottom">
<span id="admin"></span>
<span>VoxIP Kiosk</span>
</footer>
</div>
<script src="kiosk.js"></script>
<script> <script>
// The kiosk agent normally navigates away from this page within seconds. // The kiosk agent normally navigates away from this page within seconds.
// If the agent is not running, open the configured URL ourselves. // If the agent is not running, open the configured URL ourselves.
const params = new URLSearchParams(location.search);
document.getElementById("device").textContent = params.get("name") || "";
const next = params.get("next") || ""; const next = params.get("next") || "";
if (next.startsWith("https://")) { if (next.startsWith("https://")) {
setTimeout(() => location.replace(next), 45000); setTimeout(() => location.replace(next), 45000);