From 08236badc4128b912ebfcac05a435be1794fcedd Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:50:52 +0000 Subject: [PATCH 1/5] fix: preserve displays when inactive layouts change --- client/core/src/layout.rs | 65 +++++++++++++++++++++++++++++++++ client/src/platform/linux/ui.rs | 27 ++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/client/core/src/layout.rs b/client/core/src/layout.rs index 912a725..66d4f64 100644 --- a/client/core/src/layout.rs +++ b/client/core/src/layout.rs @@ -51,6 +51,36 @@ pub fn resolve_display<'a>( .or_else(|| bundle.displays.get(native_index)) } +/// 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.cells.iter().any(|cell| cell.camera_id.as_deref() == Some(&camera.id))) + .map(|camera| (camera.id.as_str(), 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) +} + 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 +116,41 @@ 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)); + } + #[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..66d1ff6 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -1476,6 +1476,23 @@ fn render_bundle( kiosk_key: &str, ) { set_reported_bundle_version(&bundle.version); + let previous_bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone()); + 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_bundle.as_ref().is_some_and(|previous| { + // Operator focus can show cameras outside the active layout. + let override_cameras_unchanged = state.focus_overrides.is_empty() + || serde_json::to_value(&previous.cameras).ok() + == serde_json::to_value(&bundle.cameras).ok(); + override_cameras_unchanged && crate::core::layout::display_render_unchanged( + previous, &bundle, id, state.current_layout_id.as_deref(), + ) + })).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()); @@ -1557,6 +1574,13 @@ 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 { + new_state.insert(bd.id.clone(), state); + continue; + } + } + let ( window, was_asleep, @@ -1661,6 +1685,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) From 00a932ea1b6e3cc226902adf18614c6d94f820be Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:04:56 +0000 Subject: [PATCH 2/5] fix: restore tile visibility when layout animation ticks are delayed --- .github/workflows/validate.yml | 2 ++ client/src/platform/linux/ui.rs | 61 +++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7bd3b2f..683332a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -77,6 +77,8 @@ 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: 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/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index 66d1ff6..de55eb6 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -2371,8 +2371,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 { @@ -2440,11 +2441,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); + } } }, ); @@ -3280,6 +3294,47 @@ fn ensure_web( #[cfg(test)] mod display_tests { + #[test] + #[ignore = "requires a graphical session; run with xvfb-run"] + fn layout_animation_restores_tiles_without_frame_ticks() { + use super::*; + gtk::init().unwrap(); + let overlay = gtk::Overlay::new(); + let old_grid = Grid::new(); + let new_grid = Grid::new(); + for index in 0..16 { + let old_tile = Label::new(Some("Old camera")); + old_tile.set_widget_name(&format!("cam:{index}:auto")); + old_grid.attach(&old_tile, index % 4, index / 4, 1, 1); + let new_tile = Label::new(Some("New camera")); + new_tile.set_widget_name(&format!("cam:{index}:{}", if index == 0 { "main" } else { "auto" })); + new_grid.attach(&new_tile, index % 4, index / 4, 1, 1); + } + overlay.set_child(Some(&old_grid)); + old_grid.allocate(800, 600, -1, None); + // Deliberately leave the overlay unmapped: idle/timer callbacks run, + // but animation frame callbacks cannot restore hidden matched tiles. + animate_layout_swap(&overlay, &new_grid); + let context = gtk::glib::MainContext::default(); + while context.pending() { context.iteration(false); } + assert_eq!(new_grid.first_child().unwrap().opacity(), 0.0); + let deadline = Instant::now() + Duration::from_millis(600); + while Instant::now() < deadline { + while context.pending() { context.iteration(false); } + std::thread::sleep(Duration::from_millis(5)); + } + let mut child = new_grid.first_child(); + let mut count = 0; + while let Some(tile) = child { + assert_eq!(tile.opacity(), 1.0, "{} stayed invisible after cleanup", tile.widget_name()); + count += 1; + child = tile.next_sibling(); + } + assert_eq!(count, 16); + assert_eq!(overlay.first_child().unwrap(), new_grid.clone().upcast::()); + assert!(new_grid.next_sibling().is_none(), "animation overlay must be removed"); + } + #[test] #[ignore = "requires a graphical session; CI runs with xvfb-run"] fn sleep_integration_preserves_deadline_and_hides_layout_until_explicit_wake() { From 736bc8608af4ccded68dba5e284bd78da5e957e2 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:15:55 +0000 Subject: [PATCH 3/5] fix: refresh preloaded cameras and preserved display metadata --- .github/workflows/validate.yml | 2 + client/core/src/layout.rs | 35 ++++++++++- client/src/platform/linux/ui.rs | 102 ++++++++++++++++++++++++++------ 3 files changed, 119 insertions(+), 20 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 683332a..15776e1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -77,6 +77,8 @@ 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 diff --git a/client/core/src/layout.rs b/client/core/src/layout.rs index 66d4f64..ee7f705 100644 --- a/client/core/src/layout.rs +++ b/client/core/src/layout.rs @@ -65,7 +65,8 @@ pub fn display_render_unchanged( 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.cells.iter().any(|cell| cell.camera_id.as_deref() == Some(&camera.id))) + .filter(|camera| layout.preload_camera_ids.contains(&camera.id) + || layout.cells.iter().any(|cell| cell.camera_id.as_deref() == Some(&camera.id))) .map(|camera| (camera.id.as_str(), camera)) .collect(); Some(serde_json::json!({ @@ -81,6 +82,18 @@ pub fn display_render_unchanged( 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| { + serde_json::to_value(prior).ok() == serde_json::to_value(camera).ok() + }) + }).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(( @@ -149,6 +162,26 @@ mod tests { 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 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] diff --git a/client/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index de55eb6..dc05efb 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -1466,6 +1466,15 @@ fn show_pairing_progress(window: &ApplicationWindow) { window.set_child(Some(&vbox)); } +fn can_preserve_display(state: &DisplayState, previous: &KioskBundle, next: &KioskBundle, id: &str) -> bool { + // Either override can show a camera outside the base layout. + let override_cameras_unchanged = (state.focus_overrides.is_empty() && state.fullscreen_override.is_none()) + || serde_json::to_value(&previous.cameras).ok() == serde_json::to_value(&next.cameras).ok(); + 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( @@ -1483,13 +1492,7 @@ fn render_bundle( let unchanged_displays: std::collections::HashSet = DISPLAYS.with(|ds| { ds.borrow().iter().filter_map(|(id, state)| { (same_auth && previous_bundle.as_ref().is_some_and(|previous| { - // Operator focus can show cameras outside the active layout. - let override_cameras_unchanged = state.focus_overrides.is_empty() - || serde_json::to_value(&previous.cameras).ok() - == serde_json::to_value(&bundle.cameras).ok(); - override_cameras_unchanged && crate::core::layout::display_render_unchanged( - previous, &bundle, id, state.current_layout_id.as_deref(), - ) + can_preserve_display(state, previous, &bundle, id) })).then(|| id.clone()) }).collect() }); @@ -1525,8 +1528,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_bundle.as_ref(), &bundle); + 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 @@ -1576,6 +1581,9 @@ fn render_bundle( 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; } @@ -2738,11 +2746,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(); @@ -2764,7 +2770,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() ); } @@ -3294,6 +3300,48 @@ fn ensure_web( #[cfg(test)] mod display_tests { + #[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::