diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7bd3b2f..15776e1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -77,6 +77,10 @@ jobs: sudo apt-get update sudo apt-get install -y libgtk-4-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libwebkitgtk-6.0-dev libssl-dev xvfb xauth dbus-x11 - run: cargo test --manifest-path client/Cargo.toml --workspace --locked + - name: Preserved display metadata and fullscreen refresh + run: xvfb-run -a dbus-run-session -- cargo test --manifest-path client/Cargo.toml display_refresh_updates -- --ignored --test-threads=1 + - name: Layout animation cleanup without frame ticks + run: xvfb-run -a dbus-run-session -- cargo test --manifest-path client/Cargo.toml layout_animation_restores_tiles -- --ignored --test-threads=1 - name: Linux sleep screen and activity regression run: xvfb-run -a dbus-run-session -- cargo test --manifest-path client/Cargo.toml sleep_integration -- --ignored --test-threads=1 - name: Confirm healthy pairing UI before enrollment diff --git a/client/core/src/layout.rs b/client/core/src/layout.rs index 912a725..4922d51 100644 --- a/client/core/src/layout.rs +++ b/client/core/src/layout.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde_json::Value; use url::Url; -use super::bundle::{BundleCell, BundleDisplayWithLayouts, BundleLayout, KioskBundle}; +use super::bundle::{BundleCell, BundleDisplayWithLayouts, BundleLayout, KioskBundle, BundleCamera}; pub const NO_LAYOUTS_ASSIGNED_MESSAGE: &str = "go into BetterFrame and assign layouts to this display"; @@ -51,6 +51,72 @@ pub fn resolve_display<'a>( .or_else(|| bundle.displays.get(native_index)) } +/// Only fields consumed by stream selection and pipeline construction. +/// Password fields must already be normalized by the platform's comparator. +pub fn camera_pipeline_inputs(camera: &BundleCamera) -> Value { + let streams: Vec<_> = camera.streams.iter() + .map(|stream| serde_json::json!({"role": stream.role, "uri": stream.rtsp_uri})) + .collect(); + serde_json::json!({ + "enabled": camera.enabled, + "url": camera.rtsp_url, + "streams": streams, + "username": camera.playback_username, + "password": camera.playback_password_encrypted, + }) +} + +/// A rename updates the visible tile label but need not reconnect its decoder. +pub fn camera_render_inputs(camera: &BundleCamera) -> Value { + serde_json::json!({"name": camera.name, "pipeline": camera_pipeline_inputs(camera)}) +} + +/// Whether a bundle refresh can keep a display's existing widgets and overrides. +/// Inactive layouts still enter the cached bundle for the next layout switch. +pub fn display_render_unchanged( + previous: &KioskBundle, + next: &KioskBundle, + display_id: &str, + active_id: Option<&str>, +) -> bool { + fn inputs(bundle: &KioskBundle, display_id: &str, active_id: Option<&str>) -> Option { + let displays = bundle.normalized_displays(); + let display = displays.iter().find(|d| d.id == display_id)?; + let active_id = active_id?; + let layout = display.layouts.iter().find(|l| l.id == active_id)?; + let cameras: std::collections::BTreeMap<_, _> = bundle.cameras.iter() + .filter(|camera| layout.preload_camera_ids.contains(&camera.id) + || layout.cells.iter().any(|cell| cell.camera_id.as_deref() == Some(&camera.id))) + .map(|camera| { + let visible = layout.cells.iter().any(|cell| cell.camera_id.as_deref() == Some(&camera.id)); + (camera.id.as_str(), if visible { camera_render_inputs(camera) } else { camera_pipeline_inputs(camera) }) + }) + .collect(); + Some(serde_json::json!({ + "width": display.width_px, + "height": display.height_px, + "layout": layout, + "cameras": cameras, + "operator_console": bundle.operator_console, + "tenant": bundle.tenant_slug, + })) + } + let old = inputs(previous, display_id, active_id); + old.is_some() && old == inputs(next, display_id, active_id) +} + +/// Pool entries are keyed by camera ID and stream role, so their configuration +/// must also match before an old decoder/RTSP connection can be reused. +pub fn unchanged_camera_ids(previous: Option<&KioskBundle>, next: &KioskBundle) -> std::collections::HashSet { + let Some(previous) = previous else { return Default::default() }; + let old: HashMap<_, _> = previous.cameras.iter().map(|camera| (&camera.id, camera)).collect(); + next.cameras.iter().filter(|camera| { + old.get(&camera.id).is_some_and(|prior| { + camera_pipeline_inputs(prior) == camera_pipeline_inputs(camera) + }) + }).map(|camera| camera.id.clone()).collect() +} + pub fn configured_cell_action(cell: &BundleCell, kind: &str) -> Option<(String, Value)> { let event = cell.input_options.as_ref()?.get("events")?.get(kind)?; Some(( @@ -86,6 +152,73 @@ mod tests { use super::*; use crate::bundle::BundleDisplayWithLayouts; + #[test] + fn refresh_only_invalidates_the_active_display_content() { + let previous: KioskBundle = serde_json::from_value(serde_json::json!({ + "kiosk_id": "k", "kiosk_name": "Kiosk", "version": "1", "cameras": [], + "displays": [{ + "id": "d", "name": "Display", "width_px": 1920, "height_px": 1080, + "idle_timeout_seconds": 0, "sleep_timeout_seconds": 0, + "layouts": [ + {"id": "active", "name": "Active", "grid_cols": 1, "grid_rows": 1, + "priority": "normal", "is_default": true, "resets_idle_timer": true, "cells": []}, + {"id": "other", "name": "Other", "grid_cols": 1, "grid_rows": 1, + "priority": "normal", "is_default": false, "resets_idle_timer": true, "cells": []} + ] + }] + })).unwrap(); + let unchanged = |next: &KioskBundle| display_render_unchanged(&previous, next, "d", Some("active")); + let mut next = previous.clone(); + next.version = "2".into(); + assert!(unchanged(&next), "unassigned edits / repeated notifications keep the display"); + next.displays[0].layouts[1].grid_cols = 2; + assert!(unchanged(&next), "inactive assigned layout edits keep the display"); + next.displays[0].layouts.remove(1); + assert!(unchanged(&next), "removing an inactive assignment keeps the display"); + next.displays[0].layouts[0].grid_cols = 2; + assert!(!unchanged(&next), "active layout edits must render"); + next = previous.clone(); + next.displays[0].layouts.remove(0); + assert!(!unchanged(&next), "removing the active assignment must render a fallback"); + next = previous.clone(); + next.displays[0].width_px = 1280; + assert!(!unchanged(&next)); + next.displays.clear(); + assert!(!unchanged(&next)); + + let mut preloaded = previous.clone(); + preloaded.cameras.push(serde_json::from_value(serde_json::json!({ + "id": "preload", "name": "Preload", "type": "onvif", "stream_policy": "auto", + "streams": [{"id": "s", "name": "Sub", "role": "sub", "rtsp_uri": "rtsp://old/sub"}] + })).unwrap()); + preloaded.displays[0].layouts[0].preload_camera_ids.push("preload".into()); + assert!(unchanged_camera_ids(Some(&preloaded), &preloaded).contains("preload")); + let mut metadata = preloaded.clone(); + metadata.cameras[0].last_seen_at = Some("2026-09-23T22:00:00Z".into()); + metadata.cameras[0].labels.push("new-label".into()); + metadata.cameras[0].capabilities.push("ptz".into()); + metadata.cameras[0].recording_config = serde_json::json!({"changed": true}); + metadata.cameras[0].onvif_password_encrypted = Some("new-event-credential".into()); + metadata.cameras[0].streams[0].name = "Renamed stream".into(); + assert!(display_render_unchanged(&preloaded, &metadata, "d", Some("active"))); + assert!(unchanged_camera_ids(Some(&preloaded), &metadata).contains("preload")); + metadata.cameras[0].name = "Renamed camera".into(); + assert!(unchanged_camera_ids(Some(&preloaded), &metadata).contains("preload")); + assert_ne!(camera_render_inputs(&preloaded.cameras[0]), camera_render_inputs(&metadata.cameras[0])); + let mut changed = preloaded.clone(); + changed.cameras[0].streams[0].rtsp_uri = "rtsp://new/sub".into(); + assert!(!display_render_unchanged(&preloaded, &changed, "d", Some("active"))); + assert!(!unchanged_camera_ids(Some(&preloaded), &changed).contains("preload")); + changed = preloaded.clone(); + changed.cameras[0].playback_password_encrypted = Some("new-encrypted-value".into()); + assert!(!display_render_unchanged(&preloaded, &changed, "d", Some("active"))); + assert!(!unchanged_camera_ids(Some(&preloaded), &changed).contains("preload")); + changed.cameras.clear(); + assert!(!display_render_unchanged(&preloaded, &changed, "d", Some("active"))); + assert!(unchanged_camera_ids(Some(&preloaded), &changed).is_empty()); + assert!(unchanged_camera_ids(None, &preloaded).is_empty()); + } + #[test] fn selects_default_then_first_layout() { let mut display: BundleDisplayWithLayouts = serde_json::from_value(serde_json::json!({ diff --git a/client/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index 3af3d51..165199a 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -1466,6 +1466,52 @@ fn show_pairing_progress(window: &ApplicationWindow) { window.set_child(Some(&vbox)); } +/// Comparison-only copy: randomized transport ciphertext is not a camera +/// configuration change. Keep opaque values on decrypt failure so unknown +/// credentials cannot accidentally compare equal to absent credentials. +fn render_comparison_bundle(bundle: &KioskBundle, decrypt_key: Option<&str>) -> KioskBundle { + use sha2::Digest; + let mut comparable = bundle.clone(); + let normalize = |credential: &mut Option| { + if let Some(ciphertext) = credential.as_ref() { + if let Some(secret) = decrypt_key.and_then(|key| onvif_events::decrypt_cluster_public(ciphertext, key)) { + *credential = Some(format!("sha256:{:x}", sha2::Sha256::digest(secret.as_bytes()))); + } + } + }; + for camera in &mut comparable.cameras { + normalize(&mut camera.playback_password_encrypted); + normalize(&mut camera.onvif_password_encrypted); + } + for layout in comparable.displays.iter_mut().flat_map(|display| &mut display.layouts) + .chain(comparable.layouts.iter_mut()) + { + for cell in &mut layout.cells { + if let Some(smart) = &mut cell.smart_url { + for step in &mut smart.steps { + normalize(&mut step.value_encrypted); + } + } + } + } + comparable +} + +fn can_preserve_display(state: &DisplayState, previous: &KioskBundle, next: &KioskBundle, id: &str) -> bool { + // Compare only the cameras actually shown by overrides. Telemetry and + // changes to other available operator cameras do not affect these tiles. + let override_cameras_unchanged = state.focus_overrides.values() + .chain(state.fullscreen_override.iter()).all(|focus| { + let inputs = |bundle: &KioskBundle| bundle.cameras.iter() + .find(|camera| camera.id == focus.camera_id) + .map(crate::core::layout::camera_render_inputs); + inputs(previous) == inputs(next) + }); + override_cameras_unchanged && crate::core::layout::display_render_unchanged( + previous, next, id, state.current_layout_id.as_deref(), + ) +} + /// Render a fresh bundle: rebuild the per-display window set, restart GPIO /// workers, recompute warm-camera needs across all displays. fn render_bundle( @@ -1476,6 +1522,21 @@ fn render_bundle( kiosk_key: &str, ) { set_reported_bundle_version(&bundle.version); + let previous_bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone()); + let decrypt_key = server::load_encrypt_key().or_else(|| server::load_cluster_key()); + let comparable = render_comparison_bundle(&bundle, decrypt_key.as_deref()); + let previous_comparable = previous_bundle.as_ref() + .map(|previous| render_comparison_bundle(previous, decrypt_key.as_deref())); + let same_auth = CURRENT_AUTH.with(|a| { + a.borrow().as_ref().is_some_and(|(url, key)| url == server_url && key == kiosk_key) + }); + let unchanged_displays: std::collections::HashSet = DISPLAYS.with(|ds| { + ds.borrow().iter().filter_map(|(id, state)| { + (same_auth && previous_comparable.as_ref().is_some_and(|previous| { + can_preserve_display(state, previous, &comparable, id) + })).then(|| id.clone()) + }).collect() + }); CURRENT_BUNDLE.with(|b| *b.borrow_mut() = Some(bundle.clone())); CURRENT_AUTH.with(|a| *a.borrow_mut() = Some((server_url.to_string(), kiosk_key.to_string()))); CURRENT_SYNC_LABEL.with(|s| *s.borrow_mut() = format_current_local_time()); @@ -1499,7 +1560,6 @@ fn render_bundle( .filter(|c| layout_cam_ids.contains(&c.id)) .cloned() .collect(); - let decrypt_key = server::load_encrypt_key().or_else(|| server::load_cluster_key()); onvif_events::start( &layout_cameras, decrypt_key.as_deref(), @@ -1508,8 +1568,10 @@ fn render_bundle( &bundle.tenant_slug, ); - // Purge warm camera pool entries for cameras no longer in the bundle at all. - purge_removed_cameras(&bundle.cameras); + // An existing camera ID does not imply that its URI/credentials are still + // current. Invalidate changed cameras before ensure_warm can reuse them. + let reusable_cameras = crate::core::layout::unchanged_camera_ids(previous_comparable.as_ref(), &comparable); + purge_obsolete_cameras(&reusable_cameras); // Match GDK monitors to bundle displays by index. Bundle display 0 → GDK // monitor 0, etc. v1 simple ordering — re-binding will land if/when the // admin UI exposes a mapping. Falls back to overlapping windows on a @@ -1557,6 +1619,16 @@ fn render_bundle( 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)); + if unchanged_displays.contains(&bd.id) { + if let Some(state) = existing { + if let Some(content) = state.content_overlay.child() { + refresh_empty_display_reference(&content, &bundle, Some(bd)); + } + new_state.insert(bd.id.clone(), state); + continue; + } + } + let ( window, was_asleep, @@ -1661,6 +1733,9 @@ fn render_bundle( // Now render each display's initial layout. for bd in &displays { + if unchanged_displays.contains(&bd.id) { + continue; + } let previous = DISPLAYS.with(|ds| { ds.borrow() .get(&bd.id) @@ -2344,8 +2419,9 @@ fn animate_layout_swap(content_overlay: >k::Overlay, new_grid: >k::Grid) { let key = c.widget_name(); if !key.is_empty() { if let Some(b) = c.compute_bounds(&old_grid) { - let paintable: gtk::gdk::Paintable = - gtk::WidgetPaintable::new(Some(&c)).upcast(); + // Freeze the image while the old widget is still parented. + // A live WidgetPaintable can become empty after grid removal. + let paintable = gtk::WidgetPaintable::new(Some(&c)).current_image(); snaps.insert( key.to_string(), CellSnap { @@ -2413,11 +2489,24 @@ fn animate_layout_swap(content_overlay: >k::Overlay, new_grid: >k::Grid) { } let ghost_weak = ghost_clone.downgrade(); + let grid_weak = new_grid_clone.downgrade(); gtk::glib::timeout_add_local_once( Duration::from_millis((LAYOUT_ANIM_MS + 50) as u64), move || { + // Frame-clock callbacks may be delayed or never run (unmapped + // widgets, a busy decoder/GPU, or a rapid second layout edit). + // Restore the real tiles before destroying their animation clock. + if let Some(grid) = grid_weak.upgrade() { + let mut child = grid.first_child(); + while let Some(tile) = child { + tile.set_opacity(1.0); + child = tile.next_sibling(); + } + } if let Some(g) = ghost_weak.upgrade() { - g.unparent(); + if let Some(parent) = g.parent().and_then(|p| p.downcast::().ok()) { + parent.remove_overlay(&g); + } } }, ); @@ -2508,10 +2597,24 @@ fn fade_out_and_drop(pic: >k::Picture, fixed: >k::Fixed) { /// Default cooling timeout when a layout doesn't specify one (or specifies 0). const DEFAULT_COOLING_SECS: u32 = 30; +/// Keep hot stream role selection identical to the pool's desired keys. +fn rewarm_hot_cameras( + required: &std::collections::HashSet, + cameras: &HashMap<&str, &crate::bundle::BundleCamera>, + mut warm: impl FnMut(&str, &crate::bundle::BundleCamera, Option<&str>), +) { + for (id, badge) in required { + if let Some(camera) = cameras.get(id.as_str()) { + let selector = match badge { 'M' => Some("main"), 'S' => Some("sub"), _ => None }; + warm(id, camera, selector); + } + } +} + /// Walk all displays' currently-active layouts (plus any priority=hot layouts) /// and recompute the warm/hot pool. Pool entries dropped from active layouts -/// transition to Cooling; new entries are NOT added here — `ensure_warm` does -/// that when the layout actually renders. +/// transition to Cooling. Hot-layout pipelines are also created here so +/// invalidation cannot leave an inactive hot layout cold. /// /// Pool keys are (camera_id, badge): a camera's main and sub streams are /// tracked independently, so flipping a cell from M→S promotes the new sub @@ -2640,6 +2743,11 @@ fn recompute_global_state() { if max_cooling_secs == 0 { max_cooling_secs = DEFAULT_COOLING_SECS; } + // Recreate hot streams invalidated by a bundle refresh even when their + // layout is inactive and no display needed to render again. + rewarm_hot_cameras(&hot_set, &cam_map, |id, camera, selector| { + let _ = ensure_warm(id, camera, selector, 0.0); + }); recompute_pool_states(&warm_set, &hot_set, max_cooling_secs); recompute_web_states(&warm_webs, &hot_webs, max_cooling_secs); } @@ -2697,11 +2805,9 @@ fn recompute_pool_states( } } -/// Remove warm camera entries for cameras no longer in the bundle. +/// Remove pipelines whose camera was removed or whose configuration changed. /// Immediately stops pipelines — no cooling period. -fn purge_removed_cameras(bundle_cameras: &[crate::bundle::BundleCamera]) { - let valid_ids: std::collections::HashSet<&str> = - bundle_cameras.iter().map(|c| c.id.as_str()).collect(); +fn purge_obsolete_cameras(valid_ids: &std::collections::HashSet) { let mut to_remove: Vec = Vec::new(); let mut to_stop: Vec = Vec::new(); @@ -2723,7 +2829,7 @@ fn purge_removed_cameras(bundle_cameras: &[crate::bundle::BundleCamera]) { } if !to_remove.is_empty() { info!( - "purged {} camera pipelines no longer in bundle", + "purged {} removed or reconfigured camera pipelines", to_remove.len() ); } @@ -3253,6 +3359,180 @@ fn ensure_web( #[cfg(test)] mod display_tests { + fn camera_bundle_for_refresh(ciphertext: &str) -> super::KioskBundle { + serde_json::from_value(serde_json::json!({ + "kiosk_id":"k", "kiosk_name":"Kiosk", "version":"v", + "cameras":[{"id":"camera", "name":"Camera", "type":"onvif", "stream_policy":"auto", + "playback_password_encrypted":ciphertext,"onvif_password_encrypted":ciphertext, + "streams":[ + {"id":"main","name":"Main","role":"main","rtsp_uri":"rtsp://new/main"}, + {"id":"sub","name":"Sub","role":"sub","rtsp_uri":"rtsp://new/sub"} + ]}], + "displays":[{"id":"d","name":"Display","width_px":800,"height_px":600, + "idle_timeout_seconds":0,"sleep_timeout_seconds":0, + "layouts":[{"id":"l","name":"Layout","grid_cols":1,"grid_rows":1, + "priority":"normal","is_default":true,"resets_idle_timer":true, + "preload_camera_ids":["camera"],"cells":[{ + "row":0,"col":0,"row_span":1,"col_span":1,"content_type":"web", + "web_url":"https://example.test", "smart_url":{"steps":[{ + "type":"fill","selector":"#password","value_encrypted":ciphertext + }]} + }]}]}] + })).unwrap() + } + + #[test] + fn randomized_camera_credentials_preserve_render_and_pool_identity() { + use super::*; + use aes_gcm::{Aes256Gcm, Nonce, aead::{Aead, KeyInit}}; + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64}; + let key = [42u8; 32]; + let encoded_key = B64.encode(key); + let encrypt = |iv: [u8; 12], secret: &[u8]| { + let encrypted = Aes256Gcm::new_from_slice(&key).unwrap() + .encrypt(Nonce::from_slice(&iv), secret).unwrap(); + let (ciphertext, tag) = encrypted.split_at(encrypted.len() - 16); + format!("v1.{}.{}.{}", B64.encode(iv), B64.encode(tag), B64.encode(ciphertext)) + }; + let first = camera_bundle_for_refresh(&encrypt([1; 12], b"test-secret")); + let second = camera_bundle_for_refresh(&encrypt([2; 12], b"test-secret")); + assert!(first.cameras[0].playback_password_encrypted != second.cameras[0].playback_password_encrypted); + let previous = render_comparison_bundle(&first, Some(&encoded_key)); + let next = render_comparison_bundle(&second, Some(&encoded_key)); + assert!(crate::core::layout::display_render_unchanged(&previous, &next, "d", Some("l"))); + assert!(crate::core::layout::unchanged_camera_ids(Some(&previous), &next).contains("camera")); + // Normalization must never replace the cached/rendered transport bundle. + assert!(second.cameras[0].playback_password_encrypted.as_ref().unwrap().starts_with("v1.")); + let changed = render_comparison_bundle( + &camera_bundle_for_refresh(&encrypt([3; 12], b"changed-secret")), Some(&encoded_key)); + assert!(!crate::core::layout::display_render_unchanged(&previous, &changed, "d", Some("l"))); + assert!(crate::core::layout::unchanged_camera_ids(Some(&previous), &changed).is_empty()); + let unknown = render_comparison_bundle(&second, None); + assert!(crate::core::layout::unchanged_camera_ids(Some(&previous), &unknown).is_empty()); + let mut smart_changed = second.clone(); + smart_changed.displays[0].layouts[0].cells[0].smart_url.as_mut().unwrap().steps[0].value_encrypted = + Some(encrypt([4; 12], b"changed-web-secret")); + let smart_changed = render_comparison_bundle(&smart_changed, Some(&encoded_key)); + assert!(!crate::core::layout::display_render_unchanged(&previous, &smart_changed, "d", Some("l"))); + assert!(crate::core::layout::unchanged_camera_ids(Some(&previous), &smart_changed).contains("camera")); + let mut legacy = second.clone(); + legacy.layouts = legacy.displays[0].layouts.clone(); + let normalized_legacy = render_comparison_bundle(&legacy, Some(&encoded_key)); + assert!(normalized_legacy.layouts[0].cells[0].smart_url.as_ref().unwrap().steps[0] + .value_encrypted.as_ref().unwrap().starts_with("sha256:")); + let wrong_key = B64.encode([43u8; 32]); + let unreadable = render_comparison_bundle(&second, Some(&wrong_key)); + assert!(crate::core::layout::unchanged_camera_ids(Some(&previous), &unreadable).is_empty()); + } + + #[test] + fn rewarming_required_hot_streams_uses_current_camera_and_exact_roles() { + use super::*; + let bundle = camera_bundle_for_refresh("opaque"); + let cameras = bundle.cameras.iter().map(|c| (c.id.as_str(), c)).collect(); + let required = std::collections::HashSet::from([ + ("camera".into(), 'M'), ("camera".into(), 'S'), ("removed".into(), 'S'), + ]); + let mut warmed = std::collections::HashSet::new(); + // Model a pool emptied by invalidation: every still-required stream + // must be recreated from current configuration without rendering a grid. + rewarm_hot_cameras(&required, &cameras, |id, camera, selector| { + let (uri, badge) = camera.pick_stream(selector, 0.0).unwrap(); + warmed.insert((id.to_string(), badge, uri)); + }); + assert_eq!(warmed, std::collections::HashSet::from([ + ("camera".into(), 'M', "rtsp://new/main".into()), + ("camera".into(), 'S', "rtsp://new/sub".into()), + ])); + } + + #[test] + #[ignore = "requires a graphical session; run with xvfb-run"] + fn display_refresh_updates_empty_metadata_and_checks_fullscreen_camera() { + use super::*; + gtk::init().unwrap(); + let mut bundle: KioskBundle = serde_json::from_value(serde_json::json!({ + "kiosk_id": "k", "kiosk_name": "Before", "version": "1", + "cameras": [{"id":"outside", "name":"Camera", "type":"rtsp", "stream_policy":"auto", + "rtsp_url":"rtsp://before/live", "streams":[]}], + "displays": [{"id":"d", "name":"Before display", "width_px":800,"height_px":600, + "idle_timeout_seconds":0,"sleep_timeout_seconds":0, + "layouts":[{"id":"l","name":"Empty","grid_cols":1,"grid_rows":1, + "priority":"normal","is_default":true,"resets_idle_timer":true,"cells":[]}]}] + })).unwrap(); + CURRENT_SYNC_LABEL.with(|s| *s.borrow_mut() = "before sync".into()); + let content = build_empty_display_message(&bundle, Some(&bundle.displays[0]), "Empty layout"); + let reference = content.last_child().unwrap().downcast::