feat: WebKit for web/html cells + display auto-discovery via heartbeat

Rust kiosk:
- web cells now use webkit6 WebView (load_uri)
- html cells use WebView.load_html (full HTML rendering)
- query_displays() reads /sys/class/drm/ for connected HDMI/DP outputs
- Heartbeat reports display geometry every 60s

Server:
- /api/kiosk/heartbeat accepts displays array
- Syncs kiosk-reported displays to display records
- Updates dimensions when changed, creates new displays for new ports
This commit is contained in:
Mitchell R 2026-05-10 22:39:53 +02:00
parent 722ddcfb12
commit 766bf8dee0
4 changed files with 72 additions and 11 deletions

View file

@ -32,3 +32,4 @@ hostname = "0.4"
tokio-tungstenite = { version = "0.24", features = ["native-tls"] } tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3" futures-util = "0.3"
url = "2" url = "2"
webkit6 = "0.4"

View file

@ -148,14 +148,18 @@ pub fn fetch_bundle(server: &str, key: &str) -> KioskBundle {
resp.json().expect("bad bundle JSON") resp.json().expect("bad bundle JSON")
} }
/// Send heartbeat. /// Send heartbeat with display geometry.
pub fn heartbeat(server: &str, key: &str) { pub fn heartbeat(server: &str, key: &str, displays: &[(String, u32, u32)]) {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let display_info: Vec<_> = displays.iter().map(|(name, w, h)| {
serde_json::json!({ "name": name, "width_px": w, "height_px": h })
}).collect();
let _ = client let _ = client
.post(format!("{server}/api/kiosk/heartbeat")) .post(format!("{server}/api/kiosk/heartbeat"))
.header("Authorization", format!("Bearer {key}")) .header("Authorization", format!("Bearer {key}"))
.json(&serde_json::json!({ .json(&serde_json::json!({
"kiosk_app_version": env!("CARGO_PKG_VERSION"), "kiosk_app_version": env!("CARGO_PKG_VERSION"),
"displays": display_info,
})) }))
.timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(5))
.send(); .send();

View file

@ -92,10 +92,11 @@ fn activate(app: &Application) {
} }
}); });
// Heartbeat loop // Heartbeat loop — also reports display geometry
loop { loop {
std::thread::sleep(std::time::Duration::from_secs(60)); std::thread::sleep(std::time::Duration::from_secs(60));
server::heartbeat(&server, &key); let displays = query_displays();
server::heartbeat(&server, &key, &displays);
} }
}); });
@ -117,6 +118,33 @@ enum WorkerMsg {
RenderBundle(KioskBundle), RenderBundle(KioskBundle),
} }
/// Query connected HDMI displays from sysfs. Returns (name, width, height).
/// Reads /sys/class/drm/*/status and /sys/class/drm/*/modes.
fn query_displays() -> Vec<(String, u32, u32)> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { return out };
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
// Skip non-HDMI connectors and the "card" parents
if !name.contains("-HDMI-") && !name.contains("-DP-") { continue; }
let path = entry.path();
let status = std::fs::read_to_string(path.join("status")).unwrap_or_default();
if status.trim() != "connected" { continue; }
let modes = std::fs::read_to_string(path.join("modes")).unwrap_or_default();
// First line = preferred mode
let mode = modes.lines().next().unwrap_or("");
let parts: Vec<&str> = mode.split('x').collect();
if parts.len() != 2 { continue; }
let w: u32 = parts[0].parse().unwrap_or(0);
let h: u32 = parts[1].trim().parse().unwrap_or(0);
if w == 0 || h == 0 { continue; }
// Strip "cardN-" prefix for cleaner name
let clean_name = name.split_once('-').map(|(_, rest)| rest.to_string()).unwrap_or(name);
out.push((clean_name, w, h));
}
out
}
fn show_pairing_code(window: &ApplicationWindow, code: &str) { fn show_pairing_code(window: &ApplicationWindow, code: &str) {
let vbox = GtkBox::new(Orientation::Vertical, 20); let vbox = GtkBox::new(Orientation::Vertical, 20);
vbox.set_valign(gtk::Align::Center); vbox.set_valign(gtk::Align::Center);
@ -212,16 +240,20 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
} }
} }
"html" => { "html" => {
let html = cell.html_content.as_deref().unwrap_or("HTML"); let html = cell.html_content.as_deref().unwrap_or("");
let label = Label::new(Some(&html.chars().take(100).collect::<String>())); let webview = webkit6::WebView::new();
add_css(&label, "label { color: #888; background-color: #111; }"); webkit6::prelude::WebViewExt::load_html(&webview, html, None);
label.set_vexpand(true); webview.set_vexpand(true);
label.set_hexpand(true); webview.set_hexpand(true);
label.upcast() webview.upcast()
} }
"web" => { "web" => {
let url = cell.web_url.as_deref().unwrap_or("about:blank"); let url = cell.web_url.as_deref().unwrap_or("about:blank");
placeholder(&format!("Web: {url}")) let webview = webkit6::WebView::new();
webkit6::prelude::WebViewExt::load_uri(&webview, url);
webview.set_vexpand(true);
webview.set_hexpand(true);
webview.upcast()
} }
_ => placeholder("Unknown content"), _ => placeholder("Unknown content"),
}; };

View file

@ -226,6 +226,7 @@ function registerKioskRoutes(
bundle_version?: string; bundle_version?: string;
kiosk_app_version?: string; kiosk_app_version?: string;
os_version?: string; os_version?: string;
displays?: Array<{ name: string; width_px: number; height_px: number }>;
}>(event); }>(event);
repo.touchKiosk(kiosk.id, { repo.touchKiosk(kiosk.id, {
@ -234,6 +235,29 @@ function registerKioskRoutes(
os_version: body?.os_version ?? null, os_version: body?.os_version ?? null,
}); });
// Sync displays reported by the kiosk
if (Array.isArray(body?.displays)) {
const existing = repo.listDisplaysForKiosk(kiosk.id);
for (const reported of body.displays) {
const match = existing.find((d) => d.name.endsWith(reported.name));
if (match) {
if (match.width_px !== reported.width_px || match.height_px !== reported.height_px) {
repo.updateDisplay(match.id, {
width_px: reported.width_px,
height_px: reported.height_px,
} as any);
}
} else {
// New display — create it
repo.createDisplayForKiosk(kiosk.id, {
name: reported.name,
width_px: reported.width_px,
height_px: reported.height_px,
});
}
}
}
return { ok: true, now: new Date().toISOString() }; return { ok: true, now: new Date().toISOString() };
}); });