diff --git a/kiosk/Cargo.lock b/kiosk/Cargo.lock index 52704e4..74ec177 100644 --- a/kiosk/Cargo.lock +++ b/kiosk/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -138,6 +173,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" name = "betterframe-kiosk" version = "0.1.0" dependencies = [ + "aes-gcm", "axum", "base64", "dirs", @@ -149,11 +185,14 @@ dependencies = [ "gstreamer-video", "gtk4", "hex", + "hkdf", "hostname", + "md5", "rand", "reqwest", "serde", "serde_json", + "sha1", "sha2", "tokio", "tokio-tungstenite", @@ -161,6 +200,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "urlencoding", "webkit6", ] @@ -263,6 +303,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -326,9 +376,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -381,6 +441,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -743,6 +804,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.20.12" @@ -1151,6 +1222,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "hostname" version = "0.4.2" @@ -1430,6 +1519,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1546,6 +1644,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -1655,6 +1759,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.79" @@ -1786,6 +1896,18 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2696,6 +2818,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2714,6 +2846,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/kiosk/Cargo.toml b/kiosk/Cargo.toml index e14674a..4778678 100644 --- a/kiosk/Cargo.toml +++ b/kiosk/Cargo.toml @@ -44,6 +44,8 @@ urlencoding = "2" # ONVIF WSSE PasswordDigest auth sha1 = "0.10" +# HTTP Digest auth for camera image fetch +md5 = "0.7" # Hardware-bound at-rest encryption of state files (kiosk_key + bundle cache # contain camera RTSP credentials in URL form). Keys derived via HKDF from diff --git a/kiosk/src/bundle.rs b/kiosk/src/bundle.rs index 5063343..35053b9 100644 --- a/kiosk/src/bundle.rs +++ b/kiosk/src/bundle.rs @@ -1,5 +1,35 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +fn de_flexible_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let v = serde_json::Value::deserialize(deserializer)?; + match v { + serde_json::Value::String(s) => Ok(s), + serde_json::Value::Number(n) => Ok(n.to_string()), + _ => Err(serde::de::Error::custom("expected string or number for id")), + } +} + +fn de_flexible_id_opt<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let v = Option::::deserialize(deserializer)?; + match v { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) if s.is_empty() => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(s)), + Some(serde_json::Value::Number(n)) => Ok(Some(n.to_string())), + _ => Err(serde::de::Error::custom("expected string or number for id")), + } +} + +fn de_flexible_id_vec<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let v = Vec::::deserialize(deserializer)?; + v.into_iter() + .map(|item| match item { + serde_json::Value::String(s) => Ok(s), + serde_json::Value::Number(n) => Ok(n.to_string()), + _ => Err(serde::de::Error::custom("expected string or number in id array")), + }) + .collect() +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct KioskBundle { @@ -49,37 +79,43 @@ impl KioskBundle { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleDisplay { - pub id: u32, + #[serde(deserialize_with = "de_flexible_id")] + pub id: String, pub name: String, pub width_px: u32, pub height_px: u32, pub idle_timeout_seconds: u32, pub sleep_timeout_seconds: u32, - pub default_layout_id: Option, + #[serde(default, deserialize_with = "de_flexible_id_opt")] + pub default_layout_id: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleDisplayWithLayouts { - pub id: u32, + #[serde(deserialize_with = "de_flexible_id")] + pub id: String, pub name: String, pub width_px: u32, pub height_px: u32, pub idle_timeout_seconds: u32, pub sleep_timeout_seconds: u32, - pub default_layout_id: Option, + #[serde(default, deserialize_with = "de_flexible_id_opt")] + pub default_layout_id: Option, #[serde(default)] pub layouts: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleLayout { - pub id: u32, + #[serde(deserialize_with = "de_flexible_id")] + pub id: String, pub name: String, pub grid_cols: u32, pub grid_rows: u32, pub priority: String, pub cooling_timeout_seconds: Option, - pub preload_camera_ids: Vec, + #[serde(default, deserialize_with = "de_flexible_id_vec")] + pub preload_camera_ids: Vec, pub is_default: bool, pub resets_idle_timer: bool, pub cells: Vec, @@ -137,7 +173,8 @@ pub struct SmartUrlStep { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleCamera { - pub id: u32, + #[serde(deserialize_with = "de_flexible_id")] + pub id: String, pub name: String, #[serde(rename = "type")] pub cam_type: String, @@ -162,7 +199,8 @@ pub struct BundleCamera { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleStream { - pub id: u32, + #[serde(deserialize_with = "de_flexible_id")] + pub id: String, pub role: String, pub name: String, pub rtsp_uri: String, @@ -174,7 +212,8 @@ pub struct BundleStream { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleGpioBinding { - pub id: u32, + #[serde(deserialize_with = "de_flexible_id")] + pub id: String, pub chip: String, pub pin: u32, pub direction: String, diff --git a/kiosk/src/local_server.rs b/kiosk/src/local_server.rs index 23eac7a..5e4aa14 100644 --- a/kiosk/src/local_server.rs +++ b/kiosk/src/local_server.rs @@ -115,7 +115,7 @@ async fn local_info_handler( async fn local_layout_handler( State(state): State, - Path(id): Path, + Path(id): Path, Query(auth): Query, ) -> Response { if !constant_time_eq(&auth.key, &state.local_key) { @@ -127,7 +127,7 @@ async fn local_layout_handler( }; if let Err(e) = tx.send(WorkerMsg::SwitchLayout { display_id: None, - layout_id: id, + layout_id: id.clone(), }) { warn!("local-server: send SwitchLayout failed: {e}"); return (StatusCode::INTERNAL_SERVER_ERROR, "send failed").into_response(); @@ -152,7 +152,7 @@ async fn local_layout_handler( /// "good enough" and isolated. async fn local_snapshot_handler( State(state): State, - Path(camera_id): Path, + Path(camera_id): Path, Query(auth): Query, ) -> Response { if !constant_time_eq(&auth.key, &state.local_key) { diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index cf4612e..9575c76 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -18,14 +18,14 @@ pub use ui::WorkerMsg; pub enum ServerMsg { ReloadBundle, - Standby(Option), - Wake(Option), + Standby(Option), + Wake(Option), /// Some(0..=255) = manual PWM. None = restore auto. Fan(Option), /// Switch to a specific layout by ID, optionally scoped to one display. SwitchLayout { - display_id: Option, - layout_id: u32, + display_id: Option, + layout_id: String, }, /// Server-pushed "go check for a firmware update now". FirmwareCheck, diff --git a/kiosk/src/onvif_events.rs b/kiosk/src/onvif_events.rs index 8d5e187..f8c2a46 100644 --- a/kiosk/src/onvif_events.rs +++ b/kiosk/src/onvif_events.rs @@ -25,7 +25,7 @@ use crate::bundle::BundleCamera; /// Active subscriptions keyed by camera id. Worker threads check this /// to know when to stop (camera removed from bundle / bundle changed). -static ACTIVE: Mutex>> = Mutex::new(None); +static ACTIVE: Mutex>> = Mutex::new(None); /// Holds the current generation Arc. When start() replaces it, the old /// Arc drops → old threads' Weak::upgrade() returns None → they exit. @@ -34,7 +34,7 @@ static ACTIVE: Mutex>> = Mutex::new(None); static GENERATION: Mutex>> = Mutex::new(None); /// Subscription status per camera — reported in heartbeat for admin visibility. -static STATUS: Mutex>> = Mutex::new(None); +static STATUS: Mutex>> = Mutex::new(None); #[derive(Clone, serde::Serialize)] pub struct SubStatus { @@ -43,10 +43,10 @@ pub struct SubStatus { pub error: Option, } -fn set_status(cam_id: u32, state: &'static str, error: Option) { +fn set_status(cam_id: &str, state: &'static str, error: Option) { let mut map = STATUS.lock().unwrap(); let map = map.get_or_insert_with(HashMap::new); - let entry = map.entry(cam_id).or_insert_with(|| SubStatus { + let entry = map.entry(cam_id.to_string()).or_insert_with(|| SubStatus { state: "subscribing", last_event_at: None, error: None, @@ -55,17 +55,17 @@ fn set_status(cam_id: u32, state: &'static str, error: Option) { entry.error = error; } -fn mark_event_received(cam_id: u32) { +fn mark_event_received(cam_id: &str) { let mut map = STATUS.lock().unwrap(); if let Some(map) = map.as_mut() { - if let Some(entry) = map.get_mut(&cam_id) { + if let Some(entry) = map.get_mut(cam_id) { entry.last_event_at = Some(crate::os_update::current_os_version_public()); // reuse timestamp helper... actually just use epoch } } } /// Get current subscription statuses for all cameras. Used by heartbeat. -pub fn get_statuses() -> HashMap { +pub fn get_statuses() -> HashMap { STATUS.lock().unwrap().clone().unwrap_or_default() } @@ -99,7 +99,7 @@ pub fn start( // Signal old workers to stop. let mut active = ACTIVE.lock().unwrap(); - let new_map: HashMap = onvif_cams.iter().map(|c| (c.id, ())).collect(); + let new_map: HashMap = onvif_cams.iter().map(|c| (c.id.clone(), ())).collect(); *active = Some(new_map); drop(active); @@ -146,18 +146,18 @@ fn run_subscription( } // 1. CreatePullPointSubscription - set_status(cam.id, "subscribing", None); + set_status(&cam.id, "subscribing", None); let sub = match create_pullpoint(&event_url, user, pass) { Ok(s) => s, Err(e) => { warn!("onvif-events: cam {} CreatePullPoint failed: {e}", cam.id); - set_status(cam.id, "failed", Some(e)); + set_status(&cam.id, "failed", Some(e)); std::thread::sleep(Duration::from_secs(30)); continue; } }; info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address); - set_status(cam.id, "active", None); + set_status(&cam.id, "active", None); // 2. Poll loop let poll_interval = Duration::from_secs(3); @@ -184,12 +184,12 @@ fn run_subscription( match pull_messages(&sub.address, user, pass) { Ok(events) => { for evt in events { - forward_event(server, kiosk_key, cam.id, &evt); + forward_event(server, kiosk_key, &cam.id, &evt, user, pass); } } Err(e) => { warn!("onvif-events: cam {} pull failed: {e}", cam.id); - set_status(cam.id, "failed", Some(e)); + set_status(&cam.id, "failed", Some(e)); std::thread::sleep(Duration::from_secs(15)); break; // resubscribe after backoff } @@ -542,12 +542,23 @@ fn extract_attr_inline(xml: &str, attr: &str) -> Option { // ---- Forward to BF server -------------------------------------------------- -fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent) { - let payload = serde_json::json!({ +fn forward_event( + server: &str, + kiosk_key: &str, + camera_id: &str, + evt: &OnvifEvent, + cam_user: &str, + cam_pass: &str, +) { + let attachments = fetch_image_attachments(&evt.data, cam_user, cam_pass); + let mut payload = serde_json::json!({ "source": evt.source, "data": evt.data, "timestamp": evt.timestamp, }); + if !attachments.is_empty() { + payload["attachments"] = serde_json::json!(attachments); + } let body = serde_json::json!({ "topic": evt.topic, "source_type": "onvif", @@ -559,10 +570,147 @@ fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent .post(format!("{server}/api/kiosk/event")) .header("Authorization", format!("Bearer {kiosk_key}")) .json(&body) - .timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) .send(); } +fn fetch_image_attachments( + data: &HashMap, + user: &str, + pass: &str, +) -> HashMap { + let mut attachments = HashMap::new(); + let image_exts = [".jpg", ".jpeg", ".png", ".bmp"]; + for (key, value) in data { + if !value.starts_with("http://") && !value.starts_with("https://") { + continue; + } + let lower = value.to_lowercase(); + if !image_exts.iter().any(|ext| lower.contains(ext)) { + continue; + } + match fetch_image_b64(value, user, pass) { + Some(b64) => { + let mime = if lower.contains(".png") { + "image/png" + } else { + "image/jpeg" + }; + attachments.insert(key.clone(), format!("data:{mime};base64,{b64}")); + } + None => { + warn!("onvif-events: failed to fetch image for {key}: {value}"); + } + } + } + attachments +} + +fn fetch_image_b64(url: &str, user: &str, pass: &str) -> Option { + use base64::Engine; + let client = reqwest::blocking::Client::new(); + let resp = client + .get(url) + .basic_auth(user, Some(pass)) + .timeout(Duration::from_secs(5)) + .send() + .ok()?; + if !resp.status().is_success() { + let status = resp.status(); + // Retry with digest auth if basic auth returned 401. + if status.as_u16() == 401 { + return fetch_image_b64_digest(url, user, pass); + } + warn!("onvif-events: image fetch HTTP {status} for {url}"); + return None; + } + let bytes = resp.bytes().ok()?; + if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 { + return None; + } + Some(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + +fn fetch_image_b64_digest(url: &str, user: &str, pass: &str) -> Option { + use base64::Engine; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .ok()?; + let resp = client + .get(url) + .header("Authorization", digest_auth_header(url, user, pass)?) + .send() + .ok()?; + if !resp.status().is_success() { + return None; + } + let bytes = resp.bytes().ok()?; + if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 { + return None; + } + Some(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + +fn digest_auth_header(url: &str, user: &str, pass: &str) -> Option { + let client = reqwest::blocking::Client::new(); + let resp = client.get(url).timeout(Duration::from_secs(3)).send().ok()?; + if resp.status().as_u16() != 401 { + return None; + } + let www_auth = resp.headers().get("www-authenticate")?.to_str().ok()?; + if !www_auth.to_lowercase().starts_with("digest ") { + return None; + } + let realm = extract_digest_field(www_auth, "realm")?; + let nonce = extract_digest_field(www_auth, "nonce")?; + let qop = extract_digest_field(www_auth, "qop").unwrap_or_default(); + let uri = url::Url::parse(url).ok().map(|u| u.path().to_string()).unwrap_or_else(|| "/".to_string()); + let ha1 = md5_hex(&format!("{user}:{realm}:{pass}")); + let ha2 = md5_hex(&format!("GET:{uri}")); + let cnonce = format!("{:08x}", rand::random::()); + let nc = "00000001"; + let response = if qop.contains("auth") { + md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}")) + } else { + md5_hex(&format!("{ha1}:{nonce}:{ha2}")) + }; + if qop.contains("auth") { + Some(format!( + r#"Digest username="{user}", realm="{realm}", nonce="{nonce}", uri="{uri}", response="{response}", qop=auth, nc={nc}, cnonce="{cnonce}""# + )) + } else { + Some(format!( + r#"Digest username="{user}", realm="{realm}", nonce="{nonce}", uri="{uri}", response="{response}""# + )) + } +} + +fn extract_digest_field(header: &str, field: &str) -> Option { + let pat = format!("{field}=\""); + let start = header.find(&pat)? + pat.len(); + let end = header[start..].find('"')?; + Some(header[start..start + end].to_string()) +} + +fn md5_hex(input: &str) -> String { + use md5::{Digest, Md5}; + let mut hasher = Md5::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + hex_lower_bytes(&result) +} + +fn hex_lower_bytes(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(HEX[(b >> 4) as usize] as char); + s.push(HEX[(b & 0x0f) as usize] as char); + } + s +} + // ---- Cluster key decryption ------------------------------------------------ /// Decrypt a value encrypted with secrets.encryptForCluster on the server. diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 758546a..1da1948 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -428,8 +428,8 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option { pub fn report_layout_change( server: &str, key: &str, - display_id: u32, - layout_id: u32, + display_id: &str, + layout_id: &str, layout_name: &str, ) { let client = reqwest::blocking::Client::new(); diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index f9ef4f5..12fda3a 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -30,7 +30,7 @@ use crate::ws_client; /// even though the GTK main loop is shared. struct DisplayState { window: ApplicationWindow, - current_layout_id: Option, + current_layout_id: Option, last_activity: Instant, is_asleep: bool, } @@ -58,7 +58,7 @@ struct PipelineEntry { /// per (main, sub, other) stream — each with independent warmth state. When a /// cell switches M↔S we promote the new variant to Warm/Hot but leave the old /// one to cool down naturally so a quick swap back is instant. -type PoolKey = (u32, char); +type PoolKey = (String, char); /// WebView pool entry. Same Hot/Warm/Cooling/Cold lifecycle as cameras — /// switching to a layout that doesn't reference a previously-loaded URL/HTML @@ -95,7 +95,7 @@ thread_local! { static CURRENT_SYNC_LABEL: RefCell = RefCell::new(String::from("unknown")); /// Per-display state, keyed by bundle display id. - static DISPLAYS: RefCell> = RefCell::new(HashMap::new()); + static DISPLAYS: RefCell> = RefCell::new(HashMap::new()); /// Has the idle-watchdog already been installed on the main loop? static WATCHDOG_INSTALLED: Cell = const { Cell::new(false) }; @@ -171,7 +171,7 @@ fn activate(app: &Application) { // cached on-disk bundle and keep retrying every 30s in the background. let initial = match server::fetch_bundle(&server, &key) { Some(b) => { - crate::axiom::set_kiosk_id(b.kiosk_id.to_string()); + crate::axiom::set_kiosk_id(b.kiosk_id.clone()); info!( "bundle: {} cameras, {} display(s)", b.cameras.len(), @@ -329,14 +329,14 @@ fn activate(app: &Application) { display_id, layout_id, } => { - if let Some(display_id) = display_id { - render_layout(display_id, layout_id); + if let Some(display_id) = &display_id { + render_layout(display_id, &layout_id); } else { - switch_layout_anywhere(layout_id); + switch_layout_anywhere(&layout_id); } } - WorkerMsg::Standby(display_id) => standby_display(display_id), - WorkerMsg::Wake(display_id) => wake_display(display_id), + WorkerMsg::Standby(display_id) => standby_display(display_id.as_deref()), + WorkerMsg::Wake(display_id) => wake_display(display_id.as_deref()), WorkerMsg::ShowTerminalCode(code) => show_terminal_code_overlay(&code), WorkerMsg::DismissTerminalCode => dismiss_terminal_code_overlay(), WorkerMsg::UpdateProgress(progress) => show_update_banner(progress), @@ -350,11 +350,11 @@ pub enum WorkerMsg { ShowPairingCode(String), RenderBundle(KioskBundle, String, String), SwitchLayout { - display_id: Option, - layout_id: u32, + display_id: Option, + layout_id: String, }, - Standby(Option), - Wake(Option), + Standby(Option), + Wake(Option), ShowTerminalCode(String), DismissTerminalCode, /// Update progress banner — shown as overlay on all displays. @@ -362,7 +362,7 @@ pub enum WorkerMsg { UpdateProgress(Option<(String, u8)>), } -fn output_name_for_display(display_id: u32) -> Option { +fn output_name_for_display(display_id: &str) -> Option { CURRENT_BUNDLE.with(|b| { b.borrow() .as_ref() @@ -376,7 +376,7 @@ fn output_name_for_display(display_id: u32) -> Option { }) } -fn standby_display(display_id: Option) { +fn standby_display(display_id: Option<&str>) { if let Some(display_id) = display_id { if let Some(output_name) = output_name_for_display(display_id) { cec::standby_output(&output_name); @@ -384,7 +384,7 @@ fn standby_display(display_id: Option) { cec::standby(); } DISPLAYS.with(|ds| { - if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + if let Some(st) = ds.borrow_mut().get_mut(display_id) { st.is_asleep = true; } }); @@ -398,7 +398,7 @@ fn standby_display(display_id: Option) { } } -fn wake_display(display_id: Option) { +fn wake_display(display_id: Option<&str>) { if let Some(display_id) = display_id { if let Some(output_name) = output_name_for_display(display_id) { cec::wake_output(&output_name); @@ -406,7 +406,7 @@ fn wake_display(display_id: Option) { cec::wake(); } DISPLAYS.with(|ds| { - if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + if let Some(st) = ds.borrow_mut().get_mut(display_id) { st.is_asleep = false; st.last_activity = Instant::now(); } @@ -423,9 +423,9 @@ fn wake_display(display_id: Option) { } /// Reset activity timer for one display. If asleep, wake it. -fn mark_activity(display_id: u32) { +fn mark_activity(display_id: &str) { DISPLAYS.with(|ds| { - if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + if let Some(st) = ds.borrow_mut().get_mut(display_id) { st.last_activity = Instant::now(); if st.is_asleep { info!("activity while asleep → waking display {display_id}"); @@ -451,11 +451,12 @@ fn send_heartbeat_now(server_url: &str, kiosk_key: &str) -> bool { .map(|(index, (name, width_px, height_px))| { let bundle_id = bundle_displays .get(index) - .map(|d| d.id) - .or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id)); + .map(|d| d.id.clone()) + .or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id.clone())); let power_state = bundle_id + .as_deref() .and_then(|id| { - DISPLAYS.with(|ds| ds.borrow().get(&id).map(|st| st.is_asleep)) + DISPLAYS.with(|ds| ds.borrow().get(id).map(|st| st.is_asleep)) }) .map(|is_asleep| if is_asleep { "standby" } else { "awake" }) .unwrap_or("unknown") @@ -614,8 +615,8 @@ fn install_idle_watchdog() { // Snapshot per-display timing decisions so we can act outside the borrow. struct Action { - display_id: u32, - revert_to: Option, + display_id: String, + revert_to: Option, sleep: bool, } let mut actions: Vec = Vec::new(); @@ -632,10 +633,10 @@ fn install_idle_watchdog() { let idle_to = d.idle_timeout_seconds as u64; let sleep_to = d.sleep_timeout_seconds as u64; let elapsed = st.last_activity.elapsed(); - let default_id = d.default_layout_id; + let default_id = d.default_layout_id.clone(); let mut act = Action { - display_id: *display_id, + display_id: display_id.clone(), revert_to: None, sleep: false, }; @@ -643,12 +644,13 @@ fn install_idle_watchdog() { if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) { let cur_resets_idle = st .current_layout_id - .and_then(|cur_id| d.layouts.iter().find(|l| l.id == cur_id)) + .as_ref() + .and_then(|cur_id| d.layouts.iter().find(|l| l.id == *cur_id)) .map(|l| l.resets_idle_timer) .unwrap_or(false); - if let (Some(cur_id), Some(def_id)) = (st.current_layout_id, default_id) { + if let (Some(cur_id), Some(def_id)) = (&st.current_layout_id, &default_id) { if cur_id != def_id && cur_resets_idle { - act.revert_to = Some(def_id); + act.revert_to = Some(def_id.clone()); } } } @@ -667,7 +669,7 @@ fn install_idle_watchdog() { "idle timeout reached → reverting display {} to default", a.display_id ); - render_layout(a.display_id, layout_id); + render_layout(&a.display_id, &layout_id); } if a.sleep { info!( @@ -790,11 +792,11 @@ fn render_bundle( // Collect camera IDs actually referenced in layout cells. let displays = bundle.normalized_displays(); - let layout_cam_ids: std::collections::HashSet = displays + let layout_cam_ids: std::collections::HashSet = displays .iter() .flat_map(|d| d.layouts.iter()) .flat_map(|l| l.cells.iter()) - .filter_map(|c| c.camera_id) + .filter_map(|c| c.camera_id.clone()) .collect(); // Only subscribe to ONVIF events for cameras in layouts (not all bundle cameras). @@ -825,12 +827,12 @@ fn render_bundle( .collect(); // Tear down any previous per-display windows we no longer need. - let keep_ids: std::collections::HashSet = displays.iter().map(|d| d.id).collect(); - let to_remove: Vec = DISPLAYS.with(|ds| { + let keep_ids: std::collections::HashSet<&str> = displays.iter().map(|d| d.id.as_str()).collect(); + let to_remove: Vec = DISPLAYS.with(|ds| { ds.borrow() .keys() - .filter(|id| !keep_ids.contains(id)) - .copied() + .filter(|id| !keep_ids.contains(id.as_str())) + .cloned() .collect() }); for id in to_remove { @@ -845,7 +847,7 @@ fn render_bundle( // displays is correct once the loop finishes. // Build/reuse window per bundle display, then render its initial layout. - let mut new_state: HashMap = HashMap::new(); + let mut new_state: HashMap = HashMap::new(); for (i, bd) in displays.iter().enumerate() { let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id)); let (window, was_asleep) = match existing { @@ -872,7 +874,7 @@ fn render_bundle( } }; new_state.insert( - bd.id, + bd.id.clone(), DisplayState { window, current_layout_id: None, @@ -892,7 +894,7 @@ fn render_bundle( for bd in &displays { let target = pick_initial_layout(bd); if let Some(layout_id) = target { - render_layout(bd.id, layout_id); + render_layout(&bd.id, &layout_id); } else { warn!("display {} has no default layout", bd.id); DISPLAYS.with(|ds| { @@ -905,19 +907,19 @@ fn render_bundle( } } -fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option { - bd.default_layout_id - .or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id)) - .or_else(|| bd.layouts.first().map(|l| l.id)) +fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option { + bd.default_layout_id.clone() + .or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id.clone())) + .or_else(|| bd.layouts.first().map(|l| l.id.clone())) } /// Find which display owns a given layout_id and render it there. -fn switch_layout_anywhere(layout_id: u32) { +fn switch_layout_anywhere(layout_id: &str) { let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone()); let Some(bundle) = bundle else { return }; for bd in bundle.normalized_displays() { if bd.layouts.iter().any(|l| l.id == layout_id) { - render_layout(bd.id, layout_id); + render_layout(&bd.id, layout_id); return; } } @@ -925,7 +927,7 @@ fn switch_layout_anywhere(layout_id: u32) { } /// Render a specific layout id on a specific display. -fn render_layout(display_id: u32, layout_id: u32) { +fn render_layout(display_id: &str, layout_id: &str) { mark_activity(display_id); let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| { @@ -950,7 +952,7 @@ fn render_layout(display_id: u32, layout_id: u32) { warn!( "render_layout: layout {layout_id} not on display {display_id}, falling back to default" ); - bd.default_layout_id + bd.default_layout_id.as_deref() .and_then(|did| bd.layouts.iter().find(|l| l.id == did)) .or_else(|| bd.layouts.iter().find(|l| l.is_default)) }); @@ -958,7 +960,7 @@ fn render_layout(display_id: u32, layout_id: u32) { let Some(layout) = layout else { warn!("render_layout: no usable layout on display {display_id}"); DISPLAYS.with(|ds| { - if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + if let Some(st) = ds.borrow_mut().get_mut(display_id) { show_empty_display_reference(&st.window, &bundle, bd); st.current_layout_id = None; } @@ -971,10 +973,10 @@ fn render_layout(display_id: u32, layout_id: u32) { let previous_layout_id = DISPLAYS.with(|ds| { let prev = ds .borrow() - .get(&display_id) - .and_then(|s| s.current_layout_id); - if let Some(st) = ds.borrow_mut().get_mut(&display_id) { - st.current_layout_id = Some(layout.id); + .get(display_id) + .and_then(|s| s.current_layout_id.clone()); + if let Some(st) = ds.borrow_mut().get_mut(display_id) { + st.current_layout_id = Some(layout.id.clone()); } prev }); @@ -992,17 +994,18 @@ fn render_layout(display_id: u32, layout_id: u32) { // Notify the server when the active layout actually changes so Node-RED // sees idle reverts + any other kiosk-initiated switch. Skip when the // layout id is unchanged (re-render of the same layout). - if previous_layout_id != Some(layout.id) { + if previous_layout_id.as_deref() != Some(layout.id.as_str()) { let layout_name = layout.name.clone(); - let layout_id_for_report = layout.id; + let layout_id_for_report = layout.id.clone(); + let display_id_for_report = display_id.to_string(); let server = server_url.clone(); let key = kiosk_key.clone(); std::thread::spawn(move || { server::report_layout_change( &server, &key, - display_id, - layout_id_for_report, + &display_id_for_report, + &layout_id_for_report, &layout_name, ); }); @@ -1012,7 +1015,7 @@ fn render_layout(display_id: u32, layout_id: u32) { warn!("layout has no cells"); recompute_global_state(); DISPLAYS.with(|ds| { - if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + if let Some(st) = ds.borrow_mut().get_mut(display_id) { show_logo(&st.window); } }); @@ -1033,21 +1036,21 @@ fn render_layout(display_id: u32, layout_id: u32) { grid.set_vexpand(true); grid.set_hexpand(true); - let cam_map: HashMap = - bundle.cameras.iter().map(|c| (c.id, c)).collect(); + let cam_map: HashMap<&str, &crate::bundle::BundleCamera> = + bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect(); let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32; // Ensure preloaded cameras have pipelines even if not visible. for cam_id in &layout.preload_camera_ids { - if let Some(cam) = cam_map.get(cam_id) { - ensure_warm(*cam_id, cam, None, 0.0); + if let Some(cam) = cam_map.get(cam_id.as_str()) { + ensure_warm(cam_id, cam, None, 0.0); } } for cell in &layout.cells { let cell_key: Option = match cell.content_type.as_str() { - "camera" => cell.camera_id.map(|id| { + "camera" => cell.camera_id.as_ref().map(|id| { format!( "cam:{id}:{}", cell.stream_selector.as_deref().unwrap_or("auto") @@ -1063,8 +1066,8 @@ fn render_layout(display_id: u32, layout_id: u32) { }; let widget: gtk::Widget = match cell.content_type.as_str() { "camera" => { - if let Some(cam_id) = cell.camera_id { - if let Some(cam) = cam_map.get(&cam_id) { + if let Some(cam_id) = cell.camera_id.as_ref() { + if let Some(cam) = cam_map.get(cam_id.as_str()) { let area = (cell.col_span * cell.row_span) as f32 / total_area; if let Some((paintable, badge)) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) @@ -1149,7 +1152,7 @@ fn render_layout(display_id: u32, layout_id: u32) { } DISPLAYS.with(|ds| { - if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + if let Some(st) = ds.borrow_mut().get_mut(display_id) { animate_layout_swap(&st.window, &grid); } }); @@ -1401,14 +1404,14 @@ fn recompute_global_state() { let mut hot_set: std::collections::HashSet = std::collections::HashSet::new(); let mut max_cooling_secs: u32 = 0; - let cam_map: HashMap = - bundle.cameras.iter().map(|c| (c.id, c)).collect(); + let cam_map: HashMap<&str, &crate::bundle::BundleCamera> = + bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect(); // Snapshot per-display active layout id outside any borrow of WARM_CAMERAS. - let active: Vec<(u32, Option)> = DISPLAYS.with(|ds| { + let active: Vec<(String, Option)> = DISPLAYS.with(|ds| { ds.borrow() .iter() - .map(|(id, st)| (*id, st.current_layout_id)) + .map(|(id, st)| (id.clone(), st.current_layout_id.clone())) .collect() }); @@ -1417,7 +1420,7 @@ fn recompute_global_state() { // missing or no streams). fn cell_keys( layout: &crate::bundle::BundleLayout, - cam_map: &HashMap, + cam_map: &HashMap<&str, &crate::bundle::BundleCamera>, out: &mut std::collections::HashSet, ) { let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32; @@ -1425,24 +1428,24 @@ fn recompute_global_state() { if cell.content_type != "camera" { continue; } - let Some(cam_id) = cell.camera_id else { + let Some(cam_id) = cell.camera_id.as_ref() else { continue; }; - let Some(cam) = cam_map.get(&cam_id) else { + let Some(cam) = cam_map.get(cam_id.as_str()) else { continue; }; let area = (cell.col_span * cell.row_span) as f32 / total_area; if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) { - out.insert((cam_id, badge)); + out.insert((cam_id.clone(), badge)); } } // Preload cameras have no cell context — let pick_stream choose // (typically sub). Different layouts that actually render them will // promote whichever badge they end up using. for cam_id in &layout.preload_camera_ids { - if let Some(cam) = cam_map.get(cam_id) { + if let Some(cam) = cam_map.get(cam_id.as_str()) { if let Some((_, badge)) = cam.pick_stream(None, 0.0) { - out.insert((*cam_id, badge)); + out.insert((cam_id.clone(), badge)); } } } @@ -1452,7 +1455,7 @@ fn recompute_global_state() { let active_id = active .iter() .find(|(id, _)| *id == bd.id) - .and_then(|(_, l)| *l); + .and_then(|(_, l)| l.clone()); if let Some(cur_id) = active_id { if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) { cell_keys(layout, &cam_map, &mut warm_set); @@ -1475,7 +1478,7 @@ fn recompute_global_state() { let active_id = active .iter() .find(|(id, _)| *id == bd.id) - .and_then(|(_, l)| *l); + .and_then(|(_, l)| l.clone()); if let Some(cur_id) = active_id { if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) { web_keys_for_layout(layout, &mut warm_webs); @@ -1525,7 +1528,7 @@ fn recompute_pool_states( continue; } if max_cooling_secs == 0 { - to_remove.push(*key); + to_remove.push(key.clone()); to_stop.push(entry.pipeline.clone()); } else { entry.state = WarmthState::Cooling; @@ -1551,15 +1554,15 @@ fn recompute_pool_states( /// Remove warm camera entries for cameras no longer in the bundle. /// Immediately stops pipelines — no cooling period. fn purge_removed_cameras(bundle_cameras: &[crate::bundle::BundleCamera]) { - let valid_ids: std::collections::HashSet = bundle_cameras.iter().map(|c| c.id).collect(); + let valid_ids: std::collections::HashSet<&str> = bundle_cameras.iter().map(|c| c.id.as_str()).collect(); let mut to_remove: Vec = Vec::new(); let mut to_stop: Vec = Vec::new(); WARM_CAMERAS.with(|w| { let mut warm = w.borrow_mut(); for (key, entry) in warm.iter() { - if !valid_ids.contains(&key.0) { - to_remove.push(*key); + if !valid_ids.contains(key.0.as_str()) { + to_remove.push(key.clone()); to_stop.push(entry.pipeline.clone()); } } @@ -1588,7 +1591,7 @@ fn expire_cooling_pipelines() { .filter(|(_, e)| { e.state == WarmthState::Cooling && e.cooling_until.is_some_and(|t| now >= t) }) - .map(|(k, _)| *k) + .map(|(k, _)| k.clone()) .collect(); for k in keys { if let Some(e) = warm.remove(&k) { @@ -1771,13 +1774,13 @@ fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool { /// that sibling entry alone — recompute_pool_states will demote it to Cooling /// so it can be reused if the cell flips back before the cooldown elapses. fn ensure_warm( - cam_id: u32, + cam_id: &str, cam: &crate::bundle::BundleCamera, selector: Option<&str>, area_fraction: f32, ) -> Option<(gtk::gdk::Paintable, char)> { let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?; - let key: PoolKey = (cam_id, desired_badge); + let key: PoolKey = (cam_id.to_string(), desired_badge); let cached = WARM_CAMERAS.with(|w| { w.borrow() @@ -2179,7 +2182,7 @@ fn add_css(widget: &impl IsA, css: &str) { thread_local! { static TERMINAL_CODE_WIDGET: RefCell> = const { RefCell::new(None) }; - static TERMINAL_CODE_SAVED_CHILD: RefCell> = const { RefCell::new(None) }; + static TERMINAL_CODE_SAVED_CHILD: RefCell> = const { RefCell::new(None) }; } fn show_terminal_code_overlay(code: &str) { @@ -2189,7 +2192,7 @@ fn show_terminal_code_overlay(code: &str) { // Instead, replace the first display window's child with the code // overlay and restore it when dismissed. let display_id = DISPLAYS.with(|ds| { - ds.borrow().keys().next().copied() + ds.borrow().keys().next().cloned() }); let Some(display_id) = display_id else { return }; @@ -2201,7 +2204,7 @@ fn show_terminal_code_overlay(code: &str) { // Save current child for restore. let old_child = win.child(); if let Some(ref c) = old_child { - TERMINAL_CODE_SAVED_CHILD.with(|s| *s.borrow_mut() = Some((display_id, c.clone()))); + TERMINAL_CODE_SAVED_CHILD.with(|s| *s.borrow_mut() = Some((display_id.clone(), c.clone()))); } // Match the pairing screen layout but with red warning theme. diff --git a/kiosk/src/ws_client.rs b/kiosk/src/ws_client.rs index 5e51a52..7244df4 100644 --- a/kiosk/src/ws_client.rs +++ b/kiosk/src/ws_client.rs @@ -151,23 +151,21 @@ async fn handle_message( } else if text.contains("\"type\":\"standby\"") { let display_id = serde_json::from_str::(text) .ok() - .and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32)); + .and_then(|m| m.get("display_id").and_then(flexible_id_from_value)); let _ = tx.send(ServerMsg::Standby(display_id)); } else if text.contains("\"type\":\"wake\"") { let display_id = serde_json::from_str::(text) .ok() - .and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32)); + .and_then(|m| m.get("display_id").and_then(flexible_id_from_value)); let _ = tx.send(ServerMsg::Wake(display_id)); } else if text.contains("\"type\":\"layout-switch\"") { let msg = serde_json::from_str::(text).ok(); let layout_id = msg.as_ref() .and_then(|m| m.get("layout_id")) - .and_then(|v| v.as_u64()) - .map(|v| v as u32); + .and_then(flexible_id_from_value); let display_id = msg.as_ref() .and_then(|m| m.get("display_id")) - .and_then(|v| v.as_u64()) - .map(|v| v as u32); + .and_then(flexible_id_from_value); if let Some(layout_id) = layout_id { let _ = tx.send(ServerMsg::SwitchLayout { display_id, layout_id }); } @@ -366,6 +364,16 @@ async fn perform_onvif_soap(req: OnvifSoapRequest) -> String { } } +/// Extract an ID from a JSON value that may be a string or a number. +/// Mirrors the flexible ID deserialization in bundle.rs. +fn flexible_id_from_value(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + fn build_ws_url(http_url: &str, token: &str) -> String { let base = if let Some(rest) = http_url.strip_prefix("https://") { format!("wss://{}", rest.split('/').next().unwrap_or(rest))