diff --git a/configurator/src/app/pages/session/catalog/tests.rs b/configurator/src/app/pages/session/catalog/tests.rs index ca61b9129..2a7f42511 100644 --- a/configurator/src/app/pages/session/catalog/tests.rs +++ b/configurator/src/app/pages/session/catalog/tests.rs @@ -18,6 +18,7 @@ fn test_item(id: &str, display_name: &str) -> SessionCatalogItem { primary_exists: true, backup_exists: false, recovery_exists: false, + unreadable_copy_exists: false, clear_marker_exists: false, lock_exists: false, non_lock_size_bytes: 2048, diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs index c985c342d..02ddd6b33 100644 --- a/configurator/src/app/search/tests.rs +++ b/configurator/src/app/search/tests.rs @@ -842,6 +842,7 @@ fn catalog_item(id: &str) -> SessionCatalogItem { primary_exists: false, backup_exists: false, recovery_exists: false, + unreadable_copy_exists: false, clear_marker_exists: false, lock_exists: false, non_lock_size_bytes: 0, diff --git a/configurator/src/app/session_catalog/tests.rs b/configurator/src/app/session_catalog/tests.rs index ee903c0d9..ff692fbcb 100644 --- a/configurator/src/app/session_catalog/tests.rs +++ b/configurator/src/app/session_catalog/tests.rs @@ -194,6 +194,7 @@ fn session_artifact_status_reports_size_when_present() { primary_exists: true, backup_exists: false, recovery_exists: false, + unreadable_copy_exists: false, clear_marker_exists: false, lock_exists: false, non_lock_size_bytes: 4096, diff --git a/configurator/src/app/update/session_catalog/tests.rs b/configurator/src/app/update/session_catalog/tests.rs index d87b7cae2..ffc43df96 100644 --- a/configurator/src/app/update/session_catalog/tests.rs +++ b/configurator/src/app/update/session_catalog/tests.rs @@ -21,6 +21,7 @@ fn catalog_item(id: &str, display_name: &str) -> SessionCatalogItem { primary_exists: false, backup_exists: false, recovery_exists: false, + unreadable_copy_exists: false, clear_marker_exists: false, lock_exists: false, non_lock_size_bytes: 0, diff --git a/configurator/src/models/session.rs b/configurator/src/models/session.rs index 5627e3cbb..e2e9881c6 100644 --- a/configurator/src/models/session.rs +++ b/configurator/src/models/session.rs @@ -242,6 +242,7 @@ pub struct SessionArtifactSummary { pub primary_exists: bool, pub backup_exists: bool, pub recovery_exists: bool, + pub unreadable_copy_exists: bool, pub clear_marker_exists: bool, pub lock_exists: bool, pub non_lock_size_bytes: u64, @@ -252,34 +253,35 @@ impl SessionArtifactSummary { let artifacts = wayscriber::session::named_session_artifact_paths(path); let non_lock_paths = wayscriber::session::named_session_non_lock_artifact_paths(path) .map_err(|err| err.to_string())?; - let recovery_name = artifacts - .recovery - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string); let mut summary = Self { primary_exists: artifact_exists(&artifacts.primary)?, backup_exists: artifact_exists(&artifacts.backup)? || artifact_exists(&artifacts.backup_recovery_marker)?, recovery_exists: false, + unreadable_copy_exists: false, clear_marker_exists: artifact_exists(&artifacts.clear_marker)?, lock_exists: artifact_exists(&artifacts.lock)?, non_lock_size_bytes: 0, }; - for path in non_lock_paths { - let Some(metadata) = artifact_metadata(&path)? else { + for artifact in non_lock_paths { + let Some(metadata) = artifact_metadata(&artifact)? else { continue; }; summary.non_lock_size_bytes = summary.non_lock_size_bytes.saturating_add(metadata.len()); - if recovery_name.as_deref().is_some_and(|name| { - path.file_name() - .and_then(|value| value.to_str()) - .is_some_and(|value| value == name || value.starts_with(&format!("{name}."))) - }) { - summary.recovery_exists = true; + match wayscriber::session::named_session_artifact_kind(path, &artifact) { + Some(wayscriber::session::SessionArtifactKind::Backup) => { + summary.backup_exists = true + } + Some(wayscriber::session::SessionArtifactKind::Recovery) => { + summary.recovery_exists = true + } + Some(wayscriber::session::SessionArtifactKind::UnreadableCopy) => { + summary.unreadable_copy_exists = true + } + _ => {} } } @@ -297,6 +299,9 @@ impl SessionArtifactSummary { if self.recovery_exists { parts.push("recovery"); } + if self.unreadable_copy_exists { + parts.push("unreadable copy"); + } if self.clear_marker_exists { parts.push("cleared"); } diff --git a/configurator/src/models/session/tests.rs b/configurator/src/models/session/tests.rs index 6b9ebd578..e3d8f65f9 100644 --- a/configurator/src/models/session/tests.rs +++ b/configurator/src/models/session/tests.rs @@ -16,6 +16,7 @@ fn session_catalog_state_replaces_items_and_inputs() { primary_exists: false, backup_exists: false, recovery_exists: false, + unreadable_copy_exists: false, clear_marker_exists: false, lock_exists: false, non_lock_size_bytes: 0, @@ -90,3 +91,32 @@ fn format_byte_count_uses_compact_units() { assert_eq!(format_byte_count(4096), "4.0 KiB"); assert_eq!(format_byte_count(2 * 1024 * 1024), "2.0 MiB"); } + +#[test] +fn artifact_summary_counts_diagnostic_copies_without_counting_similar_foreign_files() { + let temp = crate::test_temp::tempdir().unwrap(); + let primary = temp.path().join("board.wayscriber-session"); + std::fs::write(&primary, b"primary").unwrap(); + for suffix in [".corrupt-1", ".bak.corrupt-2", ".recovery.corrupt-3"] { + std::fs::write(format!("{}{suffix}", primary.display()), b"diagnostic").unwrap(); + } + std::fs::write( + format!("{}.corrupt-01", primary.display()), + b"foreign similar name", + ) + .unwrap(); + let summary = SessionArtifactSummary::from_primary_path(&primary).unwrap(); + assert!(summary.primary_exists); + // Unreadable copies restore nothing, so they are not shown as a backup + // or recovery file. + assert!(!summary.backup_exists); + assert!(!summary.recovery_exists); + assert!(summary.unreadable_copy_exists); + assert_eq!(summary.non_lock_size_bytes, 7 + 3 * 10); + assert_eq!(summary.status_label(), "primary, unreadable copy · 37 B"); + + // A primary moved aside still leaves something to show. + std::fs::remove_file(&primary).unwrap(); + let summary = SessionArtifactSummary::from_primary_path(&primary).unwrap(); + assert_eq!(summary.status_label(), "unreadable copy · 30 B"); +} diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index ae0174753..497098e01 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -77,7 +77,7 @@ Daemon mode therefore provides a persistent background service that reacts to us - Communicate with `capture::CaptureManager` for screenshot actions. - Exit when `InputState.should_exit` is set (Escape, tray close, etc.). -`WaylandState` coordinates the runtime owners handlers need. `FocusState` owns activation, focus, and startup acquisition; `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer position, board-pan and chrome gestures, cursor, pointer-lock, and single-contact touch protocol lifecycles; `ToolbarChrome` owns toolbar placement, inline interaction, and fade state; `ToolbarDrag` owns built-in and GTK drag lifecycles; `RegionCaptureRuntime` owns region selection generations, active/review/window-snap state, and the window-query and cut-preview workers; `AcquisitionRuntime` owns the capacity-one screen-acquisition and zoom-waiter registries plus eyedropper source correlation; `FrozenState` owns its availability and one-shot startup gate; `SurfaceState` owns output/fullscreen/layer placement and frozen-fullscreen transitions; `OverlaySuppressionState` owns suppression reason, keyboard policy, capture barrier, and clickthrough state; `RenderRuntime` owns the canvas layer cache, reusable image and blur caches, resolved theme, UI paint caches, render-profile baseline, and per-effect damage history; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing; `ClipboardRuntime` owns single-flight clipboard workers and queue policy; `PreferenceStores` groups durable preference stores and workers; `UiAnimationClock` owns animation scheduling; and `FontCatalogPrewarm` owns the one-shot font scan. The root retains cross-owner coordination. `handlers::route::SurfaceRouter` is the single classifier for pointer, touch, and stylus surfaces and supplies overlay screen coordinates before modality-specific dispatch. +`WaylandState` coordinates the runtime owners handlers need. `FocusState` owns activation, focus, and startup acquisition; `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer position, board-pan and chrome gestures, cursor, pointer-lock, and single-contact touch protocol lifecycles; `ToolbarChrome` owns toolbar placement, inline interaction, and fade state; `ToolbarDrag` owns built-in and GTK drag lifecycles; `RegionCaptureRuntime` owns region selection generations, active/review/window-snap state, and the window-query and cut-preview workers; `AcquisitionRuntime` owns the capacity-one screen-acquisition and zoom-waiter registries plus eyedropper source correlation; `FrozenState` owns its availability and one-shot startup gate; `SurfaceState` owns output/fullscreen/layer placement and frozen-fullscreen transitions; `OverlaySuppressionState` owns suppression reason, keyboard policy, capture barrier, and clickthrough state; `RenderRuntime` owns the canvas layer cache, reusable image and blur caches, resolved theme, UI paint caches, render-profile baseline, and per-effect damage history; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing; `ClipboardRuntime` owns single-flight clipboard workers and queue policy; `PreferenceStores` groups durable preference stores and workers; `UiAnimationClock` owns animation scheduling; and `FontCatalogPrewarm` owns the one-shot font scan. Mouse buttons and tablet tips retain the owner that accepted their press, while `TouchTarget` binds touch sequences. Canvas motion and release keep that owner when crossing an inline toolbar; strip gaps consume presses and pen strip hover stays separate from layer-shell proximity. Each inline press refreshes hover geometry before claiming a control or gap. Strip pressure cannot change canvas thickness; a palette-consumed pen contact consumes its release even if the palette closes. A pen tap on the onboarding card, a toast, or an open popover acts on that chrome and never starts a stroke, and pen-up keeps a pressure thickness only for a stroke. Canceled canvas and pan gestures reconcile pointer ownership, while live held contacts keep their routing across Leave/Enter. The root retains cross-owner coordination. `handlers::route::SurfaceRouter` is the single classifier for pointer, touch, and stylus surfaces and supplies overlay screen coordinates before modality-specific dispatch. Within `PointerRuntime`, pending chrome targets and device-owned release suppression have separate lifecycles. Clearing a toast, HUD, or zoom-chip press preserves mouse and touch suppression; cancelling a touch clears only its own release latch. @@ -544,9 +544,54 @@ capture suppression operates on the paired resources without runtime pairing che - `src/daemon/protocol_v2/session_target.rs`: writes and reads the per-generation session reports in `daemon-commands/overlay-targets/`. - `src/daemon/`: accepts daemon-toggle requests that carry an optional named session target, and remembers the session its overlay last reported across hide and show. +The first startup load blocks once in the surface callback. Earlier input can +already exist; unresolved ink stays visible and cannot overwrite a saved file. +A per-output session first loads a placeholder without an output name. Leaving +it for the first output's own session also runs to completion in the callback, +after saving ink drawn on the placeholder to the placeholder's file. Later +retries and output switches use the shared session driver without waiting in +protocol dispatch. Unresolved sources cannot autosave or save before replacing +the canvas; at exit, their new drawings go to a `.unsaved-