From 3ddf89f46156933b918a4c3b4ee4e7a2f656159a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:45:58 +0200 Subject: [PATCH 01/10] Fix mixed-scale portal raster cropping for Freeze and Zoom --- src/backend/wayland/frozen/mod.rs | 4 +- src/backend/wayland/frozen/portal.rs | 48 ++- src/backend/wayland/frozen/state.rs | 74 ++-- src/backend/wayland/frozen_geometry.rs | 25 +- src/backend/wayland/mod.rs | 1 + src/backend/wayland/portal_raster.rs | 396 ++++++++++++++++++ src/backend/wayland/state/capture/backdrop.rs | 18 + src/backend/wayland/zoom/mod.rs | 4 +- src/backend/wayland/zoom/portal.rs | 135 +++--- 9 files changed, 605 insertions(+), 100 deletions(-) create mode 100644 src/backend/wayland/portal_raster.rs diff --git a/src/backend/wayland/frozen/mod.rs b/src/backend/wayland/frozen/mod.rs index 0d0684173..039d13b4d 100644 --- a/src/backend/wayland/frozen/mod.rs +++ b/src/backend/wayland/frozen/mod.rs @@ -41,12 +41,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 7304b2546..3b4feec9d 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::{ @@ -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 @@ -104,11 +106,34 @@ impl FrozenState { let poll = self.portal.poll(); match poll { PortalPoll::Ready(Ok((target_output, layout_generation, source_geometry, image))) => { + log::info!( + "portal.freeze captured_output={target_output:?} active_output={:?} captured_layout={layout_generation} active_layout={}", + self.active_output_id, + self.output_layout_generation + ); 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); + 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(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"); @@ -242,6 +267,7 @@ mod tests { screenshot_origin: Some(origin), screenshot_size: None, known_output_count: None, + portal_outputs: None, } } @@ -291,7 +317,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?; @@ -436,7 +462,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?; @@ -458,7 +484,7 @@ mod tests { 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)))); @@ -482,7 +508,7 @@ mod tests { 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/state.rs b/src/backend/wayland/frozen/state.rs index feabc3773..60a8f3abe 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -13,7 +13,7 @@ use crate::backend::wayland::acquisition::{ 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_capture::layout_token_matches; use crate::backend::wayland::portal_task::PortalOperation; use crate::input::InputState; use crate::input::state::{Toast, ToastPriority}; @@ -34,7 +34,7 @@ struct PendingFrozenImage { #[derive(Clone, Copy)] enum FrozenCaptureSource { ActiveOutput, - Desktop, + Desktop { cropped: bool }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -332,10 +332,21 @@ impl FrozenState { layout_generation: self.output_layout_generation, source_geometry, output_transform: None, - source: FrozenCaptureSource::Desktop, + source: FrozenCaptureSource::Desktop { cropped: false }, }); } + pub(super) fn set_pending_portal_image( + &mut self, + image: FrozenImage, + target_output_id: Option, + source_geometry: Option, + ) { + self.set_pending_desktop_image(image, target_output_id, source_geometry); + self.pending_image.as_mut().expect("image just set").source = + FrozenCaptureSource::Desktop { cropped: true }; + } + pub fn has_pending_image(&self) -> bool { self.pending_image.is_some() } @@ -619,7 +630,7 @@ impl FrozenState { } } - if matches!(pending.source, FrozenCaptureSource::Desktop) { + if let FrozenCaptureSource::Desktop { cropped } = pending.source { let Some(geometry) = pending .source_geometry .as_ref() @@ -645,15 +656,24 @@ impl FrozenState { "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; + if cropped { + 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", + ); + } + } else { + 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 { @@ -707,29 +727,14 @@ impl FrozenState { 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, + let cropped = crate::backend::wayland::portal_raster::crop_portal_raster( + 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, - }) + geometry, + ) + .ok()?; + ((cropped.width, cropped.height) == (target_width, target_height)).then_some(cropped) } /// Drop frozen image if the surface size no longer matches. @@ -1135,6 +1140,7 @@ mod tests { screenshot_origin, screenshot_size: None, known_output_count: None, + portal_outputs: None, } } diff --git a/src/backend/wayland/frozen_geometry.rs b/src/backend/wayland/frozen_geometry.rs index e9244dd08..267dc7cdd 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,6 +27,8 @@ 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 { @@ -68,6 +70,7 @@ impl OutputGeometry { screenshot_origin: None, screenshot_size: None, known_output_count: None, + portal_outputs: None, }) } } @@ -447,6 +450,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 ec46ae63f..201be9a7a 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 000000000..e53f94f3f --- /dev/null +++ b/src/backend/wayland/portal_raster.rs @@ -0,0 +1,396 @@ +//! 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 active_is_known = geometry.portal_outputs.as_ref().is_some_and(|outputs| { + outputs.iter().any(|output| { + output.logical_x == geometry.logical_x + && output.logical_y == geometry.logical_y + && output.logical_width == geometry.logical_width + && output.logical_height == geometry.logical_height + && (output.physical_width, output.physical_height) == target + }) + }); + let uniform = desktop + .as_ref() + .filter(|_| active_is_known) + .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 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/capture/backdrop.rs b/src/backend/wayland/state/capture/backdrop.rs index d17dcb3c9..7cdcddee7 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/zoom/mod.rs b/src/backend/wayland/zoom/mod.rs index ef202e5e7..eb5ace518 100644 --- a/src/backend/wayland/zoom/mod.rs +++ b/src/backend/wayland/zoom/mod.rs @@ -13,12 +13,14 @@ pub(in crate::backend::wayland) use state::{ 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 7013eade9..0984f4ab4 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -5,7 +5,7 @@ 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::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; @@ -57,7 +57,7 @@ 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 = crop_portal_image(data, width, height, &geo); Ok((Some(target_output_id), layout_generation, provenance, image)) } @@ -89,6 +89,11 @@ 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.output_layout_generation + ); let output_matches = portal_output_matches(target_output, self.active_output_id); let layout_matches = layout_generation == self.output_layout_generation; @@ -109,6 +114,17 @@ impl ZoomState { 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 { @@ -196,51 +212,7 @@ fn crop_portal_image( 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, - }) + crate::backend::wayland::portal_raster::crop_portal_raster(data, width, height, geometry) } #[cfg(test)] @@ -290,6 +262,7 @@ mod tests { screenshot_origin: Some(origin), screenshot_size: None, known_output_count: None, + portal_outputs: None, } } @@ -346,6 +319,64 @@ mod tests { 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 = + crop_portal_image(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( @@ -396,7 +427,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 +561,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; @@ -571,7 +602,7 @@ mod tests { Some(1), layout_generation, provenance(1, layout_generation), - image(9), + Ok(image(9)), )) }, )); @@ -614,7 +645,7 @@ mod tests { Some(1), layout_generation, provenance(1, layout_generation), - image(3), + Ok(image(3)), )) }, )); @@ -658,7 +689,7 @@ mod tests { Some(1), layout_generation, provenance(1, layout_generation), - image(9), + Ok(image(9)), )) }, )); From 3c3c2b3e6fa0b248404be2f10f522c098df59e69 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:04:07 +0200 Subject: [PATCH 02/10] Retry stale portal layouts once with a fresh capture barrier --- .../wayland/backend/event_loop/capture.rs | 6 + src/backend/wayland/capture_preflight.rs | 51 ++++ src/backend/wayland/frozen/capture.rs | 1 + src/backend/wayland/frozen/mod.rs | 1 + src/backend/wayland/frozen/portal.rs | 102 ++++--- src/backend/wayland/frozen/retry.rs | 202 +++++++++++++ src/backend/wayland/frozen/state.rs | 25 +- src/backend/wayland/state/capture/barrier.rs | 102 ++++++- src/backend/wayland/zoom/mod.rs | 1 + src/backend/wayland/zoom/portal.rs | 12 +- src/backend/wayland/zoom/retry.rs | 274 ++++++++++++++++++ src/backend/wayland/zoom/state.rs | 15 +- 12 files changed, 743 insertions(+), 49 deletions(-) create mode 100644 src/backend/wayland/frozen/retry.rs create mode 100644 src/backend/wayland/zoom/retry.rs diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index b2598f55b..4d215c115 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -36,6 +36,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(); 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"); @@ -44,6 +47,9 @@ pub(super) fn poll_capture_deadlines( } pub(super) fn capture_timeout(state: &WaylandState, now: Instant) -> Option { + if state.frozen.has_portal_layout_retry() || state.zoom.has_portal_layout_retry() { + return Some(Duration::ZERO); + } super::min_timeout( state.overlay_capture_barrier_timeout(now), super::min_timeout( diff --git a/src/backend/wayland/capture_preflight.rs b/src/backend/wayland/capture_preflight.rs index 62e283bcf..e6f6322fd 100644 --- a/src/backend/wayland/capture_preflight.rs +++ b/src/backend/wayland/capture_preflight.rs @@ -41,6 +41,16 @@ impl CapturePreflight { 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, @@ -55,6 +65,47 @@ impl CapturePreflight { } } +/// 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, + }, + Spent, +} + +impl PortalLayoutRetry { + pub(super) fn schedule( + &mut self, + captured: Option, + active: Option, + changed: bool, + ) -> bool { + let Some(output_id) = captured else { + return false; + }; + if !matches!(self, Self::Available) || active != captured || !changed { + return false; + } + *self = Self::Pending { output_id }; + true + } + + pub(super) fn is_pending(&self) -> bool { + matches!(self, Self::Pending { .. }) + } + + pub(super) fn take_pending(&mut self) -> Option { + let Self::Pending { output_id } = *self else { + return None; + }; + *self = Self::Spent; + Some(output_id) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs index 487fb4f34..c2479223c 100644 --- a/src/backend/wayland/frozen/capture.rs +++ b/src/backend/wayland/frozen/capture.rs @@ -91,6 +91,7 @@ impl FrozenState { } self.capture_done = false; + self.layout_retry = Default::default(); let backend = self .preferred_backend() .context("no frozen capture backend is available")?; diff --git a/src/backend/wayland/frozen/mod.rs b/src/backend/wayland/frozen/mod.rs index 039d13b4d..fb34f5f5e 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; diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index 3b4feec9d..d325fd7b0 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -106,49 +106,13 @@ impl FrozenState { let poll = self.portal.poll(); match poll { PortalPoll::Ready(Ok((target_output, layout_generation, source_geometry, image))) => { - log::info!( - "portal.freeze captured_output={target_output:?} active_output={:?} captured_layout={layout_generation} active_layout={}", - self.active_output_id, - self.output_layout_generation + self.apply_portal_image( + target_output, + layout_generation, + source_geometry, + image, + input_state, ); - 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 { - 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(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.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(); } PortalPoll::Ready(Err(CaptureError::Cancelled(reason))) => { log::info!("Portal frozen capture cancelled: {reason}"); @@ -224,6 +188,60 @@ impl FrozenState { } } + fn apply_portal_image( + &mut self, + target_output: Option, + layout_generation: u64, + source_geometry: Option, + image: Result, + input_state: &mut InputState, + ) { + log::info!( + "portal.freeze captured_output={target_output:?} active_output={:?} captured_layout={layout_generation} active_layout={}", + self.active_output_id, + self.output_layout_generation + ); + 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 { + 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(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) } diff --git a/src/backend/wayland/frozen/retry.rs b/src/backend/wayland/frozen/retry.rs new file mode 100644 index 000000000..374e3729b --- /dev/null +++ b/src/backend/wayland/frozen/retry.rs @@ -0,0 +1,202 @@ +use super::state::{FrozenCaptureBackend, FrozenState}; +use crate::backend::wayland::frozen_geometry::require_verified_capture_source; + +impl FrozenState { + pub(super) fn queue_portal_layout_retry( + &mut self, + captured: Option, + changed: bool, + ) -> bool { + if require_verified_capture_source( + self.active_geometry.clone(), + self.active_output_id, + "portal freeze retry", + ) + .is_err() + || !self + .layout_retry + .schedule(captured, self.active_output_id, changed) + { + 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.output_layout_generation + ); + true + } + + pub(in crate::backend::wayland) fn retry_stale_portal_preflight( + &mut self, + backend: FrozenCaptureBackend, + ) -> bool { + let changed = backend == FrozenCaptureBackend::Portal + && self + .preflight + .changed_on_output(self.active_output_id, self.output_layout_generation); + self.queue_portal_layout_retry(self.active_output_id, changed) + } + + pub(in crate::backend::wayland) fn has_portal_layout_retry(&self) -> bool { + self.layout_retry.is_pending() + } + + /// Called only after the runtime refreshes all output geometry. + pub(in crate::backend::wayland) fn restart_portal_preflight(&mut self) -> Result { + let Some(output_id) = self.layout_retry.take_pending() else { + return Ok(false); + }; + if self.active_output_id != Some(output_id) { + return Err("Freeze failed after the display layout changed".to_string()); + } + require_verified_capture_source( + self.active_geometry.clone(), + self.active_output_id, + "portal freeze retry", + )?; + self.preflight.begin( + FrozenCaptureBackend::Portal, + Some(output_id), + self.output_layout_generation, + ); + log::info!( + "portal.freeze phase=retry-preflight output={output_id} layout={}", + self.output_layout_generation + ); + 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 std::time::Instant; + 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() + } + async fn drain(frozen: &mut FrozenState, input: &mut crate::input::InputState) { + for _ in 0..100 { + frozen.poll_portal_capture(input, Instant::now()); + 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.output_layout_generation; + 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().unwrap()); + assert_eq!( + frozen.take_preflight_pending(), + Some(FrozenCaptureBackend::Portal) + ); + let fresh_generation = frozen.output_layout_generation; + 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()); + } + } + + #[tokio::test] + async fn abandoning_a_retry_clears_its_admission_and_resources() { + 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)); + frozen.cancel(&mut input); + assert!(!frozen.has_portal_layout_retry()); + assert!(!frozen.is_in_progress()); + assert!(!frozen.restart_portal_preflight().unwrap()); + } +} diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index 60a8f3abe..b10c1a780 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -1,4 +1,4 @@ -use crate::backend::wayland::capture_preflight::CapturePreflight; +use crate::backend::wayland::capture_preflight::{CapturePreflight, PortalLayoutRetry}; use log::info; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -133,6 +133,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, @@ -197,6 +198,7 @@ impl FrozenState { runtime_wake, preflight: CapturePreflight::Idle, capture_done: false, + layout_retry: PortalLayoutRetry::default(), pending_image: None, acquisition_attempt: None, acquisition_completion: None, @@ -347,6 +349,10 @@ impl FrozenState { FrozenCaptureSource::Desktop { cropped: true }; } + pub(super) fn discard_pending_image_for_retry(&mut self) { + self.pending_image = None; + } + pub fn has_pending_image(&self) -> bool { self.pending_image.is_some() } @@ -356,6 +362,7 @@ impl FrozenState { || 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( @@ -456,6 +463,7 @@ impl FrozenState { } fn finish_attempt_resources(&mut self) { + self.layout_retry = PortalLayoutRetry::default(); if let Some(capture) = self.direct_capture.take() { capture.destroy(); } @@ -591,6 +599,14 @@ impl FrozenState { self.active_output_id, self.output_layout_generation, ) { + if matches!(pending.source, FrozenCaptureSource::Desktop { .. }) + && self.queue_portal_layout_retry( + pending.target_output_id, + pending.layout_generation != self.output_layout_generation, + ) + { + return Ok(false); + } return self.reject_pending_image( input_state, "Freeze failed after the display layout changed", @@ -637,6 +653,13 @@ impl FrozenState { .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", diff --git a/src/backend/wayland/state/capture/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index 6eed72cf1..16d6113f0 100644 --- a/src/backend/wayland/state/capture/barrier.rs +++ b/src/backend/wayland/state/capture/barrier.rs @@ -176,6 +176,16 @@ impl OverlayCaptureBarrier { Some(active.reason) } + fn submission_timeout(&self, can_render: bool) -> Option { + self.active + .filter(|active| { + can_render + && active.gtk_paint_generation.is_none() + && active.main_surface_phase == MainSurfaceCapturePhase::AwaitingRender + }) + .map(|_| Duration::ZERO) + } + fn frame_timeout(&self, now: Instant) -> Option { let active = self.active?; if active.main_surface_phase != MainSurfaceCapturePhase::AwaitingFrame { @@ -225,7 +235,14 @@ impl WaylandState { &self, now: Instant, ) -> 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, + ) + .or_else(|| self.suppression.barrier.frame_timeout(now)) } pub(in crate::backend::wayland) fn poll_overlay_capture_barrier_timeout( @@ -252,6 +269,55 @@ impl WaylandState { } } + /// Re-admit the same request after refreshing geometry, without restoring + /// any overlay pixels between the stale attempt and its one retry. + pub(in crate::backend::wayland) fn poll_portal_layout_retries(&mut self) { + if !self.frozen.has_portal_layout_retry() && !self.zoom.has_portal_layout_retry() { + return; + } + self.refresh_freeze_zoom_geometry(); + for reason in [OverlaySuppression::Frozen, OverlaySuppression::Zoom] { + let restarted = match reason { + OverlaySuppression::Frozen => self.frozen.restart_portal_preflight(), + OverlaySuppression::Zoom => self.zoom.restart_portal_preflight(), + _ => unreachable!(), + }; + match restarted { + Ok(false) => {} + Ok(true) if self.suppression.reason() == reason => { + self.suppression + .barrier + .begin(reason, self.gtk_toolbar.is_some()); + self.buffer_damage + .mark_all_full(FullDamageReason::OverlaySuppression); + self.input_state.needs_redraw = true; + self.toolbar.mark_dirty(); + } + Ok(true) => { + self.cancel_overlay_capture_preflight( + reason, + Some("Capture retry lost overlay suppression"), + ); + } + Err(error) => { + log::warn!("Portal {reason:?} retry preflight failed: {error}"); + match reason { + OverlaySuppression::Frozen if self.frozen.has_acquisition_attempt() => { + self.frozen + .finish_preflight_failure(error, &mut self.input_state); + } + OverlaySuppression::Zoom => self + .zoom + .finish_preflight_failure(&mut self.input_state, error), + _ => { + self.cancel_overlay_capture_preflight(reason, Some(&error)); + } + } + } + } + } + } + /// Records the frame callback for the fresh hidden main-surface commit. pub(in crate::backend::wayland) fn mark_overlay_capture_frame_ready( &mut self, @@ -349,6 +415,9 @@ 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); @@ -375,6 +444,9 @@ impl WaylandState { &self.tokio_handle, ) { log::warn!("Zoom preflight capture failed: {err}"); + if self.zoom.retry_stale_portal_preflight(use_fallback) { + return; + } self.zoom .finish_preflight_failure(&mut self.input_state, err.to_string()); } @@ -453,6 +525,34 @@ 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), 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), Some(Duration::ZERO)); + assert_eq!(barrier.submission_timeout(false), None); + assert_eq!(barrier.take_ready(), None); + assert_eq!(barrier.begin_main_surface_submission(), Some(retry)); + assert_eq!(barrier.submission_timeout(true), 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 main_surface_frame_deadline_starts_only_after_submission() { let mut barrier = OverlayCaptureBarrier::default(); diff --git a/src/backend/wayland/zoom/mod.rs b/src/backend/wayland/zoom/mod.rs index eb5ace518..65e6ef289 100644 --- a/src/backend/wayland/zoom/mod.rs +++ b/src/backend/wayland/zoom/mod.rs @@ -1,5 +1,6 @@ mod capture; mod portal; +mod retry; mod state; mod view; diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index 0984f4ab4..280b09795 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -111,6 +111,9 @@ impl ZoomState { 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; } @@ -132,6 +135,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; } @@ -583,11 +589,12 @@ 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)))); @@ -670,11 +677,12 @@ 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)))); diff --git a/src/backend/wayland/zoom/retry.rs b/src/backend/wayland/zoom/retry.rs new file mode 100644 index 000000000..2e6afa7c3 --- /dev/null +++ b/src/backend/wayland/zoom/retry.rs @@ -0,0 +1,274 @@ +use super::state::ZoomState; +use crate::backend::wayland::frozen_geometry::require_verified_capture_source; + +impl ZoomState { + pub(super) fn queue_portal_layout_retry( + &mut self, + captured: Option, + changed: bool, + ) -> bool { + if self.current_capture_id().is_none() + || require_verified_capture_source( + self.active_geometry.clone(), + self.active_output_id, + "portal zoom retry", + ) + .is_err() + || !self + .layout_retry + .schedule(captured, self.active_output_id, changed) + { + return false; + } + self.portal.finish(); + self.capture_done = false; + log::info!( + "portal.zoom phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", + self.output_layout_generation + ); + true + } + + pub(in crate::backend::wayland) fn retry_stale_portal_preflight( + &mut self, + use_fallback: bool, + ) -> bool { + let changed = (use_fallback || self.manager.is_none()) + && self + .preflight + .changed_on_output(self.active_output_id, self.output_layout_generation); + self.queue_portal_layout_retry(self.active_output_id, changed) + } + + pub(in crate::backend::wayland) fn has_portal_layout_retry(&self) -> bool { + self.layout_retry.is_pending() + } + + /// Retains the original capture ID, waiter, and requested Zoom activation. + pub(in crate::backend::wayland) fn restart_portal_preflight(&mut self) -> Result { + let Some(output_id) = self.layout_retry.take_pending() else { + return Ok(false); + }; + if self.active_output_id != Some(output_id) { + return Err("Zoom failed after the display layout changed".to_string()); + } + require_verified_capture_source( + self.active_geometry.clone(), + self.active_output_id, + "portal zoom retry", + )?; + self.preflight + .begin(true, Some(output_id), self.output_layout_generation); + log::info!( + "portal.zoom phase=retry-preflight output={output_id} layout={}", + self.output_layout_generation + ); + 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 std::time::Instant; + 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() + } + 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(true, &tokio::runtime::Handle::current()) + .unwrap(); + assert_eq!(zoom.take_preflight_pending(), Some(true)); + 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.output_layout_generation; + 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().unwrap()); + assert_eq!(zoom.take_preflight_pending(), Some(true)); + assert!(zoom.ensure_preflight_layout_current().is_ok()); + let fresh_generation = zoom.output_layout_generation; + 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.output_layout_generation; + 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().is_err()); + } + assert!(zoom.abort_capture()); + assert!(!zoom.has_portal_layout_retry()); + assert!(!zoom.restart_portal_preflight().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 ddb3ee654..8e374cfc9 100644 --- a/src/backend/wayland/zoom/state.rs +++ b/src/backend/wayland/zoom/state.rs @@ -1,4 +1,4 @@ -use crate::backend::wayland::capture_preflight::CapturePreflight; +use crate::backend::wayland::capture_preflight::{CapturePreflight, PortalLayoutRetry}; use std::sync::Arc; use wayland_client::protocol::wl_output; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; @@ -117,6 +117,7 @@ pub struct ZoomState { pub(super) runtime_wake: Option, 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, @@ -161,6 +162,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, @@ -269,7 +271,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)] @@ -330,6 +335,7 @@ impl ZoomState { .checked_add(1) .expect("zoom capture id space exhausted"); self.current_capture_id = Some(id); + self.layout_retry = PortalLayoutRetry::default(); id } @@ -342,6 +348,7 @@ impl ZoomState { outcome: ZoomSourceOutcome, report: Option, ) { + self.layout_retry = PortalLayoutRetry::default(); let Some(id) = self.current_capture_id.take() else { return; }; @@ -377,7 +384,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 +393,7 @@ impl ZoomState { changed = true; } self.preflight = CapturePreflight::Idle; + self.layout_retry = PortalLayoutRetry::default(); self.portal.finish(); self.pending_activation = false; if changed { @@ -461,6 +469,7 @@ impl ZoomState { capture.frame.destroy(); } self.preflight = CapturePreflight::Idle; + self.layout_retry = PortalLayoutRetry::default(); self.capture_done = true; self.portal.finish(); self.pending_activation = false; From d3db5673663a06623a88439ff1730635ad634139 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:02:13 +0200 Subject: [PATCH 03/10] Check live Freeze topology before interpreting portal crop errors SCTK can advertise a new output before the geometry refresh callback runs. Freeze now checks the live count before accepting a worker result or interpreting its raster error, matching Zoom. The regression failed before this repair and now retains the original acquisition for one retry. --- .../wayland/backend/event_loop/capture.rs | 5 +- src/backend/wayland/frozen/portal.rs | 52 +++++++++++++++++-- src/backend/wayland/frozen/retry.rs | 2 +- src/backend/wayland/zoom/portal.rs | 11 +--- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 4d215c115..8d45c48a3 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); diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index d325fd7b0..b8683bb3d 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -75,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; } @@ -112,6 +117,7 @@ impl FrozenState { source_geometry, image, input_state, + live_output_count, ); } PortalPoll::Ready(Err(CaptureError::Cancelled(reason))) => { @@ -195,6 +201,7 @@ impl FrozenState { 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={}", @@ -202,7 +209,13 @@ impl FrozenState { self.output_layout_generation ); let output_matches = portal_output_matches(target_output, self.active_output_id); - let layout_matches = layout_generation == self.output_layout_generation; + // 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.output_layout_generation && !topology_changed; if output_matches && layout_matches { match image { @@ -294,7 +307,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(()); } @@ -303,6 +316,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.output_layout_generation; + + 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()?; @@ -402,7 +446,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()); diff --git a/src/backend/wayland/frozen/retry.rs b/src/backend/wayland/frozen/retry.rs index 374e3729b..76de426eb 100644 --- a/src/backend/wayland/frozen/retry.rs +++ b/src/backend/wayland/frozen/retry.rs @@ -97,7 +97,7 @@ mod tests { } async fn drain(frozen: &mut FrozenState, input: &mut crate::input::InputState) { 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; } diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index 280b09795..b88b6b0c7 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -98,15 +98,8 @@ impl ZoomState { let layout_matches = layout_generation == self.output_layout_generation; 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) }) { From 18f96d1e297541bf0659fdb9182dc2005b7e92b2 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:04:52 +0200 Subject: [PATCH 04/10] Pace capture barrier rendering while buffers are unavailable Do not request an immediate dispatch wake while every swapchain slot is owned by the compositor. A buffer release already wakes Wayland dispatch. When a slot is available, honor the no-vsync frame cap before the hidden frame attempt. The pacing regression failed on the unconditional zero timeout and now passes. --- .../wayland/backend/event_loop/capture.rs | 19 ++++++++-- src/backend/wayland/backend/event_loop/mod.rs | 2 +- src/backend/wayland/state/capture/barrier.rs | 36 +++++++++++++++---- src/backend/wayland/surface.rs | 7 ++++ 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 8d45c48a3..b5cb53bc9 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -47,12 +47,27 @@ pub(super) fn poll_capture_deadlines( } } -pub(super) fn capture_timeout(state: &WaylandState, now: Instant) -> Option { +pub(super) fn capture_timeout( + state: &WaylandState, + now: Instant, + last_render_time: Option, +) -> Option { if state.frozen.has_portal_layout_retry() || state.zoom.has_portal_layout_retry() { return Some(Duration::ZERO); } super::min_timeout( - state.overlay_capture_barrier_timeout(now), + state.overlay_capture_barrier_timeout( + now, + if state.config.performance.enable_vsync { + Duration::ZERO + } else { + super::render::frame_rate_cap_timeout( + state.config.performance.max_fps_no_vsync, + last_render_time, + ) + .unwrap_or(Duration::ZERO) + }, + ), super::min_timeout( state.frozen.direct_capture_timeout(now), super::min_timeout( diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 86d1abbdc..35947ec58 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -323,7 +323,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, last_render_time), 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/state/capture/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index 16d6113f0..34d7fb2a7 100644 --- a/src/backend/wayland/state/capture/barrier.rs +++ b/src/backend/wayland/state/capture/barrier.rs @@ -176,14 +176,14 @@ impl OverlayCaptureBarrier { Some(active.reason) } - fn submission_timeout(&self, can_render: bool) -> Option { + 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(|_| Duration::ZERO) + .map(|_| render_delay) } fn frame_timeout(&self, now: Instant) -> Option { @@ -234,13 +234,18 @@ impl WaylandState { pub(in crate::backend::wayland) fn overlay_capture_barrier_timeout( &self, now: Instant, + render_delay: Duration, ) -> Option { self.suppression .barrier .submission_timeout( self.surface.is_configured() && !self.surface.frame_callback_pending() - && self.input_state.needs_redraw, + && 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)) } @@ -535,24 +540,41 @@ mod tests { assert_eq!(barrier.take_ready(), Some(OverlaySuppression::Frozen)); let retry = barrier.begin(OverlaySuppression::Frozen, true).unwrap(); - assert_eq!(barrier.submission_timeout(true), None); + 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), Some(Duration::ZERO)); - assert_eq!(barrier.submission_timeout(false), None); + 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), None); + 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(); diff --git a/src/backend/wayland/surface.rs b/src/backend/wayland/surface.rs index 8d9b5f742..fa9158eab 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. /// From dfc5ae2de0be8805f219dae25a38d84c4ce078f0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:10:16 +0200 Subject: [PATCH 05/10] Separate portal desktop changes from active output source validity Use a full desktop generation for portal preflight, worker results and pending portal images. Direct capture and installed modal sources retain an active viewport generation, so another monitor cannot invalidate their pixels. Preflight retains its backend to select the correct guard; Zoom now names that backend explicitly. Regression coverage checks direct Freeze activation and installed Zoom source validity while portal admission goes stale. --- src/backend/wayland/capture_preflight.rs | 14 ++- src/backend/wayland/frozen/capture.rs | 2 +- src/backend/wayland/frozen/portal.rs | 12 +-- src/backend/wayland/frozen/retry.rs | 8 +- src/backend/wayland/frozen/state.rs | 75 ++++++++++++++-- src/backend/wayland/frozen_geometry.rs | 34 ++++++++ src/backend/wayland/portal_raster.rs | 11 +-- src/backend/wayland/state/capture/barrier.rs | 6 +- src/backend/wayland/zoom/capture.rs | 22 +++-- src/backend/wayland/zoom/mod.rs | 4 +- src/backend/wayland/zoom/portal.rs | 49 +++++++---- src/backend/wayland/zoom/retry.rs | 29 ++++--- src/backend/wayland/zoom/state.rs | 91 +++++++++++++++++--- 13 files changed, 274 insertions(+), 83 deletions(-) diff --git a/src/backend/wayland/capture_preflight.rs b/src/backend/wayland/capture_preflight.rs index e6f6322fd..8cb578f6e 100644 --- a/src/backend/wayland/capture_preflight.rs +++ b/src/backend/wayland/capture_preflight.rs @@ -14,6 +14,7 @@ pub(super) enum CapturePreflight { layout: CaptureLayout, }, Capturing { + backend: B, layout: CaptureLayout, }, } @@ -37,14 +38,21 @@ 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, + Self::Pending { layout, .. } | Self::Capturing { layout, .. } => layout, }; layout.output_id.is_some() && layout.output_id == output_id @@ -54,7 +62,7 @@ impl CapturePreflight { 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, diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs index c2479223c..e3b4803ca 100644 --- a/src/backend/wayland/frozen/capture.rs +++ b/src/backend/wayland/frozen/capture.rs @@ -98,7 +98,7 @@ impl FrozenState { self.preflight.begin( backend, self.active_output_id, - self.output_layout_generation, + self.capture_layout_generation(backend), ); Ok(()) } diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index b8683bb3d..8c0541c4e 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -38,7 +38,7 @@ impl FrozenState { ) .map_err(anyhow::Error::msg)?; - let layout_generation = self.output_layout_generation; + let layout_generation = self.portal_layout_generation; // Notify user that portal fallback is in progress crate::notification::send_notification_async( tokio_handle, @@ -206,7 +206,7 @@ impl FrozenState { log::info!( "portal.freeze captured_output={target_output:?} active_output={:?} captured_layout={layout_generation} active_layout={}", self.active_output_id, - self.output_layout_generation + self.portal_layout_generation ); let output_matches = portal_output_matches(target_output, self.active_output_id); // SCTK can advertise a wl_output before its new_output callback @@ -215,7 +215,7 @@ impl FrozenState { .as_ref() .is_some_and(|geometry| geometry.output_count_conflicts_with_live(live_output_count)); let layout_matches = - layout_generation == self.output_layout_generation && !topology_changed; + layout_generation == self.portal_layout_generation && !topology_changed; if output_matches && layout_matches { match image { @@ -329,7 +329,7 @@ mod tests { frozen.set_active_geometry(Some(geometry.clone())); frozen.start_capture_for(id, owner).unwrap(); frozen.take_preflight_pending(); - let generation = frozen.output_layout_generation; + let generation = frozen.portal_layout_generation; frozen.apply_portal_image( Some(1), @@ -542,7 +542,7 @@ 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.portal_layout_generation; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -566,7 +566,7 @@ 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.portal_layout_generation; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), diff --git a/src/backend/wayland/frozen/retry.rs b/src/backend/wayland/frozen/retry.rs index 76de426eb..06545af0c 100644 --- a/src/backend/wayland/frozen/retry.rs +++ b/src/backend/wayland/frozen/retry.rs @@ -24,7 +24,7 @@ impl FrozenState { self.capture_done = false; log::info!( "portal.freeze phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", - self.output_layout_generation + self.portal_layout_generation ); true } @@ -36,7 +36,7 @@ impl FrozenState { let changed = backend == FrozenCaptureBackend::Portal && self .preflight - .changed_on_output(self.active_output_id, self.output_layout_generation); + .changed_on_output(self.active_output_id, self.portal_layout_generation); self.queue_portal_layout_retry(self.active_output_id, changed) } @@ -60,11 +60,11 @@ impl FrozenState { self.preflight.begin( FrozenCaptureBackend::Portal, Some(output_id), - self.output_layout_generation, + self.portal_layout_generation, ); log::info!( "portal.freeze phase=retry-preflight output={output_id} layout={}", - self.output_layout_generation + self.portal_layout_generation ); Ok(true) } diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index b10c1a780..c4a91ae37 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -26,6 +26,7 @@ struct PendingFrozenImage { image: FrozenImage, target_output_id: Option, layout_generation: u64, + portal_layout_generation: Option, source_geometry: Option, output_transform: Option, source: FrozenCaptureSource, @@ -123,7 +124,10 @@ pub struct FrozenState { 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. + /// Active viewport generation used by direct capture and installed sources. pub(super) output_layout_generation: u64, + /// Full desktop generation used only by portal snapshots and preflight. + pub(super) portal_layout_generation: u64, pub(super) direct_capture: Option, pub(super) image: Option>, image_provenance: Option, @@ -189,6 +193,7 @@ impl FrozenState { active_output_id: None, active_geometry: None, output_layout_generation: 0, + portal_layout_generation: 0, direct_capture: None, image: None, image_provenance: None, @@ -235,8 +240,12 @@ impl FrozenState { pub fn set_active_geometry(&mut self, geometry: Option) { if self.active_geometry != geometry { + self.portal_layout_generation = self.portal_layout_generation.wrapping_add(1); + } + if !OutputGeometry::same_active_output(self.active_geometry.as_ref(), geometry.as_ref()) { self.output_layout_generation = self.output_layout_generation.wrapping_add(1); } + self.active_geometry = geometry; } @@ -299,6 +308,7 @@ impl FrozenState { image, target_output_id: Some(target_output_id), layout_generation: self.output_layout_generation, + portal_layout_generation: None, source_geometry: Some(source_geometry), output_transform: None, source: FrozenCaptureSource::ActiveOutput, @@ -316,6 +326,7 @@ impl FrozenState { image, target_output_id: Some(target_output_id), layout_generation: self.output_layout_generation, + portal_layout_generation: None, source_geometry: Some(source_geometry), output_transform, source: FrozenCaptureSource::ActiveOutput, @@ -332,6 +343,7 @@ impl FrozenState { image, target_output_id, layout_generation: self.output_layout_generation, + portal_layout_generation: Some(self.portal_layout_generation), source_geometry, output_transform: None, source: FrozenCaptureSource::Desktop { cropped: false }, @@ -376,15 +388,27 @@ impl FrozenState { self.preflight.begin( FrozenCaptureBackend::Portal, self.active_output_id, - self.output_layout_generation, + self.portal_layout_generation, ); } + pub(super) fn capture_layout_generation(&self, backend: FrozenCaptureBackend) -> u64 { + match backend { + FrozenCaptureBackend::Portal => self.portal_layout_generation, + FrozenCaptureBackend::WlrScreencopy | FrozenCaptureBackend::ExtImageCopy => { + 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) - { + if self.preflight.layout_matches( + self.active_output_id, + self.preflight + .backend() + .map(|backend| self.capture_layout_generation(backend)) + .unwrap_or(self.output_layout_generation), + ) { Ok(()) } else { Err("Freeze failed after the display layout changed".to_string()) @@ -598,11 +622,14 @@ impl FrozenState { pending.layout_generation, self.active_output_id, self.output_layout_generation, - ) { + ) || pending + .portal_layout_generation + .is_some_and(|generation| generation != self.portal_layout_generation) + { if matches!(pending.source, FrozenCaptureSource::Desktop { .. }) && self.queue_portal_layout_retry( pending.target_output_id, - pending.layout_generation != self.output_layout_generation, + pending.portal_layout_generation != Some(self.portal_layout_generation), ) { return Ok(false); @@ -1177,7 +1204,39 @@ mod tests { state.set_active_geometry(Some(first)); assert_eq!(state.output_layout_generation, 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.output_layout_generation, 1); + assert_eq!(state.portal_layout_generation, 2); + } + + #[test] + fn other_output_metadata_does_not_invalidate_a_direct_source() { + let mut state = FrozenState::new(None); + let mut input = make_test_input_state(); + state.set_active_output(None, Some(7)); + let first = desktop_geometry(0, 0, 2, 1, 1, Some((0, 0))); + state.set_active_geometry(Some(first.clone())); + let generation = state.output_layout_generation; + let provenance = + ScreenImageProvenance::new(7, generation, 1, wl_output::Transform::Normal).unwrap(); + state.set_pending_output_image( + FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![7; 8], + }, + 7, + first.clone(), + ); + let mut changed = first.with_known_output_count(Some(2)); + changed.screenshot_origin = Some((8, 0)); + + state.set_active_geometry(Some(changed)); + + 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.portal_layout_generation > generation); } #[test] diff --git a/src/backend/wayland/frozen_geometry.rs b/src/backend/wayland/frozen_geometry.rs index 267dc7cdd..a73935893 100644 --- a/src/backend/wayland/frozen_geometry.rs +++ b/src/backend/wayland/frozen_geometry.rs @@ -32,6 +32,40 @@ pub struct OutputGeometry { } 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 + }) + }) + } + pub fn update_from( logical_pos: Option<(i32, i32)>, logical_size: Option<(i32, i32)>, diff --git a/src/backend/wayland/portal_raster.rs b/src/backend/wayland/portal_raster.rs index e53f94f3f..90ddc287b 100644 --- a/src/backend/wayland/portal_raster.rs +++ b/src/backend/wayland/portal_raster.rs @@ -133,18 +133,9 @@ pub(super) fn crop_portal_raster( .portal_outputs .as_deref() .and_then(LogicalDesktop::from_outputs); - let active_is_known = geometry.portal_outputs.as_ref().is_some_and(|outputs| { - outputs.iter().any(|output| { - output.logical_x == geometry.logical_x - && output.logical_y == geometry.logical_y - && output.logical_width == geometry.logical_width - && output.logical_height == geometry.logical_height - && (output.physical_width, output.physical_height) == target - }) - }); let uniform = desktop .as_ref() - .filter(|_| active_is_known) + .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, diff --git a/src/backend/wayland/state/capture/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index 34d7fb2a7..0452dbfb5 100644 --- a/src/backend/wayland/state/capture/barrier.rs +++ b/src/backend/wayland/state/capture/barrier.rs @@ -437,19 +437,19 @@ 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}"); - if self.zoom.retry_stale_portal_preflight(use_fallback) { + if self.zoom.retry_stale_portal_preflight(backend) { return; } self.zoom diff --git a/src/backend/wayland/zoom/capture.rs b/src/backend/wayland/zoom/capture.rs index d637202ab..93d4e4d0a 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 { @@ -133,17 +133,22 @@ impl ZoomState { self.begin_identified_capture(); self.capture_done = false; + let backend = if use_fallback || self.manager.is_none() { + ZoomCaptureBackend::Portal + } else { + ZoomCaptureBackend::WlrScreencopy + }; 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 { @@ -475,7 +480,10 @@ mod tests { 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 65e6ef289..b17a2b86d 100644 --- a/src/backend/wayland/zoom/mod.rs +++ b/src/backend/wayland/zoom/mod.rs @@ -7,8 +7,8 @@ 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; diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index b88b6b0c7..1e3bc6f96 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -35,10 +35,10 @@ impl ZoomState { ) .map_err(anyhow::Error::msg)?; - let layout_generation = self.output_layout_generation; + let layout_generation = self.portal_layout_generation; let provenance = ScreenImageProvenance::new( target_output_id, - layout_generation, + self.output_layout_generation, 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)) } @@ -92,10 +94,10 @@ impl ZoomState { log::info!( "portal.zoom captured_output={target_output:?} active_output={:?} captured_layout={layout_generation} active_layout={}", self.active_output_id, - self.output_layout_generation + self.portal_layout_generation ); 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.portal_layout_generation; if output_matches && layout_matches { // Crop used the spawn-time geometry. SCTK may advertise @@ -205,15 +207,6 @@ impl ZoomState { } } -fn crop_portal_image( - data: Vec, - width: u32, - height: u32, - geometry: &OutputGeometry, -) -> Result { - crate::backend::wayland::portal_raster::crop_portal_raster(data, width, height, geometry) -} - #[cfg(test)] mod tests { use super::*; @@ -311,8 +304,13 @@ 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); @@ -365,8 +363,13 @@ mod tests { .unwrap() .with_desktop_backdrop_geometry(DesktopBackdropGeometry::from_outputs(active, &outputs)) .with_portal_outputs(Some(outputs.to_vec())); - let image = - crop_portal_image(pixels.clone(), 24, 8, &geometry).expect("KDE mixed-scale crop"); + 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, @@ -393,7 +396,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) { diff --git a/src/backend/wayland/zoom/retry.rs b/src/backend/wayland/zoom/retry.rs index 2e6afa7c3..da229a12d 100644 --- a/src/backend/wayland/zoom/retry.rs +++ b/src/backend/wayland/zoom/retry.rs @@ -1,4 +1,4 @@ -use super::state::ZoomState; +use super::state::{ZoomCaptureBackend, ZoomState}; use crate::backend::wayland::frozen_geometry::require_verified_capture_source; impl ZoomState { @@ -24,19 +24,19 @@ impl ZoomState { self.capture_done = false; log::info!( "portal.zoom phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", - self.output_layout_generation + self.portal_layout_generation ); true } pub(in crate::backend::wayland) fn retry_stale_portal_preflight( &mut self, - use_fallback: bool, + backend: ZoomCaptureBackend, ) -> bool { - let changed = (use_fallback || self.manager.is_none()) + let changed = (backend == ZoomCaptureBackend::Portal) && self .preflight - .changed_on_output(self.active_output_id, self.output_layout_generation); + .changed_on_output(self.active_output_id, self.portal_layout_generation); self.queue_portal_layout_retry(self.active_output_id, changed) } @@ -57,11 +57,14 @@ impl ZoomState { self.active_output_id, "portal zoom retry", )?; - self.preflight - .begin(true, Some(output_id), self.output_layout_generation); + self.preflight.begin( + ZoomCaptureBackend::Portal, + Some(output_id), + self.portal_layout_generation, + ); log::info!( "portal.zoom phase=retry-preflight output={output_id} layout={}", - self.output_layout_generation + self.portal_layout_generation ); Ok(true) } @@ -121,7 +124,10 @@ mod tests { zoom.set_active_geometry(Some(geometry(0))); zoom.start_capture(true, &tokio::runtime::Handle::current()) .unwrap(); - assert_eq!(zoom.take_preflight_pending(), Some(true)); + 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 { @@ -157,7 +163,10 @@ mod tests { assert!(zoom.take_source_terminal().is_none()); assert_eq!(zoom.current_capture_id(), Some(id)); assert!(zoom.restart_portal_preflight().unwrap()); - assert_eq!(zoom.take_preflight_pending(), Some(true)); + assert_eq!( + zoom.take_preflight_pending(), + Some(ZoomCaptureBackend::Portal) + ); assert!(zoom.ensure_preflight_layout_current().is_ok()); let fresh_generation = zoom.output_layout_generation; zoom.portal.start(PortalTask::spawn( diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs index 8e374cfc9..916949ca1 100644 --- a/src/backend/wayland/zoom/state.rs +++ b/src/backend/wayland/zoom/state.rs @@ -99,15 +99,22 @@ impl ZoomWaiterRegistry { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum ZoomCaptureBackend { + WlrScreencopy, + Portal, +} + /// Zoom state, capture logic, and pan/lock bookkeeping. pub struct ZoomState { pub(super) manager: Option, 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. + /// Active viewport generation used by direct capture and installed sources. pub(super) output_layout_generation: u64, + /// Full desktop generation used only by portal snapshots and preflight. + pub(super) portal_layout_generation: u64, pub(super) capture: Option, pub(super) image: Option>, image_provenance: Option, @@ -115,7 +122,7 @@ 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, @@ -153,6 +160,7 @@ impl ZoomState { active_output_id: None, active_geometry: None, output_layout_generation: 0, + portal_layout_generation: 0, capture: None, image: None, image_provenance: None, @@ -187,8 +195,12 @@ impl ZoomState { pub fn set_active_geometry(&mut self, geometry: Option) { if self.active_geometry != geometry { + self.portal_layout_generation = self.portal_layout_generation.wrapping_add(1); + } + if !OutputGeometry::same_active_output(self.active_geometry.as_ref(), geometry.as_ref()) { self.output_layout_generation = self.output_layout_generation.wrapping_add(1); } + self.active_geometry = geometry; } @@ -282,21 +294,36 @@ 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); + self.preflight.begin( + ZoomCaptureBackend::Portal, + self.active_output_id, + self.portal_layout_generation, + ); + } + + pub(super) fn capture_layout_generation(&self, backend: ZoomCaptureBackend) -> u64 { + match backend { + ZoomCaptureBackend::Portal => self.portal_layout_generation, + ZoomCaptureBackend::WlrScreencopy => 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) - { + if self.preflight.layout_matches( + self.active_output_id, + self.preflight + .backend() + .map(|backend| self.capture_layout_generation(backend)) + .unwrap_or(self.output_layout_generation), + ) { Ok(()) } else { Err("Zoom failed after the display layout changed".to_string()) @@ -556,6 +583,50 @@ mod tests { ); } + #[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.output_layout_generation, + 1, + wl_output::Transform::Normal, + ) + .unwrap(); + state.install_image( + FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![7; 8], + }, + provenance, + ); + state + .start_capture(true, &tokio::runtime::Handle::current()) + .unwrap(); + let generation = state.image_generation(); + let mut changed = geometry.with_known_output_count(Some(2)); + changed.screenshot_size = Some((4, 1)); + + state.set_active_geometry(Some(changed)); + + assert!(state.source_context_matches(provenance)); + assert_eq!(state.image_generation(), generation); + assert!(state.ensure_preflight_layout_current().is_err()); + } + #[test] fn mismatched_terminal_takes_the_waiter_for_fail_closed_owner_cancellation() { let stale_id = ZoomCaptureId(3); From 03091cc2e714d80ab0c2c99ca0b8b01c3ce7049f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:18:06 +0200 Subject: [PATCH 06/10] Wait for portal layout updates to settle before retry admission Keep the original request suppressed while waiting for complete geometry and 100 ms without a desktop change, bounded to one second. Pace incomplete metadata checks at 50 ms instead of polling with a zero timeout. Share this admission and deadline policy across Freeze and Zoom. Extract runtime retry coordination from barrier.rs so the presentation barrier stays below its size review threshold; controller tests cover lost suppression, output switches, settling failure, activation-time retry and exactly one completion. --- .../wayland/backend/event_loop/capture.rs | 50 ++-- src/backend/wayland/capture_preflight.rs | 163 ++++++++++++- src/backend/wayland/frozen/retry.rs | 189 ++++++++++++--- src/backend/wayland/frozen_geometry.rs | 16 ++ src/backend/wayland/state/capture.rs | 1 + src/backend/wayland/state/capture/barrier.rs | 49 ---- src/backend/wayland/state/capture/retry.rs | 227 ++++++++++++++++++ src/backend/wayland/zoom/retry.rs | 81 ++++--- 8 files changed, 633 insertions(+), 143 deletions(-) create mode 100644 src/backend/wayland/state/capture/retry.rs diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index b5cb53bc9..22fc11df2 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -39,7 +39,7 @@ pub(super) fn poll_capture_deadlines( ) { // 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(); + 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"); @@ -52,33 +52,27 @@ pub(super) fn capture_timeout( now: Instant, last_render_time: Option, ) -> Option { - if state.frozen.has_portal_layout_retry() || state.zoom.has_portal_layout_retry() { - return Some(Duration::ZERO); - } - super::min_timeout( - state.overlay_capture_barrier_timeout( - now, - if state.config.performance.enable_vsync { - Duration::ZERO - } else { - super::render::frame_rate_cap_timeout( - state.config.performance.max_fps_no_vsync, - last_render_time, - ) - .unwrap_or(Duration::ZERO) - }, - ), - 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), - ), - ), - ), - ) + let render_delay = if state.config.performance.enable_vsync { + Duration::ZERO + } else { + super::render::frame_rate_cap_timeout( + state.config.performance.max_fps_no_vsync, + last_render_time, + ) + .unwrap_or(Duration::ZERO) + }; + + [ + 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/capture_preflight.rs b/src/backend/wayland/capture_preflight.rs index 8cb578f6e..55a665e9d 100644 --- a/src/backend/wayland/capture_preflight.rs +++ b/src/backend/wayland/capture_preflight.rs @@ -1,4 +1,11 @@ //! 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); #[derive(Debug, Clone, Copy)] pub(super) struct CaptureLayout { output_id: Option, @@ -80,6 +87,9 @@ pub(super) enum PortalLayoutRetry { Available, Pending { output_id: u32, + generation: u64, + not_before: Instant, + deadline: Instant, }, Spent, } @@ -90,6 +100,8 @@ impl PortalLayoutRetry { captured: Option, active: Option, changed: bool, + generation: u64, + now: Instant, ) -> bool { let Some(output_id) = captured else { return false; @@ -97,7 +109,13 @@ impl PortalLayoutRetry { if !matches!(self, Self::Available) || active != captured || !changed { return false; } - *self = Self::Pending { output_id }; + + *self = Self::Pending { + output_id, + generation, + not_before: now + PORTAL_LAYOUT_QUIET_PERIOD, + deadline: now + PORTAL_LAYOUT_SETTLE_TIMEOUT, + }; true } @@ -105,12 +123,60 @@ impl PortalLayoutRetry { matches!(self, Self::Pending { .. }) } - pub(super) fn take_pending(&mut self) -> Option { - let Self::Pending { output_id } = *self else { + 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, &'static str> { + 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("Screen capture failed after the active output changed."); + } + if now >= *deadline { + *self = Self::Spent; + return Err("Screen capture failed because the display layout did not settle."); + } + 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; - Some(output_id) + Ok(Some(output_id)) } } @@ -118,6 +184,95 @@ impl PortalLayoutRetry { 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/retry.rs b/src/backend/wayland/frozen/retry.rs index 06545af0c..c78fd0cd5 100644 --- a/src/backend/wayland/frozen/retry.rs +++ b/src/backend/wayland/frozen/retry.rs @@ -1,5 +1,5 @@ use super::state::{FrozenCaptureBackend, FrozenState}; -use crate::backend::wayland::frozen_geometry::require_verified_capture_source; +use std::time::{Duration, Instant}; impl FrozenState { pub(super) fn queue_portal_layout_retry( @@ -7,18 +7,16 @@ impl FrozenState { captured: Option, changed: bool, ) -> bool { - if require_verified_capture_source( - self.active_geometry.clone(), + if !self.layout_retry.schedule( + captured, self.active_output_id, - "portal freeze retry", - ) - .is_err() - || !self - .layout_retry - .schedule(captured, self.active_output_id, changed) - { + changed, + self.portal_layout_generation, + Instant::now(), + ) { return false; } + self.portal.finish(); self.discard_pending_image_for_retry(); self.capture_done = false; @@ -26,6 +24,7 @@ impl FrozenState { "portal.freeze phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", self.portal_layout_generation ); + true } @@ -44,19 +43,31 @@ impl FrozenState { 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) -> Result { - let Some(output_id) = self.layout_retry.take_pending() else { + 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.portal_layout_generation, + self.active_geometry.as_ref(), + now, + ) + .map_err(str::to_string)? + else { return Ok(false); }; - if self.active_output_id != Some(output_id) { - return Err("Freeze failed after the display layout changed".to_string()); - } - require_verified_capture_source( - self.active_geometry.clone(), - self.active_output_id, - "portal freeze retry", - )?; + self.preflight.begin( FrozenCaptureBackend::Portal, Some(output_id), @@ -66,6 +77,7 @@ impl FrozenState { "portal.freeze phase=retry-preflight output={output_id} layout={}", self.portal_layout_generation ); + Ok(true) } } @@ -81,7 +93,6 @@ mod tests { use crate::backend::wayland::portal_task::PortalTask; use crate::capture::CaptureError; use crate::input::state::test_support::make_test_input_state; - use std::time::Instant; use wayland_client::protocol::wl_output; fn geometry(x: i32) -> OutputGeometry { @@ -94,6 +105,7 @@ mod tests { 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 { @@ -122,7 +134,7 @@ mod tests { frozen.take_preflight_pending(), Some(FrozenCaptureBackend::Portal) ); - let old_generation = frozen.output_layout_generation; + let old_generation = frozen.portal_layout_generation; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -141,12 +153,16 @@ mod tests { assert!(frozen.has_acquisition_attempt()); assert!(!frozen.take_capture_done()); assert!(frozen.take_acquisition_completion().is_none()); - assert!(frozen.restart_portal_preflight().unwrap()); + 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.output_layout_generation; + let fresh_generation = frozen.portal_layout_generation; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -187,16 +203,121 @@ mod tests { } } + #[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 abandoning_a_retry_clears_its_admission_and_resources() { - 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)); - frozen.cancel(&mut input); - assert!(!frozen.has_portal_layout_retry()); - assert!(!frozen.is_in_progress()); - assert!(!frozen.restart_portal_preflight().unwrap()); + 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.portal_layout_generation; + 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_geometry.rs b/src/backend/wayland/frozen_geometry.rs index a73935893..477f0578c 100644 --- a/src/backend/wayland/frozen_geometry.rs +++ b/src/backend/wayland/frozen_geometry.rs @@ -66,6 +66,22 @@ impl OutputGeometry { }) } + /// 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)>, diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index d78bf3091..dbf4c9c08 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/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index 0452dbfb5..d7a4af8ee 100644 --- a/src/backend/wayland/state/capture/barrier.rs +++ b/src/backend/wayland/state/capture/barrier.rs @@ -274,55 +274,6 @@ impl WaylandState { } } - /// Re-admit the same request after refreshing geometry, without restoring - /// any overlay pixels between the stale attempt and its one retry. - pub(in crate::backend::wayland) fn poll_portal_layout_retries(&mut self) { - if !self.frozen.has_portal_layout_retry() && !self.zoom.has_portal_layout_retry() { - return; - } - self.refresh_freeze_zoom_geometry(); - for reason in [OverlaySuppression::Frozen, OverlaySuppression::Zoom] { - let restarted = match reason { - OverlaySuppression::Frozen => self.frozen.restart_portal_preflight(), - OverlaySuppression::Zoom => self.zoom.restart_portal_preflight(), - _ => unreachable!(), - }; - match restarted { - Ok(false) => {} - Ok(true) if self.suppression.reason() == reason => { - self.suppression - .barrier - .begin(reason, self.gtk_toolbar.is_some()); - self.buffer_damage - .mark_all_full(FullDamageReason::OverlaySuppression); - self.input_state.needs_redraw = true; - self.toolbar.mark_dirty(); - } - Ok(true) => { - self.cancel_overlay_capture_preflight( - reason, - Some("Capture retry lost overlay suppression"), - ); - } - Err(error) => { - log::warn!("Portal {reason:?} retry preflight failed: {error}"); - match reason { - OverlaySuppression::Frozen if self.frozen.has_acquisition_attempt() => { - self.frozen - .finish_preflight_failure(error, &mut self.input_state); - } - OverlaySuppression::Zoom => self - .zoom - .finish_preflight_failure(&mut self.input_state, error), - _ => { - self.cancel_overlay_capture_preflight(reason, Some(&error)); - } - } - } - } - } - } - /// Records the frame callback for the fresh hidden main-surface commit. pub(in crate::backend::wayland) fn mark_overlay_capture_frame_ready( &mut self, diff --git a/src/backend/wayland/state/capture/retry.rs b/src/backend/wayland/state/capture/retry.rs new file mode 100644 index 000000000..f460fca2f --- /dev/null +++ b/src/backend/wayland/state/capture/retry.rs @@ -0,0 +1,227 @@ +//! Re-admission of portal requests after a bounded layout settling period. +use super::super::core::overlay::OverlaySuppressionState; +use super::super::*; +use crate::input::state::{Toast, ToastPriority}; +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}"); + if !frozen.has_acquisition_attempt() { + input.push_toast( + ToastPriority::Critical, + "freeze", + Toast::error(error.clone()), + ); + } + 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("Capture retry lost overlay suppression".to_string()); + } + 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::{ScreenAcquisitionOwner, ScreenAcquisitionRegistry}; + use crate::backend::wayland::frozen::FrozenCaptureBackend; + use crate::backend::wayland::frozen_geometry::OutputGeometry; + use crate::backend::wayland::zoom::ZoomCaptureBackend; + 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(true, &tokio::runtime::Handle::current()) + .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)); + assert!(frozen.take_acquisition_completion().is_none()); + assert!(frozen.take_capture_done()); + assert!(!frozen.is_in_progress()); + } else { + assert_eq!(zoom.take_source_terminal().unwrap().id, zoom_id.unwrap()); + assert!(zoom.take_source_terminal().is_none()); + assert!(zoom.take_capture_done()); + assert!(!zoom.is_in_progress()); + } + } + } + } + } +} diff --git a/src/backend/wayland/zoom/retry.rs b/src/backend/wayland/zoom/retry.rs index da229a12d..6b710ad39 100644 --- a/src/backend/wayland/zoom/retry.rs +++ b/src/backend/wayland/zoom/retry.rs @@ -1,5 +1,5 @@ use super::state::{ZoomCaptureBackend, ZoomState}; -use crate::backend::wayland::frozen_geometry::require_verified_capture_source; +use std::time::{Duration, Instant}; impl ZoomState { pub(super) fn queue_portal_layout_retry( @@ -7,25 +7,27 @@ impl ZoomState { captured: Option, changed: bool, ) -> bool { - if self.current_capture_id().is_none() - || require_verified_capture_source( - self.active_geometry.clone(), - self.active_output_id, - "portal zoom retry", - ) - .is_err() - || !self - .layout_retry - .schedule(captured, self.active_output_id, changed) - { + if self.current_capture_id().is_none() { return false; } + + if !self.layout_retry.schedule( + captured, + self.active_output_id, + changed, + self.portal_layout_generation, + 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.portal_layout_generation ); + true } @@ -44,19 +46,31 @@ impl ZoomState { 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) -> Result { - let Some(output_id) = self.layout_retry.take_pending() else { + 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.portal_layout_generation, + self.active_geometry.as_ref(), + now, + ) + .map_err(str::to_string)? + else { return Ok(false); }; - if self.active_output_id != Some(output_id) { - return Err("Zoom failed after the display layout changed".to_string()); - } - require_verified_capture_source( - self.active_geometry.clone(), - self.active_output_id, - "portal zoom retry", - )?; + self.preflight.begin( ZoomCaptureBackend::Portal, Some(output_id), @@ -66,6 +80,7 @@ impl ZoomState { "portal.zoom phase=retry-preflight output={output_id} layout={}", self.portal_layout_generation ); + Ok(true) } } @@ -81,7 +96,6 @@ mod tests { }; use crate::capture::CaptureError; use crate::input::state::test_support::make_test_input_state; - use std::time::Instant; use wayland_client::protocol::wl_output; fn geometry(x: i32) -> OutputGeometry { @@ -94,6 +108,7 @@ mod tests { Some((2, 1)), ) .unwrap() + .with_known_output_count(Some(1)) } fn image(byte: u8) -> FrozenImage { FrozenImage { @@ -135,7 +150,7 @@ mod tests { owner: ZoomWaiterOwner::Ocr })); zoom.request_activation(); - let old_generation = zoom.output_layout_generation; + let old_generation = zoom.portal_layout_generation; zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -162,13 +177,16 @@ mod tests { 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().unwrap()); + 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.output_layout_generation; + let fresh_generation = zoom.portal_layout_generation; zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -272,11 +290,18 @@ mod tests { assert!(zoom.queue_portal_layout_retry(Some(1), true)); if switch_output { zoom.set_active_output(None, Some(2)); - assert!(zoom.restart_portal_preflight().is_err()); + 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().unwrap()); + assert!( + !zoom + .restart_portal_preflight(Instant::now() + Duration::from_millis(150)) + .unwrap() + ); assert_eq!(zoom.take_source_terminal().unwrap().id, id); } } From 181525b7fb252cb31230e21ea9c070fa5a6556d3 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:25:08 +0200 Subject: [PATCH 07/10] Remove obsolete Freeze cropping and split image activation ownership Portal workers already return a completed output crop. Remove the raw desktop setter, cropped flag and test-only activation crop branch; retain the installation guards and move raster acceptance coverage to portal_raster. Add marked-pixel checks for rotated output dimensions and unknown multi-output bounds. Split activation and direct attempt resources from state.rs; frozen state and capture barrier now remain below 500 production lines. Restore logical blank-line groups in the touched raster and retry functions. --- src/backend/wayland/capture_preflight.rs | 7 + src/backend/wayland/frozen/retry.rs | 12 + src/backend/wayland/frozen/state.rs | 602 +----------------- .../wayland/frozen/state/activation.rs | 277 ++++++++ src/backend/wayland/frozen/state/direct.rs | 109 ++++ src/backend/wayland/portal_raster.rs | 86 +++ src/backend/wayland/state/capture/retry.rs | 6 + src/backend/wayland/zoom/retry.rs | 13 + 8 files changed, 536 insertions(+), 576 deletions(-) create mode 100644 src/backend/wayland/frozen/state/activation.rs create mode 100644 src/backend/wayland/frozen/state/direct.rs diff --git a/src/backend/wayland/capture_preflight.rs b/src/backend/wayland/capture_preflight.rs index 55a665e9d..95277b803 100644 --- a/src/backend/wayland/capture_preflight.rs +++ b/src/backend/wayland/capture_preflight.rs @@ -6,6 +6,7 @@ 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); + #[derive(Debug, Clone, Copy)] pub(super) struct CaptureLayout { output_id: Option, @@ -45,7 +46,9 @@ impl CapturePreflight { let Self::Pending { backend, layout } = *self else { return None; }; + *self = Self::Capturing { backend, layout }; + Some(backend) } @@ -61,6 +64,7 @@ impl CapturePreflight { Self::Idle => return false, Self::Pending { layout, .. } | Self::Capturing { layout, .. } => layout, }; + layout.output_id.is_some() && layout.output_id == output_id && layout.generation != generation @@ -71,6 +75,7 @@ impl CapturePreflight { Self::Idle => return true, Self::Pending { layout, .. } | Self::Capturing { layout, .. } => layout, }; + super::portal_capture::layout_token_matches( layout.output_id, layout.generation, @@ -116,6 +121,7 @@ impl PortalLayoutRetry { not_before: now + PORTAL_LAYOUT_QUIET_PERIOD, deadline: now + PORTAL_LAYOUT_SETTLE_TIMEOUT, }; + true } @@ -162,6 +168,7 @@ impl PortalLayoutRetry { *self = Self::Spent; return Err("Screen capture failed because the display layout did not settle."); } + if active_generation != *generation { *generation = active_generation; *not_before = now + PORTAL_LAYOUT_QUIET_PERIOD; diff --git a/src/backend/wayland/frozen/retry.rs b/src/backend/wayland/frozen/retry.rs index c78fd0cd5..ed13065c2 100644 --- a/src/backend/wayland/frozen/retry.rs +++ b/src/backend/wayland/frozen/retry.rs @@ -107,6 +107,7 @@ mod tests { .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); @@ -115,6 +116,7 @@ mod tests { } tokio::task::yield_now().await; } + panic!("portal task did not finish"); } @@ -134,6 +136,7 @@ mod tests { frozen.take_preflight_pending(), Some(FrozenCaptureBackend::Portal) ); + let old_generation = frozen.portal_layout_generation; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), @@ -147,8 +150,11 @@ mod tests { )) }, )); + 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()); @@ -162,6 +168,7 @@ mod tests { frozen.take_preflight_pending(), Some(FrozenCaptureBackend::Portal) ); + let fresh_generation = frozen.portal_layout_generation; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), @@ -180,15 +187,18 @@ mod tests { )) }, )); + 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 { @@ -289,6 +299,7 @@ mod tests { 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 { @@ -310,6 +321,7 @@ mod tests { 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()); } diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index c4a91ae37..4363e7291 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -1,5 +1,9 @@ +mod activation; +mod direct; + +pub(super) use direct::{DirectCaptureAttempt, DirectCaptureContext}; + use crate::backend::wayland::capture_preflight::{CapturePreflight, PortalLayoutRetry}; -use log::info; use std::sync::Arc; use std::time::{Duration, Instant}; use wayland_client::protocol::wl_output; @@ -10,17 +14,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::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, @@ -35,7 +36,7 @@ struct PendingFrozenImage { #[derive(Clone, Copy)] enum FrozenCaptureSource { ActiveOutput, - Desktop { cropped: bool }, + Portal, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -45,72 +46,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 { @@ -298,77 +233,6 @@ 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, - portal_layout_generation: None, - 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, - portal_layout_generation: None, - 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, - portal_layout_generation: Some(self.portal_layout_generation), - source_geometry, - output_transform: None, - source: FrozenCaptureSource::Desktop { cropped: false }, - }); - } - - pub(super) fn set_pending_portal_image( - &mut self, - image: FrozenImage, - target_output_id: Option, - source_geometry: Option, - ) { - self.set_pending_desktop_image(image, target_output_id, source_geometry); - self.pending_image.as_mut().expect("image just set").source = - FrozenCaptureSource::Desktop { cropped: true }; - } - - pub(super) fn discard_pending_image_for_retry(&mut self) { - self.pending_image = None; - } - - 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() @@ -596,238 +460,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, - ) || pending - .portal_layout_generation - .is_some_and(|generation| generation != self.portal_layout_generation) - { - if matches!(pending.source, FrozenCaptureSource::Desktop { .. }) - && self.queue_portal_layout_retry( - pending.target_output_id, - pending.portal_layout_generation != Some(self.portal_layout_generation), - ) - { - 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::Desktop { cropped } = 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, - ) - }); - 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", - ); - }; - if cropped { - 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", - ); - } - } else { - 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 { - let cropped = crate::backend::wayland::portal_raster::crop_portal_raster( - image.data, - image.width, - image.height, - geometry, - ) - .ok()?; - ((cropped.width, cropped.height) == (target_width, target_height)).then_some(cropped) - } - - /// 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( @@ -943,32 +575,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); @@ -1145,30 +751,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, @@ -1239,44 +821,6 @@ mod tests { assert!(state.portal_layout_generation > generation); } - #[test] - fn desktop_capture_crops_from_screenshot_origin_not_logical_times_scale() { - let mut state = FrozenState::new(None); - let mut input_state = 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( - FrozenImage { - width: 10, - height: 1, - stride: 40, - data, - }, - Some(7), - Some(geometry), - ); - - state - .activate_pending_image(4, 1, &mut input_state) - .expect("mixed-scale screenshot origin should crop the active output"); - - 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] - ); - } - #[test] fn desktop_capture_keeps_fractional_output_pixels_for_a_larger_overlay_buffer() { let mut state = FrozenState::new(None); @@ -1284,7 +828,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, @@ -1307,81 +851,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, @@ -1394,9 +871,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()); } @@ -1406,7 +881,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, @@ -1433,7 +908,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, @@ -1453,32 +928,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); @@ -1643,7 +1092,7 @@ mod tests { MissingDesktopGeometry, StaleDesktopGeometry, MissingVerifiedPixelSize, - DesktopCropFailure, + PortalSizeMismatch, MissingOutputIdentity, AspectMismatch, } @@ -1657,7 +1106,7 @@ mod tests { Self::MissingDesktopGeometry, Self::StaleDesktopGeometry, Self::MissingVerifiedPixelSize, - Self::DesktopCropFailure, + Self::PortalSizeMismatch, Self::MissingOutputIdentity, Self::AspectMismatch, ]; @@ -1673,8 +1122,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", @@ -1727,11 +1177,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( @@ -1743,19 +1193,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)))), diff --git a/src/backend/wayland/frozen/state/activation.rs b/src/backend/wayland/frozen/state/activation.rs new file mode 100644 index 000000000..938685191 --- /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.output_layout_generation, + portal_layout_generation: None, + 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.output_layout_generation, + portal_layout_generation: None, + 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.output_layout_generation, + portal_layout_generation: Some(self.portal_layout_generation), + source_geometry, + output_transform: None, + source: FrozenCaptureSource::Portal, + }); + } + + 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); + }; + if !layout_token_matches( + pending.target_output_id, + pending.layout_generation, + self.active_output_id, + self.output_layout_generation, + ) || pending + .portal_layout_generation + .is_some_and(|generation| generation != self.portal_layout_generation) + { + if matches!(pending.source, FrozenCaptureSource::Portal) + && self.queue_portal_layout_retry( + pending.target_output_id, + pending.portal_layout_generation != Some(self.portal_layout_generation), + ) + { + 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 000000000..734e93005 --- /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/portal_raster.rs b/src/backend/wayland/portal_raster.rs index 90ddc287b..3d436d180 100644 --- a/src/backend/wayland/portal_raster.rs +++ b/src/backend/wayland/portal_raster.rs @@ -30,6 +30,7 @@ impl LogicalDesktop { 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 @@ -38,10 +39,12 @@ impl LogicalDesktop { { 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) @@ -53,9 +56,11 @@ impl LogicalDesktop { if lower > upper { return None; } + density_min = density_min.max(lower); density_max = density_max.max(upper); } + Some(Self { x, y, @@ -71,6 +76,7 @@ impl LogicalDesktop { 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)) @@ -78,6 +84,7 @@ impl LogicalDesktop { 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))?; @@ -85,10 +92,12 @@ impl LogicalDesktop { 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), @@ -112,6 +121,7 @@ pub(super) fn crop_portal_raster( 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"))?; @@ -120,6 +130,7 @@ pub(super) fn crop_portal_raster( "Portal capture aspect does not match the overlay surface", )); } + let packed = geometry .portal_crop_origin(width, height) .map(|(x, y)| Crop { @@ -129,6 +140,7 @@ pub(super) fn crop_portal_raster( height: target.1, }) .filter(|crop| contains_crop((width, height), *crop)); + let desktop = geometry .portal_outputs .as_deref() @@ -142,6 +154,7 @@ pub(super) fn crop_portal_raster( (Some(crop), _) | (_, Some(crop)) => Some(crop), _ => None, }; + log::info!( "portal.raster image={}x{} packed_expected={:?} logical_desktop={:?} crop={:?} target={:?}", width, @@ -151,12 +164,14 @@ pub(super) fn crop_portal_raster( 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, @@ -167,6 +182,7 @@ pub(super) fn crop_portal_raster( crop.height, ) .ok_or_else(|| error("Portal capture crop is invalid"))?; + resample(data, (crop.width, crop.height), target) } @@ -205,6 +221,7 @@ fn resample( data, }); } + // Cairo filters premultiplied ARGB directly, avoiding alpha/color corruption. let map_error = |err: cairo::Error| { CaptureError::ImageError(format!("Portal image resampling failed: {err}")) @@ -228,6 +245,7 @@ fn resample( dimension(target.1)?, ) .map_err(map_error)?; + { let context = cairo::Context::new(&dst).map_err(map_error)?; context.scale( @@ -242,10 +260,12 @@ fn resample( 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, @@ -320,10 +340,72 @@ mod tests { .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 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. @@ -333,7 +415,9 @@ mod tests { ]; 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 { @@ -363,7 +447,9 @@ mod tests { .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()); } diff --git a/src/backend/wayland/state/capture/retry.rs b/src/backend/wayland/state/capture/retry.rs index f460fca2f..26b12d303 100644 --- a/src/backend/wayland/state/capture/retry.rs +++ b/src/backend/wayland/state/capture/retry.rs @@ -37,6 +37,7 @@ fn advance_portal_layout_retries( now: Instant, ) -> bool { let mut restarted = false; + if frozen.has_portal_layout_retry() { match restart_suppressed_portal_retry( suppression, @@ -128,10 +129,12 @@ mod tests { 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 { @@ -162,6 +165,7 @@ mod tests { ) .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)); @@ -170,6 +174,7 @@ mod tests { frozen.set_active_geometry(None); zoom.set_active_geometry(None); } + let now = Instant::now(); if failure.is_none() { @@ -183,6 +188,7 @@ mod tests { )); assert_eq!(suppression.barrier.gtk_paint_generation(), first_gtk); } + let admitted = advance_portal_layout_retries( &mut frozen, &mut zoom, diff --git a/src/backend/wayland/zoom/retry.rs b/src/backend/wayland/zoom/retry.rs index 6b710ad39..9fab2445c 100644 --- a/src/backend/wayland/zoom/retry.rs +++ b/src/backend/wayland/zoom/retry.rs @@ -118,6 +118,7 @@ mod tests { 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)); @@ -126,6 +127,7 @@ mod tests { } tokio::task::yield_now().await; } + panic!("portal task did not finish"); } @@ -150,6 +152,7 @@ mod tests { owner: ZoomWaiterOwner::Ocr })); zoom.request_activation(); + let old_generation = zoom.portal_layout_generation; zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), @@ -169,8 +172,11 @@ mod tests { )) }, )); + 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); @@ -186,6 +192,7 @@ mod tests { Some(ZoomCaptureBackend::Portal) ); assert!(zoom.ensure_preflight_layout_current().is_ok()); + let fresh_generation = zoom.portal_layout_generation; zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), @@ -205,16 +212,20 @@ mod tests { )) }, )); + 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); @@ -266,9 +277,11 @@ mod tests { 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 { From 89be3b3640e203b400cc71a488a1cdd11755dada Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:25:08 +0200 Subject: [PATCH 08/10] Document KDE capture support and revised module ownership Give Plasma/KWin an explicit support-matrix entry explaining that Freeze and Zoom require its screenshot portal. Update scoped guides for portal raster validation, separate generations, retry settling, image activation, and direct capture resources. --- README.md | 5 ++++- src/backend/wayland/AGENTS.md | 2 ++ src/backend/wayland/frozen/AGENTS.md | 4 +++- src/backend/wayland/state/AGENTS.md | 1 + src/backend/wayland/zoom/AGENTS.md | 4 +++- 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9cd04f02e..10cd9510a 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/src/backend/wayland/AGENTS.md b/src/backend/wayland/AGENTS.md index 083f7cc5f..8da6d031b 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 and the shared, bounded portal layout settling/retry policy. - `clipboard/`, `frozen/`, `zoom/`, and runtime session helpers integrate external compositor, clipboard, capture, and persistence behavior. ## Invariants diff --git a/src/backend/wayland/frozen/AGENTS.md b/src/backend/wayland/frozen/AGENTS.md index 33744e35c..72f782724 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; `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/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index b2cc68d2c..5f9434cff 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. `capture/retry.rs` coordinates settled portal re-admission while retaining suppression and domain-specific completion ownership. Capture render wakes respect available swapchain slots and the frame cap. - `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/zoom/AGENTS.md b/src/backend/wayland/zoom/AGENTS.md index f4e1b1b5a..d09fc429c 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 runtime state and distinguishes direct and portal capture backends. +- `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 From 8b124b767f5e289c39792757ef5c5e03dd0ab3bc Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:30:56 +0200 Subject: [PATCH 09/10] Preserve retry terminal categories and production import scopes Return typed retry admission errors so an output switch remains StaleLayout for Zoom rather than becoming a generic failure. The coordinator regression failed on that category change and now passes. Limit the removed Zoom wrapper imports to tests so strict production builds remain warning-free. --- src/backend/wayland/capture_preflight.rs | 16 +++++++++++----- src/backend/wayland/frozen/retry.rs | 14 +++++++++++--- src/backend/wayland/frozen/state/activation.rs | 1 + src/backend/wayland/state/capture/retry.rs | 10 ++++++++-- src/backend/wayland/zoom/portal.rs | 8 ++++++-- src/backend/wayland/zoom/retry.rs | 14 +++++++++++--- 6 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/backend/wayland/capture_preflight.rs b/src/backend/wayland/capture_preflight.rs index 95277b803..7811c54f7 100644 --- a/src/backend/wayland/capture_preflight.rs +++ b/src/backend/wayland/capture_preflight.rs @@ -85,6 +85,12 @@ impl CapturePreflight { } } +#[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 { @@ -104,14 +110,14 @@ impl PortalLayoutRetry { &mut self, captured: Option, active: Option, - changed: bool, + layout_changed: bool, generation: u64, now: Instant, ) -> bool { let Some(output_id) = captured else { return false; }; - if !matches!(self, Self::Available) || active != captured || !changed { + if !matches!(self, Self::Available) || active != captured || !layout_changed { return false; } @@ -150,7 +156,7 @@ impl PortalLayoutRetry { active_generation: u64, geometry: Option<&OutputGeometry>, now: Instant, - ) -> Result, &'static str> { + ) -> Result, PortalRetryError> { let Self::Pending { output_id, generation, @@ -162,11 +168,11 @@ impl PortalLayoutRetry { }; if active_output != Some(*output_id) { *self = Self::Spent; - return Err("Screen capture failed after the active output changed."); + return Err(PortalRetryError::OutputChanged); } if now >= *deadline { *self = Self::Spent; - return Err("Screen capture failed because the display layout did not settle."); + return Err(PortalRetryError::LayoutDidNotSettle); } if active_generation != *generation { diff --git a/src/backend/wayland/frozen/retry.rs b/src/backend/wayland/frozen/retry.rs index ed13065c2..cbe88e90f 100644 --- a/src/backend/wayland/frozen/retry.rs +++ b/src/backend/wayland/frozen/retry.rs @@ -1,16 +1,17 @@ use super::state::{FrozenCaptureBackend, FrozenState}; +use crate::backend::wayland::capture_preflight::PortalRetryError; use std::time::{Duration, Instant}; impl FrozenState { pub(super) fn queue_portal_layout_retry( &mut self, captured: Option, - changed: bool, + layout_changed: bool, ) -> bool { if !self.layout_retry.schedule( captured, self.active_output_id, - changed, + layout_changed, self.portal_layout_generation, Instant::now(), ) { @@ -63,7 +64,14 @@ impl FrozenState { self.active_geometry.as_ref(), now, ) - .map_err(str::to_string)? + .map_err(|error| match error { + PortalRetryError::OutputChanged => { + "Freeze failed after the display layout changed".to_string() + } + PortalRetryError::LayoutDidNotSettle => { + "Freeze failed because the display layout did not settle".to_string() + } + })? else { return Ok(false); }; diff --git a/src/backend/wayland/frozen/state/activation.rs b/src/backend/wayland/frozen/state/activation.rs index 938685191..df84f3574 100644 --- a/src/backend/wayland/frozen/state/activation.rs +++ b/src/backend/wayland/frozen/state/activation.rs @@ -106,6 +106,7 @@ impl FrozenState { "Freeze failed after the display layout changed", ); } + let mut image = pending.image; let mut provenance = None; diff --git a/src/backend/wayland/state/capture/retry.rs b/src/backend/wayland/state/capture/retry.rs index 26b12d303..9c34c0806 100644 --- a/src/backend/wayland/state/capture/retry.rs +++ b/src/backend/wayland/state/capture/retry.rs @@ -102,7 +102,7 @@ mod tests { use crate::backend::wayland::acquisition::{ScreenAcquisitionOwner, ScreenAcquisitionRegistry}; use crate::backend::wayland::frozen::FrozenCaptureBackend; use crate::backend::wayland::frozen_geometry::OutputGeometry; - use crate::backend::wayland::zoom::ZoomCaptureBackend; + use crate::backend::wayland::zoom::{ZoomCaptureBackend, ZoomSourceOutcome}; use std::time::Duration; #[tokio::test] @@ -221,7 +221,13 @@ mod tests { assert!(frozen.take_capture_done()); assert!(!frozen.is_in_progress()); } else { - assert_eq!(zoom.take_source_terminal().unwrap().id, zoom_id.unwrap()); + let terminal = zoom.take_source_terminal().unwrap(); + assert_eq!(terminal.id, zoom_id.unwrap()); + if failure == Some("output") { + 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/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index 1e3bc6f96..05fcb575d 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -2,8 +2,12 @@ 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}; +#[cfg(test)] +use crate::backend::wayland::frozen::FrozenImage; +use crate::backend::wayland::frozen::ScreenImageProvenance; +#[cfg(test)] +use crate::backend::wayland::frozen_geometry::OutputGeometry; +use crate::backend::wayland::frozen_geometry::require_verified_capture_source; use crate::backend::wayland::portal_capture::{ capture_via_portal_fullscreen_bytes, portal_output_matches, }; diff --git a/src/backend/wayland/zoom/retry.rs b/src/backend/wayland/zoom/retry.rs index 9fab2445c..abccff2c3 100644 --- a/src/backend/wayland/zoom/retry.rs +++ b/src/backend/wayland/zoom/retry.rs @@ -1,11 +1,12 @@ use super::state::{ZoomCaptureBackend, ZoomState}; +use crate::backend::wayland::capture_preflight::PortalRetryError; use std::time::{Duration, Instant}; impl ZoomState { pub(super) fn queue_portal_layout_retry( &mut self, captured: Option, - changed: bool, + layout_changed: bool, ) -> bool { if self.current_capture_id().is_none() { return false; @@ -14,7 +15,7 @@ impl ZoomState { if !self.layout_retry.schedule( captured, self.active_output_id, - changed, + layout_changed, self.portal_layout_generation, Instant::now(), ) { @@ -66,7 +67,14 @@ impl ZoomState { self.active_geometry.as_ref(), now, ) - .map_err(str::to_string)? + .map_err(|error| match error { + PortalRetryError::OutputChanged => { + "Zoom failed after the display layout changed".to_string() + } + PortalRetryError::LayoutDidNotSettle => { + "Zoom failed because the display layout did not settle".to_string() + } + })? else { return Ok(false); }; From e0b56a6fdb4653d7d33e67d2d05fd8869cc121f6 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:44:41 +0200 Subject: [PATCH 10/10] Type preflight failures and restore desktop-wide PDF layout checks - Replace string-matched capture terminals with CapturePreflightError. Stale layouts and layouts that never settle end as StaleLayout in both Freeze and Zoom, so region capture does not fall back silently. - Share active-output/desktop generation bookkeeping. Board PDF desktop captures use the full-desktop generation again. - Abandon unowned Freeze preflight failures and clear pending retries. - Move Zoom request, waiter and terminal ownership to zoom/state/source.rs and select the Zoom backend with ZoomCaptureBackend. - Compute the frame-cap delay once for render and capture wakes. - Cover buffer availability, packed mixed-density crops, desktop PDF contexts and typed terminal categories. - Update capture invalidation docs and module guides. --- CONTRIBUTING.md | 9 +- docs/SETUP.md | 8 +- src/backend/wayland/AGENTS.md | 2 +- .../wayland/backend/event_loop/capture.rs | 12 +- src/backend/wayland/backend/event_loop/mod.rs | 11 +- src/backend/wayland/capture.rs | 74 +++++ src/backend/wayland/capture_preflight.rs | 90 +++++ src/backend/wayland/frozen/AGENTS.md | 2 +- src/backend/wayland/frozen/capture.rs | 4 +- src/backend/wayland/frozen/ext_image_copy.rs | 4 +- src/backend/wayland/frozen/portal.rs | 19 +- src/backend/wayland/frozen/retry.rs | 35 +- src/backend/wayland/frozen/state.rs | 108 +++--- .../wayland/frozen/state/activation.rs | 33 +- src/backend/wayland/portal_raster.rs | 24 ++ src/backend/wayland/state/AGENTS.md | 2 +- src/backend/wayland/state/capture/barrier.rs | 20 +- src/backend/wayland/state/capture/pdf.rs | 5 +- src/backend/wayland/state/capture/retry.rs | 31 +- src/backend/wayland/state/zoom.rs | 23 +- src/backend/wayland/surface.rs | 56 ++++ src/backend/wayland/zoom/AGENTS.md | 2 +- src/backend/wayland/zoom/capture.rs | 18 +- src/backend/wayland/zoom/portal.rs | 20 +- src/backend/wayland/zoom/retry.rs | 42 ++- src/backend/wayland/zoom/state.rs | 307 +++--------------- src/backend/wayland/zoom/state/source.rs | 232 +++++++++++++ 27 files changed, 746 insertions(+), 447 deletions(-) create mode 100644 src/backend/wayland/zoom/state/source.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bf1e125a..db6e3bec2 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/docs/SETUP.md b/docs/SETUP.md index bd2c598ad..344e94788 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 8da6d031b..cfd1f5722 100644 --- a/src/backend/wayland/AGENTS.md +++ b/src/backend/wayland/AGENTS.md @@ -10,7 +10,7 @@ - `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 and the shared, bounded portal layout settling/retry policy. +- `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 22fc11df2..589833ad4 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -50,18 +50,8 @@ pub(super) fn poll_capture_deadlines( pub(super) fn capture_timeout( state: &WaylandState, now: Instant, - last_render_time: Option, + render_delay: Duration, ) -> Option { - let render_delay = if state.config.performance.enable_vsync { - Duration::ZERO - } else { - super::render::frame_rate_cap_timeout( - state.config.performance.max_fps_no_vsync, - last_render_time, - ) - .unwrap_or(Duration::ZERO) - }; - [ state.frozen.portal_layout_retry_timeout(now), state.zoom.portal_layout_retry_timeout(now), diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 35947ec58..0a345b18d 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, last_render_time), + 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 ccb3ed53c..c49b20d10 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 7811c54f7..62048be45 100644 --- a/src/backend/wayland/capture_preflight.rs +++ b/src/backend/wayland/capture_preflight.rs @@ -7,6 +7,82 @@ 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, @@ -83,6 +159,18 @@ 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)] @@ -170,6 +258,8 @@ impl PortalLayoutRetry { *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); diff --git a/src/backend/wayland/frozen/AGENTS.md b/src/backend/wayland/frozen/AGENTS.md index 72f782724..55f7ee84e 100644 --- a/src/backend/wayland/frozen/AGENTS.md +++ b/src/backend/wayland/frozen/AGENTS.md @@ -7,7 +7,7 @@ - `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 and acquisition ownership. `state/activation.rs` validates and installs completed output images; `state/direct.rs` owns direct attempt resources and deadlines. +- `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 diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs index e3b4803ca..113c65431 100644 --- a/src/backend/wayland/frozen/capture.rs +++ b/src/backend/wayland/frozen/capture.rs @@ -249,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, ), }); @@ -421,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 48f08d3e5..39b7d07a7 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/portal.rs b/src/backend/wayland/frozen/portal.rs index 8c0541c4e..2ec2f8545 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -38,7 +38,7 @@ impl FrozenState { ) .map_err(anyhow::Error::msg)?; - let layout_generation = self.portal_layout_generation; + let layout_generation = self.layout_generations.desktop; // Notify user that portal fallback is in progress crate::notification::send_notification_async( tokio_handle, @@ -206,7 +206,7 @@ impl FrozenState { log::info!( "portal.freeze captured_output={target_output:?} active_output={:?} captured_layout={layout_generation} active_layout={}", self.active_output_id, - self.portal_layout_generation + 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 @@ -215,7 +215,7 @@ impl FrozenState { .as_ref() .is_some_and(|geometry| geometry.output_count_conflicts_with_live(live_output_count)); let layout_matches = - layout_generation == self.portal_layout_generation && !topology_changed; + layout_generation == self.layout_generations.desktop && !topology_changed; if output_matches && layout_matches { match image { @@ -223,7 +223,12 @@ impl FrozenState { Err(err) => { warn!("Portal frozen raster rejected: {err}"); if self.has_acquisition_attempt() { - self.finish_preflight_failure(err.to_string(), input_state); + self.finish_preflight_failure( + crate::backend::wayland::capture_preflight::CapturePreflightError::Backend( + err.to_string(), + ), + input_state, + ); } else { input_state.push_toast( ToastPriority::Critical, @@ -329,7 +334,7 @@ mod tests { frozen.set_active_geometry(Some(geometry.clone())); frozen.start_capture_for(id, owner).unwrap(); frozen.take_preflight_pending(); - let generation = frozen.portal_layout_generation; + let generation = frozen.layout_generations.desktop; frozen.apply_portal_image( Some(1), @@ -542,7 +547,7 @@ 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.portal_layout_generation; + let layout_generation = frozen.layout_generations.desktop; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -566,7 +571,7 @@ 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.portal_layout_generation; + let layout_generation = frozen.layout_generations.desktop; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), diff --git a/src/backend/wayland/frozen/retry.rs b/src/backend/wayland/frozen/retry.rs index cbe88e90f..e1cec89c6 100644 --- a/src/backend/wayland/frozen/retry.rs +++ b/src/backend/wayland/frozen/retry.rs @@ -1,5 +1,5 @@ use super::state::{FrozenCaptureBackend, FrozenState}; -use crate::backend::wayland::capture_preflight::PortalRetryError; +use crate::backend::wayland::capture_preflight::CapturePreflightError; use std::time::{Duration, Instant}; impl FrozenState { @@ -12,7 +12,7 @@ impl FrozenState { captured, self.active_output_id, layout_changed, - self.portal_layout_generation, + self.layout_generations.desktop, Instant::now(), ) { return false; @@ -23,7 +23,7 @@ impl FrozenState { self.capture_done = false; log::info!( "portal.freeze phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", - self.portal_layout_generation + self.layout_generations.desktop ); true @@ -33,11 +33,11 @@ impl FrozenState { &mut self, backend: FrozenCaptureBackend, ) -> bool { - let changed = backend == FrozenCaptureBackend::Portal + let portal_layout_changed = backend == FrozenCaptureBackend::Portal && self .preflight - .changed_on_output(self.active_output_id, self.portal_layout_generation); - self.queue_portal_layout_retry(self.active_output_id, changed) + .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 { @@ -55,23 +55,16 @@ impl FrozenState { pub(in crate::backend::wayland) fn restart_portal_preflight( &mut self, now: Instant, - ) -> Result { + ) -> Result { let Some(output_id) = self .layout_retry .take_ready( self.active_output_id, - self.portal_layout_generation, + self.layout_generations.desktop, self.active_geometry.as_ref(), now, ) - .map_err(|error| match error { - PortalRetryError::OutputChanged => { - "Freeze failed after the display layout changed".to_string() - } - PortalRetryError::LayoutDidNotSettle => { - "Freeze failed because the display layout did not settle".to_string() - } - })? + .map_err(CapturePreflightError::from)? else { return Ok(false); }; @@ -79,11 +72,11 @@ impl FrozenState { self.preflight.begin( FrozenCaptureBackend::Portal, Some(output_id), - self.portal_layout_generation, + self.layout_generations.desktop, ); log::info!( "portal.freeze phase=retry-preflight output={output_id} layout={}", - self.portal_layout_generation + self.layout_generations.desktop ); Ok(true) @@ -145,7 +138,7 @@ mod tests { Some(FrozenCaptureBackend::Portal) ); - let old_generation = frozen.portal_layout_generation; + let old_generation = frozen.layout_generations.desktop; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -177,7 +170,7 @@ mod tests { Some(FrozenCaptureBackend::Portal) ); - let fresh_generation = frozen.portal_layout_generation; + let fresh_generation = frozen.layout_generations.desktop; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -282,7 +275,7 @@ mod tests { frozen.set_active_geometry(Some(geometry(0))); frozen.start_capture_for(id, owner).unwrap(); frozen.take_preflight_pending(); - let generation = frozen.portal_layout_generation; + let generation = frozen.layout_generations.desktop; frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index 4363e7291..aad3e9912 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -3,7 +3,9 @@ mod direct; pub(super) use direct::{DirectCaptureAttempt, DirectCaptureContext}; -use crate::backend::wayland::capture_preflight::{CapturePreflight, PortalLayoutRetry}; +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; @@ -27,7 +29,6 @@ struct PendingFrozenImage { image: FrozenImage, target_output_id: Option, layout_generation: u64, - portal_layout_generation: Option, source_geometry: Option, output_transform: Option, source: FrozenCaptureSource, @@ -36,7 +37,7 @@ struct PendingFrozenImage { #[derive(Clone, Copy)] enum FrozenCaptureSource { ActiveOutput, - Portal, + Portal { desktop_generation: u64 }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -57,12 +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. - /// Active viewport generation used by direct capture and installed sources. - pub(super) output_layout_generation: u64, - /// Full desktop generation used only by portal snapshots and preflight. - pub(super) portal_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, @@ -127,8 +124,7 @@ impl FrozenState { active_output: None, active_output_id: None, active_geometry: None, - output_layout_generation: 0, - portal_layout_generation: 0, + layout_generations: CaptureLayoutGenerations::default(), direct_capture: None, image: None, image_provenance: None, @@ -174,18 +170,13 @@ impl FrozenState { } pub fn set_active_geometry(&mut self, geometry: Option) { - if self.active_geometry != geometry { - self.portal_layout_generation = self.portal_layout_generation.wrapping_add(1); - } - if !OutputGeometry::same_active_output(self.active_geometry.as_ref(), geometry.as_ref()) { - 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( @@ -193,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 { @@ -252,31 +243,27 @@ impl FrozenState { self.preflight.begin( FrozenCaptureBackend::Portal, self.active_output_id, - self.portal_layout_generation, + self.layout_generations.desktop, ); } pub(super) fn capture_layout_generation(&self, backend: FrozenCaptureBackend) -> u64 { match backend { - FrozenCaptureBackend::Portal => self.portal_layout_generation, + FrozenCaptureBackend::Portal => self.layout_generations.desktop, FrozenCaptureBackend::WlrScreencopy | FrozenCaptureBackend::ExtImageCopy => { - self.output_layout_generation + self.layout_generations.active_output } } } - pub(super) fn ensure_preflight_layout_current(&self) -> Result<(), String> { - if self.preflight.layout_matches( + 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.output_layout_generation), - ) { - Ok(()) - } else { - Err("Freeze failed after the display layout changed".to_string()) - } + .unwrap_or(self.layout_generations.active_output), + ) } #[cfg(test)] @@ -296,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()); @@ -315,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); } @@ -779,15 +775,15 @@ mod tests { #[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, 1); - assert_eq!(state.portal_layout_generation, 2); + assert_eq!(state.layout_generations.active_output, 1); + assert_eq!(state.layout_generations.desktop, 2); } #[test] @@ -797,7 +793,7 @@ mod tests { state.set_active_output(None, Some(7)); let first = desktop_geometry(0, 0, 2, 1, 1, Some((0, 0))); state.set_active_geometry(Some(first.clone())); - let generation = state.output_layout_generation; + let generation = state.layout_generations.active_output; let provenance = ScreenImageProvenance::new(7, generation, 1, wl_output::Transform::Normal).unwrap(); state.set_pending_output_image( @@ -818,7 +814,7 @@ mod tests { 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.portal_layout_generation > generation); + assert!(state.layout_generations.desktop > generation); } #[test] @@ -1395,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 @@ -1404,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 index df84f3574..a6333492d 100644 --- a/src/backend/wayland/frozen/state/activation.rs +++ b/src/backend/wayland/frozen/state/activation.rs @@ -13,8 +13,7 @@ impl FrozenState { self.pending_image = Some(PendingFrozenImage { image, target_output_id: Some(target_output_id), - layout_generation: self.output_layout_generation, - portal_layout_generation: None, + layout_generation: self.layout_generations.active_output, source_geometry: Some(source_geometry), output_transform: None, source: FrozenCaptureSource::ActiveOutput, @@ -31,8 +30,7 @@ impl FrozenState { self.pending_image = Some(PendingFrozenImage { image, target_output_id: Some(target_output_id), - layout_generation: self.output_layout_generation, - portal_layout_generation: None, + layout_generation: self.layout_generations.active_output, source_geometry: Some(source_geometry), output_transform, source: FrozenCaptureSource::ActiveOutput, @@ -48,11 +46,12 @@ impl FrozenState { self.pending_image = Some(PendingFrozenImage { image, target_output_id, - layout_generation: self.output_layout_generation, - portal_layout_generation: Some(self.portal_layout_generation), + layout_generation: self.layout_generations.active_output, source_geometry, output_transform: None, - source: FrozenCaptureSource::Portal, + source: FrozenCaptureSource::Portal { + desktop_generation: self.layout_generations.desktop, + }, }); } @@ -84,20 +83,20 @@ impl FrozenState { 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.output_layout_generation, - ) || pending - .portal_layout_generation - .is_some_and(|generation| generation != self.portal_layout_generation) + self.layout_generations.active_output, + ) || portal_layout_changed { - if matches!(pending.source, FrozenCaptureSource::Portal) - && self.queue_portal_layout_retry( - pending.target_output_id, - pending.portal_layout_generation != Some(self.portal_layout_generation), - ) + if matches!(pending.source, FrozenCaptureSource::Portal { .. }) + && self.queue_portal_layout_retry(pending.target_output_id, portal_layout_changed) { return Ok(false); } @@ -143,7 +142,7 @@ impl FrozenState { } } - if let FrozenCaptureSource::Portal = pending.source { + if let FrozenCaptureSource::Portal { .. } = pending.source { let Some(geometry) = pending .source_geometry .as_ref() diff --git a/src/backend/wayland/portal_raster.rs b/src/backend/wayland/portal_raster.rs index 3d436d180..172e208cd 100644 --- a/src/backend/wayland/portal_raster.rs +++ b/src/backend/wayland/portal_raster.rs @@ -370,6 +370,29 @@ mod tests { 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( @@ -395,6 +418,7 @@ mod tests { ) .is_err() ); + let image = crop_portal_raster( pixels.clone(), 2, diff --git a/src/backend/wayland/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index 5f9434cff..0ee19956e 100644 --- a/src/backend/wayland/state/AGENTS.md +++ b/src/backend/wayland/state/AGENTS.md @@ -7,7 +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. `capture/retry.rs` coordinates settled portal re-admission while retaining suppression and domain-specific completion ownership. Capture render wakes respect available swapchain slots and the frame cap. +- `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/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index d7a4af8ee..f75fce408 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 } @@ -375,8 +379,10 @@ impl WaylandState { 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, @@ -403,8 +409,10 @@ impl WaylandState { if self.zoom.retry_stale_portal_preflight(backend) { return; } - self.zoom - .finish_preflight_failure(&mut self.input_state, err.to_string()); + self.zoom.finish_preflight_failure( + &mut self.input_state, + CapturePreflightError::from_backend(err), + ); } } OverlaySuppression::Capture | OverlaySuppression::DesktopBackdrop => { @@ -715,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 1566fe860..954221888 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 index 9c34c0806..10bcda680 100644 --- a/src/backend/wayland/state/capture/retry.rs +++ b/src/backend/wayland/state/capture/retry.rs @@ -1,7 +1,7 @@ //! Re-admission of portal requests after a bounded layout settling period. use super::super::core::overlay::OverlaySuppressionState; use super::super::*; -use crate::input::state::{Toast, ToastPriority}; +use crate::backend::wayland::capture_preflight::CapturePreflightError; use std::time::Instant; impl WaylandState { @@ -48,13 +48,6 @@ fn advance_portal_layout_retries( Ok(ready) => restarted |= ready, Err(error) => { log::warn!("Portal Freeze retry preflight failed: {error}"); - if !frozen.has_acquisition_attempt() { - input.push_toast( - ToastPriority::Critical, - "freeze", - Toast::error(error.clone()), - ); - } frozen.finish_preflight_failure(error, input); } } @@ -83,10 +76,10 @@ fn restart_suppressed_portal_retry( suppression: &mut OverlaySuppressionState, reason: OverlaySuppression, wait_for_gtk: bool, - restart: impl FnOnce() -> Result, -) -> Result { + restart: impl FnOnce() -> Result, +) -> Result { if suppression.reason() != reason { - return Err("Capture retry lost overlay suppression".to_string()); + return Err(CapturePreflightError::LostSuppression); } if !restart()? { return Ok(false); @@ -99,7 +92,9 @@ fn restart_suppressed_portal_retry( #[cfg(test)] mod tests { use super::*; - use crate::backend::wayland::acquisition::{ScreenAcquisitionOwner, ScreenAcquisitionRegistry}; + 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}; @@ -143,7 +138,7 @@ mod tests { frozen.set_active_geometry(Some(changed)); assert!(frozen.retry_stale_portal_preflight(FrozenCaptureBackend::Portal)); } else { - zoom.start_capture(true, &tokio::runtime::Handle::current()) + zoom.start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::Portal) .unwrap(); zoom_id = zoom.current_capture_id(); zoom.take_preflight_pending(); @@ -217,13 +212,21 @@ mod tests { 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 failure == Some("output") { + if matches!(failure, Some("output" | "metadata")) { assert_eq!(terminal.outcome, ZoomSourceOutcome::StaleLayout); } else { assert!(matches!(terminal.outcome, ZoomSourceOutcome::Failed(_))); diff --git a/src/backend/wayland/state/zoom.rs b/src/backend/wayland/state/zoom.rs index 04f2444fe..aab3a9f92 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 fa9158eab..2549f73ab 100644 --- a/src/backend/wayland/surface.rs +++ b/src/backend/wayland/surface.rs @@ -513,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 d09fc429c..abab5ac2b 100644 --- a/src/backend/wayland/zoom/AGENTS.md +++ b/src/backend/wayland/zoom/AGENTS.md @@ -6,7 +6,7 @@ ## Architecture - `capture.rs` coordinates zoom capture. - `portal.rs` provides portal fallback behavior. -- `state.rs` owns zoom runtime state and distinguishes direct and portal capture backends. +- `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. diff --git a/src/backend/wayland/zoom/capture.rs b/src/backend/wayland/zoom/capture.rs index 93d4e4d0a..e1e78ca40 100644 --- a/src/backend/wayland/zoom/capture.rs +++ b/src/backend/wayland/zoom/capture.rs @@ -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,10 +133,10 @@ impl ZoomState { self.begin_identified_capture(); self.capture_done = false; - let backend = if use_fallback || self.manager.is_none() { + let backend = if self.manager.is_none() { ZoomCaptureBackend::Portal } else { - ZoomCaptureBackend::WlrScreencopy + preferred_backend }; self.preflight.begin( backend, @@ -194,7 +194,7 @@ impl ZoomState { let context = CaptureContext::new( target_output_id, source_geometry, - self.output_layout_generation, + self.layout_generations.active_output, ); info!( @@ -362,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); } @@ -475,7 +475,7 @@ 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()); diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index 05fcb575d..ec5cb3bae 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -2,11 +2,7 @@ use anyhow::Result; use log::warn; use std::time::{Duration, Instant}; -#[cfg(test)] -use crate::backend::wayland::frozen::FrozenImage; use crate::backend::wayland::frozen::ScreenImageProvenance; -#[cfg(test)] -use crate::backend::wayland::frozen_geometry::OutputGeometry; use crate::backend::wayland::frozen_geometry::require_verified_capture_source; use crate::backend::wayland::portal_capture::{ capture_via_portal_fullscreen_bytes, portal_output_matches, @@ -39,10 +35,10 @@ impl ZoomState { ) .map_err(anyhow::Error::msg)?; - let layout_generation = self.portal_layout_generation; + let layout_generation = self.layout_generations.desktop; let provenance = ScreenImageProvenance::new( target_output_id, - self.output_layout_generation, + self.layout_generations.active_output, geo.scale, geo.transform, ) @@ -98,10 +94,10 @@ impl ZoomState { log::info!( "portal.zoom captured_output={target_output:?} active_output={:?} captured_layout={layout_generation} active_layout={}", self.active_output_id, - self.portal_layout_generation + self.layout_generations.desktop ); let output_matches = portal_output_matches(target_output, self.active_output_id); - let layout_matches = layout_generation == self.portal_layout_generation; + let layout_matches = layout_generation == self.layout_generations.desktop; if output_matches && layout_matches { // Crop used the spawn-time geometry. SCTK may advertise @@ -214,6 +210,8 @@ impl ZoomState { #[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; @@ -607,7 +605,7 @@ mod tests { 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(), @@ -650,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(), @@ -695,7 +693,7 @@ mod tests { 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(), diff --git a/src/backend/wayland/zoom/retry.rs b/src/backend/wayland/zoom/retry.rs index abccff2c3..0e36e7cad 100644 --- a/src/backend/wayland/zoom/retry.rs +++ b/src/backend/wayland/zoom/retry.rs @@ -1,5 +1,5 @@ use super::state::{ZoomCaptureBackend, ZoomState}; -use crate::backend::wayland::capture_preflight::PortalRetryError; +use crate::backend::wayland::capture_preflight::CapturePreflightError; use std::time::{Duration, Instant}; impl ZoomState { @@ -16,7 +16,7 @@ impl ZoomState { captured, self.active_output_id, layout_changed, - self.portal_layout_generation, + self.layout_generations.desktop, Instant::now(), ) { return false; @@ -26,7 +26,7 @@ impl ZoomState { self.capture_done = false; log::info!( "portal.zoom phase=retry-queued output={captured:?} current_layout={} budget_remaining=0", - self.portal_layout_generation + self.layout_generations.desktop ); true @@ -36,11 +36,11 @@ impl ZoomState { &mut self, backend: ZoomCaptureBackend, ) -> bool { - let changed = (backend == ZoomCaptureBackend::Portal) + let portal_layout_changed = (backend == ZoomCaptureBackend::Portal) && self .preflight - .changed_on_output(self.active_output_id, self.portal_layout_generation); - self.queue_portal_layout_retry(self.active_output_id, changed) + .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 { @@ -58,23 +58,16 @@ impl ZoomState { pub(in crate::backend::wayland) fn restart_portal_preflight( &mut self, now: Instant, - ) -> Result { + ) -> Result { let Some(output_id) = self .layout_retry .take_ready( self.active_output_id, - self.portal_layout_generation, + self.layout_generations.desktop, self.active_geometry.as_ref(), now, ) - .map_err(|error| match error { - PortalRetryError::OutputChanged => { - "Zoom failed after the display layout changed".to_string() - } - PortalRetryError::LayoutDidNotSettle => { - "Zoom failed because the display layout did not settle".to_string() - } - })? + .map_err(CapturePreflightError::from)? else { return Ok(false); }; @@ -82,11 +75,11 @@ impl ZoomState { self.preflight.begin( ZoomCaptureBackend::Portal, Some(output_id), - self.portal_layout_generation, + self.layout_generations.desktop, ); log::info!( "portal.zoom phase=retry-preflight output={output_id} layout={}", - self.portal_layout_generation + self.layout_generations.desktop ); Ok(true) @@ -118,6 +111,7 @@ mod tests { .unwrap() .with_known_output_count(Some(1)) } + fn image(byte: u8) -> FrozenImage { FrozenImage { width: 2, @@ -147,7 +141,7 @@ mod tests { let mut input = make_test_input_state(); zoom.set_active_output(None, Some(1)); zoom.set_active_geometry(Some(geometry(0))); - zoom.start_capture(true, &tokio::runtime::Handle::current()) + zoom.start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::Portal) .unwrap(); assert_eq!( zoom.take_preflight_pending(), @@ -161,7 +155,7 @@ mod tests { })); zoom.request_activation(); - let old_generation = zoom.portal_layout_generation; + let old_generation = zoom.layout_generations.desktop; zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -201,7 +195,7 @@ mod tests { ); assert!(zoom.ensure_preflight_layout_current().is_ok()); - let fresh_generation = zoom.portal_layout_generation; + let fresh_generation = zoom.layout_generations.desktop; zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -259,7 +253,7 @@ mod tests { zoom.set_active_output(None, Some(1)); zoom.set_active_geometry(Some(geometry(0))); let id = zoom.begin_identified_capture(); - let generation = zoom.output_layout_generation; + let generation = zoom.layout_generations.active_output; zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), @@ -287,6 +281,7 @@ mod tests { } drain(&mut zoom, &mut input).await; + assert!(!zoom.has_portal_layout_retry()); assert!(!zoom.is_in_progress()); @@ -309,13 +304,16 @@ mod tests { 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!( diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs index 916949ca1..d3428049f 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, PortalLayoutRetry}; +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,92 +14,12 @@ 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, -} - -#[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) use source::{ + ZoomCaptureId, ZoomSourceOutcome, ZoomSourceTerminal, ZoomTerminalReport, ZoomWaiter, + ZoomWaiterOwner, ZoomWaiterRegistry, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(in crate::backend::wayland) enum ZoomCaptureBackend { @@ -111,10 +33,8 @@ pub struct ZoomState { pub(super) active_output: Option, pub(super) active_output_id: Option, pub(super) active_geometry: Option, - /// Active viewport generation used by direct capture and installed sources. - pub(super) output_layout_generation: u64, - /// Full desktop generation used only by portal snapshots and preflight. - pub(super) portal_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, @@ -159,8 +79,7 @@ impl ZoomState { active_output: None, active_output_id: None, active_geometry: None, - output_layout_generation: 0, - portal_layout_generation: 0, + layout_generations: CaptureLayoutGenerations::default(), capture: None, image: None, image_provenance: None, @@ -194,13 +113,8 @@ impl ZoomState { } pub fn set_active_geometry(&mut self, geometry: Option) { - if self.active_geometry != geometry { - self.portal_layout_generation = self.portal_layout_generation.wrapping_add(1); - } - if !OutputGeometry::same_active_output(self.active_geometry.as_ref(), geometry.as_ref()) { - 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; } @@ -209,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 { @@ -305,29 +219,25 @@ impl ZoomState { self.preflight.begin( ZoomCaptureBackend::Portal, self.active_output_id, - self.portal_layout_generation, + self.layout_generations.desktop, ); } pub(super) fn capture_layout_generation(&self, backend: ZoomCaptureBackend) -> u64 { match backend { - ZoomCaptureBackend::Portal => self.portal_layout_generation, - ZoomCaptureBackend::WlrScreencopy => self.output_layout_generation, + ZoomCaptureBackend::Portal => self.layout_generations.desktop, + ZoomCaptureBackend::WlrScreencopy => self.layout_generations.active_output, } } - pub(super) fn ensure_preflight_layout_current(&self) -> Result<(), String> { - if self.preflight.layout_matches( + 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.output_layout_generation), - ) { - Ok(()) - } else { - Err("Zoom failed after the display layout changed".to_string()) - } + .unwrap_or(self.layout_generations.active_output), + ) } #[cfg(test)] @@ -345,56 +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); - self.layout_retry = PortalLayoutRetry::default(); - 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, - ) { - 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: "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 } @@ -459,61 +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.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; - } - fn bump_image_generation(&mut self) { self.image_generation = self.image_generation.wrapping_add(1).max(1); } @@ -533,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()); @@ -547,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); @@ -558,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!( @@ -599,7 +404,7 @@ mod tests { state.set_active_geometry(Some(geometry.clone())); let provenance = ScreenImageProvenance::new( 7, - state.output_layout_generation, + state.layout_generations.active_output, 1, wl_output::Transform::Normal, ) @@ -614,7 +419,7 @@ mod tests { provenance, ); state - .start_capture(true, &tokio::runtime::Handle::current()) + .start_capture(crate::backend::wayland::zoom::ZoomCaptureBackend::Portal) .unwrap(); let generation = state.image_generation(); let mut changed = geometry.with_known_output_count(Some(2)); @@ -627,26 +432,6 @@ mod tests { assert!(state.ensure_preflight_layout_current().is_err()); } - #[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); - } - #[test] fn typed_terminal_is_emitted_once_for_each_current_capture() { let outcomes = [ @@ -713,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 000000000..8bb280b1c --- /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); + } +}