diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bf1e125..db6e3bec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -206,9 +206,12 @@ timings during pointer motion. Also record the process peak-RSS delta while enco `Ctrl+A` crop. Keep these measurements with the change's manual test evidence; they are intentionally not collected by CI. -These captures now fail when the active output does not advertise a current `wl_output` mode. -Plugging or unplugging any monitor also cancels in-flight Freeze and Zoom captures on unrelated -outputs, because output count is part of the layout identity used to reject stale frames. +Freeze and Zoom fail when the active output does not advertise a current `wl_output` mode. +Changes confined to another monitor preserve direct captures and installed active-output sources. +Portal captures discard stale desktop snapshots and retry once after complete geometry settles, +while preserving the original output identity and request. A second change during the retry or an +active-output switch is terminal. Board PDF desktop captures retain a full-desktop generation check, +including changes to other monitors even when the screenshot dimensions stay the same. `./tools/code-health-report.sh` reports navigational maintainability metrics. Its CI artifact is observational, not a global file/function-size gate; use the report to find code worth understanding, diff --git a/README.md b/README.md index 9cd04f02..10cd9510 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,13 @@ https://github.com/user-attachments/assets/4b5ed159-8d1c-44cb-8fe4-e0f2ea41d818 | Platform | Status | Notes | |----------|--------|-------| -| Wayland (layer-shell) | ✅ Supported | Hyprland, Sway, River, Wayfire, Niri/Cosmic, Plasma/KWin | +| Wayland (layer-shell) | ✅ Supported | Hyprland, Sway, River, Wayfire, Niri/Cosmic | +| Plasma/KWin | ✅ Supported | Layer-shell overlay; Freeze and Zoom use the `xdg-desktop-portal-kde` screenshot portal | | GNOME | ⚠️ Partial | Normal overlay and Freeze via portal when available; [light passthrough](#light-passthrough-mode) unavailable | | X11 | ❌ | Not supported | +On Plasma/KWin, Freeze and Zoom require a working screenshot portal and may require portal consent. + The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See the notes in [Debian and Ubuntu](#debian-and-ubuntu) and [Fedora and RHEL](#fedora-and-rhel). The AUR packages and Nix are unaffected.
diff --git a/docs/SETUP.md b/docs/SETUP.md index bd2c598a..344e9478 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -186,7 +186,13 @@ Then use the configurator's Daemon tab, or create a GNOME custom shortcut that r wayscriber --daemon-toggle ``` -Freeze prefers compositor-native `wlr-screencopy` or `ext-image-copy-capture` when either protocol is available, then falls back to the screenshot portal. On GNOME, Freeze works when that portal is available and responsive; the first use may show a desktop permission prompt. Portal capture can be slower than direct compositor capture. On mixed-DPI or multi-monitor layouts, Wayscriber accepts a portal image only when it can validate the active output's crop; otherwise Freeze fails instead of guessing at an origin or scale. Freeze and Zoom also require the active output to advertise a current mode. Connecting or disconnecting a monitor cancels any in-flight Freeze or Zoom capture, including on screens that did not change. +Freeze prefers compositor-native `wlr-screencopy` or `ext-image-copy-capture` when either protocol is available, then falls back to the screenshot portal. On GNOME, Freeze works when that portal is available and responsive; the first use may show a desktop permission prompt. Portal capture can be slower than direct compositor capture. On mixed-DPI or multi-monitor layouts, Wayscriber accepts a portal image only when it can validate the active output's crop; otherwise Freeze fails instead of guessing at an origin or scale. Freeze and Zoom also require the active output to advertise a current mode. + +Direct Freeze and Zoom captures remain valid when only another monitor changes. +Portal captures discard a stale desktop snapshot and retry once on the same active +output, after complete output geometry settles. A further change during the retry +ends the capture. Switching or replacing the active output also ends the request; +start a new capture on the desired output. Light passthrough mode is not available in the regular app on stock GNOME Wayland. GNOME's xdg-shell fallback does not expose the shell-level overlay behavior needed to keep annotations visible while input goes to apps underneath, so `--light-toggle` is intentionally disabled instead of pretending to pass input through. A GNOME Shell extension companion would be the real path for that workflow. diff --git a/src/backend/wayland/AGENTS.md b/src/backend/wayland/AGENTS.md index 083f7cc5..cfd1f572 100644 --- a/src/backend/wayland/AGENTS.md +++ b/src/backend/wayland/AGENTS.md @@ -9,6 +9,8 @@ - `handlers/` translates Wayland/Smithay callbacks into state and input calls. - `state.rs` is the central live runtime state root; `state/` contains state helpers for boards, buffers, damage, clipboard paste, toolbar plumbing, zoom, onboarding, and export handoff. - `toolbar/` owns runtime toolbar layout, hit testing, rows, surfaces, rendering, and widgets. +- `portal_raster.rs` validates complete portal desktop snapshots, crops the active logical rectangle, and resamples it to transformed native output pixels. `frozen_geometry.rs` keeps active viewport validity separate from full desktop validity. +- `capture_preflight.rs` retains backend-specific admission, shared active-output/full-desktop generation bookkeeping, typed preflight failures, and the bounded portal layout settling/retry policy. Board PDF desktop contexts use the full-desktop generation; direct capture and installed sources use the active-output generation. - `clipboard/`, `frozen/`, `zoom/`, and runtime session helpers integrate external compositor, clipboard, capture, and persistence behavior. ## Invariants diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index b2598f55..589833ad 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -16,12 +16,13 @@ use crate::input::state::{InputEffect, InputEffectDrain, PendingBackendAction}; use crate::notification; pub(super) fn poll_portal_captures(state: &mut WaylandState, now: Instant) { + let live_output_count = state.live_output_count(); + // Apply any completed portal fallback captures without blocking. state .frozen - .poll_portal_capture(&mut state.input_state, now); + .poll_portal_capture(&mut state.input_state, now, live_output_count); handle_pending_frozen_image(state, now); - let live_output_count = state.live_output_count(); state .zoom .poll_portal_capture(&mut state.input_state, now, live_output_count); @@ -36,6 +37,9 @@ pub(super) fn poll_capture_deadlines( qh: &wayland_client::QueueHandle, now: Instant, ) { + // Re-admit retries after dispatch, before GTK synchronization and rendering. + // This lets a fresh GTK generation be published in the same iteration. + state.poll_portal_layout_retries(now); state.poll_overlay_capture_barrier_timeout(now); if let Some(backend) = state.frozen.take_timed_out_direct_capture(now) { warn!("{backend:?} frozen capture timed out; trying the next backend"); @@ -43,20 +47,22 @@ pub(super) fn poll_capture_deadlines( } } -pub(super) fn capture_timeout(state: &WaylandState, now: Instant) -> Option { - super::min_timeout( - state.overlay_capture_barrier_timeout(now), - super::min_timeout( - state.frozen.direct_capture_timeout(now), - super::min_timeout( - state.frozen.portal_timeout(now), - super::min_timeout( - state.zoom.portal_timeout(now), - state.surface.placement().xdg_frozen().timeout(now), - ), - ), - ), - ) +pub(super) fn capture_timeout( + state: &WaylandState, + now: Instant, + render_delay: Duration, +) -> Option { + [ + state.frozen.portal_layout_retry_timeout(now), + state.zoom.portal_layout_retry_timeout(now), + state.overlay_capture_barrier_timeout(now, render_delay), + state.frozen.direct_capture_timeout(now), + state.frozen.portal_timeout(now), + state.zoom.portal_timeout(now), + state.surface.placement().xdg_frozen().timeout(now), + ] + .into_iter() + .fold(None, super::min_timeout) } fn handle_pending_frozen_image(state: &mut WaylandState, now: Instant) { diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 86d1abbd..0a345b18 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -269,6 +269,11 @@ fn event_loop_timeout( last_render_time: Option, ) -> Option { let vsync_enabled = state.config.performance.enable_vsync; + let frame_cap_timeout = if vsync_enabled { + None + } else { + render::frame_rate_cap_timeout(state.config.performance.max_fps_no_vsync, last_render_time) + }; let should_block = capture_active || !state.surface.is_configured() || (vsync_enabled && state.surface.frame_callback_pending()); @@ -297,10 +302,6 @@ fn event_loop_timeout( let base_timeout = if should_block { min_timeout(autosave_timeout, focus_exit_timeout) } else if !vsync_enabled && state.input_state.needs_redraw { - let frame_cap_timeout = render::frame_rate_cap_timeout( - state.config.performance.max_fps_no_vsync, - last_render_time, - ); let frame_timeout = match (frame_cap_timeout, animation_timeout) { (Some(frame), Some(animation)) => Some(frame.min(animation)), (Some(frame), None) => Some(frame), @@ -323,7 +324,7 @@ fn event_loop_timeout( state.toolbar_drag_handoff_timeout(now), state.input_state.command_palette_repeat_timeout(now), state.input_state.font_picker_repeat_timeout(now), - capture::capture_timeout(state, now), + capture::capture_timeout(state, now, frame_cap_timeout.unwrap_or(Duration::ZERO)), interaction::interaction_timeout(state.spotlight.wheel_idle_deadline(), now), durable_action_retry_timeout(state, now), pending_backend_action_timeout, diff --git a/src/backend/wayland/capture.rs b/src/backend/wayland/capture.rs index ccb3ed53..c49b20d1 100644 --- a/src/backend/wayland/capture.rs +++ b/src/backend/wayland/capture.rs @@ -68,6 +68,21 @@ pub(in crate::backend::wayland) struct PendingBoardPaste { } impl CaptureLayoutContext { + pub(in crate::backend::wayland) fn for_desktop( + target_output_id: u32, + source: &super::frozen::FrozenState, + ) -> Self { + Self::new(target_output_id, source.desktop_layout_generation()) + } + + pub(in crate::backend::wayland) fn matches_desktop( + self, + active_output_id: Option, + source: &super::frozen::FrozenState, + ) -> bool { + self.matches(active_output_id, source.desktop_layout_generation()) + } + pub(in crate::backend::wayland) fn new(target_output_id: u32, layout_generation: u64) -> Self { Self { target_output_id, @@ -619,4 +634,63 @@ mod tests { assert!(!context.matches(Some(7), 4)); assert!(!context.matches(None, 3)); } + + #[test] + fn desktop_capture_context_rejects_other_output_changes_with_unchanged_raster_size() { + use crate::backend::wayland::frozen::FrozenState; + use crate::backend::wayland::frozen_geometry::OutputGeometry; + use wayland_client::protocol::wl_output; + + let mut source = FrozenState::new(None); + let mut backdrop = crate::capture::DesktopBackdropGeometry { + logical_x: 0, + logical_y: 0, + logical_width: 2, + logical_height: 1, + physical_width: Some(2), + physical_height: Some(1), + crop_x: Some(0), + crop_y: Some(0), + screenshot_width: Some(4), + screenshot_height: Some(1), + }; + let geometry = OutputGeometry::update_from( + Some((0, 0)), + Some((2, 1)), + (2, 1), + 1, + wl_output::Transform::Normal, + Some((2, 1)), + ) + .unwrap() + .with_desktop_backdrop_geometry(Some(backdrop)) + .with_known_output_count(Some(2)); + source.set_active_output(None, Some(7)); + source.set_active_geometry(Some(geometry.clone())); + source.set_pending_output_image( + crate::backend::wayland::frozen::FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![7; 8], + }, + 7, + geometry.clone(), + ); + let mut input = crate::input::state::test_support::make_test_input_state(); + assert!(source.activate_pending_image(2, 1, &mut input).unwrap()); + let context = CaptureLayoutContext::for_desktop(7, &source); + let direct_provenance = source.image_provenance().unwrap(); + + assert!(context.matches_desktop(Some(7), &source)); + assert!(!context.matches_desktop(Some(8), &source)); + + backdrop.crop_x = Some(1); + source.set_active_geometry(Some( + geometry.with_desktop_backdrop_geometry(Some(backdrop)), + )); + + assert!(source.source_context_matches(direct_provenance)); + assert!(!context.matches_desktop(Some(7), &source)); + } } diff --git a/src/backend/wayland/capture_preflight.rs b/src/backend/wayland/capture_preflight.rs index 62e283bc..62048be4 100644 --- a/src/backend/wayland/capture_preflight.rs +++ b/src/backend/wayland/capture_preflight.rs @@ -1,4 +1,88 @@ //! Capture admission and the layout identity retained through fallback. +use std::time::{Duration, Instant}; + +use super::frozen_geometry::OutputGeometry; + +const PORTAL_LAYOUT_QUIET_PERIOD: Duration = Duration::from_millis(100); +const PORTAL_LAYOUT_SETTLE_TIMEOUT: Duration = Duration::from_secs(1); +const INCOMPLETE_LAYOUT_POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// Direct/installed pixels depend on the active viewport; desktop captures also +/// depend on other outputs and the screenshot's crop bounds. +#[derive(Debug, Clone, Copy, Default)] +pub(super) struct CaptureLayoutGenerations { + pub(super) active_output: u64, + pub(super) desktop: u64, +} + +impl CaptureLayoutGenerations { + pub(super) fn update( + &mut self, + before: Option<&OutputGeometry>, + after: Option<&OutputGeometry>, + ) { + if before != after { + self.desktop = self.desktop.wrapping_add(1); + } + if !OutputGeometry::same_active_output(before, after) { + self.active_output = self.active_output.wrapping_add(1); + } + } +} + +/// Failure category survives until the domain owner publishes its terminal. +/// Display text is produced only for diagnostics and the user-facing report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum CapturePreflightError { + StaleLayout, + LayoutDidNotSettle, + LostSuppression, + Backend(String), +} + +impl CapturePreflightError { + pub(super) fn message(&self, domain: &str) -> String { + match self { + Self::StaleLayout => format!("{domain} failed after the display layout changed"), + Self::LayoutDidNotSettle => { + format!("{domain} failed because the display layout did not settle") + } + Self::LostSuppression => "Capture retry lost overlay suppression".to_string(), + Self::Backend(message) => message.clone(), + } + } + + /// Layout churn that outlasted the settling bound is still a layout-change + /// terminal, not a backend failure that owners may route to a fallback. + pub(super) fn is_stale_layout(&self) -> bool { + matches!(self, Self::StaleLayout | Self::LayoutDidNotSettle) + } + + pub(super) fn from_backend(error: anyhow::Error) -> Self { + match error.downcast::() { + Ok(error) => error, + Err(error) => Self::Backend(error.to_string()), + } + } +} + +impl std::fmt::Display for CapturePreflightError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message("Screen capture")) + } +} + +impl std::error::Error for CapturePreflightError {} + +impl From for CapturePreflightError { + fn from(error: PortalRetryError) -> Self { + match error { + PortalRetryError::OutputChanged => Self::StaleLayout, + PortalRetryError::LayoutDidNotSettle => Self::LayoutDidNotSettle, + } + } +} + #[derive(Debug, Clone, Copy)] pub(super) struct CaptureLayout { output_id: Option, @@ -14,6 +98,7 @@ pub(super) enum CapturePreflight { layout: CaptureLayout, }, Capturing { + backend: B, layout: CaptureLayout, }, } @@ -37,15 +122,36 @@ impl CapturePreflight { let Self::Pending { backend, layout } = *self else { return None; }; - *self = Self::Capturing { layout }; + + *self = Self::Capturing { backend, layout }; + Some(backend) } + pub(super) fn backend(&self) -> Option { + match *self { + Self::Idle => None, + Self::Pending { backend, .. } | Self::Capturing { backend, .. } => Some(backend), + } + } + + pub(super) fn changed_on_output(&self, output_id: Option, generation: u64) -> bool { + let layout = match self { + Self::Idle => return false, + Self::Pending { layout, .. } | Self::Capturing { layout, .. } => layout, + }; + + layout.output_id.is_some() + && layout.output_id == output_id + && layout.generation != generation + } + pub(super) fn layout_matches(&self, output_id: Option, generation: u64) -> bool { let layout = match self { Self::Idle => return true, - Self::Pending { layout, .. } | Self::Capturing { layout } => layout, + Self::Pending { layout, .. } | Self::Capturing { layout, .. } => layout, }; + super::portal_capture::layout_token_matches( layout.output_id, layout.generation, @@ -53,12 +159,223 @@ impl CapturePreflight { generation, ) } + + pub(super) fn ensure_layout_current( + &self, + output_id: Option, + generation: u64, + ) -> Result<(), CapturePreflightError> { + if self.layout_matches(output_id, generation) { + Ok(()) + } else { + Err(CapturePreflightError::StaleLayout) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PortalRetryError { + OutputChanged, + LayoutDidNotSettle, +} + +/// One retry retained by the original request while its portal result is discarded. +#[derive(Debug, Clone, Copy, Default)] +pub(super) enum PortalLayoutRetry { + #[default] + Available, + Pending { + output_id: u32, + generation: u64, + not_before: Instant, + deadline: Instant, + }, + Spent, +} + +impl PortalLayoutRetry { + pub(super) fn schedule( + &mut self, + captured: Option, + active: Option, + layout_changed: bool, + generation: u64, + now: Instant, + ) -> bool { + let Some(output_id) = captured else { + return false; + }; + if !matches!(self, Self::Available) || active != captured || !layout_changed { + return false; + } + + *self = Self::Pending { + output_id, + generation, + not_before: now + PORTAL_LAYOUT_QUIET_PERIOD, + deadline: now + PORTAL_LAYOUT_SETTLE_TIMEOUT, + }; + + true + } + + pub(super) fn is_pending(&self) -> bool { + matches!(self, Self::Pending { .. }) + } + + pub(super) fn timeout(&self, now: Instant) -> Option { + let Self::Pending { + not_before, + deadline, + .. + } = *self + else { + return None; + }; + + Some(not_before.min(deadline).saturating_duration_since(now)) + } + + /// Admit only a complete snapshot after a quiet period. Output events can + /// arrive in multiple batches; a deadline bounds metadata that never settles. + pub(super) fn take_ready( + &mut self, + active_output: Option, + active_generation: u64, + geometry: Option<&OutputGeometry>, + now: Instant, + ) -> Result, PortalRetryError> { + let Self::Pending { + output_id, + generation, + not_before, + deadline, + } = self + else { + return Ok(None); + }; + if active_output != Some(*output_id) { + *self = Self::Spent; + return Err(PortalRetryError::OutputChanged); + } + // Admission itself is bounded, even if dispatch wakes late after the + // geometry's quiet period. Never start a new barrier after this deadline. + if now >= *deadline { + *self = Self::Spent; + return Err(PortalRetryError::LayoutDidNotSettle); + } + + if active_generation != *generation { + *generation = active_generation; + *not_before = now + PORTAL_LAYOUT_QUIET_PERIOD; + } + if now < *not_before { + return Ok(None); + } + if !geometry.is_some_and(OutputGeometry::portal_layout_is_complete) { + *not_before = now + INCOMPLETE_LAYOUT_POLL_INTERVAL; + return Ok(None); + } + + let output_id = *output_id; + *self = Self::Spent; + Ok(Some(output_id)) + } } #[cfg(test)] mod tests { use super::*; + fn geometry() -> OutputGeometry { + OutputGeometry::update_from( + Some((0, 0)), + Some((2, 1)), + (2, 1), + 1, + wayland_client::protocol::wl_output::Transform::Normal, + Some((2, 1)), + ) + .unwrap() + .with_known_output_count(Some(1)) + } + + #[test] + fn retry_waits_for_both_output_update_batches_and_spends_the_budget_once() { + let now = Instant::now(); + let geometry = geometry(); + let mut retry = PortalLayoutRetry::default(); + assert!(retry.schedule(Some(1), Some(1), true, 4, now)); + + assert_eq!(retry.timeout(now), Some(Duration::from_millis(100))); + assert_eq!( + retry.take_ready(Some(1), 4, Some(&geometry), now + Duration::from_millis(99)), + Ok(None) + ); + assert_eq!( + retry.take_ready( + Some(1), + 5, + Some(&geometry), + now + Duration::from_millis(100) + ), + Ok(None) + ); + assert_eq!( + retry.timeout(now + Duration::from_millis(100)), + Some(Duration::from_millis(100)) + ); + assert_eq!( + retry.take_ready( + Some(1), + 5, + Some(&geometry), + now + Duration::from_millis(199) + ), + Ok(None) + ); + assert_eq!( + retry.take_ready( + Some(1), + 5, + Some(&geometry), + now + Duration::from_millis(200) + ), + Ok(Some(1)) + ); + assert_eq!(retry.timeout(now + Duration::from_millis(200)), None); + assert!(!retry.schedule(Some(1), Some(1), true, 6, now)); + } + + #[test] + fn incomplete_topology_wait_is_paced_and_bounded() { + let now = Instant::now(); + let incomplete = geometry().with_known_output_count(Some(2)); + let mut retry = PortalLayoutRetry::default(); + assert!(retry.schedule(Some(1), Some(1), true, 4, now)); + + assert_eq!( + retry.take_ready( + Some(1), + 4, + Some(&incomplete), + now + Duration::from_millis(100) + ), + Ok(None) + ); + assert_eq!( + retry.timeout(now + Duration::from_millis(100)), + Some(Duration::from_millis(50)) + ); + assert!( + retry + .take_ready(Some(1), 4, Some(&incomplete), now + Duration::from_secs(1)) + .is_err() + ); + assert!(!retry.is_pending()); + assert_eq!(retry.timeout(now + Duration::from_secs(1)), None); + } + #[test] fn dispatch_retains_layout_until_reset() { let mut phase = CapturePreflight::default(); diff --git a/src/backend/wayland/frozen/AGENTS.md b/src/backend/wayland/frozen/AGENTS.md index 33744e35..55f7ee84 100644 --- a/src/backend/wayland/frozen/AGENTS.md +++ b/src/backend/wayland/frozen/AGENTS.md @@ -7,11 +7,13 @@ - `capture.rs` coordinates frozen capture setup. - `image.rs` owns frozen image data handling. - `portal.rs` provides portal-based fallback behavior. -- `state.rs` tracks frozen-mode state. +- `state.rs` tracks frozen-mode state and acquisition ownership. `state/activation.rs` validates and installs completed output images and owns their resize, unfreeze, and cancellation lifecycle; `state/direct.rs` owns direct attempt resources and deadlines. +- `retry.rs` retains the original Freeze acquisition through the shared portal settling policy and fresh preflight. ## Invariants - Preserve overlay-hide, capture, restore, and render ordering. - Treat user cancellation as cancellation, not a hard failure. +- Portal crop and resampling run in the worker through `../portal_raster.rs`. Activation checks identity, layout, topology, and native dimensions before installation. - Keep coordinate and scale handling aligned with `src/backend/wayland/frozen_geometry.rs`, zoom behavior, and render state. ## Coupled Changes diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs index 487fb4f3..113c6543 100644 --- a/src/backend/wayland/frozen/capture.rs +++ b/src/backend/wayland/frozen/capture.rs @@ -91,13 +91,14 @@ impl FrozenState { } self.capture_done = false; + self.layout_retry = Default::default(); let backend = self .preferred_backend() .context("no frozen capture backend is available")?; self.preflight.begin( backend, self.active_output_id, - self.output_layout_generation, + self.capture_layout_generation(backend), ); Ok(()) } @@ -248,7 +249,7 @@ impl FrozenState { self.direct_capture = Some(DirectCaptureAttempt::WlrScreencopy { session: Box::new(capture), context: DirectCaptureContext::new( - CaptureLayoutContext::new(target_output_id, self.output_layout_generation), + CaptureLayoutContext::new(target_output_id, self.layout_generations.active_output), source_geometry, ), }); @@ -420,7 +421,7 @@ impl FrozenState { let image = result?; if !context .layout - .matches(self.active_output_id, self.output_layout_generation) + .matches(self.active_output_id, self.layout_generations.active_output) { return Ok(false); } diff --git a/src/backend/wayland/frozen/ext_image_copy.rs b/src/backend/wayland/frozen/ext_image_copy.rs index 48f08d3e..39b7d07a 100644 --- a/src/backend/wayland/frozen/ext_image_copy.rs +++ b/src/backend/wayland/frozen/ext_image_copy.rs @@ -180,7 +180,7 @@ impl FrozenState { self.direct_capture = Some(DirectCaptureAttempt::ExtImageCopy { session: Box::new(ExtImageCopySession::new(source, session, pool)), context: DirectCaptureContext::new( - CaptureLayoutContext::new(target_output_id, self.output_layout_generation), + CaptureLayoutContext::new(target_output_id, self.layout_generations.active_output), source_geometry, ), }); @@ -405,7 +405,7 @@ impl FrozenState { (*capture).destroy(); if !context .layout - .matches(self.active_output_id, self.output_layout_generation) + .matches(self.active_output_id, self.layout_generations.active_output) { return Ok(false); } diff --git a/src/backend/wayland/frozen/mod.rs b/src/backend/wayland/frozen/mod.rs index 0d068417..fb34f5f5 100644 --- a/src/backend/wayland/frozen/mod.rs +++ b/src/backend/wayland/frozen/mod.rs @@ -2,6 +2,7 @@ mod capture; mod ext_image_copy; mod image; mod portal; +mod retry; mod state; use wayland_client::protocol::wl_output; @@ -41,12 +42,14 @@ pub(in crate::backend::wayland) use image::{copy_shm_argb, validate_shm_buffer_l pub(in crate::backend::wayland) use state::FrozenCaptureBackend; pub use state::FrozenState; +// Keep raster rejection beside the capture snapshot so layout validation runs +// before interpreting a size mismatch. Portal and decode failures remain terminal. type PortalCaptureResult = Result< ( Option, u64, Option, - self::image::FrozenImage, + Result, ), crate::capture::types::CaptureError, >; diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index 7304b254..2ec2f854 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -4,6 +4,7 @@ use log::warn; use std::time::{Duration, Instant}; use crate::backend::wayland::acquisition::ScreenAcquisitionOutcome; +#[cfg(test)] use crate::backend::wayland::frozen::FrozenImage; use crate::backend::wayland::frozen_geometry::require_verified_capture_source; use crate::backend::wayland::portal_capture::{ @@ -37,7 +38,7 @@ impl FrozenState { ) .map_err(anyhow::Error::msg)?; - let layout_generation = self.output_layout_generation; + let layout_generation = self.layout_generations.desktop; // Notify user that portal fallback is in progress crate::notification::send_notification_async( tokio_handle, @@ -54,16 +55,17 @@ impl FrozenState { CaptureError::ImageError(format!("Decode failed: {error}")) })?; + let image = crate::backend::wayland::portal_raster::crop_portal_raster( + data, + width, + height, + &source_geometry, + ); Ok(( Some(target_output_id), layout_generation, Some(source_geometry), - FrozenImage { - width, - height, - stride: (width * 4) as i32, - data, - }, + image, )) } .await @@ -73,7 +75,12 @@ impl FrozenState { } /// Check for completed portal capture and apply result if present. - pub fn poll_portal_capture(&mut self, input_state: &mut InputState, now: Instant) { + pub fn poll_portal_capture( + &mut self, + input_state: &mut InputState, + now: Instant, + live_output_count: Option, + ) { if !self.portal.is_running() { return; } @@ -104,26 +111,14 @@ impl FrozenState { let poll = self.portal.poll(); match poll { PortalPoll::Ready(Ok((target_output, layout_generation, source_geometry, image))) => { - let output_matches = portal_output_matches(target_output, self.active_output_id); - let layout_matches = layout_generation == self.output_layout_generation; - - if output_matches && layout_matches { - self.set_pending_desktop_image(image, target_output, source_geometry); - } else { - if !layout_matches { - warn!("Portal capture discarded after the output layout changed"); - } else { - warn!("Portal capture for inactive output discarded"); - } - if self.has_acquisition_attempt() { - self.finish_acquisition(ScreenAcquisitionOutcome::StaleLayout, input_state); - } else { - Self::push_stale_layout_toast(input_state); - self.capture_done = true; - } - } - - self.finish_portal_task(); + self.apply_portal_image( + target_output, + layout_generation, + source_geometry, + image, + input_state, + live_output_count, + ); } PortalPoll::Ready(Err(CaptureError::Cancelled(reason))) => { log::info!("Portal frozen capture cancelled: {reason}"); @@ -199,6 +194,72 @@ impl FrozenState { } } + fn apply_portal_image( + &mut self, + target_output: Option, + layout_generation: u64, + source_geometry: Option, + image: Result, + input_state: &mut InputState, + live_output_count: Option, + ) { + log::info!( + "portal.freeze captured_output={target_output:?} active_output={:?} captured_layout={layout_generation} active_layout={}", + self.active_output_id, + self.layout_generations.desktop + ); + let output_matches = portal_output_matches(target_output, self.active_output_id); + // SCTK can advertise a wl_output before its new_output callback + // refreshes geometry. Discard that snapshot before reading a crop error. + let topology_changed = source_geometry + .as_ref() + .is_some_and(|geometry| geometry.output_count_conflicts_with_live(live_output_count)); + let layout_matches = + layout_generation == self.layout_generations.desktop && !topology_changed; + + if output_matches && layout_matches { + match image { + Ok(image) => self.set_pending_portal_image(image, target_output, source_geometry), + Err(err) => { + warn!("Portal frozen raster rejected: {err}"); + if self.has_acquisition_attempt() { + self.finish_preflight_failure( + crate::backend::wayland::capture_preflight::CapturePreflightError::Backend( + err.to_string(), + ), + input_state, + ); + } else { + input_state.push_toast( + ToastPriority::Critical, + "freeze", + Toast::error(err.to_string()), + ); + input_state.set_frozen_active(false); + self.capture_done = true; + } + } + } + } else { + if !layout_matches { + warn!("Portal capture discarded after the output layout changed"); + } else { + warn!("Portal capture for inactive output discarded"); + } + if self.queue_portal_layout_retry(target_output, !layout_matches) { + return; + } + if self.has_acquisition_attempt() { + self.finish_acquisition(ScreenAcquisitionOutcome::StaleLayout, input_state); + } else { + Self::push_stale_layout_toast(input_state); + self.capture_done = true; + } + } + + self.finish_portal_task(); + } + pub fn portal_timeout(&self, now: Instant) -> Option { self.portal.timeout(now) } @@ -242,6 +303,7 @@ mod tests { screenshot_origin: Some(origin), screenshot_size: None, known_output_count: None, + portal_outputs: None, } } @@ -250,7 +312,7 @@ mod tests { input: &mut InputState, ) -> anyhow::Result<()> { for _ in 0..100 { - frozen.poll_portal_capture(input, Instant::now()); + frozen.poll_portal_capture(input, Instant::now(), None); if !frozen.portal.is_running() { return Ok(()); } @@ -259,6 +321,37 @@ mod tests { anyhow::bail!("frozen portal task did not finish") } + #[test] + fn live_topology_is_checked_before_a_crop_error() { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); + let mut input = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let owner = ScreenAcquisitionOwner::Ocr; + let id = registry.request(owner).unwrap(); + let geometry = crop_geometry((0, 0)).with_known_output_count(Some(1)); + frozen.set_active_output(None, Some(1)); + frozen.set_active_geometry(Some(geometry.clone())); + frozen.start_capture_for(id, owner).unwrap(); + frozen.take_preflight_pending(); + let generation = frozen.layout_generations.desktop; + + frozen.apply_portal_image( + Some(1), + generation, + Some(geometry), + Err(CaptureError::ImageError("stale raster size".to_string())), + &mut input, + Some(2), + ); + + assert!(frozen.has_portal_layout_retry()); + assert!(frozen.has_acquisition_attempt()); + assert!(!frozen.has_pending_image()); + assert!(frozen.take_acquisition_completion().is_none()); + assert!(!frozen.take_capture_done()); + } + #[tokio::test] async fn portal_start_requires_verifiable_geometry_and_output_identity() -> anyhow::Result<()> { let wake = crate::backend::wayland::RuntimeWakeSource::new()?; @@ -291,7 +384,7 @@ mod tests { frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async { Ok((Some(1), 0, Some(crop_geometry((0, 0))), image(0))) }, + async { Ok((Some(1), 0, Some(crop_geometry((0, 0))), Ok(image(0)))) }, )); poll_until_finished(&mut frozen, &mut input).await?; @@ -358,7 +451,7 @@ mod tests { PortalTask::disconnected_for_test(now) }); - frozen.poll_portal_capture(&mut input, now); + frozen.poll_portal_capture(&mut input, now, None); assert!(!frozen.is_in_progress()); assert!(!frozen.portal.is_running()); @@ -436,7 +529,7 @@ mod tests { frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async { Ok((Some(1), 0, None, image(9))) }, + async { Ok((Some(1), 0, None, Ok(image(9)))) }, )); poll_until_finished(&mut frozen, &mut input).await?; @@ -454,11 +547,11 @@ mod tests { let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); frozen.set_active_geometry(Some(crop_geometry((0, 0)))); - let layout_generation = frozen.output_layout_generation; + let layout_generation = frozen.layout_generations.desktop; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async move { Ok((None, layout_generation, None, image(9))) }, + async move { Ok((None, layout_generation, None, Ok(image(9)))) }, )); frozen.set_active_geometry(Some(crop_geometry((6, 0)))); @@ -478,11 +571,11 @@ mod tests { let mut input = make_test_input_state(); let geometry = crop_geometry((0, 0)); frozen.set_active_geometry(Some(geometry.clone())); - let layout_generation = frozen.output_layout_generation; + let layout_generation = frozen.layout_generations.desktop; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async move { Ok((None, layout_generation, None, image(0))) }, + async move { Ok((None, layout_generation, None, Ok(image(0)))) }, )); frozen.set_active_geometry(Some(geometry)); diff --git a/src/backend/wayland/frozen/retry.rs b/src/backend/wayland/frozen/retry.rs new file mode 100644 index 00000000..e1cec89c --- /dev/null +++ b/src/backend/wayland/frozen/retry.rs @@ -0,0 +1,336 @@ +use super::state::{FrozenCaptureBackend, FrozenState}; +use crate::backend::wayland::capture_preflight::CapturePreflightError; +use std::time::{Duration, Instant}; + +impl FrozenState { + pub(super) fn queue_portal_layout_retry( + &mut self, + captured: Option, + layout_changed: bool, + ) -> bool { + if !self.layout_retry.schedule( + captured, + self.active_output_id, + layout_changed, + self.layout_generations.desktop, + Instant::now(), + ) { + return false; + } + + self.portal.finish(); + self.discard_pending_image_for_retry(); + self.capture_done = false; + log::info!( + "portal.freeze phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", + self.layout_generations.desktop + ); + + true + } + + pub(in crate::backend::wayland) fn retry_stale_portal_preflight( + &mut self, + backend: FrozenCaptureBackend, + ) -> bool { + let portal_layout_changed = backend == FrozenCaptureBackend::Portal + && self + .preflight + .changed_on_output(self.active_output_id, self.layout_generations.desktop); + self.queue_portal_layout_retry(self.active_output_id, portal_layout_changed) + } + + pub(in crate::backend::wayland) fn has_portal_layout_retry(&self) -> bool { + self.layout_retry.is_pending() + } + + pub(in crate::backend::wayland) fn portal_layout_retry_timeout( + &self, + now: Instant, + ) -> Option { + self.layout_retry.timeout(now) + } + + /// Called only after the runtime refreshes all output geometry. + pub(in crate::backend::wayland) fn restart_portal_preflight( + &mut self, + now: Instant, + ) -> Result { + let Some(output_id) = self + .layout_retry + .take_ready( + self.active_output_id, + self.layout_generations.desktop, + self.active_geometry.as_ref(), + now, + ) + .map_err(CapturePreflightError::from)? + else { + return Ok(false); + }; + + self.preflight.begin( + FrozenCaptureBackend::Portal, + Some(output_id), + self.layout_generations.desktop, + ); + log::info!( + "portal.freeze phase=retry-preflight output={output_id} layout={}", + self.layout_generations.desktop + ); + + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::acquisition::{ + ScreenAcquisitionOutcome, ScreenAcquisitionOwner, ScreenAcquisitionRegistry, + }; + use crate::backend::wayland::frozen::FrozenImage; + use crate::backend::wayland::frozen_geometry::OutputGeometry; + use crate::backend::wayland::portal_task::PortalTask; + use crate::capture::CaptureError; + use crate::input::state::test_support::make_test_input_state; + use wayland_client::protocol::wl_output; + + fn geometry(x: i32) -> OutputGeometry { + OutputGeometry::update_from( + Some((x, 0)), + Some((2, 1)), + (2, 1), + 1, + wl_output::Transform::Normal, + Some((2, 1)), + ) + .unwrap() + .with_known_output_count(Some(1)) + } + + async fn drain(frozen: &mut FrozenState, input: &mut crate::input::InputState) { + for _ in 0..100 { + frozen.poll_portal_capture(input, Instant::now(), None); + if !frozen.portal.is_running() { + return; + } + tokio::task::yield_now().await; + } + + panic!("portal task did not finish"); + } + + #[tokio::test] + async fn retry_retains_the_acquisition_and_completes_once() { + for second_change in [false, true] { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); + let mut input = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let owner = ScreenAcquisitionOwner::Ocr; + let id = registry.request(owner).unwrap(); + frozen.set_active_output(None, Some(1)); + frozen.set_active_geometry(Some(geometry(0))); + frozen.start_capture_for(id, owner).unwrap(); + assert_eq!( + frozen.take_preflight_pending(), + Some(FrozenCaptureBackend::Portal) + ); + + let old_generation = frozen.layout_generations.desktop; + frozen.portal.start(PortalTask::spawn( + &tokio::runtime::Handle::current(), + wake.handle(), + async move { + Ok(( + Some(1), + old_generation, + Some(geometry(0)), + Err(CaptureError::ImageError("stale size".to_string())), + )) + }, + )); + + frozen.set_active_geometry(Some(geometry(8))); + + drain(&mut frozen, &mut input).await; + + assert!(frozen.has_portal_layout_retry()); + assert!(frozen.has_acquisition_attempt()); + assert!(!frozen.take_capture_done()); + assert!(frozen.take_acquisition_completion().is_none()); + assert!( + frozen + .restart_portal_preflight(Instant::now() + Duration::from_millis(150)) + .unwrap() + ); + assert_eq!( + frozen.take_preflight_pending(), + Some(FrozenCaptureBackend::Portal) + ); + + let fresh_generation = frozen.layout_generations.desktop; + frozen.portal.start(PortalTask::spawn( + &tokio::runtime::Handle::current(), + wake.handle(), + async move { + Ok(( + Some(1), + fresh_generation, + Some(geometry(8)), + Ok(FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![9; 8], + }), + )) + }, + )); + + if second_change { + frozen.set_active_geometry(Some(geometry(16))); + } + + drain(&mut frozen, &mut input).await; + if !second_change { + assert!(frozen.activate_pending_image(2, 1, &mut input).unwrap()); + assert_eq!(frozen.image().unwrap().data, vec![9; 8]); + assert!(input.frozen_active()); + } + + let terminal = frozen.take_acquisition_completion().unwrap(); + assert_eq!((terminal.id, terminal.owner), (id, owner)); + assert!(if second_change { + terminal.outcome == ScreenAcquisitionOutcome::StaleLayout + } else { + matches!(terminal.outcome, ScreenAcquisitionOutcome::Ready { .. }) + }); + assert!(!frozen.has_portal_layout_retry()); + assert!(!frozen.has_acquisition_attempt()); + assert!(frozen.take_capture_done()); + assert!(frozen.take_acquisition_completion().is_none()); + } + } + + #[test] + fn activation_rechecks_layout_and_topology_before_installing_a_portal_crop() { + for live_topology_changed in [false, true] { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); + let mut input = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let owner = ScreenAcquisitionOwner::Ocr; + let id = registry.request(owner).unwrap(); + frozen.set_active_output(None, Some(1)); + frozen.set_active_geometry(Some(geometry(0))); + frozen.start_capture_for(id, owner).unwrap(); + frozen.take_preflight_pending(); + frozen.set_pending_portal_image( + FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![9; 8], + }, + Some(1), + Some(geometry(0)), + ); + if !live_topology_changed { + frozen.set_active_geometry(Some(geometry(8))); + } + + assert!( + !frozen + .activate_pending_image_with_live_outputs( + 2, + 1, + &mut input, + Some(if live_topology_changed { 2 } else { 1 }) + ) + .unwrap() + ); + + assert!(frozen.has_portal_layout_retry()); + assert!(frozen.has_acquisition_attempt()); + assert!(frozen.take_acquisition_completion().is_none()); + assert!(!frozen.take_capture_done()); + assert!(!frozen.has_pending_image()); + assert!(frozen.image().is_none()); + assert!(!input.frozen_active()); + } + } + + #[tokio::test] + async fn cancellation_and_stable_raster_failure_do_not_schedule_retry() { + for cancelled in [false, true] { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); + let mut input = make_test_input_state(); + let mut registry = ScreenAcquisitionRegistry::default(); + let owner = ScreenAcquisitionOwner::Ocr; + let id = registry.request(owner).unwrap(); + frozen.set_active_output(None, Some(1)); + frozen.set_active_geometry(Some(geometry(0))); + frozen.start_capture_for(id, owner).unwrap(); + frozen.take_preflight_pending(); + let generation = frozen.layout_generations.desktop; + frozen.portal.start(PortalTask::spawn( + &tokio::runtime::Handle::current(), + wake.handle(), + async move { + if cancelled { + Err(CaptureError::Cancelled("dismissed".to_string())) + } else { + Ok(( + Some(1), + generation, + Some(geometry(0)), + Err(CaptureError::ImageError("stable wrong size".to_string())), + )) + } + }, + )); + if cancelled { + frozen.set_active_geometry(Some(geometry(8))); + } + + drain(&mut frozen, &mut input).await; + + assert!(!frozen.has_portal_layout_retry()); + assert!(!frozen.is_in_progress()); + + let terminal = frozen.take_acquisition_completion().unwrap(); + assert_eq!((terminal.id, terminal.owner), (id, owner)); + assert!(if cancelled { + terminal.outcome == ScreenAcquisitionOutcome::Cancelled + } else { + matches!(terminal.outcome, ScreenAcquisitionOutcome::Failed(_)) + }); + assert!(frozen.take_acquisition_completion().is_none()); + } + } + + #[test] + fn cancel_clears_a_retry_and_an_output_switch_cannot_redirect_it() { + for switch_output in [false, true] { + let mut frozen = FrozenState::new(None); + let mut input = make_test_input_state(); + frozen.set_active_output(None, Some(1)); + frozen.set_active_geometry(Some(geometry(0))); + assert!(frozen.queue_portal_layout_retry(Some(1), true)); + if switch_output { + frozen.set_active_output(None, Some(2)); + + assert!(frozen.restart_portal_preflight(Instant::now()).is_err()); + } + + frozen.cancel(&mut input); + + assert!(!frozen.has_portal_layout_retry()); + assert!(!frozen.is_in_progress()); + assert!(!frozen.restart_portal_preflight(Instant::now()).unwrap()); + } + } +} diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index feabc377..aad3e991 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -1,5 +1,11 @@ -use crate::backend::wayland::capture_preflight::CapturePreflight; -use log::info; +mod activation; +mod direct; + +pub(super) use direct::{DirectCaptureAttempt, DirectCaptureContext}; + +use crate::backend::wayland::capture_preflight::{ + CaptureLayoutGenerations, CapturePreflight, CapturePreflightError, PortalLayoutRetry, +}; use std::sync::Arc; use std::time::{Duration, Instant}; use wayland_client::protocol::wl_output; @@ -10,17 +16,14 @@ use crate::backend::wayland::acquisition::{ ScreenAcquisitionCompletion, ScreenAcquisitionId, ScreenAcquisitionOutcome, ScreenAcquisitionOwner, }; -use crate::backend::wayland::capture::CaptureLayoutContext; use crate::backend::wayland::frozen::{FrozenImage, ScreenImageProvenance}; use crate::backend::wayland::frozen_geometry::OutputGeometry; -use crate::backend::wayland::portal_capture::{crop_argb, layout_token_matches}; use crate::backend::wayland::portal_task::PortalOperation; use crate::input::InputState; use crate::input::state::{Toast, ToastPriority}; use super::PortalCaptureResult; -use super::capture::CaptureSession; -use super::ext_image_copy::{ExtImageCopyManagers, ExtImageCopySession}; +use super::ext_image_copy::ExtImageCopyManagers; struct PendingFrozenImage { image: FrozenImage, @@ -34,7 +37,7 @@ struct PendingFrozenImage { #[derive(Clone, Copy)] enum FrozenCaptureSource { ActiveOutput, - Desktop, + Portal { desktop_generation: u64 }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -44,72 +47,6 @@ pub(in crate::backend::wayland) enum FrozenCaptureBackend { Portal, } -pub(super) enum DirectCaptureAttempt { - WlrScreencopy { - session: Box, - context: DirectCaptureContext, - }, - ExtImageCopy { - session: Box, - context: DirectCaptureContext, - }, -} - -impl DirectCaptureAttempt { - fn backend(&self) -> FrozenCaptureBackend { - match self { - Self::WlrScreencopy { .. } => FrozenCaptureBackend::WlrScreencopy, - Self::ExtImageCopy { .. } => FrozenCaptureBackend::ExtImageCopy, - } - } - - fn context(&self) -> &DirectCaptureContext { - match self { - Self::WlrScreencopy { context, .. } | Self::ExtImageCopy { context, .. } => context, - } - } - - fn destroy(self) { - match self { - Self::WlrScreencopy { session, .. } => session.frame.destroy(), - Self::ExtImageCopy { session, .. } => (*session).destroy(), - } - } -} - -pub(super) const DIRECT_CAPTURE_TIMEOUT: Duration = Duration::from_secs(3); - -pub(super) struct DirectCaptureContext { - pub(super) layout: CaptureLayoutContext, - pub(super) source_geometry: OutputGeometry, - started_at: Instant, -} - -impl DirectCaptureContext { - pub(super) fn new(layout: CaptureLayoutContext, source_geometry: OutputGeometry) -> Self { - Self::new_at(layout, source_geometry, Instant::now()) - } - - fn new_at( - layout: CaptureLayoutContext, - source_geometry: OutputGeometry, - started_at: Instant, - ) -> Self { - Self { - layout, - source_geometry, - started_at, - } - } - - fn timeout(&self, now: Instant) -> Duration { - self.started_at - .checked_add(DIRECT_CAPTURE_TIMEOUT) - .map(|deadline| deadline.saturating_duration_since(now)) - .unwrap_or(Duration::ZERO) - } -} - /// End-to-end controller for frozen mode capture and image storage. #[allow(clippy::type_complexity)] pub struct FrozenState { @@ -121,9 +58,8 @@ pub struct FrozenState { pub(super) active_output: Option, pub(super) active_output_id: Option, pub(super) active_geometry: Option, - /// Bumped when freeze/zoom crop geometry actually changes so in-flight - /// portal captures can be rejected after a layout change. - pub(super) output_layout_generation: u64, + /// Shared active-output and full-desktop validity generations. + pub(super) layout_generations: CaptureLayoutGenerations, pub(super) direct_capture: Option, pub(super) image: Option>, image_provenance: Option, @@ -133,6 +69,7 @@ pub struct FrozenState { pub(super) runtime_wake: Option, pub(super) preflight: CapturePreflight, pub(super) capture_done: bool, + pub(super) layout_retry: PortalLayoutRetry, pending_image: Option, acquisition_attempt: Option<(ScreenAcquisitionId, ScreenAcquisitionOwner)>, acquisition_completion: Option, @@ -187,7 +124,7 @@ impl FrozenState { active_output: None, active_output_id: None, active_geometry: None, - output_layout_generation: 0, + layout_generations: CaptureLayoutGenerations::default(), direct_capture: None, image: None, image_provenance: None, @@ -197,6 +134,7 @@ impl FrozenState { runtime_wake, preflight: CapturePreflight::Idle, capture_done: false, + layout_retry: PortalLayoutRetry::default(), pending_image: None, acquisition_attempt: None, acquisition_completion: None, @@ -232,14 +170,13 @@ impl FrozenState { } pub fn set_active_geometry(&mut self, geometry: Option) { - if self.active_geometry != geometry { - self.output_layout_generation = self.output_layout_generation.wrapping_add(1); - } + self.layout_generations + .update(self.active_geometry.as_ref(), geometry.as_ref()); self.active_geometry = geometry; } - pub(in crate::backend::wayland) fn output_layout_generation(&self) -> u64 { - self.output_layout_generation + pub(in crate::backend::wayland) fn desktop_layout_generation(&self) -> u64 { + self.layout_generations.desktop } pub(in crate::backend::wayland) fn source_context_matches( @@ -247,7 +184,7 @@ impl FrozenState { provenance: ScreenImageProvenance, ) -> bool { self.active_output_id == Some(provenance.output_id) - && self.output_layout_generation == provenance.output_layout_generation + && self.layout_generations.active_output == provenance.output_layout_generation } pub(in crate::backend::wayland) fn image_provenance(&self) -> Option { @@ -287,64 +224,12 @@ impl FrozenState { self.bump_image_generation(); } - pub fn set_pending_output_image( - &mut self, - image: FrozenImage, - target_output_id: u32, - source_geometry: OutputGeometry, - ) { - self.pending_image = Some(PendingFrozenImage { - image, - target_output_id: Some(target_output_id), - layout_generation: self.output_layout_generation, - source_geometry: Some(source_geometry), - output_transform: None, - source: FrozenCaptureSource::ActiveOutput, - }); - } - - pub(super) fn set_pending_output_image_with_transform( - &mut self, - image: FrozenImage, - target_output_id: u32, - source_geometry: OutputGeometry, - output_transform: Option, - ) { - self.pending_image = Some(PendingFrozenImage { - image, - target_output_id: Some(target_output_id), - layout_generation: self.output_layout_generation, - source_geometry: Some(source_geometry), - output_transform, - source: FrozenCaptureSource::ActiveOutput, - }); - } - - pub fn set_pending_desktop_image( - &mut self, - image: FrozenImage, - target_output_id: Option, - source_geometry: Option, - ) { - self.pending_image = Some(PendingFrozenImage { - image, - target_output_id, - layout_generation: self.output_layout_generation, - source_geometry, - output_transform: None, - source: FrozenCaptureSource::Desktop, - }); - } - - pub fn has_pending_image(&self) -> bool { - self.pending_image.is_some() - } - pub fn is_in_progress(&self) -> bool { self.direct_capture.is_some() || self.portal.is_running() || self.preflight.is_pending() || self.pending_image.is_some() + || self.layout_retry.is_pending() } pub(in crate::backend::wayland) fn take_preflight_pending( @@ -358,21 +243,29 @@ impl FrozenState { self.preflight.begin( FrozenCaptureBackend::Portal, self.active_output_id, - self.output_layout_generation, + self.layout_generations.desktop, ); } - pub(super) fn ensure_preflight_layout_current(&self) -> Result<(), String> { - if self - .preflight - .layout_matches(self.active_output_id, self.output_layout_generation) - { - Ok(()) - } else { - Err("Freeze failed after the display layout changed".to_string()) + pub(super) fn capture_layout_generation(&self, backend: FrozenCaptureBackend) -> u64 { + match backend { + FrozenCaptureBackend::Portal => self.layout_generations.desktop, + FrozenCaptureBackend::WlrScreencopy | FrozenCaptureBackend::ExtImageCopy => { + self.layout_generations.active_output + } } } + pub(super) fn ensure_preflight_layout_current(&self) -> Result<(), CapturePreflightError> { + self.preflight.ensure_layout_current( + self.active_output_id, + self.preflight + .backend() + .map(|backend| self.capture_layout_generation(backend)) + .unwrap_or(self.layout_generations.active_output), + ) + } + #[cfg(test)] pub fn preflight_layout_is_current(&self) -> bool { self.ensure_preflight_layout_current().is_ok() @@ -390,7 +283,10 @@ impl FrozenState { &mut self, input_state: &mut InputState, ) { - let stale_message = self.ensure_preflight_layout_current().err(); + let stale_message = self + .ensure_preflight_layout_current() + .err() + .map(|error| error.message("Freeze")); let message = stale_message .clone() .unwrap_or_else(|| "Freeze could not capture the screen.".to_string()); @@ -409,14 +305,20 @@ impl FrozenState { pub(in crate::backend::wayland) fn finish_preflight_failure( &mut self, - message: String, + error: CapturePreflightError, input_state: &mut InputState, ) { - let outcome = if self.ensure_preflight_layout_current().is_err() { + if !self.has_acquisition_attempt() { + self.abandon_acquisition(input_state); + return; + } + + let outcome = if error.is_stale_layout() { ScreenAcquisitionOutcome::StaleLayout } else { - ScreenAcquisitionOutcome::Failed(message) + ScreenAcquisitionOutcome::Failed(error.message("Freeze")) }; + self.finish_acquisition(outcome, input_state); } @@ -445,6 +347,7 @@ impl FrozenState { } fn finish_attempt_resources(&mut self) { + self.layout_retry = PortalLayoutRetry::default(); if let Some(capture) = self.direct_capture.take() { capture.destroy(); } @@ -553,226 +456,6 @@ impl FrozenState { capture.destroy(); Some(backend) } - - #[cfg(test)] - pub fn activate_pending_image( - &mut self, - phys_width: u32, - phys_height: u32, - input_state: &mut InputState, - ) -> Result { - self.activate_pending_image_with_live_outputs(phys_width, phys_height, input_state, None) - } - - pub fn activate_pending_image_with_live_outputs( - &mut self, - phys_width: u32, - phys_height: u32, - input_state: &mut InputState, - live_output_count: Option, - ) -> Result { - let Some(pending) = self.pending_image.take() else { - return Ok(false); - }; - if !layout_token_matches( - pending.target_output_id, - pending.layout_generation, - self.active_output_id, - self.output_layout_generation, - ) { - return self.reject_pending_image( - input_state, - "Freeze failed after the display layout changed", - ); - } - let mut image = pending.image; - let mut provenance = None; - - if matches!(pending.source, FrozenCaptureSource::ActiveOutput) { - let Some(geometry) = pending.source_geometry.as_ref() else { - return self - .reject_pending_image(input_state, "Freeze capture geometry is unavailable"); - }; - let output_transform = pending.output_transform.unwrap_or(geometry.transform); - provenance = pending.target_output_id.and_then(|output_id| { - ScreenImageProvenance::new( - output_id, - pending.layout_generation, - geometry.scale, - output_transform, - ) - }); - image = match image.with_output_transform(output_transform) { - Ok(image) => image, - Err(error) => { - return self.reject_pending_image( - input_state, - format!("Freeze capture transform failed: {error}"), - ); - } - }; - if !geometry.accepts_transformed_pixel_size(image.width, image.height) { - return self.reject_pending_image( - input_state, - "Freeze capture dimensions do not match the active output", - ); - } - } - - if matches!(pending.source, FrozenCaptureSource::Desktop) { - let Some(geometry) = pending - .source_geometry - .as_ref() - .cloned() - .and_then(|geometry| geometry.with_revalidated_output_count(live_output_count)) - else { - return self.reject_pending_image( - input_state, - "Freeze failed after the output layout changed", - ); - }; - provenance = pending.target_output_id.and_then(|output_id| { - ScreenImageProvenance::new( - output_id, - pending.layout_generation, - geometry.scale, - geometry.transform, - ) - }); - let Some((capture_width, capture_height)) = geometry.verified_pixel_size() else { - return self.reject_pending_image( - input_state, - "Freeze failed after the display changed size", - ); - }; - let Some(cropped) = - self.crop_pending_image(image, &geometry, capture_width, capture_height) - else { - return self.reject_pending_image( - input_state, - "Freeze failed after the display changed size", - ); - }; - image = cropped; - } - - let Some(provenance) = provenance else { - return self.reject_pending_image( - input_state, - "Freeze capture source identity is unavailable", - ); - }; - - if !OutputGeometry::dimensions_have_compatible_aspect( - (image.width, image.height), - (phys_width, phys_height), - ) { - return self.reject_pending_image( - input_state, - "Freeze capture aspect does not match the overlay surface", - ); - } - - self.image_target_dimensions = Some((phys_width, phys_height)); - self.image = Some(Arc::new(image)); - self.image_provenance = Some(provenance); - self.bump_image_generation(); - input_state.set_frozen_active(true); - input_state.dirty_tracker.mark_full(); - input_state.needs_redraw = true; - self.finish_ready_acquisition(input_state); - // Legacy tests and late callbacks without an acquisition still use the - // capture-done wakeup as their resource-restoration boundary. - self.capture_done = true; - Ok(true) - } - - fn reject_pending_image( - &mut self, - input_state: &mut InputState, - error: impl Into, - ) -> Result { - let error = error.into(); - self.finish_acquisition(ScreenAcquisitionOutcome::Failed(error.clone()), input_state); - self.capture_done = true; - input_state.set_frozen_active(false); - input_state.needs_redraw = true; - Err(error) - } - - fn crop_pending_image( - &self, - image: FrozenImage, - geometry: &OutputGeometry, - target_width: u32, - target_height: u32, - ) -> Option { - if target_width == 0 || target_height == 0 { - return None; - } - let (origin_x, origin_y) = geometry.portal_crop_origin(image.width, image.height)?; - let (width, height, data) = crop_argb( - &image.data, - image.width, - image.height, - origin_x, - origin_y, - target_width, - target_height, - )?; - if width != target_width || height != target_height { - return None; - } - let stride = i32::try_from(target_width.checked_mul(4)?).ok()?; - Some(FrozenImage { - width: target_width, - height: target_height, - stride, - data, - }) - } - - /// Drop frozen image if the surface size no longer matches. - pub fn handle_resize( - &mut self, - phys_width: u32, - phys_height: u32, - input_state: &mut InputState, - ) { - if let Some(target_dimensions) = self.image_target_dimensions - && target_dimensions != (phys_width, phys_height) - { - info!("Surface resized; clearing frozen image"); - self.clear_image(); - input_state.set_frozen_active(false); - } - } - - /// Toggle unfreeze: drop the image and mark redraw. - pub fn unfreeze(&mut self, input_state: &mut InputState) { - self.clear_image(); - input_state.set_frozen_active(false); - input_state.dirty_tracker.mark_full(); - input_state.needs_redraw = true; - } - - pub fn cancel(&mut self, input_state: &mut InputState) { - self.abandon_acquisition(input_state); - } - - fn clear_image(&mut self) -> bool { - let had_image = self.image.take().is_some(); - self.image_provenance = None; - self.image_target_dimensions = None; - if had_image { - self.bump_image_generation(); - } - had_image - } - - fn bump_image_generation(&mut self) { - self.image_generation = self.image_generation.wrapping_add(1).max(1); - } } fn select_capture_backend( @@ -888,32 +571,6 @@ mod tests { ); } - #[test] - fn direct_capture_context_tracks_its_deadline_and_output_identity() { - let started_at = Instant::now(); - let geometry = OutputGeometry::update_from( - Some((0, 0)), - Some((1, 1)), - (1, 1), - 1, - wl_output::Transform::Normal, - Some((1, 1)), - ) - .expect("geometry"); - let capture = - DirectCaptureContext::new_at(CaptureLayoutContext::new(7, 3), geometry, started_at); - - assert_eq!(capture.timeout(started_at), DIRECT_CAPTURE_TIMEOUT); - assert_eq!( - capture.timeout(started_at + DIRECT_CAPTURE_TIMEOUT), - Duration::ZERO - ); - assert!(capture.layout.matches(Some(7), 3)); - assert!(!capture.layout.matches(Some(8), 3)); - assert!(!capture.layout.matches(None, 3)); - assert!(!capture.layout.matches(Some(7), 4)); - } - #[test] fn active_output_capture_accepts_native_fractional_scale_dimensions() { let mut state = FrozenState::new(None); @@ -1090,30 +747,6 @@ mod tests { assert!(!input_state.frozen_active()); } - #[test] - fn desktop_capture_still_requires_a_crop_covering_the_target() { - let mut state = FrozenState::new(None); - let mut input_state = make_test_input_state(); - state.set_pending_desktop_image( - FrozenImage { - width: 4, - height: 3, - stride: 16, - data: vec![0; 4 * 3 * 4], - }, - None, - None, - ); - - assert!( - state - .activate_pending_image(6, 4, &mut input_state) - .is_err() - ); - assert!(state.image().is_none()); - assert!(!input_state.frozen_active()); - } - fn desktop_geometry( logical_x: i32, logical_y: i32, @@ -1135,58 +768,53 @@ mod tests { screenshot_origin, screenshot_size: None, known_output_count: None, + portal_outputs: None, } } #[test] fn output_layout_generation_bumps_only_when_geometry_changes() { let mut state = FrozenState::new(None); - assert_eq!(state.output_layout_generation, 0); + assert_eq!(state.layout_generations.active_output, 0); let first = desktop_geometry(0, 0, 4, 1, 1, Some((0, 0))); state.set_active_geometry(Some(first.clone())); - assert_eq!(state.output_layout_generation, 1); + assert_eq!(state.layout_generations.active_output, 1); state.set_active_geometry(Some(first)); - assert_eq!(state.output_layout_generation, 1); + assert_eq!(state.layout_generations.active_output, 1); state.set_active_geometry(Some(desktop_geometry(0, 0, 4, 1, 1, Some((6, 0))))); - assert_eq!(state.output_layout_generation, 2); + assert_eq!(state.layout_generations.active_output, 1); + assert_eq!(state.layout_generations.desktop, 2); } #[test] - fn desktop_capture_crops_from_screenshot_origin_not_logical_times_scale() { + fn other_output_metadata_does_not_invalidate_a_direct_source() { let mut state = FrozenState::new(None); - let mut input_state = make_test_input_state(); + let mut input = make_test_input_state(); state.set_active_output(None, Some(7)); - let mut data = Vec::new(); - for pixel in 0u8..10 { - data.extend_from_slice(&[pixel, pixel, pixel, 255]); - } - let geometry = desktop_geometry(0, 0, 4, 1, 1, Some((6, 0))); - assert_eq!(geometry.physical_origin(), (0, 0)); - - state.set_pending_desktop_image( + let first = desktop_geometry(0, 0, 2, 1, 1, Some((0, 0))); + state.set_active_geometry(Some(first.clone())); + let generation = state.layout_generations.active_output; + let provenance = + ScreenImageProvenance::new(7, generation, 1, wl_output::Transform::Normal).unwrap(); + state.set_pending_output_image( FrozenImage { - width: 10, + width: 2, height: 1, - stride: 40, - data, + stride: 8, + data: vec![7; 8], }, - Some(7), - Some(geometry), + 7, + first.clone(), ); + let mut changed = first.with_known_output_count(Some(2)); + changed.screenshot_origin = Some((8, 0)); - state - .activate_pending_image(4, 1, &mut input_state) - .expect("mixed-scale screenshot origin should crop the active output"); + state.set_active_geometry(Some(changed)); - let image = state.image().expect("the frozen image should be active"); - assert_eq!( - state.image_provenance(), - ScreenImageProvenance::new(7, 0, 1, wl_output::Transform::Normal) - ); - assert_eq!( - image.data, - vec![6, 6, 6, 255, 7, 7, 7, 255, 8, 8, 8, 255, 9, 9, 9, 255] - ); + assert!(state.source_context_matches(provenance)); + assert!(state.activate_pending_image(2, 1, &mut input).unwrap()); + assert_eq!(state.image_provenance(), Some(provenance)); + assert!(state.layout_generations.desktop > generation); } #[test] @@ -1196,7 +824,7 @@ mod tests { state.set_active_output(None, Some(7)); let mut geometry = desktop_geometry(0, 0, 3, 2, 2, Some((0, 0))); geometry.pixel_size = Some((5, 3)); - state.set_pending_desktop_image( + state.set_pending_portal_image( FrozenImage { width: 5, height: 3, @@ -1219,81 +847,14 @@ mod tests { } #[test] - fn desktop_capture_rejects_a_portal_image_from_a_different_layout_size() { - let mut state = FrozenState::new(None); - let mut input_state = make_test_input_state(); - let geometry = desktop_geometry(0, 0, 4, 1, 1, None).with_desktop_backdrop_geometry(Some( - crate::capture::DesktopBackdropGeometry { - logical_x: 0, - logical_y: 0, - logical_width: 4, - logical_height: 1, - physical_width: Some(4), - physical_height: Some(1), - crop_x: Some(6), - crop_y: Some(0), - screenshot_width: Some(10), - screenshot_height: Some(1), - }, - )); - state.set_pending_desktop_image( - FrozenImage { - width: 11, - height: 1, - stride: 44, - data: vec![0; 11 * 4], - }, - None, - Some(geometry), - ); - - assert!( - state - .activate_pending_image(4, 1, &mut input_state) - .is_err() - ); - assert!(state.image().is_none()); - assert!(!input_state.frozen_active()); - } - - #[test] - fn desktop_capture_fails_closed_when_screenshot_origin_is_unknown() { - let mut state = FrozenState::new(None); - let mut input_state = make_test_input_state(); - let geometry = desktop_geometry(10, 20, 2, 1, 2, None); - assert_eq!(geometry.physical_origin(), (20, 40)); - assert_eq!(geometry.portal_crop_origin(4, 2), None); - assert_eq!(geometry.portal_crop_origin(10, 2), None); - - state.set_pending_desktop_image( - FrozenImage { - width: 4, - height: 2, - stride: 16, - data: vec![7; 4 * 2 * 4], - }, - None, - Some(geometry), - ); - - assert!( - state - .activate_pending_image(4, 2, &mut input_state) - .is_err() - ); - assert!(state.image().is_none()); - assert!(!input_state.frozen_active()); - } - - #[test] - fn desktop_capture_crops_at_buffer_origin_for_a_proven_single_output() { + fn portal_activation_installs_a_completed_single_output_crop() { let mut state = FrozenState::new(None); let mut input_state = make_test_input_state(); state.set_active_output(None, Some(7)); let geometry = desktop_geometry(10, 20, 2, 1, 2, None).with_known_output_count(Some(1)); assert_eq!(geometry.portal_crop_origin(4, 2), Some((0, 0))); - state.set_pending_desktop_image( + state.set_pending_portal_image( FrozenImage { width: 4, height: 2, @@ -1306,9 +867,7 @@ mod tests { state .activate_pending_image_with_live_outputs(4, 2, &mut input_state, Some(1)) - .expect( - "a single-output desktop shot without zxdg layout still crops at the buffer origin", - ); + .expect("a completed portal crop retains its native pixel dimensions"); assert!(state.image().is_some()); assert!(input_state.frozen_active()); } @@ -1318,7 +877,7 @@ mod tests { let mut state = FrozenState::new(None); let mut input_state = make_test_input_state(); let geometry = desktop_geometry(0, 0, 2, 1, 1, Some((0, 0))); - state.set_pending_desktop_image( + state.set_pending_portal_image( FrozenImage { width: 2, height: 1, @@ -1345,7 +904,7 @@ mod tests { let mut input_state = make_test_input_state(); let geometry = desktop_geometry(10, 20, 2, 1, 2, None).with_known_output_count(Some(1)); - state.set_pending_desktop_image( + state.set_pending_portal_image( FrozenImage { width: 4, height: 2, @@ -1365,32 +924,6 @@ mod tests { assert!(!input_state.frozen_active()); } - #[test] - fn desktop_capture_fails_when_screenshot_origin_is_unknown_and_image_is_larger() { - let mut state = FrozenState::new(None); - let mut input_state = make_test_input_state(); - let geometry = desktop_geometry(10, 20, 2, 1, 2, None); - - state.set_pending_desktop_image( - FrozenImage { - width: 10, - height: 2, - stride: 40, - data: vec![7; 10 * 2 * 4], - }, - None, - Some(geometry), - ); - - assert!( - state - .activate_pending_image(4, 2, &mut input_state) - .is_err() - ); - assert!(state.image().is_none()); - assert!(!input_state.frozen_active()); - } - #[test] fn pending_capture_is_rejected_if_output_changes_before_activation() { let mut state = FrozenState::new(None); @@ -1555,7 +1088,7 @@ mod tests { MissingDesktopGeometry, StaleDesktopGeometry, MissingVerifiedPixelSize, - DesktopCropFailure, + PortalSizeMismatch, MissingOutputIdentity, AspectMismatch, } @@ -1569,7 +1102,7 @@ mod tests { Self::MissingDesktopGeometry, Self::StaleDesktopGeometry, Self::MissingVerifiedPixelSize, - Self::DesktopCropFailure, + Self::PortalSizeMismatch, Self::MissingOutputIdentity, Self::AspectMismatch, ]; @@ -1585,8 +1118,9 @@ mod tests { Self::MissingDesktopGeometry | Self::StaleDesktopGeometry => { "Freeze failed after the output layout changed" } - Self::MissingVerifiedPixelSize | Self::DesktopCropFailure => { - "Freeze failed after the display changed size" + Self::MissingVerifiedPixelSize => "Freeze failed after the display changed size", + Self::PortalSizeMismatch => { + "Freeze portal crop dimensions do not match the active output" } Self::MissingOutputIdentity => "Freeze capture source identity is unavailable", Self::AspectMismatch => "Freeze capture aspect does not match the overlay surface", @@ -1639,11 +1173,11 @@ mod tests { ((2, 1), None) } Self::MissingDesktopGeometry => { - state.set_pending_desktop_image(image(2, 1, 8, 8), Some(7), None); + state.set_pending_portal_image(image(2, 1, 8, 8), Some(7), None); ((2, 1), None) } Self::StaleDesktopGeometry => { - state.set_pending_desktop_image( + state.set_pending_portal_image( image(2, 1, 8, 8), None, Some( @@ -1655,19 +1189,19 @@ mod tests { Self::MissingVerifiedPixelSize => { let mut geometry = desktop_geometry(0, 0, 2, 1, 1, Some((0, 0))); geometry.pixel_size = None; - state.set_pending_desktop_image(image(2, 1, 8, 8), Some(7), Some(geometry)); + state.set_pending_portal_image(image(2, 1, 8, 8), Some(7), Some(geometry)); ((2, 1), None) } - Self::DesktopCropFailure => { - state.set_pending_desktop_image( - image(2, 1, 8, 8), + Self::PortalSizeMismatch => { + state.set_pending_portal_image( + image(3, 1, 12, 12), Some(7), - Some(desktop_geometry(0, 0, 2, 1, 1, Some((3, 0)))), + Some(desktop_geometry(0, 0, 2, 1, 1, Some((0, 0)))), ); ((2, 1), None) } Self::MissingOutputIdentity => { - state.set_pending_desktop_image( + state.set_pending_portal_image( image(2, 1, 8, 8), None, Some(desktop_geometry(0, 0, 2, 1, 1, Some((0, 0)))), @@ -1857,7 +1391,13 @@ mod tests { (1, 1), ))); - state.finish_preflight_failure("backend refused".to_string(), &mut input_state); + let error = state + .ensure_preflight_layout_current() + .map_err(anyhow::Error::msg) + .unwrap_err(); + + state + .finish_preflight_failure(CapturePreflightError::from_backend(error), &mut input_state); assert_eq!( state @@ -1866,4 +1406,22 @@ mod tests { Some(ScreenAcquisitionOutcome::StaleLayout) ); } + + #[test] + fn unowned_preflight_failure_clears_retry_and_publishes_capture_done() { + let mut state = FrozenState::new_inner(None, None, true, None, true, false); + let mut input_state = make_test_input_state(); + state.set_active_output(None, Some(1)); + state.start_capture().unwrap(); + state.take_preflight_pending(); + assert!(state.queue_portal_layout_retry(Some(1), true)); + + state.finish_preflight_failure(CapturePreflightError::LostSuppression, &mut input_state); + + assert!(!state.has_portal_layout_retry()); + assert!(!state.is_in_progress()); + assert!(state.take_capture_done()); + assert!(state.take_acquisition_completion().is_none()); + assert!(input_state.needs_redraw); + } } diff --git a/src/backend/wayland/frozen/state/activation.rs b/src/backend/wayland/frozen/state/activation.rs new file mode 100644 index 00000000..a6333492 --- /dev/null +++ b/src/backend/wayland/frozen/state/activation.rs @@ -0,0 +1,277 @@ +//! Validate and install completed output images; portal pixels are processed on the worker. +use super::*; +use crate::backend::wayland::portal_capture::layout_token_matches; +use log::info; + +impl FrozenState { + pub fn set_pending_output_image( + &mut self, + image: FrozenImage, + target_output_id: u32, + source_geometry: OutputGeometry, + ) { + self.pending_image = Some(PendingFrozenImage { + image, + target_output_id: Some(target_output_id), + layout_generation: self.layout_generations.active_output, + source_geometry: Some(source_geometry), + output_transform: None, + source: FrozenCaptureSource::ActiveOutput, + }); + } + + pub(in crate::backend::wayland::frozen) fn set_pending_output_image_with_transform( + &mut self, + image: FrozenImage, + target_output_id: u32, + source_geometry: OutputGeometry, + output_transform: Option, + ) { + self.pending_image = Some(PendingFrozenImage { + image, + target_output_id: Some(target_output_id), + layout_generation: self.layout_generations.active_output, + source_geometry: Some(source_geometry), + output_transform, + source: FrozenCaptureSource::ActiveOutput, + }); + } + + pub(in crate::backend::wayland::frozen) fn set_pending_portal_image( + &mut self, + image: FrozenImage, + target_output_id: Option, + source_geometry: Option, + ) { + self.pending_image = Some(PendingFrozenImage { + image, + target_output_id, + layout_generation: self.layout_generations.active_output, + source_geometry, + output_transform: None, + source: FrozenCaptureSource::Portal { + desktop_generation: self.layout_generations.desktop, + }, + }); + } + + pub(in crate::backend::wayland::frozen) fn discard_pending_image_for_retry(&mut self) { + self.pending_image = None; + } + + pub fn has_pending_image(&self) -> bool { + self.pending_image.is_some() + } + + #[cfg(test)] + pub fn activate_pending_image( + &mut self, + phys_width: u32, + phys_height: u32, + input_state: &mut InputState, + ) -> Result { + self.activate_pending_image_with_live_outputs(phys_width, phys_height, input_state, None) + } + + pub fn activate_pending_image_with_live_outputs( + &mut self, + phys_width: u32, + phys_height: u32, + input_state: &mut InputState, + live_output_count: Option, + ) -> Result { + let Some(pending) = self.pending_image.take() else { + return Ok(false); + }; + let portal_layout_changed = matches!( + pending.source, + FrozenCaptureSource::Portal { desktop_generation } + if desktop_generation != self.layout_generations.desktop + ); + if !layout_token_matches( + pending.target_output_id, + pending.layout_generation, + self.active_output_id, + self.layout_generations.active_output, + ) || portal_layout_changed + { + if matches!(pending.source, FrozenCaptureSource::Portal { .. }) + && self.queue_portal_layout_retry(pending.target_output_id, portal_layout_changed) + { + return Ok(false); + } + return self.reject_pending_image( + input_state, + "Freeze failed after the display layout changed", + ); + } + + let mut image = pending.image; + let mut provenance = None; + + if matches!(pending.source, FrozenCaptureSource::ActiveOutput) { + let Some(geometry) = pending.source_geometry.as_ref() else { + return self + .reject_pending_image(input_state, "Freeze capture geometry is unavailable"); + }; + let output_transform = pending.output_transform.unwrap_or(geometry.transform); + + provenance = pending.target_output_id.and_then(|output_id| { + ScreenImageProvenance::new( + output_id, + pending.layout_generation, + geometry.scale, + output_transform, + ) + }); + + image = match image.with_output_transform(output_transform) { + Ok(image) => image, + Err(error) => { + return self.reject_pending_image( + input_state, + format!("Freeze capture transform failed: {error}"), + ); + } + }; + if !geometry.accepts_transformed_pixel_size(image.width, image.height) { + return self.reject_pending_image( + input_state, + "Freeze capture dimensions do not match the active output", + ); + } + } + + if let FrozenCaptureSource::Portal { .. } = pending.source { + let Some(geometry) = pending + .source_geometry + .as_ref() + .cloned() + .and_then(|geometry| geometry.with_revalidated_output_count(live_output_count)) + else { + let topology_changed = pending + .source_geometry + .as_ref() + .is_some_and(|geo| geo.output_count_conflicts_with_live(live_output_count)); + if self.queue_portal_layout_retry(pending.target_output_id, topology_changed) { + return Ok(false); + } + return self.reject_pending_image( + input_state, + "Freeze failed after the output layout changed", + ); + }; + + provenance = pending.target_output_id.and_then(|output_id| { + ScreenImageProvenance::new( + output_id, + pending.layout_generation, + geometry.scale, + geometry.transform, + ) + }); + + if geometry.verified_pixel_size().is_none() { + return self.reject_pending_image( + input_state, + "Freeze failed after the display changed size", + ); + } + if !geometry.accepts_transformed_pixel_size(image.width, image.height) { + return self.reject_pending_image( + input_state, + "Freeze portal crop dimensions do not match the active output", + ); + } + } + + let Some(provenance) = provenance else { + return self.reject_pending_image( + input_state, + "Freeze capture source identity is unavailable", + ); + }; + + if !OutputGeometry::dimensions_have_compatible_aspect( + (image.width, image.height), + (phys_width, phys_height), + ) { + return self.reject_pending_image( + input_state, + "Freeze capture aspect does not match the overlay surface", + ); + } + + self.image_target_dimensions = Some((phys_width, phys_height)); + self.image = Some(Arc::new(image)); + self.image_provenance = Some(provenance); + self.bump_image_generation(); + input_state.set_frozen_active(true); + input_state.dirty_tracker.mark_full(); + input_state.needs_redraw = true; + self.finish_ready_acquisition(input_state); + + // Legacy tests and late callbacks without an acquisition still use the + // capture-done wakeup as their resource-restoration boundary. + self.capture_done = true; + + Ok(true) + } + + fn reject_pending_image( + &mut self, + input_state: &mut InputState, + error: impl Into, + ) -> Result { + let error = error.into(); + self.finish_acquisition(ScreenAcquisitionOutcome::Failed(error.clone()), input_state); + self.capture_done = true; + input_state.set_frozen_active(false); + input_state.needs_redraw = true; + + Err(error) + } + + /// Drop frozen image if the surface size no longer matches. + pub fn handle_resize( + &mut self, + phys_width: u32, + phys_height: u32, + input_state: &mut InputState, + ) { + if let Some(target_dimensions) = self.image_target_dimensions + && target_dimensions != (phys_width, phys_height) + { + info!("Surface resized; clearing frozen image"); + self.clear_image(); + input_state.set_frozen_active(false); + } + } + + /// Toggle unfreeze: drop the image and mark redraw. + pub fn unfreeze(&mut self, input_state: &mut InputState) { + self.clear_image(); + input_state.set_frozen_active(false); + input_state.dirty_tracker.mark_full(); + input_state.needs_redraw = true; + } + + pub fn cancel(&mut self, input_state: &mut InputState) { + self.abandon_acquisition(input_state); + } + + pub(super) fn clear_image(&mut self) -> bool { + let had_image = self.image.take().is_some(); + self.image_provenance = None; + self.image_target_dimensions = None; + if had_image { + self.bump_image_generation(); + } + + had_image + } + + pub(super) fn bump_image_generation(&mut self) { + self.image_generation = self.image_generation.wrapping_add(1).max(1); + } +} diff --git a/src/backend/wayland/frozen/state/direct.rs b/src/backend/wayland/frozen/state/direct.rs new file mode 100644 index 00000000..734e9300 --- /dev/null +++ b/src/backend/wayland/frozen/state/direct.rs @@ -0,0 +1,109 @@ +//! Direct capture attempt resources, output snapshot, and timeout. +use super::super::capture::CaptureSession; +use super::super::ext_image_copy::ExtImageCopySession; +use super::FrozenCaptureBackend; +use crate::backend::wayland::capture::CaptureLayoutContext; +use crate::backend::wayland::frozen_geometry::OutputGeometry; +use std::time::{Duration, Instant}; + +pub(in crate::backend::wayland::frozen) enum DirectCaptureAttempt { + WlrScreencopy { + session: Box, + context: DirectCaptureContext, + }, + ExtImageCopy { + session: Box, + context: DirectCaptureContext, + }, +} + +impl DirectCaptureAttempt { + pub(super) fn backend(&self) -> FrozenCaptureBackend { + match self { + Self::WlrScreencopy { .. } => FrozenCaptureBackend::WlrScreencopy, + Self::ExtImageCopy { .. } => FrozenCaptureBackend::ExtImageCopy, + } + } + + pub(super) fn context(&self) -> &DirectCaptureContext { + match self { + Self::WlrScreencopy { context, .. } | Self::ExtImageCopy { context, .. } => context, + } + } + + pub(super) fn destroy(self) { + match self { + Self::WlrScreencopy { session, .. } => session.frame.destroy(), + Self::ExtImageCopy { session, .. } => (*session).destroy(), + } + } +} + +pub(in crate::backend::wayland::frozen) const DIRECT_CAPTURE_TIMEOUT: Duration = + Duration::from_secs(3); + +pub(in crate::backend::wayland::frozen) struct DirectCaptureContext { + pub(in crate::backend::wayland::frozen) layout: CaptureLayoutContext, + pub(in crate::backend::wayland::frozen) source_geometry: OutputGeometry, + started_at: Instant, +} + +impl DirectCaptureContext { + pub(in crate::backend::wayland::frozen) fn new( + layout: CaptureLayoutContext, + source_geometry: OutputGeometry, + ) -> Self { + Self::new_at(layout, source_geometry, Instant::now()) + } + + fn new_at( + layout: CaptureLayoutContext, + source_geometry: OutputGeometry, + started_at: Instant, + ) -> Self { + Self { + layout, + source_geometry, + started_at, + } + } + + pub(super) fn timeout(&self, now: Instant) -> Duration { + self.started_at + .checked_add(DIRECT_CAPTURE_TIMEOUT) + .map(|deadline| deadline.saturating_duration_since(now)) + .unwrap_or(Duration::ZERO) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wayland_client::protocol::wl_output; + + #[test] + fn direct_capture_context_tracks_its_deadline_and_output_identity() { + let started_at = Instant::now(); + let geometry = OutputGeometry::update_from( + Some((0, 0)), + Some((1, 1)), + (1, 1), + 1, + wl_output::Transform::Normal, + Some((1, 1)), + ) + .expect("geometry"); + let capture = + DirectCaptureContext::new_at(CaptureLayoutContext::new(7, 3), geometry, started_at); + + assert_eq!(capture.timeout(started_at), DIRECT_CAPTURE_TIMEOUT); + assert_eq!( + capture.timeout(started_at + DIRECT_CAPTURE_TIMEOUT), + Duration::ZERO + ); + assert!(capture.layout.matches(Some(7), 3)); + assert!(!capture.layout.matches(Some(8), 3)); + assert!(!capture.layout.matches(None, 3)); + assert!(!capture.layout.matches(Some(7), 4)); + } +} diff --git a/src/backend/wayland/frozen_geometry.rs b/src/backend/wayland/frozen_geometry.rs index e9244dd0..477f0578 100644 --- a/src/backend/wayland/frozen_geometry.rs +++ b/src/backend/wayland/frozen_geometry.rs @@ -1,6 +1,6 @@ use wayland_client::protocol::wl_output; -use crate::capture::DesktopBackdropGeometry; +use crate::capture::{DesktopBackdropGeometry, DesktopBackdropOutputGeometry}; /// Geometry and scale details for the active output, used for cropping fallback captures. #[derive(Clone, Debug, PartialEq, Eq)] @@ -27,9 +27,61 @@ pub struct OutputGeometry { /// Number of live `wl_output` objects when this snapshot was taken. /// `None` means topology was not recorded (tests / incomplete refresh). pub(super) known_output_count: Option, + /// Complete, sorted logical/native output snapshot for uniform portal rasters. + pub(super) portal_outputs: Option>, } impl OutputGeometry { + /// Identity of the active viewport, independent of portal desktop bounds. + pub(super) fn same_active_output(before: Option<&Self>, after: Option<&Self>) -> bool { + match (before, after) { + (Some(before), Some(after)) => { + before.logical_x == after.logical_x + && before.logical_y == after.logical_y + && before.logical_width == after.logical_width + && before.logical_height == after.logical_height + && before.scale == after.scale + && before.transform == after.transform + && before.overlay_buffer_size == after.overlay_buffer_size + && before.pixel_size == after.pixel_size + } + (None, None) => true, + _ => false, + } + } + + pub(super) fn active_output_is_in_portal_snapshot(&self) -> bool { + let Some(target) = self.verified_pixel_size() else { + return false; + }; + + self.portal_outputs.as_ref().is_some_and(|outputs| { + outputs.iter().any(|output| { + output.logical_x == self.logical_x + && output.logical_y == self.logical_y + && output.logical_width == self.logical_width + && output.logical_height == self.logical_height + && (output.physical_width, output.physical_height) == target + }) + }) + } + + /// Multi-output portal inference requires metadata for every live output. + /// A proven single output can also use its exact native-size screenshot. + pub(super) fn portal_layout_is_complete(&self) -> bool { + if self.verified_pixel_size().is_none() { + return false; + } + + match self.known_output_count { + Some(1) => true, + Some(count) if count > 1 => self.portal_outputs.as_ref().is_some_and(|outputs| { + outputs.len() == count as usize && self.active_output_is_in_portal_snapshot() + }), + _ => false, + } + } + pub fn update_from( logical_pos: Option<(i32, i32)>, logical_size: Option<(i32, i32)>, @@ -68,6 +120,7 @@ impl OutputGeometry { screenshot_origin: None, screenshot_size: None, known_output_count: None, + portal_outputs: None, }) } } @@ -447,6 +500,26 @@ impl OutputGeometry { self } + pub(super) fn with_portal_outputs( + mut self, + outputs: Option>, + ) -> Self { + self.portal_outputs = outputs.map(|mut outputs| { + outputs.sort_by_key(|output| { + ( + output.logical_x, + output.logical_y, + output.logical_width, + output.logical_height, + output.physical_width, + output.physical_height, + ) + }); + outputs + }); + self + } + pub fn with_known_output_count(mut self, count: Option) -> Self { self.known_output_count = count; self diff --git a/src/backend/wayland/mod.rs b/src/backend/wayland/mod.rs index ec46ae63..201be9a7 100644 --- a/src/backend/wayland/mod.rs +++ b/src/backend/wayland/mod.rs @@ -10,6 +10,7 @@ mod handlers; pub(crate) mod input_monitor; mod overlay_passthrough; mod portal_capture; +mod portal_raster; mod portal_task; mod runtime_operation; mod runtime_ui_state; diff --git a/src/backend/wayland/portal_raster.rs b/src/backend/wayland/portal_raster.rs new file mode 100644 index 00000000..172e208c --- /dev/null +++ b/src/backend/wayland/portal_raster.rs @@ -0,0 +1,497 @@ +//! Verified mapping from a portal desktop raster into one output's native pixels. +use crate::capture::{CaptureError, DesktopBackdropOutputGeometry}; +use crate::screen_pixels::ScreenImage; + +use super::frozen_geometry::OutputGeometry; +use super::portal_capture::crop_argb; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Crop { + x: u32, + y: u32, + width: u32, + height: u32, +} + +#[derive(Debug)] +struct LogicalDesktop { + x: i64, + y: i64, + width: u32, + height: u32, + density_min: f64, + density_max: f64, +} + +impl LogicalDesktop { + fn from_outputs(outputs: &[DesktopBackdropOutputGeometry]) -> Option { + let first = outputs.first()?; + let (mut x, mut y) = (i64::from(first.logical_x), i64::from(first.logical_y)); + let (mut right, mut bottom) = (x, y); + // KWin CaptureWorkspace uses at least 1x, then the maximum output scale. + let (mut density_min, mut density_max) = (1.0_f64, 1.0_f64); + + for output in outputs { + if output.logical_width == 0 + || output.logical_height == 0 + || output.physical_width == 0 + || output.physical_height == 0 + { + return None; + } + + x = x.min(i64::from(output.logical_x)); + y = y.min(i64::from(output.logical_y)); + right = right.max(i64::from(output.logical_x) + i64::from(output.logical_width)); + bottom = bottom.max(i64::from(output.logical_y) + i64::from(output.logical_height)); + + // xdg-output rounds logical sizes to integers. Intersect both axes' + // scale intervals instead of treating wl_output's integer scale as native density. + let lower = (f64::from(output.physical_width) + / (f64::from(output.logical_width) + 0.5)) + .max(f64::from(output.physical_height) / (f64::from(output.logical_height) + 0.5)); + let upper = (f64::from(output.physical_width) + / (f64::from(output.logical_width) - 0.5)) + .min(f64::from(output.physical_height) / (f64::from(output.logical_height) - 0.5)); + if lower > upper { + return None; + } + + density_min = density_min.max(lower); + density_max = density_max.max(upper); + } + + Some(Self { + x, + y, + width: u32::try_from(right.checked_sub(x)?).ok()?, + height: u32::try_from(bottom.checked_sub(y)?).ok()?, + density_min, + density_max, + }) + } + + fn crop(&self, geometry: &OutputGeometry, raster: (u32, u32)) -> Option { + let (width, height) = raster; + if self.width == 0 || self.height == 0 || width == 0 || height == 0 { + return None; + } + + let lower = ((f64::from(width) - 0.5) / f64::from(self.width)) + .max((f64::from(height) - 0.5) / f64::from(self.height)); + let upper = ((f64::from(width) + 0.5) / f64::from(self.width)) + .min((f64::from(height) + 0.5) / f64::from(self.height)); + if lower > upper || lower > self.density_max || upper < self.density_min { + return None; + } + + let x = i64::from(geometry.logical_x).checked_sub(self.x)?; + let y = i64::from(geometry.logical_y).checked_sub(self.y)?; + let right = x.checked_add(i64::from(geometry.logical_width))?; + let bottom = y.checked_add(i64::from(geometry.logical_height))?; + if x < 0 || y < 0 || right > i64::from(self.width) || bottom > i64::from(self.height) { + return None; + } + + let map_x = + |edge: i64| (edge as f64 * f64::from(width) / f64::from(self.width)).round() as u32; + let map_y = + |edge: i64| (edge as f64 * f64::from(height) / f64::from(self.height)).round() as u32; + + Some(Crop { + x: map_x(x), + y: map_y(y), + width: map_x(right).checked_sub(map_x(x))?, + height: map_y(bottom).checked_sub(map_y(y))?, + }) + } +} + +pub(super) fn crop_portal_raster( + data: Vec, + width: u32, + height: u32, + geometry: &OutputGeometry, +) -> Result { + let error = |message: &str| CaptureError::ImageError(message.to_string()); + let expected_len = u64::from(width) + .checked_mul(u64::from(height)) + .and_then(|pixels| pixels.checked_mul(4)) + .and_then(|bytes| usize::try_from(bytes).ok()); + if width == 0 || height == 0 || expected_len != Some(data.len()) { + return Err(error("Portal capture buffer does not match its dimensions")); + } + + let target = geometry + .verified_pixel_size() + .ok_or_else(|| error("Portal capture output dimensions are unavailable"))?; + if !OutputGeometry::dimensions_have_compatible_aspect(target, geometry.buffer_size()) { + return Err(error( + "Portal capture aspect does not match the overlay surface", + )); + } + + let packed = geometry + .portal_crop_origin(width, height) + .map(|(x, y)| Crop { + x, + y, + width: target.0, + height: target.1, + }) + .filter(|crop| contains_crop((width, height), *crop)); + + let desktop = geometry + .portal_outputs + .as_deref() + .and_then(LogicalDesktop::from_outputs); + let uniform = desktop + .as_ref() + .filter(|_| geometry.active_output_is_in_portal_snapshot()) + .and_then(|desktop| desktop.crop(geometry, (width, height))); + let crop = match (packed, uniform) { + (Some(packed), Some(uniform)) if packed != uniform => None, + (Some(crop), _) | (_, Some(crop)) => Some(crop), + _ => None, + }; + + log::info!( + "portal.raster image={}x{} packed_expected={:?} logical_desktop={:?} crop={:?} target={:?}", + width, + height, + geometry.screenshot_size, + desktop, + crop, + target, + ); + + let crop = crop.ok_or_else(|| { + error("Portal capture does not match an unambiguous active output layout") + })?; + if !contains_crop((width, height), crop) { + return Err(error("Portal capture does not contain the active output")); + } + + let (_, _, data) = crop_argb( + &data, + width, + height, + crop.x, + crop.y, + crop.width, + crop.height, + ) + .ok_or_else(|| error("Portal capture crop is invalid"))?; + + resample(data, (crop.width, crop.height), target) +} + +fn contains_crop(raster: (u32, u32), crop: Crop) -> bool { + crop.width > 0 + && crop.height > 0 + && crop + .x + .checked_add(crop.width) + .is_some_and(|edge| edge <= raster.0) + && crop + .y + .checked_add(crop.height) + .is_some_and(|edge| edge <= raster.1) +} + +fn resample( + data: Vec, + source: (u32, u32), + target: (u32, u32), +) -> Result { + let error = |message: &str| CaptureError::ImageError(message.to_string()); + let dimension = + |value| i32::try_from(value).map_err(|_| error("Portal image dimension is too large")); + let stride = dimension( + target + .0 + .checked_mul(4) + .ok_or_else(|| error("Portal image stride overflow"))?, + )?; + if source == target { + return Ok(ScreenImage { + width: target.0, + height: target.1, + stride, + data, + }); + } + + // Cairo filters premultiplied ARGB directly, avoiding alpha/color corruption. + let map_error = |err: cairo::Error| { + CaptureError::ImageError(format!("Portal image resampling failed: {err}")) + }; + let src = cairo::ImageSurface::create_for_data( + data, + cairo::Format::ARgb32, + dimension(source.0)?, + dimension(source.1)?, + dimension( + source + .0 + .checked_mul(4) + .ok_or_else(|| error("Portal image stride overflow"))?, + )?, + ) + .map_err(map_error)?; + let mut dst = cairo::ImageSurface::create( + cairo::Format::ARgb32, + dimension(target.0)?, + dimension(target.1)?, + ) + .map_err(map_error)?; + + { + let context = cairo::Context::new(&dst).map_err(map_error)?; + context.scale( + f64::from(target.0) / f64::from(source.0), + f64::from(target.1) / f64::from(source.1), + ); + context + .set_source_surface(&src, 0.0, 0.0) + .map_err(map_error)?; + context.source().set_filter(cairo::Filter::Bilinear); + context.source().set_extend(cairo::Extend::Pad); + context.set_operator(cairo::Operator::Source); + context.paint().map_err(map_error)?; + } + + let data = dst + .data() + .map_err(|err| CaptureError::ImageError(format!("Portal image pixels unavailable: {err}")))? + .to_vec(); + + Ok(ScreenImage { + width: target.0, + height: target.1, + stride, + data, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use wayland_client::protocol::wl_output; + + fn output( + x: i32, + y: i32, + logical: (u32, u32), + native: (u32, u32), + ) -> DesktopBackdropOutputGeometry { + DesktopBackdropOutputGeometry { + logical_x: x, + logical_y: y, + logical_width: logical.0, + logical_height: logical.1, + physical_width: native.0, + physical_height: native.1, + } + } + + fn geometry( + active: DesktopBackdropOutputGeometry, + outputs: &[DesktopBackdropOutputGeometry], + ) -> OutputGeometry { + OutputGeometry::update_from( + Some((active.logical_x, active.logical_y)), + Some((active.logical_width as i32, active.logical_height as i32)), + (active.physical_width, active.physical_height), + 1, + wl_output::Transform::Normal, + Some((active.physical_width, active.physical_height)), + ) + .unwrap() + .with_desktop_backdrop_geometry(crate::capture::DesktopBackdropGeometry::from_outputs( + active, outputs, + )) + .with_portal_outputs(Some(outputs.to_vec())) + .with_known_output_count(Some(outputs.len() as u32)) + } + + #[test] + fn negative_vertical_and_gapped_layout_maps_both_crop_edges() { + let outputs = [ + output(-10, -20, (8, 4), (8, 4)), + output(-8, -14, (2, 4), (4, 8)), + ]; + let geo = geometry(outputs[1], &outputs); + let desktop = LogicalDesktop::from_outputs(&outputs).unwrap(); + assert_eq!((desktop.width, desktop.height), (8, 10)); + assert_eq!( + desktop.crop(&geo, (16, 20)), + Some(Crop { + x: 4, + y: 12, + width: 4, + height: 8 + }) + ); + let mut data = 0xff000000_u32.to_ne_bytes().repeat(16 * 20); + for y in 12..20 { + for x in 4..8 { + data[(y * 16 + x) * 4..(y * 16 + x + 1) * 4] + .copy_from_slice(&0xff123456_u32.to_ne_bytes()); + } + } + + let image = crop_portal_raster(data, 16, 20, &geo).unwrap(); + + assert_eq!(image.data, 0xff123456_u32.to_ne_bytes().repeat(4 * 8)); + } + + #[test] + fn rotated_output_uses_transformed_native_dimensions_without_rotating_pixels_again() { + let outputs = [output(0, 0, (8, 4), (8, 4)), output(8, 0, (1, 3), (2, 6))]; + let mut geo = geometry(outputs[1], &outputs); + geo.transform = wl_output::Transform::_90; + geo.scale = 2; + geo.overlay_buffer_size = (2, 6); + let mut data = 0xff000000_u32.to_ne_bytes().repeat(18 * 8); + let mut expected = Vec::new(); + for y in 0..6 { + for x in 16..18 { + let pixel = (0xff000000_u32 | ((y + 1) << 8) | x).to_ne_bytes(); + data[((y * 18 + x) * 4) as usize..((y * 18 + x + 1) * 4) as usize] + .copy_from_slice(&pixel); + expected.extend_from_slice(&pixel); + } + } + + let image = crop_portal_raster(data, 18, 8, &geo).unwrap(); + + assert_eq!((image.width, image.height), (2, 6)); + assert_eq!(image.data, expected); + } + + #[test] + fn packed_mixed_density_crop_uses_screenshot_origin_instead_of_logical_times_scale() { + let outputs = [output(0, 0, (4, 2), (4, 2)), output(4, 0, (4, 2), (8, 4))]; + let mut geo = geometry(outputs[1], &outputs); + geo.scale = 2; + let mut pixels = Vec::new(); + let mut expected = Vec::new(); + for y in 0..4 { + for x in 0..12 { + let pixel = (0xff000000_u32 | (y << 8) | x).to_ne_bytes(); + pixels.extend_from_slice(&pixel); + if x >= 4 { + expected.extend_from_slice(&pixel); + } + } + } + + let image = crop_portal_raster(pixels, 12, 4, &geo).unwrap(); + + assert_eq!((image.width, image.height, image.stride), (8, 4, 32)); + assert_eq!(image.data, expected); + } + + #[test] + fn native_sized_raster_requires_a_proven_single_output_when_bounds_are_missing() { + let geometry = OutputGeometry::update_from( + Some((10, 20)), + Some((2, 1)), + (2, 1), + 1, + wl_output::Transform::Normal, + Some((2, 1)), + ) + .unwrap(); + let pixels = [0xff123456_u32, 0xff654321_u32] + .into_iter() + .flat_map(u32::to_ne_bytes) + .collect::>(); + + assert!( + crop_portal_raster( + pixels.clone(), + 2, + 1, + &geometry.clone().with_known_output_count(Some(2)) + ) + .is_err() + ); + + let image = crop_portal_raster( + pixels.clone(), + 2, + 1, + &geometry.with_known_output_count(Some(1)), + ) + .unwrap(); + + assert_eq!(image.data, pixels); + } + + #[test] + fn fractional_uniform_raster_accepts_integer_logical_rounding() { + // 1.3x output with xdg-output's rounded 1477x831 logical dimensions. + let outputs = [ + output(0, 0, (1477, 831), (1920, 1080)), + output(1477, 0, (1280, 720), (1280, 720)), + ]; + let geo = geometry(outputs[1], &outputs); + let desktop = LogicalDesktop::from_outputs(&outputs).unwrap(); + + let crop = desktop.crop(&geo, (3584, 1080)).unwrap(); + + assert_eq!( + crop, + Crop { + x: 1920, + y: 0, + width: 1664, + height: 936 + } + ); + assert_eq!(desktop.crop(&geo, (4000, 1200)), None); + assert_eq!(desktop.crop(&geo, (3584, 1100)), None); + } + + #[test] + fn packed_uniform_ambiguity_fails_closed() { + // A smaller low-density output overlaps the high-density desktop bounds. + // Packed and uniform screenshots have identical sizes but different crops. + let outputs = [output(0, 0, (8, 4), (16, 8)), output(2, 1, (4, 2), (4, 2))]; + let geo = geometry(outputs[1], &outputs); + assert_eq!(geo.screenshot_size, Some((16, 8))); + assert!(crop_portal_raster(vec![0; 16 * 8 * 4], 16, 8, &geo).is_err()); + } + + #[test] + fn resampling_preserves_premultiplied_alpha_and_interpolates_pixels() { + let data: Vec = [0x80202020_u32, 0x80606060_u32] + .into_iter() + .flat_map(u32::to_ne_bytes) + .collect(); + + let image = resample(data, (2, 1), (1, 1)).unwrap(); + + assert_eq!(image.data, 0x80404040_u32.to_ne_bytes()); + } + + #[test] + fn malformed_or_unexpected_raster_is_rejected() { + let outputs = [ + output(0, 0, (1280, 720), (1280, 720)), + output(1280, 0, (640, 360), (1280, 720)), + ]; + let geo = geometry(outputs[0], &outputs); + assert!(crop_portal_raster(vec![0; 16], 3840, 1440, &geo).is_err()); + assert!(crop_portal_raster(vec![0; 1920 * 720 * 4], 1920, 720, &geo).is_err()); + assert!( + LogicalDesktop::from_outputs(&[ + output(i32::MIN, 0, (1, 1), (1, 1)), + output(i32::MAX, 0, (1, 1), (1, 1)) + ]) + .is_none() + ); + } +} diff --git a/src/backend/wayland/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index b2cc68d2..0ee19956 100644 --- a/src/backend/wayland/state/AGENTS.md +++ b/src/backend/wayland/state/AGENTS.md @@ -7,6 +7,7 @@ ## Architecture - This subtree supports live overlay runtime state: buffers, damage, boards, capture routing, clipboard paste, color picker, onboarding, PDF export, render helpers, toolbar plumbing, zoom, and core accessors. - Runtime owners extracted from `WaylandState` live beside it: `focus.rs` (activation, focus, and startup acquisition), `protocol_globals.rs` (bound globals and toolkit handler state), `pointer_runtime.rs` (pointer position, board-pan and chrome gestures, cursor, pointer-lock, and touch lifecycles), `region_capture/runtime.rs` (region generations, active/review/window-snap state, and query/preview workers), `acquisition.rs` (screen acquisition, zoom waiters, and eyedropper source correlation), `input_hud.rs` (system-reader lifecycle and reconciliation), `spotlight_runtime.rs` (render memory, warning latches, and wheel timing), `clipboard_runtime.rs` (single-flight workers and queue policy), `preference_stores.rs` (persistence stores and workers), `ui_animation.rs` (animation scheduling), `font_catalog.rs` (font-catalog prewarm), `text_input.rs` (text-input-v3 lifecycle and commit serials), `tablet_runtime.rs` (tablet-input-v2 objects and stylus contact), `key_repeat.rs` (manual key-repeat timing), and `helper_launch.rs` (About/configurator launches requested by input). +- `capture/barrier.rs` owns GTK/main-surface presentation proof and checks available swapchain slots before a render wake. `capture/retry.rs` coordinates settled portal re-admission while retaining suppression and domain-specific completion ownership. `../backend/event_loop/` computes the frame-cap delay shared by normal rendering and capture wakes. - `core/overlay.rs` owns suppression policy and capture-barrier state; `../surface.rs` owns output/fullscreen/layer placement and frozen-fullscreen transitions. - `render/` separates mutable preparation, pure `FramePlan` decisions, Cairo painting, and buffer submission. `CanvasRenderCtx` borrows frame parameters; `RenderRuntime` owns the canvas layer cache, explicit drawing caches, resolved theme, UI paint caches, profile baseline, and per-effect damage history. Direct canvas rendering, layer baking, previews, and board thumbnails borrow that drawing owner in short scopes. `toolbar/` owns `ToolbarChrome` (placement, inline interaction, and fade state), `ToolbarDrag` (built-in and GTK drag lifecycles), and runtime toolbar effects; `clipboard/` owns session paste helpers. diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index d78bf309..dbf4c9c0 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -6,6 +6,7 @@ use crate::input::state::{Toast, ToastPriority}; mod backdrop; mod barrier; mod pdf; +mod retry; pub(super) use barrier::OverlayCaptureBarrier; diff --git a/src/backend/wayland/state/capture/backdrop.rs b/src/backend/wayland/state/capture/backdrop.rs index d17dcb3c..7cdcddee 100644 --- a/src/backend/wayland/state/capture/backdrop.rs +++ b/src/backend/wayland/state/capture/backdrop.rs @@ -81,6 +81,22 @@ impl WaylandState { DesktopBackdropGeometry::from_outputs(active, &outputs) } + fn portal_output_geometries_excluding( + &self, + exclude: Option<&wl_output::WlOutput>, + ) -> Option> { + self.protocol + .output() + .outputs() + .filter(|candidate| !exclude.is_some_and(|destroyed| destroyed == candidate)) + .map(|candidate| { + desktop_backdrop_output_geometry_from_info( + &self.protocol.output().info(&candidate)?, + ) + }) + .collect() + } + /// Count advertised `wl_output` objects, including ones whose `new_output` /// callback has not run yet. SCTK can insert the proxy before metadata /// completes; Freeze/Zoom must not treat that window as a proven single @@ -114,9 +130,11 @@ impl WaylandState { ) { let backdrop_geometry = self.desktop_backdrop_geometry_excluding(exclude); let known_output_count = self.known_output_count_excluding(exclude); + let portal_outputs = self.portal_output_geometries_excluding(exclude); let geometry = geometry.map(|geo| { geo.with_desktop_backdrop_geometry(backdrop_geometry) .with_known_output_count(known_output_count) + .with_portal_outputs(portal_outputs) }); self.frozen.set_active_geometry(geometry.clone()); self.zoom.set_active_geometry(geometry); diff --git a/src/backend/wayland/state/capture/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index 6eed72cf..f75fce40 100644 --- a/src/backend/wayland/state/capture/barrier.rs +++ b/src/backend/wayland/state/capture/barrier.rs @@ -1,4 +1,5 @@ use super::super::*; +use crate::backend::wayland::capture_preflight::CapturePreflightError; use crate::input::state::{Toast, ToastPriority}; use std::time::{Duration, Instant}; @@ -20,7 +21,10 @@ fn finish_zoom_preflight_cancellation( message: &str, ) -> bool { let terminal_will_report = zoom.current_capture_id().is_some(); - zoom.finish_preflight_failure(input_state, message.to_string()); + zoom.finish_preflight_failure( + input_state, + CapturePreflightError::Backend(message.to_string()), + ); terminal_will_report } @@ -176,6 +180,16 @@ impl OverlayCaptureBarrier { Some(active.reason) } + fn submission_timeout(&self, can_render: bool, render_delay: Duration) -> Option { + self.active + .filter(|active| { + can_render + && active.gtk_paint_generation.is_none() + && active.main_surface_phase == MainSurfaceCapturePhase::AwaitingRender + }) + .map(|_| render_delay) + } + fn frame_timeout(&self, now: Instant) -> Option { let active = self.active?; if active.main_surface_phase != MainSurfaceCapturePhase::AwaitingFrame { @@ -224,8 +238,20 @@ impl WaylandState { pub(in crate::backend::wayland) fn overlay_capture_barrier_timeout( &self, now: Instant, + render_delay: Duration, ) -> Option { - self.suppression.barrier.frame_timeout(now) + self.suppression + .barrier + .submission_timeout( + self.surface.is_configured() + && !self.surface.frame_callback_pending() + && self.input_state.needs_redraw + && self + .surface + .has_available_buffer(self.config.performance.buffer_count as usize), + render_delay, + ) + .or_else(|| self.suppression.barrier.frame_timeout(now)) } pub(in crate::backend::wayland) fn poll_overlay_capture_barrier_timeout( @@ -349,9 +375,14 @@ impl WaylandState { &self.tokio_handle, ) { log::warn!("Frozen preflight capture failed: {err}"); + if self.frozen.retry_stale_portal_preflight(backend) { + return; + } if self.frozen.has_acquisition_attempt() { - self.frozen - .finish_preflight_failure(err.to_string(), &mut self.input_state); + self.frozen.finish_preflight_failure( + CapturePreflightError::from_backend(err), + &mut self.input_state, + ); } else { self.input_state.push_toast( ToastPriority::Critical, @@ -363,20 +394,25 @@ impl WaylandState { } } OverlaySuppression::Zoom => { - let Some(use_fallback) = self.zoom.take_preflight_pending() else { + let Some(backend) = self.zoom.take_preflight_pending() else { log::warn!("Zoom capture barrier completed without a pending preflight"); self.cancel_overlay_capture_preflight(reason, None); return; }; if let Err(err) = self.zoom.begin_preflight_capture( - use_fallback, + backend, self.protocol.shm(), qh, &self.tokio_handle, ) { log::warn!("Zoom preflight capture failed: {err}"); - self.zoom - .finish_preflight_failure(&mut self.input_state, err.to_string()); + if self.zoom.retry_stale_portal_preflight(backend) { + return; + } + self.zoom.finish_preflight_failure( + &mut self.input_state, + CapturePreflightError::from_backend(err), + ); } } OverlaySuppression::Capture | OverlaySuppression::DesktopBackdrop => { @@ -453,6 +489,51 @@ mod tests { use crate::backend::wayland::zoom::ZoomWaiterOwner; use crate::input::state::test_support::make_test_input_state; + #[test] + fn completed_barrier_retry_requires_fresh_gtk_and_main_presentations() { + let mut barrier = OverlayCaptureBarrier::default(); + let first = barrier.begin(OverlaySuppression::Frozen, true).unwrap(); + assert!(barrier.acknowledge_gtk_paint(first)); + assert_eq!(barrier.begin_main_surface_submission(), Some(first)); + barrier.mark_main_surface_frame_ready(first); + assert_eq!(barrier.take_ready(), Some(OverlaySuppression::Frozen)); + + let retry = barrier.begin(OverlaySuppression::Frozen, true).unwrap(); + assert_eq!(barrier.submission_timeout(true, Duration::ZERO), None); + assert_ne!(retry, first); + assert!(!barrier.acknowledge_gtk_paint(first)); + barrier.mark_main_surface_frame_ready(first); + assert_eq!(barrier.begin_main_surface_submission(), None); + assert_eq!(barrier.take_ready(), None); + assert!(barrier.acknowledge_gtk_paint(retry)); + assert_eq!( + barrier.submission_timeout(true, Duration::ZERO), + Some(Duration::ZERO) + ); + assert_eq!(barrier.submission_timeout(false, Duration::ZERO), None); + assert_eq!(barrier.take_ready(), None); + assert_eq!(barrier.begin_main_surface_submission(), Some(retry)); + assert_eq!(barrier.submission_timeout(true, Duration::ZERO), None); + barrier.mark_main_surface_frame_ready(first); + assert_eq!(barrier.take_ready(), None); + barrier.mark_main_surface_frame_ready(retry); + assert_eq!(barrier.take_ready(), Some(OverlaySuppression::Frozen)); + } + + #[test] + fn submission_wakeup_respects_buffer_availability_and_frame_pacing() { + let mut barrier = OverlayCaptureBarrier::default(); + barrier.begin(OverlaySuppression::Zoom, false); + let cap_delay = Duration::from_millis(16); + + assert_eq!(barrier.submission_timeout(false, Duration::ZERO), None); + assert_eq!(barrier.submission_timeout(true, cap_delay), Some(cap_delay)); + assert_eq!( + barrier.submission_timeout(true, Duration::ZERO), + Some(Duration::ZERO) + ); + } + #[test] fn main_surface_frame_deadline_starts_only_after_submission() { let mut barrier = OverlayCaptureBarrier::default(); @@ -642,7 +723,7 @@ mod tests { for owner in [Some(ZoomWaiterOwner::Ocr), None] { let mut zoom = crate::backend::wayland::zoom::ZoomState::new(None); let mut input_state = make_test_input_state(); - zoom.start_capture(false, &tokio::runtime::Handle::current()) + zoom.start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::WlrScreencopy) .expect("identified zoom capture"); assert!(finish_zoom_preflight_cancellation( diff --git a/src/backend/wayland/state/capture/pdf.rs b/src/backend/wayland/state/capture/pdf.rs index 1566fe86..95422188 100644 --- a/src/backend/wayland/state/capture/pdf.rs +++ b/src/backend/wayland/state/capture/pdf.rs @@ -118,7 +118,7 @@ impl WaylandState { .and_then(|output| self.protocol.output().info(&output).map(|info| info.id)); if !pending .layout_context - .matches(active_output_id, self.frozen.output_layout_generation()) + .matches_desktop(active_output_id, &self.frozen) { let message = "Board PDF export failed: output layout changed during desktop capture".to_string(); @@ -233,8 +233,7 @@ impl WaylandState { let output = self.surface.current_output()?; let output_id = self.protocol.output().info(&output)?.id; let geometry = self.desktop_backdrop_geometry()?; - let layout_context = - CaptureLayoutContext::new(output_id, self.frozen.output_layout_generation()); + let layout_context = CaptureLayoutContext::for_desktop(output_id, &self.frozen); let request = DesktopBackdropCaptureRequest { logical_width: self.surface.width(), logical_height: self.surface.height(), diff --git a/src/backend/wayland/state/capture/retry.rs b/src/backend/wayland/state/capture/retry.rs new file mode 100644 index 00000000..10bcda68 --- /dev/null +++ b/src/backend/wayland/state/capture/retry.rs @@ -0,0 +1,242 @@ +//! Re-admission of portal requests after a bounded layout settling period. +use super::super::core::overlay::OverlaySuppressionState; +use super::super::*; +use crate::backend::wayland::capture_preflight::CapturePreflightError; +use std::time::Instant; + +impl WaylandState { + pub(in crate::backend::wayland) fn poll_portal_layout_retries(&mut self, now: Instant) { + if !self.frozen.has_portal_layout_retry() && !self.zoom.has_portal_layout_retry() { + return; + } + + self.refresh_freeze_zoom_geometry(); + if advance_portal_layout_retries( + &mut self.frozen, + &mut self.zoom, + &mut self.suppression, + &mut self.input_state, + self.gtk_toolbar.is_some(), + now, + ) { + self.buffer_damage + .mark_all_full(FullDamageReason::OverlaySuppression); + self.toolbar.mark_dirty(); + } + } +} + +/// Keep eligibility, domain terminals and barrier admission together. The +/// runtime publishes redraw damage and GTK updates before it renders again. +fn advance_portal_layout_retries( + frozen: &mut FrozenState, + zoom: &mut ZoomState, + suppression: &mut OverlaySuppressionState, + input: &mut InputState, + wait_for_gtk: bool, + now: Instant, +) -> bool { + let mut restarted = false; + + if frozen.has_portal_layout_retry() { + match restart_suppressed_portal_retry( + suppression, + OverlaySuppression::Frozen, + wait_for_gtk, + || frozen.restart_portal_preflight(now), + ) { + Ok(ready) => restarted |= ready, + Err(error) => { + log::warn!("Portal Freeze retry preflight failed: {error}"); + frozen.finish_preflight_failure(error, input); + } + } + } + + if zoom.has_portal_layout_retry() { + match restart_suppressed_portal_retry( + suppression, + OverlaySuppression::Zoom, + wait_for_gtk, + || zoom.restart_portal_preflight(now), + ) { + Ok(ready) => restarted |= ready, + Err(error) => { + log::warn!("Portal Zoom retry preflight failed: {error}"); + zoom.finish_preflight_failure(input, error); + } + } + } + + input.needs_redraw |= restarted; + restarted +} + +fn restart_suppressed_portal_retry( + suppression: &mut OverlaySuppressionState, + reason: OverlaySuppression, + wait_for_gtk: bool, + restart: impl FnOnce() -> Result, +) -> Result { + if suppression.reason() != reason { + return Err(CapturePreflightError::LostSuppression); + } + if !restart()? { + return Ok(false); + } + + suppression.barrier.begin(reason, wait_for_gtk); + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::acquisition::{ + ScreenAcquisitionOutcome, ScreenAcquisitionOwner, ScreenAcquisitionRegistry, + }; + use crate::backend::wayland::frozen::FrozenCaptureBackend; + use crate::backend::wayland::frozen_geometry::OutputGeometry; + use crate::backend::wayland::zoom::{ZoomCaptureBackend, ZoomSourceOutcome}; + use std::time::Duration; + + #[tokio::test] + async fn retry_coordinator_preserves_suppression_and_finishes_failure_once() { + for reason in [OverlaySuppression::Frozen, OverlaySuppression::Zoom] { + // Successful admission, lost suppression, switched output, and + // metadata that never becomes complete exercise the real owners. + for failure in [None, Some("suppression"), Some("output"), Some("metadata")] { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); + let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); + let mut input = crate::input::state::test_support::make_test_input_state(); + let geometry = OutputGeometry::update_from( + Some((0, 0)), + Some((2, 1)), + (2, 1), + 1, + wl_output::Transform::Normal, + Some((2, 1)), + ) + .unwrap() + .with_known_output_count(Some(1)); + frozen.set_active_output(None, Some(1)); + frozen.set_active_geometry(Some(geometry.clone())); + zoom.set_active_output(None, Some(1)); + zoom.set_active_geometry(Some(geometry.clone())); + + let mut registry = ScreenAcquisitionRegistry::default(); + let owner = ScreenAcquisitionOwner::Ocr; + let id = registry.request(owner).unwrap(); + let mut zoom_id = None; + + let mut changed = geometry; + changed.logical_x = 8; + if reason == OverlaySuppression::Frozen { + frozen.start_capture_for(id, owner).unwrap(); + frozen.take_preflight_pending(); + frozen.set_active_geometry(Some(changed)); + assert!(frozen.retry_stale_portal_preflight(FrozenCaptureBackend::Portal)); + } else { + zoom.start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::Portal) + .unwrap(); + zoom_id = zoom.current_capture_id(); + zoom.take_preflight_pending(); + zoom.set_active_geometry(Some(changed)); + assert!(zoom.retry_stale_portal_preflight(ZoomCaptureBackend::Portal)); + } + + let mut suppression = OverlaySuppressionState::default(); + let active_reason = if failure == Some("suppression") { + OverlaySuppression::ExternalDialog + } else { + reason + }; + suppression + .enter( + active_reason, + OverlaySuppressionKeyboardPolicy::Release, + true, + ) + .unwrap(); + let first_gtk = suppression.barrier.gtk_paint_generation(); + + if failure == Some("output") { + frozen.set_active_output(None, Some(2)); + zoom.set_active_output(None, Some(2)); + } + if failure == Some("metadata") { + frozen.set_active_geometry(None); + zoom.set_active_geometry(None); + } + + let now = Instant::now(); + + if failure.is_none() { + assert!(!advance_portal_layout_retries( + &mut frozen, + &mut zoom, + &mut suppression, + &mut input, + true, + now + )); + assert_eq!(suppression.barrier.gtk_paint_generation(), first_gtk); + } + + let admitted = advance_portal_layout_retries( + &mut frozen, + &mut zoom, + &mut suppression, + &mut input, + true, + now + if failure == Some("metadata") { + Duration::from_secs(2) + } else { + Duration::from_millis(150) + }, + ); + + assert_eq!(suppression.reason(), active_reason); + assert!(!frozen.has_portal_layout_retry()); + assert!(!zoom.has_portal_layout_retry()); + if failure.is_none() { + assert!(admitted); + assert_ne!(suppression.barrier.gtk_paint_generation(), first_gtk); + assert!(input.needs_redraw); + assert!(frozen.take_acquisition_completion().is_none()); + assert!(zoom.take_source_terminal().is_none()); + } else { + assert!(!admitted); + assert_eq!(suppression.barrier.gtk_paint_generation(), first_gtk); + if reason == OverlaySuppression::Frozen { + let terminal = frozen.take_acquisition_completion().unwrap(); + assert_eq!((terminal.id, terminal.owner), (id, owner)); + if matches!(failure, Some("output" | "metadata")) { + assert_eq!(terminal.outcome, ScreenAcquisitionOutcome::StaleLayout); + } else { + assert!(matches!( + terminal.outcome, + ScreenAcquisitionOutcome::Failed(_) + )); + } + assert!(frozen.take_acquisition_completion().is_none()); + assert!(frozen.take_capture_done()); + assert!(!frozen.is_in_progress()); + } else { + let terminal = zoom.take_source_terminal().unwrap(); + assert_eq!(terminal.id, zoom_id.unwrap()); + if matches!(failure, Some("output" | "metadata")) { + assert_eq!(terminal.outcome, ZoomSourceOutcome::StaleLayout); + } else { + assert!(matches!(terminal.outcome, ZoomSourceOutcome::Failed(_))); + } + assert!(zoom.take_source_terminal().is_none()); + assert!(zoom.take_capture_done()); + assert!(!zoom.is_in_progress()); + } + } + } + } + } +} diff --git a/src/backend/wayland/state/zoom.rs b/src/backend/wayland/state/zoom.rs index 04f2444f..aab3a9f9 100644 --- a/src/backend/wayland/state/zoom.rs +++ b/src/backend/wayland/state/zoom.rs @@ -1,4 +1,5 @@ use super::*; +use crate::backend::wayland::zoom::ZoomCaptureBackend; use crate::input::{ZoomAnchor, ZoomRequest}; impl WaylandState { @@ -210,19 +211,23 @@ impl WaylandState { warn!("Zoom capture requested while frozen capture is in progress; ignoring"); return Ok(()); } - let use_fallback = !self.zoom.manager_available(); - if use_fallback { + let backend = if self.zoom.manager_available() { + ZoomCaptureBackend::WlrScreencopy + } else { + ZoomCaptureBackend::Portal + }; + if backend == ZoomCaptureBackend::Portal { warn!("Zoom: screencopy unavailable, using portal fallback"); } else { log::info!("Zoom: using screencopy fast path"); } if !self.enter_overlay_suppression_with_keyboard_policy( OverlaySuppression::Zoom, - zoom_suppression_keyboard_policy(use_fallback), + zoom_suppression_keyboard_policy(backend), ) { anyhow::bail!("Zoom capture requested while another overlay operation is preparing"); } - match self.zoom.start_capture(use_fallback, &self.tokio_handle) { + match self.zoom.start_capture(backend) { Ok(()) => Ok(()), Err(err) => { self.exit_overlay_suppression(OverlaySuppression::Zoom); @@ -232,8 +237,10 @@ impl WaylandState { } } -fn zoom_suppression_keyboard_policy(use_fallback: bool) -> OverlaySuppressionKeyboardPolicy { - if use_fallback { +fn zoom_suppression_keyboard_policy( + backend: ZoomCaptureBackend, +) -> OverlaySuppressionKeyboardPolicy { + if backend == ZoomCaptureBackend::Portal { // Portal dialogs need the compositor to release Wayscriber's focus. OverlaySuppressionKeyboardPolicy::Release } else { @@ -290,11 +297,11 @@ mod tests { #[test] fn native_zoom_retains_keyboard_but_portal_zoom_releases_it() { assert_eq!( - zoom_suppression_keyboard_policy(false), + zoom_suppression_keyboard_policy(ZoomCaptureBackend::WlrScreencopy), OverlaySuppressionKeyboardPolicy::Retain ); assert_eq!( - zoom_suppression_keyboard_policy(true), + zoom_suppression_keyboard_policy(ZoomCaptureBackend::Portal), OverlaySuppressionKeyboardPolicy::Release ); } diff --git a/src/backend/wayland/surface.rs b/src/backend/wayland/surface.rs index 8d9b5f74..2549f73a 100644 --- a/src/backend/wayland/surface.rs +++ b/src/backend/wayland/surface.rs @@ -442,6 +442,13 @@ impl SurfaceState { Ok(()) } + /// A capture barrier may wake for a render only if a slot can be acquired. + /// Buffer releases wake Wayland dispatch; waiting on them avoids polling. + pub(super) fn has_available_buffer(&self, buffer_count: usize) -> bool { + self.slots.len() < buffer_count.max(1) + || self.slots.iter().any(|slot| !slot.has_active_buffers()) + } + /// Hands out a buffer for this frame, or `None` while the compositor still /// owns every slot. /// @@ -506,6 +513,62 @@ impl SurfaceState { mod tests { use super::*; + struct BufferGlobals(wl_shm::WlShm); + + wayland_client::delegate_noop!(BufferGlobals: ignore wayland_client::protocol::wl_registry::WlRegistry); + wayland_client::delegate_noop!(BufferGlobals: ignore wl_shm::WlShm); + + impl smithay_client_toolkit::globals::ProvidesBoundGlobal for BufferGlobals { + fn bound_global( + &self, + ) -> Result { + Ok(self.0.clone()) + } + } + + #[test] + fn capture_buffer_availability_tracks_real_slot_activation_release_and_pool_reset() { + // Only local client objects are needed: SCTK owns the slot/active-buffer + // bookkeeping. Keep a private socket peer alive; never use the desktop. + let (client, _peer) = std::os::unix::net::UnixStream::pair().unwrap(); + let connection = wayland_client::Connection::from_socket(client).unwrap(); + let queue = connection.new_event_queue::(); + let qh = queue.handle(); + let registry = connection.display().get_registry(&qh, ()); + let globals = BufferGlobals(registry.bind::(1, 1, &qh, ())); + let mut pool = SlotPool::new(128, &globals).unwrap(); + let mut surface = SurfaceState::new(SurfacePlacement::new(None, false, false)); + let first = pool.new_slot(64).unwrap(); + let second = pool.new_slot(64).unwrap(); + let first_buffer = pool + .create_buffer_in(&first, 1, 1, 4, wl_shm::Format::Argb8888) + .unwrap(); + let second_buffer = pool + .create_buffer_in(&second, 1, 1, 4, wl_shm::Format::Argb8888) + .unwrap(); + surface.slots = vec![first, second]; + + assert!(surface.has_available_buffer(2)); + + first_buffer.activate().unwrap(); + + assert!(surface.has_available_buffer(2)); + + second_buffer.activate().unwrap(); + + assert!(!surface.has_available_buffer(2)); + assert!(!surface.has_available_buffer(0)); + assert!(surface.has_available_buffer(3)); + + first_buffer.deactivate().unwrap(); + + assert!(surface.has_available_buffer(2)); + + surface.drop_pool(); + + assert!(surface.has_available_buffer(0)); + } + #[test] fn frozen_fullscreen_deadline_uses_injected_time() { let start = Instant::now(); diff --git a/src/backend/wayland/zoom/AGENTS.md b/src/backend/wayland/zoom/AGENTS.md index f4e1b1b5..abab5ac2 100644 --- a/src/backend/wayland/zoom/AGENTS.md +++ b/src/backend/wayland/zoom/AGENTS.md @@ -6,12 +6,14 @@ ## Architecture - `capture.rs` coordinates zoom capture. - `portal.rs` provides portal fallback behavior. -- `state.rs` owns zoom runtime state. +- `state.rs` owns zoom image/view state and distinguishes direct and portal capture backends. `state/source.rs` owns identified request IDs, waiters, terminal reports, and the shared terminal cleanup that the lifecycle entry points in `state.rs` (deactivate, cancel, fail, stale direct capture) delegate to; `abort_capture` stays in `state.rs` because it ends a request without touching view or input state. +- `retry.rs` retains capture ID, waiter, and requested activation through the shared portal settling policy and fresh preflight. - `view.rs` owns zoom view transforms. ## Invariants - Preserve coordinate transforms, capture-source fallback behavior, and state transitions. - Treat user cancellation as cancellation, not a hard failure. +- Portal crop and resampling run in the worker through `../portal_raster.rs`; full desktop changes invalidate portal admission without invalidating installed active-output sources. - Keep zoom behavior aligned with frozen capture, render state, and input zoom actions. ## Coupled Changes diff --git a/src/backend/wayland/zoom/capture.rs b/src/backend/wayland/zoom/capture.rs index d637202a..e1e78ca4 100644 --- a/src/backend/wayland/zoom/capture.rs +++ b/src/backend/wayland/zoom/capture.rs @@ -17,7 +17,7 @@ use crate::backend::wayland::frozen::{ use crate::backend::wayland::frozen_geometry::{OutputGeometry, require_verified_capture_source}; use crate::input::InputState; -use super::state::ZoomState; +use super::state::{ZoomCaptureBackend, ZoomState}; /// Internal capture session tracking a single screencopy frame. pub(super) struct CaptureSession { @@ -116,11 +116,11 @@ fn finalize_capture_image(image: FrozenImage, context: &CaptureContext) -> Resul } impl ZoomState { - /// Start a screencopy capture for the active output. - pub fn start_capture( + /// Queue capture using the preferred backend, with portal fallback when + /// the compositor has no screencopy manager. + pub(in crate::backend::wayland) fn start_capture( &mut self, - use_fallback: bool, - _tokio_handle: &tokio::runtime::Handle, + preferred_backend: ZoomCaptureBackend, ) -> Result<()> { anyhow::ensure!( self.source_terminal.is_none(), @@ -133,17 +133,22 @@ impl ZoomState { self.begin_identified_capture(); self.capture_done = false; + let backend = if self.manager.is_none() { + ZoomCaptureBackend::Portal + } else { + preferred_backend + }; self.preflight.begin( - use_fallback || self.manager.is_none(), + backend, self.active_output_id, - self.output_layout_generation, + self.capture_layout_generation(backend), ); Ok(()) } - pub fn begin_preflight_capture( + pub(in crate::backend::wayland) fn begin_preflight_capture( &mut self, - use_fallback: bool, + backend: ZoomCaptureBackend, shm: &Shm, qh: &QueueHandle, tokio_handle: &tokio::runtime::Handle, @@ -154,7 +159,7 @@ impl ZoomState { { self.ensure_preflight_layout_current() .map_err(anyhow::Error::msg)?; - if use_fallback || self.manager.is_none() { + if backend == ZoomCaptureBackend::Portal { info!("capture.preflight component=zoom phase=portal-start suppression_ready=true"); self.capture_via_portal(tokio_handle) } else { @@ -189,7 +194,7 @@ impl ZoomState { let context = CaptureContext::new( target_output_id, source_geometry, - self.output_layout_generation, + self.layout_generations.active_output, ); info!( @@ -357,7 +362,7 @@ impl ZoomState { if !capture .context .layout - .matches(self.active_output_id, self.output_layout_generation) + .matches(self.active_output_id, self.layout_generations.active_output) { return Ok(false); } @@ -470,12 +475,15 @@ mod tests { let mut state = ZoomState::new_with_runtime_wake(None, wake.handle()); state - .start_capture(false, &tokio::runtime::Handle::current()) + .start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::WlrScreencopy) .expect("queue portal zoom capture"); assert!(state.preflight_pending()); assert!(!state.portal.is_running()); - assert_eq!(state.take_preflight_pending(), Some(true)); + assert_eq!( + state.take_preflight_pending(), + Some(ZoomCaptureBackend::Portal) + ); } #[test] diff --git a/src/backend/wayland/zoom/mod.rs b/src/backend/wayland/zoom/mod.rs index ef202e5e..b17a2b86 100644 --- a/src/backend/wayland/zoom/mod.rs +++ b/src/backend/wayland/zoom/mod.rs @@ -1,24 +1,27 @@ mod capture; mod portal; +mod retry; mod state; mod view; pub use state::ZoomState; #[allow(unused_imports)] pub(in crate::backend::wayland) use state::{ - ZoomCaptureId, ZoomSourceOutcome, ZoomSourceTerminal, ZoomTerminalReport, ZoomWaiter, - ZoomWaiterOwner, ZoomWaiterRegistry, + ZoomCaptureBackend, ZoomCaptureId, ZoomSourceOutcome, ZoomSourceTerminal, ZoomTerminalReport, + ZoomWaiter, ZoomWaiterOwner, ZoomWaiterRegistry, }; const MIN_ZOOM_SCALE: f64 = 1.0; const MAX_ZOOM_SCALE: f64 = 8.0; +// Keep raster rejection beside the capture snapshot so layout validation runs +// before interpreting a size mismatch. Portal and decode failures remain terminal. type PortalCaptureResult = Result< ( Option, u64, crate::backend::wayland::frozen::ScreenImageProvenance, - crate::backend::wayland::frozen::FrozenImage, + Result, ), crate::capture::types::CaptureError, >; diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index 7013eade..ec5cb3ba 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -2,10 +2,10 @@ use anyhow::Result; use log::warn; use std::time::{Duration, Instant}; -use crate::backend::wayland::frozen::{FrozenImage, ScreenImageProvenance}; -use crate::backend::wayland::frozen_geometry::{OutputGeometry, require_verified_capture_source}; +use crate::backend::wayland::frozen::ScreenImageProvenance; +use crate::backend::wayland::frozen_geometry::require_verified_capture_source; use crate::backend::wayland::portal_capture::{ - capture_via_portal_fullscreen_bytes, crop_argb, portal_output_matches, + capture_via_portal_fullscreen_bytes, portal_output_matches, }; use crate::backend::wayland::portal_task::{PortalPoll, PortalTask}; use crate::capture::sources::frozen::decode_image_to_argb; @@ -35,10 +35,10 @@ impl ZoomState { ) .map_err(anyhow::Error::msg)?; - let layout_generation = self.output_layout_generation; + let layout_generation = self.layout_generations.desktop; let provenance = ScreenImageProvenance::new( target_output_id, - layout_generation, + self.layout_generations.active_output, geo.scale, geo.transform, ) @@ -57,7 +57,9 @@ impl ZoomState { let (data, width, height) = decode_image_to_argb(&bytes).map_err(|error| { CaptureError::ImageError(format!("Decode failed: {error}")) })?; - let image = crop_portal_image(data, width, height, &geo)?; + let image = crate::backend::wayland::portal_raster::crop_portal_raster( + data, width, height, &geo, + ); Ok((Some(target_output_id), layout_generation, provenance, image)) } @@ -89,26 +91,38 @@ impl ZoomState { let poll = self.portal.poll(); match poll { PortalPoll::Ready(Ok((target_output, layout_generation, provenance, image))) => { + log::info!( + "portal.zoom captured_output={target_output:?} active_output={:?} captured_layout={layout_generation} active_layout={}", + self.active_output_id, + self.layout_generations.desktop + ); let output_matches = portal_output_matches(target_output, self.active_output_id); - let layout_matches = layout_generation == self.output_layout_generation; + let layout_matches = layout_generation == self.layout_generations.desktop; if output_matches && layout_matches { - // Crop used the spawn-time geometry moved into the task. - // A processed topology change updates `known_output_count`, - // so `OutputGeometry`'s equality bumps - // `output_layout_generation` and `layout_matches` drops the - // result. This live-count check covers the SCTK window - // where a new `wl_output` is already in `OutputState` - // before `new_output` refreshes `active_geometry`. Freeze - // instead revalidates the pending snapshot, because it - // crops on the Wayland thread at activate. + // Crop used the spawn-time geometry. SCTK may advertise + // a new wl_output before its callback refreshes that snapshot. if self.active_geometry.as_ref().is_some_and(|geometry| { geometry.output_count_conflicts_with_live(live_output_count) }) { warn!("Portal zoom capture discarded after output topology changed"); + if self.queue_portal_layout_retry(target_output, true) { + return; + } self.finish_failed_portal_task(input_state, ZoomSourceOutcome::StaleLayout); return; } + let image = match image { + Ok(image) => image, + Err(err) => { + warn!("Portal zoom raster rejected: {err}"); + self.finish_failed_portal_task( + input_state, + ZoomSourceOutcome::Failed(err.to_string()), + ); + return; + } + }; self.install_image(image, provenance); } else { if !layout_matches { @@ -116,6 +130,9 @@ impl ZoomState { } else { warn!("Portal zoom capture for inactive output discarded"); } + if self.queue_portal_layout_retry(target_output, !layout_matches) { + return; + } self.finish_failed_portal_task(input_state, ZoomSourceOutcome::StaleLayout); return; } @@ -190,62 +207,11 @@ impl ZoomState { } } -fn crop_portal_image( - data: Vec, - width: u32, - height: u32, - geometry: &OutputGeometry, -) -> Result { - let expected_len = u64::from(width) - .checked_mul(u64::from(height)) - .and_then(|pixels| pixels.checked_mul(4)) - .and_then(|bytes| usize::try_from(bytes).ok()) - .ok_or_else(|| CaptureError::ImageError("Zoom capture is too large".to_string()))?; - if data.len() != expected_len { - return Err(CaptureError::ImageError( - "Zoom capture buffer length does not match its dimensions".to_string(), - )); - } - - let (phys_w, phys_h) = geometry.verified_pixel_size().ok_or_else(|| { - CaptureError::ImageError("Zoom capture output dimensions are invalid".to_string()) - })?; - let buffer_size = geometry.buffer_size(); - if !OutputGeometry::dimensions_have_compatible_aspect((phys_w, phys_h), buffer_size) { - return Err(CaptureError::ImageError( - "Zoom capture aspect does not match the overlay surface".to_string(), - )); - } - let (origin_x, origin_y) = geometry.portal_crop_origin(width, height).ok_or_else(|| { - CaptureError::ImageError("Zoom capture does not match the active output layout".to_string()) - })?; - let (cropped_w, cropped_h, cropped) = - crop_argb(&data, width, height, origin_x, origin_y, phys_w, phys_h).ok_or_else(|| { - CaptureError::ImageError("Zoom capture does not contain the active output".to_string()) - })?; - if cropped_w != phys_w || cropped_h != phys_h { - return Err(CaptureError::ImageError( - "Zoom capture does not contain the active output".to_string(), - )); - } - let stride = i32::try_from( - cropped_w - .checked_mul(4) - .ok_or_else(|| CaptureError::ImageError("Zoom capture stride overflow".to_string()))?, - ) - .map_err(|_| CaptureError::ImageError("Zoom capture stride is too large".to_string()))?; - - Ok(FrozenImage { - width: cropped_w, - height: cropped_h, - stride, - data: cropped, - }) -} - #[cfg(test)] mod tests { use super::*; + use crate::backend::wayland::frozen::FrozenImage; + use crate::backend::wayland::frozen_geometry::OutputGeometry; use crate::backend::wayland::portal_task::PORTAL_CAPTURE_TIMEOUT; use crate::input::state::test_support::make_test_input_state; @@ -290,6 +256,7 @@ mod tests { screenshot_origin: Some(origin), screenshot_size: None, known_output_count: None, + portal_outputs: None, } } @@ -339,13 +306,81 @@ mod tests { screenshot_height: Some(3), })); - let image = crop_portal_image(vec![7; 5 * 3 * 4], 5, 3, &geometry) - .expect("fractional output screenshot"); + let image = crate::backend::wayland::portal_raster::crop_portal_raster( + vec![7; 5 * 3 * 4], + 5, + 3, + &geometry, + ) + .expect("fractional output screenshot"); assert_eq!((image.width, image.height), (5, 3)); assert_eq!(image.stride, 20); } + #[test] + fn mixed_scale_workspace_crops_the_correct_monitor() { + use crate::capture::{DesktopBackdropGeometry, DesktopBackdropOutputGeometry}; + let outputs = [ + DesktopBackdropOutputGeometry { + logical_x: 0, + logical_y: 0, + logical_width: 8, + logical_height: 4, + physical_width: 8, + physical_height: 4, + }, + DesktopBackdropOutputGeometry { + logical_x: 8, + logical_y: 0, + logical_width: 4, + logical_height: 2, + physical_width: 8, + physical_height: 4, + }, + ]; + let mut pixels = Vec::new(); + for _y in 0..8 { + for x in 0..24 { + pixels.extend_from_slice( + &if x < 16 { + 0xff112233_u32 + } else { + 0xffaabbcc_u32 + } + .to_ne_bytes(), + ); + } + } + for (index, color) in [(0, 0xff112233_u32), (1, 0xffaabbcc_u32)] { + let active = outputs[index]; + let geometry = OutputGeometry::update_from( + Some((active.logical_x, active.logical_y)), + Some((active.logical_width as i32, active.logical_height as i32)), + (active.logical_width, active.logical_height), + if index == 0 { 1 } else { 2 }, + wayland_client::protocol::wl_output::Transform::Normal, + Some((8, 4)), + ) + .unwrap() + .with_desktop_backdrop_geometry(DesktopBackdropGeometry::from_outputs(active, &outputs)) + .with_portal_outputs(Some(outputs.to_vec())); + let image = crate::backend::wayland::portal_raster::crop_portal_raster( + pixels.clone(), + 24, + 8, + &geometry, + ) + .expect("KDE mixed-scale crop"); + assert_eq!((image.width, image.height), (8, 4)); + assert_eq!( + image.data, + color.to_ne_bytes().repeat(32), + "monitor {index}" + ); + } + } + #[test] fn portal_crop_rejects_a_different_screenshot_layout() { let geometry = crop_geometry((0, 0)).with_desktop_backdrop_geometry(Some( @@ -363,7 +398,15 @@ mod tests { }, )); - assert!(crop_portal_image(vec![0; 3 * 4], 3, 1, &geometry).is_err()); + assert!( + crate::backend::wayland::portal_raster::crop_portal_raster( + vec![0; 3 * 4], + 3, + 1, + &geometry + ) + .is_err() + ); } async fn poll_until_finished(zoom: &mut ZoomState, input: &mut InputState) { @@ -396,7 +439,7 @@ mod tests { zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async { Ok((Some(1), 0, provenance(1, 0), image(3))) }, + async { Ok((Some(1), 0, provenance(1, 0), Ok(image(3)))) }, )); poll_until_finished(&mut zoom, &mut input).await; @@ -530,7 +573,7 @@ mod tests { zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), - async { Ok((Some(1), 0, provenance(1, 0), image(9))) }, + async { Ok((Some(1), 0, provenance(1, 0), Ok(image(9)))) }, )); poll_until_finished(&mut zoom, &mut input).await; @@ -552,16 +595,17 @@ mod tests { } #[tokio::test] - async fn stale_layout_preserves_the_current_zoom_image_and_activation() { + async fn exhausted_layout_retry_preserves_the_current_zoom_image_and_activation() { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); let id = zoom.begin_identified_capture(); + zoom.layout_retry = crate::backend::wayland::capture_preflight::PortalLayoutRetry::Spent; zoom.set_image(image(4)); let generation = zoom.image_generation(); zoom.set_active_geometry(Some(crop_geometry((0, 0)))); zoom.set_active_output(None, Some(1)); - let layout_generation = zoom.output_layout_generation; + let layout_generation = zoom.layout_generations.active_output; zoom.request_activation(); zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), @@ -571,7 +615,7 @@ mod tests { Some(1), layout_generation, provenance(1, layout_generation), - image(9), + Ok(image(9)), )) }, )); @@ -604,7 +648,7 @@ mod tests { let geometry = crop_geometry((0, 0)); zoom.set_active_geometry(Some(geometry.clone())); zoom.set_active_output(None, Some(1)); - let layout_generation = zoom.output_layout_generation; + let layout_generation = zoom.layout_generations.active_output; zoom.request_activation(); zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), @@ -614,7 +658,7 @@ mod tests { Some(1), layout_generation, provenance(1, layout_generation), - image(3), + Ok(image(3)), )) }, )); @@ -639,16 +683,17 @@ mod tests { } #[tokio::test] - async fn stale_live_output_count_discards_a_single_output_portal_image() { + async fn exhausted_topology_retry_discards_a_single_output_portal_image() { let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); let id = zoom.begin_identified_capture(); + zoom.layout_retry = crate::backend::wayland::capture_preflight::PortalLayoutRetry::Spent; zoom.set_image(image(4)); let generation = zoom.image_generation(); zoom.set_active_geometry(Some(crop_geometry((0, 0)).with_known_output_count(Some(1)))); zoom.set_active_output(None, Some(1)); - let layout_generation = zoom.output_layout_generation; + let layout_generation = zoom.layout_generations.active_output; zoom.request_activation(); zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), @@ -658,7 +703,7 @@ mod tests { Some(1), layout_generation, provenance(1, layout_generation), - image(9), + Ok(image(9)), )) }, )); diff --git a/src/backend/wayland/zoom/retry.rs b/src/backend/wayland/zoom/retry.rs new file mode 100644 index 00000000..0e36e7ca --- /dev/null +++ b/src/backend/wayland/zoom/retry.rs @@ -0,0 +1,327 @@ +use super::state::{ZoomCaptureBackend, ZoomState}; +use crate::backend::wayland::capture_preflight::CapturePreflightError; +use std::time::{Duration, Instant}; + +impl ZoomState { + pub(super) fn queue_portal_layout_retry( + &mut self, + captured: Option, + layout_changed: bool, + ) -> bool { + if self.current_capture_id().is_none() { + return false; + } + + if !self.layout_retry.schedule( + captured, + self.active_output_id, + layout_changed, + self.layout_generations.desktop, + Instant::now(), + ) { + return false; + } + + self.portal.finish(); + self.capture_done = false; + log::info!( + "portal.zoom phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", + self.layout_generations.desktop + ); + + true + } + + pub(in crate::backend::wayland) fn retry_stale_portal_preflight( + &mut self, + backend: ZoomCaptureBackend, + ) -> bool { + let portal_layout_changed = (backend == ZoomCaptureBackend::Portal) + && self + .preflight + .changed_on_output(self.active_output_id, self.layout_generations.desktop); + self.queue_portal_layout_retry(self.active_output_id, portal_layout_changed) + } + + pub(in crate::backend::wayland) fn has_portal_layout_retry(&self) -> bool { + self.layout_retry.is_pending() + } + + pub(in crate::backend::wayland) fn portal_layout_retry_timeout( + &self, + now: Instant, + ) -> Option { + self.layout_retry.timeout(now) + } + + /// Retains the original capture ID, waiter, and requested Zoom activation. + pub(in crate::backend::wayland) fn restart_portal_preflight( + &mut self, + now: Instant, + ) -> Result { + let Some(output_id) = self + .layout_retry + .take_ready( + self.active_output_id, + self.layout_generations.desktop, + self.active_geometry.as_ref(), + now, + ) + .map_err(CapturePreflightError::from)? + else { + return Ok(false); + }; + + self.preflight.begin( + ZoomCaptureBackend::Portal, + Some(output_id), + self.layout_generations.desktop, + ); + log::info!( + "portal.zoom phase=retry-preflight output={output_id} layout={}", + self.layout_generations.desktop + ); + + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::frozen::{FrozenImage, ScreenImageProvenance}; + use crate::backend::wayland::frozen_geometry::OutputGeometry; + use crate::backend::wayland::portal_task::PortalTask; + use crate::backend::wayland::zoom::{ + ZoomSourceOutcome, ZoomWaiter, ZoomWaiterOwner, ZoomWaiterRegistry, + }; + use crate::capture::CaptureError; + use crate::input::state::test_support::make_test_input_state; + use wayland_client::protocol::wl_output; + + fn geometry(x: i32) -> OutputGeometry { + OutputGeometry::update_from( + Some((x, 0)), + Some((2, 1)), + (2, 1), + 1, + wl_output::Transform::Normal, + Some((2, 1)), + ) + .unwrap() + .with_known_output_count(Some(1)) + } + + fn image(byte: u8) -> FrozenImage { + FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![byte; 8], + } + } + + async fn drain(zoom: &mut ZoomState, input: &mut crate::input::InputState) { + for _ in 0..100 { + zoom.poll_portal_capture(input, Instant::now(), Some(1)); + if !zoom.portal.is_running() { + return; + } + tokio::task::yield_now().await; + } + + panic!("portal task did not finish"); + } + + #[tokio::test] + async fn stale_raster_retries_once_with_the_same_waiter_and_activation() { + for second_change in [false, true] { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); + let mut input = make_test_input_state(); + zoom.set_active_output(None, Some(1)); + zoom.set_active_geometry(Some(geometry(0))); + zoom.start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::Portal) + .unwrap(); + assert_eq!( + zoom.take_preflight_pending(), + Some(ZoomCaptureBackend::Portal) + ); + let id = zoom.current_capture_id().unwrap(); + let mut waiters = ZoomWaiterRegistry::default(); + assert!(waiters.register(ZoomWaiter { + id, + owner: ZoomWaiterOwner::Ocr + })); + zoom.request_activation(); + + let old_generation = zoom.layout_generations.desktop; + zoom.portal.start(PortalTask::spawn( + &tokio::runtime::Handle::current(), + wake.handle(), + async move { + Ok(( + Some(1), + old_generation, + ScreenImageProvenance::new( + 1, + old_generation, + 1, + wl_output::Transform::Normal, + ) + .unwrap(), + Err(CaptureError::ImageError("old raster size".to_string())), + )) + }, + )); + + zoom.set_active_geometry(Some(geometry(8))); + + drain(&mut zoom, &mut input).await; + + assert!(zoom.is_in_progress()); + assert!(zoom.has_portal_layout_retry()); + assert!(zoom.pending_activation); + assert!(!zoom.take_capture_done()); + assert!(zoom.take_source_terminal().is_none()); + assert_eq!(zoom.current_capture_id(), Some(id)); + assert!( + zoom.restart_portal_preflight(Instant::now() + Duration::from_millis(150)) + .unwrap() + ); + assert_eq!( + zoom.take_preflight_pending(), + Some(ZoomCaptureBackend::Portal) + ); + assert!(zoom.ensure_preflight_layout_current().is_ok()); + + let fresh_generation = zoom.layout_generations.desktop; + zoom.portal.start(PortalTask::spawn( + &tokio::runtime::Handle::current(), + wake.handle(), + async move { + Ok(( + Some(1), + fresh_generation, + ScreenImageProvenance::new( + 1, + fresh_generation, + 1, + wl_output::Transform::Normal, + ) + .unwrap(), + Ok(image(9)), + )) + }, + )); + + if second_change { + zoom.set_active_geometry(Some(geometry(16))); + } + + drain(&mut zoom, &mut input).await; + + let terminal = zoom.take_source_terminal().unwrap(); + assert_eq!(terminal.id, id); + assert!(waiters.take_for_terminal(&terminal).unwrap().1); + assert!(!zoom.has_portal_layout_retry()); + assert!(zoom.take_capture_done()); + assert!(zoom.take_source_terminal().is_none()); + + if second_change { + assert_eq!(terminal.outcome, ZoomSourceOutcome::StaleLayout); + assert!(!zoom.active); + assert!(zoom.image().is_none()); + } else { + assert!(matches!(terminal.outcome, ZoomSourceOutcome::Ready { .. })); + assert!(zoom.active); + assert_eq!(zoom.image().unwrap().data, vec![9; 8]); + assert_eq!( + zoom.image_provenance().unwrap().output_layout_generation, + fresh_generation + ); + } + } + } + + #[tokio::test] + async fn cancellation_and_stable_raster_failure_do_not_schedule_retry() { + for cancelled in [false, true] { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); + let mut input = make_test_input_state(); + zoom.set_active_output(None, Some(1)); + zoom.set_active_geometry(Some(geometry(0))); + let id = zoom.begin_identified_capture(); + let generation = zoom.layout_generations.active_output; + zoom.portal.start(PortalTask::spawn( + &tokio::runtime::Handle::current(), + wake.handle(), + async move { + if cancelled { + Err(CaptureError::Cancelled("dismissed".to_string())) + } else { + Ok(( + Some(1), + generation, + ScreenImageProvenance::new( + 1, + generation, + 1, + wl_output::Transform::Normal, + ) + .unwrap(), + Err(CaptureError::ImageError("stable wrong size".to_string())), + )) + } + }, + )); + if cancelled { + zoom.set_active_geometry(Some(geometry(8))); + } + + drain(&mut zoom, &mut input).await; + + assert!(!zoom.has_portal_layout_retry()); + assert!(!zoom.is_in_progress()); + + let terminal = zoom.take_source_terminal().unwrap(); + assert_eq!(terminal.id, id); + assert!(if cancelled { + terminal.outcome == ZoomSourceOutcome::Cancelled + } else { + matches!(terminal.outcome, ZoomSourceOutcome::Failed(_)) + }); + } + } + + #[tokio::test] + async fn abort_clears_a_queued_retry_and_output_switch_cannot_redirect_it() { + for switch_output in [false, true] { + let mut zoom = ZoomState::new(None); + zoom.set_active_output(None, Some(1)); + zoom.set_active_geometry(Some(geometry(0))); + let id = zoom.begin_identified_capture(); + zoom.request_activation(); + assert!(zoom.queue_portal_layout_retry(Some(1), true)); + + if switch_output { + zoom.set_active_output(None, Some(2)); + + assert!( + zoom.restart_portal_preflight(Instant::now() + Duration::from_millis(150)) + .is_err() + ); + } + + assert!(zoom.abort_capture()); + assert!(!zoom.has_portal_layout_retry()); + assert!( + !zoom + .restart_portal_preflight(Instant::now() + Duration::from_millis(150)) + .unwrap() + ); + assert_eq!(zoom.take_source_terminal().unwrap().id, id); + } + } +} diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs index ddb3ee65..d3428049 100644 --- a/src/backend/wayland/zoom/state.rs +++ b/src/backend/wayland/zoom/state.rs @@ -1,4 +1,6 @@ -use crate::backend::wayland::capture_preflight::CapturePreflight; +use crate::backend::wayland::capture_preflight::{ + CaptureLayoutGenerations, CapturePreflight, CapturePreflightError, PortalLayoutRetry, +}; use std::sync::Arc; use wayland_client::protocol::wl_output; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; @@ -12,91 +14,17 @@ use crate::input::InputState; use super::capture::CaptureSession; use super::{MIN_ZOOM_SCALE, PortalCaptureResult}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(in crate::backend::wayland) struct ZoomCaptureId(u64); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(in crate::backend::wayland) enum ZoomSourceOutcome { - Ready { installed_generation: u64 }, - Aborted, - Cancelled, - Deactivated, - StaleLayout, - Failed(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(in crate::backend::wayland) struct ZoomSourceTerminal { - pub id: ZoomCaptureId, - pub outcome: ZoomSourceOutcome, - pub report: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(in crate::backend::wayland) struct ZoomTerminalReport { - pub source: &'static str, - pub message: String, -} - -#[cfg(test)] -impl ZoomSourceTerminal { - pub fn for_test(outcome: ZoomSourceOutcome, report: Option) -> Self { - Self { - id: ZoomCaptureId(1), - outcome, - report, - } - } -} +mod source; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(in crate::backend::wayland) enum ZoomWaiterOwner { - Eyedropper, - Ocr, - RegionCapture, -} +pub(in crate::backend::wayland) use source::{ + ZoomCaptureId, ZoomSourceOutcome, ZoomSourceTerminal, ZoomTerminalReport, ZoomWaiter, + ZoomWaiterOwner, ZoomWaiterRegistry, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::backend::wayland) struct ZoomWaiter { - pub id: ZoomCaptureId, - pub owner: ZoomWaiterOwner, -} - -#[derive(Debug, Default)] -pub(in crate::backend::wayland) struct ZoomWaiterRegistry { - waiter: Option, -} - -impl ZoomWaiterRegistry { - pub fn register(&mut self, waiter: ZoomWaiter) -> bool { - if self.waiter.is_some() { - return false; - } - self.waiter = Some(waiter); - true - } - - #[cfg(test)] - pub fn waiter(&self) -> Option { - self.waiter - } - - pub fn take_for_terminal( - &mut self, - terminal: &ZoomSourceTerminal, - ) -> Option<(ZoomWaiter, bool)> { - let waiter = self.waiter.take()?; - let matches = waiter.id == terminal.id; - Some((waiter, matches)) - } - - pub fn clear_owner(&mut self, owner: ZoomWaiterOwner) -> bool { - if !self.waiter.is_some_and(|waiter| waiter.owner == owner) { - return false; - } - self.waiter.take(); - true - } +pub(in crate::backend::wayland) enum ZoomCaptureBackend { + WlrScreencopy, + Portal, } /// Zoom state, capture logic, and pan/lock bookkeeping. @@ -105,9 +33,8 @@ pub struct ZoomState { pub(super) active_output: Option, pub(super) active_output_id: Option, pub(super) active_geometry: Option, - /// Bumped when freeze/zoom crop geometry actually changes so in-flight - /// portal captures can be rejected after a layout change. - pub(super) output_layout_generation: u64, + /// Shared active-output and full-desktop validity generations. + pub(super) layout_generations: CaptureLayoutGenerations, pub(super) capture: Option, pub(super) image: Option>, image_provenance: Option, @@ -115,8 +42,9 @@ pub struct ZoomState { image_generation: u64, pub(super) portal: PortalOperation, pub(super) runtime_wake: Option, - pub(super) preflight: CapturePreflight, + pub(super) preflight: CapturePreflight, pub(super) capture_done: bool, + pub(super) layout_retry: PortalLayoutRetry, next_capture_id: u64, current_capture_id: Option, pub(super) source_terminal: Option, @@ -151,7 +79,7 @@ impl ZoomState { active_output: None, active_output_id: None, active_geometry: None, - output_layout_generation: 0, + layout_generations: CaptureLayoutGenerations::default(), capture: None, image: None, image_provenance: None, @@ -161,6 +89,7 @@ impl ZoomState { runtime_wake, preflight: CapturePreflight::Idle, capture_done: false, + layout_retry: PortalLayoutRetry::default(), next_capture_id: 1, current_capture_id: None, source_terminal: None, @@ -184,9 +113,8 @@ impl ZoomState { } pub fn set_active_geometry(&mut self, geometry: Option) { - if self.active_geometry != geometry { - self.output_layout_generation = self.output_layout_generation.wrapping_add(1); - } + self.layout_generations + .update(self.active_geometry.as_ref(), geometry.as_ref()); self.active_geometry = geometry; } @@ -195,7 +123,7 @@ impl ZoomState { provenance: ScreenImageProvenance, ) -> bool { self.active_output_id == Some(provenance.output_id) - && self.output_layout_generation == provenance.output_layout_generation + && self.layout_generations.active_output == provenance.output_layout_generation } pub(in crate::backend::wayland) fn image_provenance(&self) -> Option { @@ -269,7 +197,10 @@ impl ZoomState { } pub fn is_in_progress(&self) -> bool { - self.capture.is_some() || self.portal.is_running() || self.preflight.is_pending() + self.capture.is_some() + || self.portal.is_running() + || self.preflight.is_pending() + || self.layout_retry.is_pending() } #[cfg(test)] @@ -277,27 +208,38 @@ impl ZoomState { self.preflight.is_pending() } - pub fn take_preflight_pending(&mut self) -> Option { + pub(in crate::backend::wayland) fn take_preflight_pending( + &mut self, + ) -> Option { self.preflight.take_pending() } #[cfg(test)] pub(super) fn snapshot_preflight_layout(&mut self) { - self.preflight - .begin(true, self.active_output_id, self.output_layout_generation); - } - - pub(super) fn ensure_preflight_layout_current(&self) -> Result<(), String> { - if self - .preflight - .layout_matches(self.active_output_id, self.output_layout_generation) - { - Ok(()) - } else { - Err("Zoom failed after the display layout changed".to_string()) + self.preflight.begin( + ZoomCaptureBackend::Portal, + self.active_output_id, + self.layout_generations.desktop, + ); + } + + pub(super) fn capture_layout_generation(&self, backend: ZoomCaptureBackend) -> u64 { + match backend { + ZoomCaptureBackend::Portal => self.layout_generations.desktop, + ZoomCaptureBackend::WlrScreencopy => self.layout_generations.active_output, } } + pub(super) fn ensure_preflight_layout_current(&self) -> Result<(), CapturePreflightError> { + self.preflight.ensure_layout_current( + self.active_output_id, + self.preflight + .backend() + .map(|backend| self.capture_layout_generation(backend)) + .unwrap_or(self.layout_generations.active_output), + ) + } + #[cfg(test)] pub fn preflight_layout_is_current(&self) -> bool { self.ensure_preflight_layout_current().is_ok() @@ -313,54 +255,6 @@ impl ZoomState { done } - pub(in crate::backend::wayland) fn current_capture_id(&self) -> Option { - self.current_capture_id - } - - pub(in crate::backend::wayland) fn take_source_terminal( - &mut self, - ) -> Option { - self.source_terminal.take() - } - - pub(super) fn begin_identified_capture(&mut self) -> ZoomCaptureId { - let id = ZoomCaptureId(self.next_capture_id); - self.next_capture_id = self - .next_capture_id - .checked_add(1) - .expect("zoom capture id space exhausted"); - self.current_capture_id = Some(id); - id - } - - pub(super) fn finish_source_capture(&mut self, outcome: ZoomSourceOutcome) { - self.finish_source_capture_with_report(outcome, None); - } - - fn finish_source_capture_with_report( - &mut self, - outcome: ZoomSourceOutcome, - report: Option, - ) { - let Some(id) = self.current_capture_id.take() else { - return; - }; - let report = report.or_else(|| { - matches!(outcome, ZoomSourceOutcome::StaleLayout).then(|| ZoomTerminalReport { - source: "zoom", - message: "Zoom failed after the display layout changed".to_string(), - }) - }); - debug_assert!(self.source_terminal.is_none()); - if self.source_terminal.is_none() { - self.source_terminal = Some(ZoomSourceTerminal { - id, - outcome, - report, - }); - } - } - pub fn is_engaged(&self) -> bool { self.active || self.pending_activation } @@ -377,7 +271,7 @@ impl ZoomState { } pub fn abort_capture(&mut self) -> bool { - let mut changed = self.pending_activation; + let mut changed = self.pending_activation || self.layout_retry.is_pending(); if let Some(capture) = self.capture.take() { capture.frame.destroy(); changed = true; @@ -386,6 +280,7 @@ impl ZoomState { changed = true; } self.preflight = CapturePreflight::Idle; + self.layout_retry = PortalLayoutRetry::default(); self.portal.finish(); self.pending_activation = false; if changed { @@ -424,60 +319,6 @@ impl ZoomState { ); } - pub(in crate::backend::wayland) fn finish_preflight_failure( - &mut self, - input_state: &mut InputState, - message: String, - ) { - let report = ZoomTerminalReport { - source: "zoom", - message: message.clone(), - }; - let outcome = if message == "Zoom failed after the display layout changed" { - ZoomSourceOutcome::StaleLayout - } else { - ZoomSourceOutcome::Failed(message) - }; - self.cancel_with_outcome_and_report(input_state, false, outcome, Some(report)); - } - - pub(super) fn cancel_with_outcome( - &mut self, - input_state: &mut InputState, - force_reset: bool, - outcome: ZoomSourceOutcome, - ) { - self.cancel_with_outcome_and_report(input_state, force_reset, outcome, None); - } - - fn cancel_with_outcome_and_report( - &mut self, - input_state: &mut InputState, - force_reset: bool, - outcome: ZoomSourceOutcome, - report: Option, - ) { - if let Some(capture) = self.capture.take() { - capture.frame.destroy(); - } - self.preflight = CapturePreflight::Idle; - self.capture_done = true; - self.portal.finish(); - self.pending_activation = false; - self.finish_source_capture_with_report(outcome, report); - - if force_reset || self.image.is_none() { - self.active = false; - self.locked = false; - self.reset_view(); - self.clear_image(); - } - - input_state.set_zoom_status(self.active, self.locked, self.scale, self.view_offset); - input_state.dirty_tracker.mark_full(); - input_state.needs_redraw = true; - } - fn bump_image_generation(&mut self) { self.image_generation = self.image_generation.wrapping_add(1).max(1); } @@ -497,7 +338,7 @@ mod tests { assert_eq!(state.take_source_terminal(), None); state - .start_capture(false, &tokio::runtime::Handle::current()) + .start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::WlrScreencopy) .expect("first capture starts"); let first = state.current_capture_id().expect("first capture id"); assert!(state.abort_capture()); @@ -511,7 +352,7 @@ mod tests { ); state - .start_capture(false, &tokio::runtime::Handle::current()) + .start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::WlrScreencopy) .expect("second capture starts"); let second = state.current_capture_id().expect("second capture id"); assert!(second > first); @@ -522,13 +363,13 @@ mod tests { let mut state = ZoomState::new(None); state - .start_capture(false, &tokio::runtime::Handle::current()) + .start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::WlrScreencopy) .expect("first capture starts"); let first = state.current_capture_id().expect("first capture id"); assert!(state.abort_capture()); let error = state - .start_capture(false, &tokio::runtime::Handle::current()) + .start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::WlrScreencopy) .expect_err("an undrained terminal blocks the next capture"); assert_eq!( @@ -547,24 +388,48 @@ mod tests { ); } - #[test] - fn mismatched_terminal_takes_the_waiter_for_fail_closed_owner_cancellation() { - let stale_id = ZoomCaptureId(3); - let newer = ZoomWaiter { - id: ZoomCaptureId(4), - owner: ZoomWaiterOwner::Ocr, - }; - let mut registry = ZoomWaiterRegistry::default(); - assert!(registry.register(newer)); + #[tokio::test] + async fn portal_desktop_change_preserves_installed_source_but_invalidates_preflight() { + let mut state = ZoomState::new(None); + state.set_active_output(None, Some(7)); + let geometry = OutputGeometry::update_from( + Some((0, 0)), + Some((2, 1)), + (2, 1), + 1, + wl_output::Transform::Normal, + Some((2, 1)), + ) + .unwrap(); + state.set_active_geometry(Some(geometry.clone())); + let provenance = ScreenImageProvenance::new( + 7, + state.layout_generations.active_output, + 1, + wl_output::Transform::Normal, + ) + .unwrap(); + state.install_image( + FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![7; 8], + }, + provenance, + ); + state + .start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::Portal) + .unwrap(); + let generation = state.image_generation(); + let mut changed = geometry.with_known_output_count(Some(2)); + changed.screenshot_size = Some((4, 1)); - let terminal = ZoomSourceTerminal { - id: stale_id, - outcome: ZoomSourceOutcome::Failed("old failure".to_string()), - report: None, - }; + state.set_active_geometry(Some(changed)); - assert_eq!(registry.take_for_terminal(&terminal), Some((newer, false))); - assert_eq!(registry.waiter(), None); + assert!(state.source_context_matches(provenance)); + assert_eq!(state.image_generation(), generation); + assert!(state.ensure_preflight_layout_current().is_err()); } #[test] @@ -633,23 +498,31 @@ mod tests { #[test] fn preflight_failure_terminal_carries_the_specific_error_report() { - let mut state = ZoomState::new(None); - let mut input_state = make_test_input_state(); - let id = state.begin_identified_capture(); + for message in [ + "specific backend failure", + "Zoom failed after the display layout changed", + ] { + let mut state = ZoomState::new(None); + let mut input_state = make_test_input_state(); + let id = state.begin_identified_capture(); - state.finish_preflight_failure(&mut input_state, "specific backend failure".to_string()); + state.finish_preflight_failure( + &mut input_state, + CapturePreflightError::Backend(message.to_string()), + ); - assert_eq!( - state.take_source_terminal(), - Some(ZoomSourceTerminal { - id, - outcome: ZoomSourceOutcome::Failed("specific backend failure".to_string()), - report: Some(ZoomTerminalReport { - source: "zoom", - message: "specific backend failure".to_string(), - }), - }) - ); + assert_eq!( + state.take_source_terminal(), + Some(ZoomSourceTerminal { + id, + outcome: ZoomSourceOutcome::Failed(message.to_string()), + report: Some(ZoomTerminalReport { + source: "zoom", + message: message.to_string(), + }), + }) + ); + } } #[test] diff --git a/src/backend/wayland/zoom/state/source.rs b/src/backend/wayland/zoom/state/source.rs new file mode 100644 index 00000000..8bb280b1 --- /dev/null +++ b/src/backend/wayland/zoom/state/source.rs @@ -0,0 +1,232 @@ +//! Identified Zoom requests, waiters, terminal reports and cancellation cleanup. +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(in crate::backend::wayland) struct ZoomCaptureId(u64); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) enum ZoomSourceOutcome { + Ready { installed_generation: u64 }, + Aborted, + Cancelled, + Deactivated, + StaleLayout, + Failed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ZoomSourceTerminal { + pub id: ZoomCaptureId, + pub outcome: ZoomSourceOutcome, + pub report: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ZoomTerminalReport { + pub source: &'static str, + pub message: String, +} + +#[cfg(test)] +impl ZoomSourceTerminal { + pub fn for_test(outcome: ZoomSourceOutcome, report: Option) -> Self { + Self { + id: ZoomCaptureId(1), + outcome, + report, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(in crate::backend::wayland) enum ZoomWaiterOwner { + Eyedropper, + Ocr, + RegionCapture, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ZoomWaiter { + pub id: ZoomCaptureId, + pub owner: ZoomWaiterOwner, +} + +#[derive(Debug, Default)] +pub(in crate::backend::wayland) struct ZoomWaiterRegistry { + waiter: Option, +} + +impl ZoomWaiterRegistry { + pub fn register(&mut self, waiter: ZoomWaiter) -> bool { + if self.waiter.is_some() { + return false; + } + + self.waiter = Some(waiter); + + true + } + + #[cfg(test)] + pub fn waiter(&self) -> Option { + self.waiter + } + + pub fn take_for_terminal( + &mut self, + terminal: &ZoomSourceTerminal, + ) -> Option<(ZoomWaiter, bool)> { + let waiter = self.waiter.take()?; + let matches = waiter.id == terminal.id; + Some((waiter, matches)) + } + + pub fn clear_owner(&mut self, owner: ZoomWaiterOwner) -> bool { + if !self.waiter.is_some_and(|waiter| waiter.owner == owner) { + return false; + } + + self.waiter.take(); + + true + } +} + +impl ZoomState { + pub(in crate::backend::wayland) fn current_capture_id(&self) -> Option { + self.current_capture_id + } + + pub(in crate::backend::wayland) fn take_source_terminal( + &mut self, + ) -> Option { + self.source_terminal.take() + } + + pub(in crate::backend::wayland::zoom) fn begin_identified_capture(&mut self) -> ZoomCaptureId { + let id = ZoomCaptureId(self.next_capture_id); + self.next_capture_id = self + .next_capture_id + .checked_add(1) + .expect("zoom capture id space exhausted"); + self.current_capture_id = Some(id); + self.layout_retry = PortalLayoutRetry::default(); + + id + } + + pub(in crate::backend::wayland::zoom) fn finish_source_capture( + &mut self, + outcome: ZoomSourceOutcome, + ) { + self.finish_source_capture_with_report(outcome, None); + } + + fn finish_source_capture_with_report( + &mut self, + outcome: ZoomSourceOutcome, + report: Option, + ) { + self.layout_retry = PortalLayoutRetry::default(); + let Some(id) = self.current_capture_id.take() else { + return; + }; + + let report = report.or_else(|| { + matches!(outcome, ZoomSourceOutcome::StaleLayout).then(|| ZoomTerminalReport { + source: "zoom", + message: CapturePreflightError::StaleLayout.message("Zoom"), + }) + }); + debug_assert!(self.source_terminal.is_none()); + if self.source_terminal.is_none() { + self.source_terminal = Some(ZoomSourceTerminal { + id, + outcome, + report, + }); + } + } + + pub(in crate::backend::wayland) fn finish_preflight_failure( + &mut self, + input_state: &mut InputState, + error: CapturePreflightError, + ) { + let message = error.message("Zoom"); + let report = ZoomTerminalReport { + source: "zoom", + message: message.clone(), + }; + let outcome = if error.is_stale_layout() { + ZoomSourceOutcome::StaleLayout + } else { + ZoomSourceOutcome::Failed(message) + }; + + self.cancel_with_outcome_and_report(input_state, false, outcome, Some(report)); + } + + pub(in crate::backend::wayland::zoom) fn cancel_with_outcome( + &mut self, + input_state: &mut InputState, + force_reset: bool, + outcome: ZoomSourceOutcome, + ) { + self.cancel_with_outcome_and_report(input_state, force_reset, outcome, None); + } + + fn cancel_with_outcome_and_report( + &mut self, + input_state: &mut InputState, + force_reset: bool, + outcome: ZoomSourceOutcome, + report: Option, + ) { + if let Some(capture) = self.capture.take() { + capture.frame.destroy(); + } + self.preflight = CapturePreflight::Idle; + self.layout_retry = PortalLayoutRetry::default(); + self.capture_done = true; + self.portal.finish(); + self.pending_activation = false; + self.finish_source_capture_with_report(outcome, report); + + if force_reset || self.image.is_none() { + self.active = false; + self.locked = false; + self.reset_view(); + self.clear_image(); + } + + input_state.set_zoom_status(self.active, self.locked, self.scale, self.view_offset); + input_state.dirty_tracker.mark_full(); + input_state.needs_redraw = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mismatched_terminal_takes_the_waiter_for_fail_closed_owner_cancellation() { + let stale_id = ZoomCaptureId(3); + let newer = ZoomWaiter { + id: ZoomCaptureId(4), + owner: ZoomWaiterOwner::Ocr, + }; + let mut registry = ZoomWaiterRegistry::default(); + assert!(registry.register(newer)); + + let terminal = ZoomSourceTerminal { + id: stale_id, + outcome: ZoomSourceOutcome::Failed("old failure".to_string()), + report: None, + }; + + assert_eq!(registry.take_for_terminal(&terminal), Some((newer, false))); + assert_eq!(registry.waiter(), None); + } +}