use std::fs; use std::path::PathBuf; use std::process::Command; use std::time::Duration; use serde::Deserialize; use serde_json::Value; use tracing::info; use crate::bundle::KioskBundle; pub struct DisplayReport { pub index: usize, pub name: String, pub width_px: u32, pub height_px: u32, pub power_state: String, } fn kiosk_app_version() -> &'static str { option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) } fn reported_hostname() -> Option { hostname::get() .ok() .map(|h| h.to_string_lossy().trim().to_string()) .filter(|h| !h.is_empty()) } fn read_network_interfaces() -> Vec { let out = match Command::new("ip").args(["-j", "addr", "show"]).output() { Ok(out) if out.status.success() => out, Ok(out) => { tracing::warn!("ip -j addr show exited with {}", out.status); return Vec::new(); } Err(err) => { tracing::warn!("ip -j addr show failed: {err}"); return Vec::new(); } }; let parsed: Value = match serde_json::from_slice(&out.stdout) { Ok(v) => v, Err(err) => { tracing::warn!("ip -j addr show parse failed: {err}"); return Vec::new(); } }; let Some(items) = parsed.as_array() else { return Vec::new(); }; items .iter() .filter_map(|item| { let name = item.get("ifname")?.as_str()?; let addr_info = item.get("addr_info")?.as_array()?; let ips: Vec = addr_info .iter() .filter_map(|addr| { let family = addr.get("family")?.as_str()?; if family != "inet" && family != "inet6" { return None; } let local = addr.get("local")?.as_str()?; let prefix = addr.get("prefixlen").and_then(|v| v.as_u64()); Some(match prefix { Some(prefix) => Value::String(format!("{local}/{prefix}")), None => Value::String(local.to_string()), }) }) .collect(); if ips.is_empty() { return None; } Some(serde_json::json!({ "name": name, "mac": item.get("address").and_then(|v| v.as_str()), "operstate": item.get("operstate").and_then(|v| v.as_str()), "ips": ips, })) }) .collect() } fn state_dir() -> PathBuf { let home = dirs::home_dir().expect("no home directory"); let dir = home.join(".betterframe-kiosk"); fs::create_dir_all(&dir).ok(); dir } fn key_file() -> PathBuf { state_dir().join("kiosk.key") } fn server_file() -> PathBuf { state_dir().join("server.url") } fn bundle_cache_path() -> PathBuf { state_dir().join("bundle.json") } fn local_key_file() -> PathBuf { state_dir().join("local.key") } /// Load (or generate) the kiosk-local API key used by the LAN-side GET /// layout-switch endpoint. Persisted hex, 32 bytes random. Stored /// encrypted-at-rest (hardware-bound) so pulling the SD card doesn't yield /// the key plaintext. pub fn load_or_create_local_key() -> String { let path = local_key_file(); if let Ok(raw) = fs::read(&path) { let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok(); if let Some(trimmed) = crate::at_rest::read_text_maybe_encrypted(&path) { if trimmed.len() >= 16 { if !was_encrypted { let _ = crate::at_rest::write_encrypted(&path, trimmed.as_bytes()); } return trimmed; } } } use rand::RngCore; let mut buf = [0u8; 32]; rand::thread_rng().fill_bytes(&mut buf); let hex_key = hex::encode(buf); let _ = crate::at_rest::write_encrypted(&path, hex_key.as_bytes()); hex_key } /// Persist the latest bundle to disk for offline boot. Encrypted at rest /// because the bundle contains camera RTSP URIs with credentials in URL /// form (rtsp://user:pass@host/...). pub fn save_bundle(bundle: &KioskBundle) { match serde_json::to_vec(bundle) { Ok(bytes) => { if let Err(e) = crate::at_rest::write_encrypted(&bundle_cache_path(), &bytes) { tracing::warn!("failed to save bundle cache: {e}"); } } Err(e) => tracing::warn!("failed to serialize bundle: {e}"), } } /// Load a cached bundle from disk. Returns None if file missing or invalid. /// Tolerates legacy plaintext (kiosks upgraded from a pre-at_rest build) /// so pairing survives the rollout. pub fn load_cached_bundle() -> Option { let bytes = crate::at_rest::read_maybe_encrypted(&bundle_cache_path())?; match serde_json::from_slice::(&bytes) { Ok(b) => Some(b), Err(e) => { tracing::warn!("cached bundle invalid: {e}"); None } } } /// Discover the BetterFrame server. pub fn discover_server(override_url: Option<&str>) -> String { if let Some(url) = override_url { return url.to_string(); } // Check saved if let Ok(saved) = fs::read_to_string(server_file()) { let saved = saved.trim().to_string(); if check_health(&saved) { return saved; } } // Probe order: on-device → LAN mDNS → BetterCorp managed cloud. // Single image works for aio (server beside kiosk on same Pi), on-prem // (server on the LAN, discoverable by mDNS), and client-only (no local // server — falls through to the cloud). let candidates = [ "http://localhost", "http://betterframe.local", "https://frame-eu.betterportal.net", ]; for url in candidates { info!("trying {url}..."); if check_health(url) { fs::write(server_file(), url).ok(); return url.to_string(); } } panic!("Could not find BetterFrame server"); } fn check_health(url: &str) -> bool { reqwest::blocking::Client::new() .get(format!("{url}/healthz")) .timeout(Duration::from_secs(3)) .send() .map(|r| r.status().is_success()) .unwrap_or(false) } /// Check if already paired (key file exists). pub fn is_paired() -> bool { key_file().exists() } /// Read stored kiosk key. Detects legacy plaintext (kiosks upgraded from /// a pre-at_rest build) and re-stores it ciphertext in place so subsequent /// SD-card extractions don't see the bearer token. pub fn load_key() -> String { let path = key_file(); let raw = fs::read(&path).expect("failed to read kiosk key"); let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok(); let key = crate::at_rest::read_text_maybe_encrypted(&path).expect("failed to decode kiosk key"); if !was_encrypted { // Best-effort migrate. If write fails (e.g. RO mount during a // recovery boot) we still hand back the key so the kiosk works. let _ = crate::at_rest::write_encrypted(&path, key.as_bytes()); } key } #[derive(Deserialize)] struct InitiateResp { code: String, expires_at: String, } /// Initiate pairing — returns (code, expires_at). pub fn initiate_pairing(server: &str) -> (String, String) { let hostname = hostname::get() .map(|h| h.to_string_lossy().to_string()) .unwrap_or_else(|_| "kiosk".into()); let hw_model = fs::read_to_string("/proc/device-tree/model") .unwrap_or_else(|_| "unknown".into()) .replace('\0', ""); let client = reqwest::blocking::Client::new(); let resp: InitiateResp = client .post(format!("{server}/api/pair/initiate")) .json(&serde_json::json!({ "proposed_name": hostname, "hardware_model": hw_model, "capabilities": ["rtsp", "gstreamer", "gtk4"] })) .send() .expect("pairing initiate failed") .json() .expect("bad initiate response"); (resp.code, resp.expires_at) } #[derive(Deserialize)] struct ClaimResp { status: String, kiosk_key: Option, kiosk_name: Option, } /// Poll for pairing claim. Returns (name, key) when admin confirms. pub fn poll_claim(server: &str, code: &str) -> (String, String) { let client = reqwest::blocking::Client::new(); loop { let resp = client .post(format!("{server}/api/pair/claim")) .json(&serde_json::json!({ "code": code })) .send() .expect("claim request failed"); if resp.status().as_u16() == 200 { let claim: ClaimResp = resp.json().expect("bad claim response"); if claim.status == "claimed" { let key = claim.kiosk_key.expect("missing kiosk_key"); let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into()); crate::at_rest::write_encrypted(&key_file(), key.as_bytes()) .expect("failed to save kiosk key"); return (name, key); } } std::thread::sleep(Duration::from_secs(2)); } } /// Fetch bundle from server. Returns None on network/HTTP/parse failure. /// On success, also writes the bundle to the on-disk cache. pub fn fetch_bundle(server: &str, key: &str) -> Option { let client = reqwest::blocking::Client::new(); let resp = match client .get(format!("{server}/api/kiosk/bundle")) .header("Authorization", format!("Bearer {key}")) .timeout(Duration::from_secs(10)) .send() { Ok(r) => r, Err(e) => { tracing::warn!("bundle fetch failed: {e}"); return None; } }; if !resp.status().is_success() { tracing::warn!("bundle fetch returned {}", resp.status()); return None; } match resp.json::() { Ok(b) => { save_bundle(&b); Some(b) } Err(e) => { tracing::warn!("bundle parse failed: {e}"); None } } } /// Send heartbeat with display geometry + hwmon. /// Report a kiosk-side layout switch to the server, which forwards to /// node-red as a `layout.changed` event. Covers idle reverts and any other /// switch the kiosk performs without an admin click (admin clicks already /// emit server-side). pub fn report_layout_change( server: &str, key: &str, display_id: u32, layout_id: u32, layout_name: &str, ) { let client = reqwest::blocking::Client::new(); let _ = client .post(format!("{server}/api/kiosk/event")) .header("Authorization", format!("Bearer {key}")) .json(&serde_json::json!({ "topic": "layout.changed", "source_type": "system", "payload": { "display_id": display_id, "layout_id": layout_id, "layout_name": layout_name, }, })) .timeout(Duration::from_secs(5)) .send(); } pub fn report_kiosk_log(server: &str, key: &str, level: &str, message: &str, payload: Value) { let client = reqwest::blocking::Client::new(); let _ = client .post(format!("{server}/api/kiosk/event")) .header("Authorization", format!("Bearer {key}")) .json(&serde_json::json!({ "topic": "kiosk.log", "source_type": "system", "payload": { "level": level, "message": message, "context": payload, }, })) .timeout(Duration::from_secs(5)) .send(); } pub fn heartbeat( server: &str, key: &str, displays: &[DisplayReport], hw: &crate::hwmon::HwInfo, ) -> bool { let client = reqwest::blocking::Client::new(); let display_info: Vec<_> = displays.iter().map(|d| { serde_json::json!({ "index": d.index, "name": &d.name, "width_px": d.width_px, "height_px": d.height_px, "power_state": &d.power_state, }) }).collect(); // Surface the LAN-side local key + port to admin so the UI can show a // copy-paste URL for bookmark-style layout switches. let local_key = load_or_create_local_key(); let local_port: u16 = std::env::var("BF_KIOSK_LOCAL_PORT") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(18090); let hostname = reported_hostname(); let network_interfaces = read_network_interfaces(); client .post(format!("{server}/api/kiosk/heartbeat")) .header("Authorization", format!("Bearer {key}")) .json(&serde_json::json!({ "kiosk_app_version": kiosk_app_version(), "displays": display_info, "cpu_temp_c": hw.cpu_temp_c, "cpu_load_percent": hw.cpu_load_percent, "fan_rpm": hw.fan_rpm, "fan_pwm": hw.fan_pwm, "memory_total_mb": hw.memory_total_mb, "memory_used_mb": hw.memory_used_mb, "disk_total_mb": hw.disk_total_mb, "disk_free_mb": hw.disk_free_mb, "disk_used_percent": hw.disk_used_percent, "local_key": local_key, "local_port": local_port, "reported_hostname": hostname, "network_interfaces": network_interfaces, })) .timeout(Duration::from_secs(5)) .send() .map(|r| r.status().is_success()) .unwrap_or(false) }