diff --git a/Cargo.lock b/Cargo.lock index 195cf000..2a44f864 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3230,6 +3230,7 @@ dependencies = [ "anyhow", "cairo-rs", "flate2", + "getrandom 0.3.4", "glib", "gtk4", "gtk4-layer-shell", diff --git a/Cargo.toml b/Cargo.toml index 9d94d4ab..ca972771 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ workspace = true [dependencies] # Wayland wayland-client = "0.31" -wayland-protocols = { version = "0.32", features = ["client", "unstable"] } +wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] } wayland-protocols-wlr = { version = "0.3", features = ["client"] } smithay-client-toolkit = { version = "0.20", default-features = false, features = ["calloop", "xkbcommon"] } @@ -53,6 +53,7 @@ tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time", "syn # Screenshot capture zbus = { version = "5.0", optional = true, default-features = false, features = ["tokio"] } +getrandom = { version = "0.3", optional = true } serde_json = "1.0" png = "0.18" @@ -72,7 +73,7 @@ xkbcommon = { version = "0.8", optional = true } tablet-input = [] # D-Bus dependent functionality (portal capture, notifications, tray) dbus = ["zbus"] -portal = ["dbus"] +portal = ["dbus", "dep:getrandom"] tray = ["dbus", "ksni"] config-schema = ["dep:schemars"] # GTK4-rendered toolbars on layer-shell compositors; the built-in Cairo diff --git a/README.md b/README.md index 7ef21bcd..865a020d 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — - Presenter mode (Ctrl+Shift+M): hides UI, forces click highlights - Input HUD (Ctrl+Shift+K): on-screen keystroke and click chips for demos and screencasts (opt-in system-wide capture via the `input-monitor` build feature — see [docs/CONFIG.md](docs/CONFIG.md#uiinput_hud---input-hud-keystrokes-and-clicks)) - Light passthrough (layer-shell): draw while input passes through to the app underneath — see [Light passthrough mode](#light-passthrough-mode) -- Screen freeze (Ctrl+Shift+F): pause the display while apps keep running. On GNOME, this uses the screenshot portal when available +- Screen freeze (Ctrl+Shift+F): pause the display while apps keep running. Freeze prefers compositor-native `wlr-screencopy` or `ext-image-copy-capture` and falls back to the screenshot portal when available - Spotlight: drag an ellipse to dim everything around it; stack several to highlight multiple areas. Dim strength and edge softness are configurable under `[spotlight]` ### Callouts and zoom diff --git a/docs/SETUP.md b/docs/SETUP.md index b5e7a41a..2a2d01f3 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -181,7 +181,7 @@ Then use the configurator's Daemon tab, or create a GNOME custom shortcut that r wayscriber --daemon-toggle ``` -Freeze works on GNOME when the screenshot portal is available and responsive; the first use may show a desktop permission prompt. Portal capture can be slower than compositor screencopy, and mixed-DPI or multi-monitor setups may depend on client-side crop behavior. +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, and mixed-DPI or multi-monitor setups may depend on client-side crop behavior. 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/docs/codebase-overview.md b/docs/codebase-overview.md index 819f56f3..e0b07d85 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -76,6 +76,8 @@ Daemon mode therefore provides a persistent background service that reacts to us `WaylandState` centralizes everything the handlers need: current buffers, Cairo context, mouse positions, capture state, and tokio handle for async work. +Freeze capture waits for the overlay-suppression frame, then selects `wlr-screencopy`, `ext-image-copy-capture`, or the screenshot portal in that order. The two direct protocols capture the active output into shared memory; the portal captures the desktop and the client crops the selected output when needed. + --- ## 4. Input Handling & Drawing State diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 229a1884..d371f9bd 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -23,18 +23,29 @@ pub(super) fn poll_portal_captures(state: &mut WaylandState, now: Instant) { state.apply_capture_completion(); } -pub(super) fn poll_capture_deadlines(state: &mut WaylandState, now: Instant) { +pub(super) fn poll_capture_deadlines( + state: &mut WaylandState, + qh: &wayland_client::QueueHandle, + now: Instant, +) { 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"); + state.continue_frozen_capture_after_failure(backend, qh); + } } pub(super) fn capture_timeout(state: &WaylandState, now: Instant) -> Option { super::min_timeout( state.overlay_capture_barrier_timeout(now), super::min_timeout( - state.frozen.portal_timeout(now), + state.frozen.direct_capture_timeout(now), super::min_timeout( - state.zoom.portal_timeout(now), - state.xdg_frozen_fullscreen_timeout(now), + state.frozen.portal_timeout(now), + super::min_timeout( + state.zoom.portal_timeout(now), + state.xdg_frozen_fullscreen_timeout(now), + ), ), ), ) @@ -146,7 +157,7 @@ fn handle_frozen_toggle(state: &mut WaylandState) { if !state.frozen_enabled() { warn!( - "Frozen mode unavailable: no screencopy backend and no screenshot portal backend; ignoring toggle" + "Frozen mode unavailable: no direct capture backend and no screenshot portal backend; ignoring toggle" ); state.input_state.push_toast( ToastPriority::Info, @@ -159,12 +170,6 @@ fn handle_frozen_toggle(state: &mut WaylandState) { state.restore_xdg_after_frozen(); state.frozen.unfreeze(&mut state.input_state); } else { - let use_fallback = !state.frozen.manager_available(); - if use_fallback { - warn!("Frozen mode: screencopy unavailable, using portal fallback"); - } else { - info!("Frozen mode: using screencopy fast path"); - } if !state.enter_overlay_suppression(OverlaySuppression::Frozen) { warn!("Frozen mode requested while overlay is suppressed; ignoring toggle"); state.input_state.push_toast( @@ -174,10 +179,7 @@ fn handle_frozen_toggle(state: &mut WaylandState) { ); return; } - if let Err(err) = state - .frozen - .start_capture(use_fallback, &state.tokio_handle) - { + if let Err(err) = state.frozen.start_capture() { warn!("Frozen capture failed to start: {}", err); state.exit_overlay_suppression(OverlaySuppression::Frozen); state.frozen.cancel(&mut state.input_state); diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 548994a5..7675818e 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -182,7 +182,7 @@ pub(super) fn run_event_loop( // A capture-barrier deadline may be what woke dispatch. Apply its // recovery before this iteration reaches toolbar synchronization and // rendering so the restored frame is not delayed by another block. - capture::poll_capture_deadlines(state, Instant::now()); + capture::poll_capture_deadlines(state, qh, Instant::now()); if !state.input_state.should_exit { state.reconcile_live_source_interaction_if_idle( diff --git a/src/backend/wayland/backend/setup.rs b/src/backend/wayland/backend/setup.rs index 94f78005..5a6d8aa8 100644 --- a/src/backend/wayland/backend/setup.rs +++ b/src/backend/wayland/backend/setup.rs @@ -14,12 +14,19 @@ use smithay_client_toolkit::{ shm::Shm, }; use wayland_client::{Connection, EventQueue, globals::registry_queue_init}; +use wayland_protocols::ext::{ + image_capture_source::v1::client::ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1, + image_copy_capture::v1::client::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1, +}; use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; use crate::env_vars::{XDG_CURRENT_DESKTOP_ENV, XDG_SESSION_DESKTOP_ENV}; -use super::super::state::{WaylandGlobals, WaylandState}; +use super::super::{ + frozen::ExtImageCopyManagers, + state::{WaylandGlobals, WaylandState}, +}; // Freeze/zoom capture currently consumes wl_shm buffer events and ignores linux-dmabuf. // Version 3 can negotiate linux-dmabuf-only frames on newer wlroots/NVIDIA stacks, so @@ -34,6 +41,7 @@ pub(super) struct WaylandSetup { pub(super) qh: wayland_client::QueueHandle, pub(super) state_globals: WaylandGlobals, pub(super) screencopy_manager: Option, + pub(super) ext_image_copy_managers: Option, pub(super) text_input_manager: Option, pub(super) layer_shell_available: bool, } @@ -135,6 +143,27 @@ pub(super) fn setup_wayland() -> Result { } }; + let ext_image_copy_manager = globals + .bind::(&qh, 1..=1, ()) + .ok(); + let ext_output_source_manager = globals + .bind::(&qh, 1..=1, ()) + .ok(); + let ext_image_copy_managers = match (ext_image_copy_manager, ext_output_source_manager) { + (Some(capture), Some(output_source)) => { + debug!("Bound ext-image-copy-capture output backend"); + Some(ExtImageCopyManagers::new(capture, output_source)) + } + (capture, output_source) => { + debug!( + "ext-image-copy-capture output backend unavailable: capture_manager={}, output_source_manager={}", + capture.is_some(), + output_source.is_some() + ); + None + } + }; + // IME / text-input-v3 for the text and sticky-note tools. Optional: when // the compositor lacks it, editing falls back to the raw keysym path // (single-key characters only). @@ -175,6 +204,7 @@ pub(super) fn setup_wayland() -> Result { qh, state_globals, screencopy_manager, + ext_image_copy_managers, text_input_manager, layer_shell_available, }) diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index ec2df9e0..412fee6a 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -104,14 +104,17 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul }; input_state.set_session_preflight_options(session_options.clone()); let screencopy_supported = setup.screencopy_manager.is_some(); + let image_copy_capture_supported = setup.ext_image_copy_managers.is_some(); let portal_freeze_supported = screenshot_portal_available(&backend.tokio_runtime); - let frozen_supported = screencopy_supported || portal_freeze_supported; + let direct_capture_supported = screencopy_supported || image_copy_capture_supported; + let frozen_supported = direct_capture_supported || portal_freeze_supported; let tokio_handle = backend.tokio_runtime.handle().clone(); // Set compositor capabilities based on detected Wayland protocols input_state.compositor_capabilities = CompositorCapabilities { layer_shell: setup.layer_shell_available, screencopy: screencopy_supported, + image_copy_capture: image_copy_capture_supported, freeze_capture: frozen_supported, pointer_constraints: setup .state_globals @@ -190,7 +193,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul let freeze_on_start = if backend.freeze_on_start && !frozen_supported { warn!( - "Frozen mode unavailable: no screencopy backend and no screenshot portal backend; ignoring --freeze" + "Frozen mode unavailable: no direct capture backend and no screenshot portal backend; ignoring --freeze" ); false } else { @@ -218,6 +221,8 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul main_surface_uses_overlay_layer: output_prefs.main_surface_uses_overlay_layer, pending_freeze_on_start: freeze_on_start, screencopy_manager: setup.screencopy_manager, + ext_image_copy_managers: setup.ext_image_copy_managers, + portal_freeze_supported, text_input_manager: setup.text_input_manager, #[cfg(feature = "tablet-input")] tablet_manager, diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs index 43fb3974..9e65c465 100644 --- a/src/backend/wayland/frozen/capture.rs +++ b/src/backend/wayland/frozen/capture.rs @@ -5,15 +5,26 @@ use smithay_client_toolkit::shm::{ slot::{Buffer, SlotPool}, }; use wayland_client::{Dispatch, QueueHandle, WEnum, protocol::wl_shm}; +use wayland_protocols::ext::{ + image_capture_source::v1::client::{ + ext_image_capture_source_v1::ExtImageCaptureSourceV1, + ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1, + }, + image_copy_capture::v1::client::{ + ext_image_copy_capture_frame_v1::ExtImageCopyCaptureFrameV1, + ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1, + ext_image_copy_capture_session_v1::ExtImageCopyCaptureSessionV1, + }, +}; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{ Event as FrameEvent, Flags, ZwlrScreencopyFrameV1, }; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; -use crate::backend::wayland::frozen::FrozenImage; use crate::input::InputState; -use super::state::FrozenState; +use super::image::{copy_shm_argb, validate_shm_buffer_layout}; +use super::state::{DirectCaptureAttempt, DirectCaptureContext, FrozenCaptureBackend, FrozenState}; /// Internal capture session tracking a single screencopy frame. pub(super) struct CaptureSession { @@ -69,38 +80,129 @@ impl CaptureSession { impl FrozenState { /// Start a screencopy capture for the active output. - pub fn start_capture( - &mut self, - use_fallback: bool, - _tokio_handle: &tokio::runtime::Handle, - ) -> Result<()> { - if self.capture.is_some() || self.portal_in_progress || self.preflight_pending { + pub fn start_capture(&mut self) -> Result<()> { + if self.direct_capture.is_some() || self.portal_in_progress || self.preflight_pending { warn!("Frozen-mode capture already in progress; ignoring toggle"); return Ok(()); } self.capture_done = false; - self.preflight_use_fallback = use_fallback || self.manager.is_none(); + self.preflight_backend = Some( + self.preferred_backend() + .context("no frozen capture backend is available")?, + ); self.preflight_pending = true; Ok(()) } pub fn begin_preflight_capture( &mut self, - use_fallback: bool, + backend: FrozenCaptureBackend, shm: &Shm, qh: &QueueHandle, tokio_handle: &tokio::runtime::Handle, ) -> Result<()> where - State: - Dispatch + Dispatch + 'static, + State: Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + 'static, + { + self.begin_capture_chain(backend, shm, qh, tokio_handle) + } + + pub(in crate::backend::wayland) fn begin_fallback_capture( + &mut self, + failed_backend: FrozenCaptureBackend, + shm: &Shm, + qh: &QueueHandle, + tokio_handle: &tokio::runtime::Handle, + ) -> Result<()> + where + State: Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + 'static, + { + let backend = self + .next_backend_after(failed_backend) + .context("no remaining frozen capture backend is available")?; + self.begin_capture_chain(backend, shm, qh, tokio_handle) + } + + fn begin_capture_chain( + &mut self, + first_backend: FrozenCaptureBackend, + shm: &Shm, + qh: &QueueHandle, + tokio_handle: &tokio::runtime::Handle, + ) -> Result<()> + where + State: Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + 'static, { - if use_fallback || self.manager.is_none() { - info!("Suppression frame committed; using fallback portal capture for frozen mode"); - self.capture_via_portal(tokio_handle) - } else { - self.begin_screencopy(shm, qh) + let mut backend = Some(first_backend); + let mut last_error = None; + + while let Some(current) = backend { + match self.begin_capture_backend(current, shm, qh, tokio_handle) { + Ok(()) => return Ok(()), + Err(error) => { + warn!("Failed to start {current:?} frozen capture: {error:#}"); + last_error = Some(error); + backend = self.next_backend_after(current); + } + } + } + + Err(last_error + .unwrap_or_else(|| anyhow::anyhow!("no frozen capture backend was attempted"))) + } + + fn begin_capture_backend( + &mut self, + backend: FrozenCaptureBackend, + shm: &Shm, + qh: &QueueHandle, + tokio_handle: &tokio::runtime::Handle, + ) -> Result<()> + where + State: Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + 'static, + { + match backend { + FrozenCaptureBackend::WlrScreencopy => { + info!("Suppression frame committed; using wlr-screencopy for frozen mode"); + self.begin_screencopy(shm, qh) + } + FrozenCaptureBackend::ExtImageCopy => { + info!("Suppression frame committed; using ext-image-copy for frozen mode"); + self.begin_ext_image_copy(shm, qh) + } + FrozenCaptureBackend::Portal => { + info!("Suppression frame committed; using portal capture for frozen mode"); + self.capture_via_portal(tokio_handle) + } } } @@ -122,23 +224,34 @@ impl FrozenState { anyhow::bail!("No active output available for frozen capture"); } }; + let target_output_id = self + .active_output_id + .context("Active output has no stable identity for frozen capture")?; + let source_geometry = self.active_geometry.clone(); + let pool = SlotPool::new(4, shm).context("Failed to create frozen capture pool")?; debug!("Requesting screencopy frame for active output"); let frame = manager.capture_output(0, &output, qh, ()); - self.capture = Some(CaptureSession::new(frame)); - - // Pre-allocate a pool to avoid repeated allocations; size adjusted on buffer event - // (SlotPool resize is cheap, so start with minimal size). - if let Some(capture) = self.capture.as_mut() { - capture.pool = - Some(SlotPool::new(4, shm).context("Failed to create frozen capture pool")?); - } + let mut capture = CaptureSession::new(frame); + capture.pool = Some(pool); + self.direct_capture = Some(DirectCaptureAttempt::WlrScreencopy { + session: Box::new(capture), + context: DirectCaptureContext::new(target_output_id, source_geometry), + }); Ok(()) } /// Handle screencopy frame events. - pub fn handle_frame_event(&mut self, event: FrameEvent, input_state: &mut InputState) { + pub fn handle_frame_event(&mut self, event: FrameEvent, input_state: &mut InputState) -> bool { + if !matches!( + self.direct_capture.as_ref(), + Some(DirectCaptureAttempt::WlrScreencopy { .. }) + ) { + debug!("Ignoring screencopy frame event without an active WLR frozen capture"); + return false; + } + match event { FrameEvent::Buffer { format, @@ -148,7 +261,7 @@ impl FrozenState { } => { if let Err(err) = self.on_buffer(format, width, height, stride) { warn!("Failed to prepare screencopy buffer: {}", err); - self.cancel(input_state); + return self.fail_wlr_capture(); } } FrameEvent::LinuxDmabuf { .. } => { @@ -158,11 +271,14 @@ impl FrozenState { FrameEvent::BufferDone => { if let Err(err) = self.on_buffer_done() { warn!("Failed to issue screencopy copy: {}", err); - self.cancel(input_state); + return self.fail_wlr_capture(); } } FrameEvent::Flags { flags } => { - if let Some(capture) = self.capture.as_mut() { + if let Some(DirectCaptureAttempt::WlrScreencopy { + session: capture, .. + }) = self.direct_capture.as_mut() + { let raw_flags = match flags { WEnum::Value(v) => v.bits(), WEnum::Unknown(raw) => raw, @@ -172,21 +288,43 @@ impl FrozenState { .unwrap_or(false); } } - FrameEvent::Ready { .. } => { - if let Err(err) = self.on_ready() { + FrameEvent::Ready { .. } => match self.on_ready() { + Ok(true) => input_state.needs_redraw = true, + Ok(false) => { + warn!("Discarded WLR frozen capture because the active output changed"); + self.finish_stale_direct_capture(input_state); + } + Err(err) => { warn!("Frozen capture ready handling failed: {}", err); - self.cancel(input_state); - return; + // `on_ready` owns and destroys the completed WLR attempt, + // including error paths, so only the fallback decision + // remains here. + return true; } - - input_state.needs_redraw = true; - } + }, FrameEvent::Failed => { warn!("Frozen capture failed"); - self.cancel(input_state); + return self.fail_wlr_capture(); } _ => {} } + false + } + + fn fail_wlr_capture(&mut self) -> bool { + let Some(capture) = self.direct_capture.take() else { + return false; + }; + match capture { + DirectCaptureAttempt::WlrScreencopy { session, .. } => { + session.frame.destroy(); + true + } + capture @ DirectCaptureAttempt::ExtImageCopy { .. } => { + self.direct_capture = Some(capture); + false + } + } } fn on_buffer( @@ -196,10 +334,10 @@ impl FrozenState { height: u32, stride: u32, ) -> Result<()> { - let capture = self - .capture - .as_mut() - .context("No capture session present for buffer event")?; + let capture = match self.direct_capture.as_mut() { + Some(DirectCaptureAttempt::WlrScreencopy { session, .. }) => session, + _ => anyhow::bail!("No WLR capture session present for buffer event"), + }; let format = match format { WEnum::Value(fmt) => fmt, @@ -208,19 +346,19 @@ impl FrozenState { } }; + let layout = validate_shm_buffer_layout(width, height, stride)?; capture.width = width; capture.height = height; - capture.stride = stride as i32; + capture.stride = layout.stride; capture.format = Some(format); // Resize pool and create buffer let pool = capture.pool.as_mut().context("Capture pool missing")?; - let total_size = (capture.stride as usize) * (height as usize); - if total_size > pool.len() { - pool.resize(total_size)?; + if layout.total_size > pool.len() { + pool.resize(layout.total_size)?; } let (buffer, _) = pool - .create_buffer(width as i32, height as i32, capture.stride, format) + .create_buffer(layout.width, layout.height, layout.stride, format) .context("Failed to create capture buffer")?; capture.buffer = Some(buffer); capture.request_copy(); @@ -228,65 +366,56 @@ impl FrozenState { } fn on_buffer_done(&mut self) -> Result<()> { - let capture = self - .capture - .as_mut() - .context("No capture session present for buffer_done")?; + let capture = match self.direct_capture.as_mut() { + Some(DirectCaptureAttempt::WlrScreencopy { session, .. }) => session, + _ => anyhow::bail!("No WLR capture session present for buffer_done"), + }; capture.request_copy(); Ok(()) } - fn on_ready(&mut self) -> Result<()> { - let mut capture = self - .capture + fn on_ready(&mut self) -> Result { + let attempt = self + .direct_capture .take() - .context("No capture session present for ready event")?; - - let pool = capture.pool.as_mut().context("Capture pool missing")?; - let buffer = capture.buffer.as_ref().context("Capture buffer missing")?; - - let canvas = buffer - .canvas(pool) - .context("Unable to map capture buffer")?; - - let pixel_width = (capture.width * 4) as usize; - let stride = capture.stride as usize; - if stride < pixel_width { - anyhow::bail!("Capture stride smaller than expected pixel width"); - } - - let mut data = vec![0u8; (capture.width * capture.height * 4) as usize]; - - for row in 0..capture.height as usize { - let src_row = &canvas[(row * stride)..(row * stride + pixel_width)]; - let dest_row_index = if capture.y_invert { - (capture.height as usize - 1 - row) * pixel_width - } else { - row * pixel_width - }; - data[dest_row_index..dest_row_index + pixel_width].copy_from_slice(src_row); - } - - if matches!(capture.format, Some(wl_shm::Format::Xrgb8888)) { - for chunk in data.chunks_exact_mut(4) { - // Ensure alpha channel is opaque - chunk[3] = 0xFF; + .context("No WLR capture attempt present for ready event")?; + let (mut capture, context) = match attempt { + DirectCaptureAttempt::WlrScreencopy { session, context } => (session, context), + attempt @ DirectCaptureAttempt::ExtImageCopy { .. } => { + self.direct_capture = Some(attempt); + anyhow::bail!("No WLR capture attempt present for ready event"); } + }; + let result = (|| { + let pool = capture.pool.as_mut().context("Capture pool missing")?; + let buffer = capture.buffer.as_ref().context("Capture buffer missing")?; + let canvas = buffer + .canvas(pool) + .context("Unable to map capture buffer")?; + let format = capture.format.context("Capture format missing")?; + copy_shm_argb( + canvas, + capture.width, + capture.height, + capture.stride, + format, + capture.y_invert, + ) + })(); + capture.frame.destroy(); + let image = result?; + if !context.output_matches(self.active_output_id) { + return Ok(false); } - capture.frame.destroy(); + self.set_pending_output_image(image, context.target_output_id, context.source_geometry); - let source_geometry = self.active_geometry.clone(); - self.set_pending_output_image( - FrozenImage { - width: capture.width, - height: capture.height, - stride: (capture.width * 4) as i32, - data, - }, - source_geometry, - ); + Ok(true) + } - Ok(()) + pub(super) fn finish_stale_direct_capture(&mut self, input_state: &mut InputState) { + self.capture_done = true; + input_state.set_frozen_active(false); + input_state.needs_redraw = true; } } diff --git a/src/backend/wayland/frozen/ext_image_copy.rs b/src/backend/wayland/frozen/ext_image_copy.rs new file mode 100644 index 00000000..b42b995c --- /dev/null +++ b/src/backend/wayland/frozen/ext_image_copy.rs @@ -0,0 +1,518 @@ +use anyhow::{Context, Result}; +use log::{debug, warn}; +use smithay_client_toolkit::shm::{ + Shm, + slot::{Buffer, SlotPool}, +}; +use wayland_client::{ + Dispatch, QueueHandle, WEnum, + protocol::{wl_output, wl_shm}, +}; +use wayland_protocols::ext::{ + image_capture_source::v1::client::{ + ext_image_capture_source_v1::ExtImageCaptureSourceV1, + ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1, + }, + image_copy_capture::v1::client::{ + ext_image_copy_capture_frame_v1::{ + Event as FrameEvent, ExtImageCopyCaptureFrameV1, FailureReason, + }, + ext_image_copy_capture_manager_v1::{ExtImageCopyCaptureManagerV1, Options}, + ext_image_copy_capture_session_v1::{Event as SessionEvent, ExtImageCopyCaptureSessionV1}, + }, +}; + +use crate::input::InputState; + +use super::image::{copy_shm_argb, validate_shm_buffer_layout}; +use super::state::{DirectCaptureAttempt, DirectCaptureContext, FrozenState}; + +const MAX_CONSTRAINT_RETRIES: u8 = 2; + +#[derive(Clone)] +pub(in crate::backend::wayland) struct ExtImageCopyManagers { + capture: ExtImageCopyCaptureManagerV1, + output_source: ExtOutputImageCaptureSourceManagerV1, +} + +impl ExtImageCopyManagers { + pub(in crate::backend::wayland) fn new( + capture: ExtImageCopyCaptureManagerV1, + output_source: ExtOutputImageCaptureSourceManagerV1, + ) -> Self { + Self { + capture, + output_source, + } + } +} + +pub(super) struct ExtImageCopySession { + source: ExtImageCaptureSourceV1, + session: ExtImageCopyCaptureSessionV1, + pool: SlotPool, + constraints: ConstraintTracker, + frame: Option, +} + +struct ExtImageCopyFrame { + proxy: ExtImageCopyCaptureFrameV1, + buffer: Buffer, + width: u32, + height: u32, + stride: i32, + format: wl_shm::Format, + transform: Option, +} + +impl ExtImageCopySession { + fn new( + source: ExtImageCaptureSourceV1, + session: ExtImageCopyCaptureSessionV1, + pool: SlotPool, + ) -> Self { + Self { + source, + session, + pool, + constraints: ConstraintTracker::default(), + frame: None, + } + } + + pub(super) fn destroy(mut self) { + if let Some(frame) = self.frame.take() { + frame.proxy.destroy(); + } + self.session.destroy(); + self.source.destroy(); + } +} + +#[derive(Default)] +struct ConstraintTracker { + pending: ExtBufferConstraints, + replacement: Option, + retries: u8, +} + +impl ConstraintTracker { + fn record_size(&mut self, width: u32, height: u32) { + self.pending.size = Some((width, height)); + } + + fn record_format(&mut self, format: WEnum) { + if let WEnum::Value(format) = format + && !self.pending.formats.contains(&format) + { + self.pending.formats.push(format); + } + } + + fn finish_batch(&mut self, frame_pending: bool) -> Option { + let batch = std::mem::take(&mut self.pending); + if frame_pending { + self.replacement = Some(batch); + None + } else { + Some(batch) + } + } + + fn take_replacement_for_retry(&mut self) -> Result { + if self.retries >= MAX_CONSTRAINT_RETRIES { + anyhow::bail!( + "ext-image-copy exceeded its {MAX_CONSTRAINT_RETRIES} buffer-constraint retries" + ); + } + self.retries += 1; + self.replacement.take().context( + "compositor reported a buffer-constraints failure without a complete replacement batch", + ) + } +} + +#[derive(Default)] +struct ExtBufferConstraints { + size: Option<(u32, u32)>, + formats: Vec, +} + +impl FrozenState { + pub(super) fn begin_ext_image_copy( + &mut self, + shm: &Shm, + qh: &QueueHandle, + ) -> Result<()> + where + State: Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + 'static, + { + let managers = self + .ext_managers + .as_ref() + .context("ext-image-copy-capture managers are unavailable")?; + let output = self + .active_output + .as_ref() + .context("No active output available for ext-image-copy capture")?; + let target_output_id = self + .active_output_id + .context("Active output has no stable identity for ext-image-copy capture")?; + let source_geometry = self.active_geometry.clone(); + + // Allocate the only fallible local resource before creating protocol + // objects so an allocation failure cannot leave a live source/session. + let pool = + SlotPool::new(4, shm).context("Failed to create ext-image-copy shared-memory pool")?; + let source = managers.output_source.create_source(output, qh, ()); + let session = managers + .capture + .create_session(&source, Options::empty(), qh, ()); + self.direct_capture = Some(DirectCaptureAttempt::ExtImageCopy { + session: Box::new(ExtImageCopySession::new(source, session, pool)), + context: DirectCaptureContext::new(target_output_id, source_geometry), + }); + debug!("Requested ext-image-copy capture constraints for active output"); + Ok(()) + } + + pub(in crate::backend::wayland) fn handle_ext_session_event( + &mut self, + event: SessionEvent, + qh: &QueueHandle, + ) -> bool + where + State: Dispatch + 'static, + { + if !matches!( + self.direct_capture.as_ref(), + Some(DirectCaptureAttempt::ExtImageCopy { .. }) + ) { + debug!("Ignoring ext-image-copy session event without an active ext capture"); + return false; + } + + let result = match event { + SessionEvent::BufferSize { width, height } => { + let capture = self.ext_capture_mut(); + capture.map(|capture| capture.constraints.record_size(width, height)) + } + SessionEvent::ShmFormat { format } => { + let capture = self.ext_capture_mut(); + capture.map(|capture| capture.constraints.record_format(format)) + } + SessionEvent::Done => self.finish_constraint_batch(qh), + SessionEvent::Stopped => Err(anyhow::anyhow!( + "ext-image-copy capture session was stopped by the compositor" + )), + SessionEvent::DmabufDevice { .. } | SessionEvent::DmabufFormat { .. } => Ok(()), + _ => Ok(()), + }; + + if let Err(error) = result { + warn!("Ext-image-copy session failed: {error:#}"); + return self.fail_ext_capture(); + } + false + } + + fn finish_constraint_batch(&mut self, qh: &QueueHandle) -> Result<()> + where + State: Dispatch + 'static, + { + let constraints = { + let capture = self.ext_capture_mut()?; + capture.constraints.finish_batch(capture.frame.is_some()) + }; + if let Some(constraints) = constraints { + self.submit_ext_frame(qh, constraints)?; + } else { + debug!("Deferred replacement ext-image-copy constraints until the current frame ends"); + } + Ok(()) + } + + fn submit_ext_frame( + &mut self, + qh: &QueueHandle, + constraints: ExtBufferConstraints, + ) -> Result<()> + where + State: Dispatch + 'static, + { + let capture = self.ext_capture_mut()?; + if capture.frame.is_some() { + anyhow::bail!("ext-image-copy frame submitted while another frame is pending"); + } + let (width, height) = constraints + .size + .context("compositor omitted the ext-image-copy buffer size")?; + let format = select_shm_format(&constraints.formats) + .context("compositor did not advertise an ARGB/XRGB shared-memory format")?; + let stride = width + .checked_mul(4) + .context("ext-image-copy buffer stride overflow")?; + let layout = validate_shm_buffer_layout(width, height, stride)?; + if layout.total_size > capture.pool.len() { + capture.pool.resize(layout.total_size)?; + } + let (buffer, _) = capture + .pool + .create_buffer(layout.width, layout.height, layout.stride, format) + .context("Failed to create ext-image-copy buffer")?; + let frame = capture.session.create_frame(qh, ()); + frame.attach_buffer(buffer.wl_buffer()); + frame.damage_buffer(0, 0, layout.width, layout.height); + frame.capture(); + capture.frame = Some(ExtImageCopyFrame { + proxy: frame, + buffer, + width, + height, + stride: layout.stride, + format, + transform: None, + }); + Ok(()) + } + + pub(in crate::backend::wayland) fn handle_ext_frame_event( + &mut self, + event: FrameEvent, + qh: &QueueHandle, + input_state: &mut InputState, + ) -> bool + where + State: Dispatch + 'static, + { + if !matches!( + self.direct_capture.as_ref(), + Some(DirectCaptureAttempt::ExtImageCopy { .. }) + ) { + debug!("Ignoring ext-image-copy frame event without an active ext capture"); + return false; + } + + match event { + FrameEvent::Ready => match self.finish_ext_frame() { + Ok(true) => input_state.needs_redraw = true, + Ok(false) => { + warn!( + "Discarded ext-image-copy frozen capture because the active output changed" + ); + self.finish_stale_direct_capture(input_state); + } + Err(error) => { + warn!("Ext-image-copy frame failed: {error:#}"); + return self.fail_ext_capture(); + } + }, + FrameEvent::Failed { reason } => { + match reason { + WEnum::Value(FailureReason::BufferConstraints) => { + if let Err(error) = self.retry_ext_frame_after_constraints(qh) { + warn!( + "Failed to retry ext-image-copy with replacement constraints: {error:#}" + ); + return self.fail_ext_capture(); + } + return false; + } + WEnum::Value(FailureReason::Unknown) => { + warn!("Ext-image-copy frame failed: unknown compositor error"); + } + WEnum::Value(FailureReason::Stopped) => { + warn!("Ext-image-copy frame failed: capture session stopped"); + } + WEnum::Unknown(_) => { + warn!("Ext-image-copy frame failed: unknown failure reason"); + } + _ => { + warn!("Ext-image-copy frame failed: unsupported failure reason"); + } + } + return self.fail_ext_capture(); + } + FrameEvent::Transform { transform } => { + if let Ok(capture) = self.ext_capture_mut() + && let Some(frame) = capture.frame.as_mut() + && let WEnum::Value(transform) = transform + { + frame.transform = Some(transform); + } + } + FrameEvent::Damage { .. } | FrameEvent::PresentationTime { .. } => {} + _ => {} + } + false + } + + fn retry_ext_frame_after_constraints(&mut self, qh: &QueueHandle) -> Result<()> + where + State: Dispatch + 'static, + { + let replacement = { + let capture = self.ext_capture_mut()?; + let frame = capture + .frame + .take() + .context("failed ext-image-copy frame missing")?; + frame.proxy.destroy(); + capture.constraints.take_replacement_for_retry() + }; + + let replacement = replacement?; + debug!("Retrying ext-image-copy with replacement buffer constraints"); + self.submit_ext_frame(qh, replacement) + } + + fn finish_ext_frame(&mut self) -> Result { + let frame = self.ext_capture_mut()?; + let frame = frame.frame.take().context("ext-image-copy frame missing")?; + let result: Result<_> = (|| { + let capture = self.ext_capture_mut()?; + let canvas = frame + .buffer + .canvas(&mut capture.pool) + .context("Unable to map ext-image-copy buffer")?; + let image = copy_shm_argb( + canvas, + frame.width, + frame.height, + frame.stride, + frame.format, + false, + )?; + Ok((image, frame.transform)) + })(); + frame.proxy.destroy(); + let (image, output_transform) = result?; + let (capture, context) = self + .take_ext_capture() + .context("ext-image-copy capture attempt missing after frame completion")?; + (*capture).destroy(); + if !context.output_matches(self.active_output_id) { + return Ok(false); + } + self.set_pending_output_image_with_transform( + image, + context.target_output_id, + context.source_geometry, + output_transform, + ); + Ok(true) + } + + fn fail_ext_capture(&mut self) -> bool { + if let Some((capture, _)) = self.take_ext_capture() { + (*capture).destroy(); + true + } else { + false + } + } + + fn ext_capture_mut(&mut self) -> Result<&mut ExtImageCopySession> { + match self.direct_capture.as_mut() { + Some(DirectCaptureAttempt::ExtImageCopy { session, .. }) => Ok(session.as_mut()), + _ => anyhow::bail!("ext-image-copy capture session missing"), + } + } + + fn take_ext_capture(&mut self) -> Option<(Box, DirectCaptureContext)> { + let attempt = self.direct_capture.take()?; + match attempt { + DirectCaptureAttempt::ExtImageCopy { session, context } => Some((session, context)), + attempt @ DirectCaptureAttempt::WlrScreencopy { .. } => { + self.direct_capture = Some(attempt); + None + } + } + } +} + +fn select_shm_format(formats: &[wl_shm::Format]) -> Option { + [wl_shm::Format::Argb8888, wl_shm::Format::Xrgb8888] + .into_iter() + .find(|preferred| formats.contains(preferred)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shm_format_selection_prefers_argb_and_accepts_xrgb() { + let formats = [wl_shm::Format::Xrgb8888, wl_shm::Format::Argb8888]; + assert_eq!(select_shm_format(&formats), Some(wl_shm::Format::Argb8888)); + assert_eq!( + select_shm_format(&[wl_shm::Format::Xrgb8888]), + Some(wl_shm::Format::Xrgb8888) + ); + assert_eq!(select_shm_format(&[wl_shm::Format::Rgb565]), None); + } + + #[test] + fn replacement_constraint_batch_does_not_mutate_the_in_flight_layout() { + let mut tracker = ConstraintTracker::default(); + tracker.record_size(1920, 1080); + tracker.record_format(WEnum::Value(wl_shm::Format::Argb8888)); + let initial = tracker + .finish_batch(false) + .expect("initial constraints should be submitted"); + + tracker.record_size(1280, 720); + tracker.record_format(WEnum::Value(wl_shm::Format::Xrgb8888)); + assert!( + tracker.finish_batch(true).is_none(), + "replacement constraints wait while the original frame is pending" + ); + + assert_eq!(initial.size, Some((1920, 1080))); + assert_eq!(initial.formats, vec![wl_shm::Format::Argb8888]); + let replacement = tracker + .take_replacement_for_retry() + .expect("replacement constraints retained for retry"); + assert_eq!(replacement.size, Some((1280, 720))); + assert_eq!(replacement.formats, vec![wl_shm::Format::Xrgb8888]); + } + + #[test] + fn constraint_batches_do_not_accumulate_old_formats() { + let mut tracker = ConstraintTracker::default(); + tracker.record_size(100, 100); + tracker.record_format(WEnum::Value(wl_shm::Format::Argb8888)); + let _ = tracker.finish_batch(false); + + tracker.record_size(200, 200); + tracker.record_format(WEnum::Value(wl_shm::Format::Xrgb8888)); + let replacement = tracker + .finish_batch(false) + .expect("second batch should be independent"); + + assert_eq!(replacement.formats, vec![wl_shm::Format::Xrgb8888]); + } + + #[test] + fn constraint_retries_are_bounded_before_backend_fallback() { + let mut tracker = ConstraintTracker::default(); + for attempt in 0..MAX_CONSTRAINT_RETRIES { + tracker.record_size(100 + u32::from(attempt), 100); + tracker.record_format(WEnum::Value(wl_shm::Format::Argb8888)); + assert!(tracker.finish_batch(true).is_none()); + tracker + .take_replacement_for_retry() + .expect("retry remains within the budget"); + } + + tracker.record_size(200, 100); + tracker.record_format(WEnum::Value(wl_shm::Format::Argb8888)); + assert!(tracker.finish_batch(true).is_none()); + assert!(tracker.take_replacement_for_retry().is_err()); + } +} diff --git a/src/backend/wayland/frozen/image.rs b/src/backend/wayland/frozen/image.rs index e49f3245..574f13f0 100644 --- a/src/backend/wayland/frozen/image.rs +++ b/src/backend/wayland/frozen/image.rs @@ -1,4 +1,52 @@ -use wayland_client::protocol::wl_output; +use anyhow::{Context, Result}; +use wayland_client::protocol::{wl_output, wl_shm}; + +pub(super) struct ShmBufferLayout { + pub(super) width: i32, + pub(super) height: i32, + pub(super) stride: i32, + pub(super) total_size: usize, +} + +/// Validate compositor-owned dimensions before allocating or creating a +/// `wl_buffer` from them. +pub(super) fn validate_shm_buffer_layout( + width: u32, + height: u32, + stride: u32, +) -> Result { + if width == 0 || height == 0 { + anyhow::bail!("Frozen capture advertised an empty SHM buffer"); + } + + let buffer_width = + i32::try_from(width).context("Frozen capture width exceeds the Wayland limit")?; + let buffer_height = + i32::try_from(height).context("Frozen capture height exceeds the Wayland limit")?; + let buffer_stride = + i32::try_from(stride).context("Frozen capture stride exceeds the Wayland limit")?; + let row_bytes = width + .checked_mul(4) + .context("Frozen capture row size overflow")?; + if stride < row_bytes { + anyhow::bail!("Frozen capture stride is smaller than its pixel row"); + } + let total_size = usize::try_from(stride) + .ok() + .and_then(|stride| { + usize::try_from(height) + .ok() + .and_then(|height| stride.checked_mul(height)) + }) + .context("Frozen capture buffer size overflow")?; + + Ok(ShmBufferLayout { + width: buffer_width, + height: buffer_height, + stride: buffer_stride, + total_size, + }) +} /// CPU-side frozen image ready for Cairo rendering. pub struct FrozenImage { @@ -35,6 +83,78 @@ impl FrozenImage { } } +/// Copy a compositor-provided SHM buffer into tightly packed Cairo-compatible +/// ARGB data while validating every advertised dimension and row boundary. +pub(super) fn copy_shm_argb( + canvas: &[u8], + width: u32, + height: u32, + stride: i32, + format: wl_shm::Format, + y_invert: bool, +) -> Result { + if !matches!(format, wl_shm::Format::Argb8888 | wl_shm::Format::Xrgb8888) { + anyhow::bail!("Unsupported frozen capture SHM format: {format:?}"); + } + + let width = usize::try_from(width).context("Frozen capture width does not fit in memory")?; + let height = usize::try_from(height).context("Frozen capture height does not fit in memory")?; + let row_bytes = width + .checked_mul(4) + .context("Frozen capture row size overflow")?; + let stride = usize::try_from(stride).context("Frozen capture stride is negative")?; + if stride < row_bytes { + anyhow::bail!("Frozen capture stride is smaller than its pixel row"); + } + + let image_size = row_bytes + .checked_mul(height) + .context("Frozen capture image size overflow")?; + let mut data = vec![0; image_size]; + for source_row in 0..height { + let source_start = source_row + .checked_mul(stride) + .context("Frozen capture source row offset overflow")?; + let source_end = source_start + .checked_add(row_bytes) + .context("Frozen capture source row end overflow")?; + let target_row = if y_invert { + height - 1 - source_row + } else { + source_row + }; + let target_start = target_row + .checked_mul(row_bytes) + .context("Frozen capture target row offset overflow")?; + let target_end = target_start + .checked_add(row_bytes) + .context("Frozen capture target row end overflow")?; + let source = canvas + .get(source_start..source_end) + .context("Frozen capture buffer is shorter than advertised")?; + let target = data + .get_mut(target_start..target_end) + .context("Frozen capture image allocation is shorter than expected")?; + target.copy_from_slice(source); + } + + if format == wl_shm::Format::Xrgb8888 { + for pixel in data.chunks_exact_mut(4) { + pixel[3] = 0xff; + } + } + + let width = u32::try_from(width).context("Frozen capture width exceeds u32")?; + let height = u32::try_from(height).context("Frozen capture height exceeds u32")?; + let stride = i32::try_from(row_bytes).context("Frozen capture packed stride exceeds i32")?; + Ok(FrozenImage { + width, + height, + stride, + data, + }) +} + fn transform_argb( width: usize, height: usize, @@ -140,4 +260,52 @@ mod tests { assert_eq!((transformed.width, transformed.height), (3, 2)); assert_eq!(values(&transformed), vec![3, 2, 1, 6, 5, 4]); } + + #[test] + fn shm_copy_removes_padding_and_applies_y_invert() { + let image = copy_shm_argb( + &[ + 1, 0, 0, 255, 2, 0, 0, 255, 9, 9, 9, 9, // + 3, 0, 0, 255, 4, 0, 0, 255, 8, 8, 8, 8, + ], + 2, + 2, + 12, + wl_shm::Format::Argb8888, + true, + ) + .expect("valid padded SHM buffer"); + + assert_eq!(values(&image), vec![3, 4, 1, 2]); + assert_eq!(image.stride, 8); + } + + #[test] + fn shm_copy_makes_xrgb_pixels_opaque_and_rejects_short_buffers() { + let image = copy_shm_argb(&[1, 2, 3, 0], 1, 1, 4, wl_shm::Format::Xrgb8888, false) + .expect("valid XRGB buffer"); + assert_eq!(image.data, vec![1, 2, 3, 255]); + + assert!(copy_shm_argb(&[1, 2, 3], 1, 1, 4, wl_shm::Format::Argb8888, false,).is_err()); + } + + #[test] + fn shm_layout_rejects_invalid_compositor_dimensions_before_allocation() { + assert!(validate_shm_buffer_layout(0, 1, 4).is_err()); + assert!(validate_shm_buffer_layout(1, 0, 4).is_err()); + assert!(validate_shm_buffer_layout(2, 1, 4).is_err()); + assert!(validate_shm_buffer_layout(u32::MAX, 1, u32::MAX).is_err()); + assert!(validate_shm_buffer_layout(1, 1, u32::MAX).is_err()); + } + + #[test] + fn shm_layout_accepts_padding_and_returns_checked_wayland_values() { + let layout = validate_shm_buffer_layout(2, 3, 12) + .expect("the test establishes positive in-range dimensions and sufficient stride"); + + assert_eq!(layout.width, 2); + assert_eq!(layout.height, 3); + assert_eq!(layout.stride, 12); + assert_eq!(layout.total_size, 36); + } } diff --git a/src/backend/wayland/frozen/mod.rs b/src/backend/wayland/frozen/mod.rs index 1131f565..4f0980cb 100644 --- a/src/backend/wayland/frozen/mod.rs +++ b/src/backend/wayland/frozen/mod.rs @@ -1,9 +1,12 @@ mod capture; +mod ext_image_copy; mod image; mod portal; mod state; +pub(in crate::backend::wayland) use ext_image_copy::ExtImageCopyManagers; pub use image::FrozenImage; +pub(in crate::backend::wayland) use state::FrozenCaptureBackend; pub use state::FrozenState; type PortalCaptureResult = Result< @@ -12,5 +15,5 @@ type PortalCaptureResult = Result< Option, self::image::FrozenImage, ), - String, + crate::capture::types::CaptureError, >; diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index ef30d2da..dee4d17c 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -9,12 +9,13 @@ use crate::backend::wayland::portal_capture::{ }; use crate::backend::wayland::portal_task::{PortalPoll, PortalTask}; use crate::capture::sources::frozen::decode_image_to_argb; +use crate::capture::types::CaptureError; use crate::input::InputState; use super::state::FrozenState; impl FrozenState { - pub(super) fn capture_via_portal( + pub(in crate::backend::wayland) fn capture_via_portal( &mut self, tokio_handle: &tokio::runtime::Handle, ) -> Result<()> { @@ -43,8 +44,8 @@ impl FrozenState { async { let bytes = capture_via_portal_fullscreen_bytes().await?; - let (data, width, height) = - decode_image_to_argb(&bytes).map_err(|e| format!("Decode failed: {}", e))?; + let (data, width, height) = decode_image_to_argb(&bytes) + .map_err(|error| CaptureError::ImageError(format!("Decode failed: {error}")))?; Ok(( target_output_id, @@ -97,7 +98,7 @@ impl FrozenState { let output_matches = portal_output_matches(target_output, self.active_output_id); if output_matches { - self.set_pending_desktop_image(image, source_geometry); + self.set_pending_desktop_image(image, target_output, source_geometry); } else { warn!("Portal capture for inactive output discarded"); self.capture_done = true; @@ -105,7 +106,14 @@ impl FrozenState { self.finish_portal_task(); } - PortalPoll::Ready(Err(err)) | PortalPoll::Failed(err) => { + PortalPoll::Ready(Err(CaptureError::Cancelled(reason))) => { + log::info!("Portal frozen capture cancelled: {reason}"); + input_state.set_frozen_active(false); + input_state.needs_redraw = true; + self.finish_portal_task(); + self.capture_done = true; + } + PortalPoll::Ready(Err(err)) => { warn!("Portal frozen capture failed: {err}"); input_state.push_toast( ToastPriority::Critical, @@ -116,6 +124,17 @@ impl FrozenState { self.finish_portal_task(); self.capture_done = true; } + PortalPoll::Failed(err) => { + warn!("Portal frozen capture task failed: {err}"); + input_state.push_toast( + ToastPriority::Critical, + "freeze", + Toast::error("Freeze could not capture the screen."), + ); + input_state.set_frozen_active(false); + self.finish_portal_task(); + self.capture_done = true; + } PortalPoll::Pending => {} PortalPoll::Disconnected => { warn!("Portal frozen capture channel disconnected"); @@ -154,20 +173,23 @@ mod tests { } } - async fn poll_until_finished(frozen: &mut FrozenState, input: &mut InputState) { + async fn poll_until_finished( + frozen: &mut FrozenState, + input: &mut InputState, + ) -> anyhow::Result<()> { for _ in 0..100 { frozen.poll_portal_capture(input, Instant::now()); if !frozen.portal_in_progress { - return; + return Ok(()); } tokio::task::yield_now().await; } - panic!("frozen portal task did not finish"); + anyhow::bail!("frozen portal task did not finish") } #[tokio::test] - async fn poll_portal_applies_image() { - let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + async fn poll_portal_applies_image() -> anyhow::Result<()> { + let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); @@ -177,7 +199,7 @@ mod tests { async { Ok((None, None, image(0))) }, )); frozen.portal_in_progress = true; - poll_until_finished(&mut frozen, &mut input).await; + poll_until_finished(&mut frozen, &mut input).await?; assert!(!input.frozen_active()); assert!(frozen.has_pending_image()); @@ -192,12 +214,13 @@ mod tests { assert!(input.frozen_active()); assert!(frozen.image.is_some()); assert!(frozen.take_capture_done()); + Ok(()) } #[tokio::test] - async fn domain_error_and_task_panic_restore_the_frozen_lifecycle() { + async fn domain_error_and_task_panic_restore_the_frozen_lifecycle() -> anyhow::Result<()> { for panic_task in [false, true] { - let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); frozen.portal_task = Some(if panic_task { @@ -206,32 +229,36 @@ mod tests { }) } else { PortalTask::spawn(&tokio::runtime::Handle::current(), wake.handle(), async { - Err("portal denied".to_string()) + Err(CaptureError::PermissionDenied) }) }); frozen.portal_in_progress = true; - poll_until_finished(&mut frozen, &mut input).await; + poll_until_finished(&mut frozen, &mut input).await?; assert!(!frozen.is_in_progress()); assert!(frozen.portal_task.is_none()); assert!(frozen.take_capture_done()); assert!(!input.frozen_active()); } + Ok(()) } #[tokio::test] - async fn disconnect_and_deadline_expiry_restore_without_a_producer_result() { + async fn disconnect_and_deadline_expiry_restore_without_a_producer_result() -> anyhow::Result<()> + { let now = Instant::now(); for timed_out in [false, true] { - let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); frozen.portal_task = Some(if timed_out { PortalTask::spawn_at_for_test( &tokio::runtime::Handle::current(), wake.handle(), - now.checked_sub(PORTAL_CAPTURE_TIMEOUT).unwrap(), + now.checked_sub(PORTAL_CAPTURE_TIMEOUT).ok_or_else(|| { + anyhow::anyhow!("monotonic clock cannot represent the test deadline") + })?, std::future::pending(), ) } else { @@ -246,11 +273,38 @@ mod tests { assert!(frozen.take_capture_done()); assert!(!input.frozen_active()); } + Ok(()) + } + + #[tokio::test] + async fn user_cancellation_restores_quietly_without_an_error_toast() -> anyhow::Result<()> { + let wake = crate::backend::wayland::RuntimeWakeSource::new()?; + let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); + let mut input = make_test_input_state(); + frozen.portal_task = Some(PortalTask::spawn( + &tokio::runtime::Handle::current(), + wake.handle(), + async { + Err(CaptureError::Cancelled( + "user closed the chooser".to_string(), + )) + }, + )); + frozen.portal_in_progress = true; + + poll_until_finished(&mut frozen, &mut input).await?; + + assert!(!frozen.is_in_progress()); + assert!(frozen.take_capture_done()); + assert!(!input.frozen_active()); + assert!(input.ui_toast.is_none()); + Ok(()) } #[tokio::test] - async fn stale_output_is_discarded_without_mutating_current_frozen_state() { - let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + async fn stale_output_is_discarded_without_mutating_current_frozen_state() -> anyhow::Result<()> + { + let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); input.set_frozen_active(true); @@ -262,16 +316,18 @@ mod tests { )); frozen.portal_in_progress = true; - poll_until_finished(&mut frozen, &mut input).await; + poll_until_finished(&mut frozen, &mut input).await?; assert!(input.frozen_active()); assert!(!frozen.has_pending_image()); assert!(frozen.take_capture_done()); + Ok(()) } #[tokio::test] - async fn supersession_is_ignored_and_explicit_cancel_owns_task_cancellation() { - let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + async fn supersession_is_ignored_and_explicit_cancel_owns_task_cancellation() + -> anyhow::Result<()> { + let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); frozen.portal_task = Some(PortalTask::spawn( @@ -281,13 +337,12 @@ mod tests { )); frozen.portal_in_progress = true; - frozen - .capture_via_portal(&tokio::runtime::Handle::current()) - .unwrap(); + frozen.capture_via_portal(&tokio::runtime::Handle::current())?; assert!(frozen.portal_task.is_some()); frozen.cancel(&mut input); assert!(frozen.portal_task.is_none()); assert!(!frozen.portal_in_progress); assert!(frozen.take_capture_done()); + Ok(()) } } diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index e4c62772..6f5f9651 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -1,4 +1,5 @@ use log::info; +use std::time::{Duration, Instant}; use wayland_client::protocol::wl_output; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; @@ -11,10 +12,13 @@ use crate::input::InputState; use super::PortalCaptureResult; use super::capture::CaptureSession; +use super::ext_image_copy::{ExtImageCopyManagers, ExtImageCopySession}; struct PendingFrozenImage { image: FrozenImage, + target_output_id: Option, source_geometry: Option, + output_transform: Option, needs_output_transform: bool, source: FrozenCaptureSource, } @@ -25,14 +29,93 @@ enum FrozenCaptureSource { Desktop, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum FrozenCaptureBackend { + WlrScreencopy, + ExtImageCopy, + 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) target_output_id: u32, + pub(super) source_geometry: Option, + started_at: Instant, +} + +impl DirectCaptureContext { + pub(super) fn new(target_output_id: u32, source_geometry: Option) -> Self { + Self::new_at(target_output_id, source_geometry, Instant::now()) + } + + fn new_at( + target_output_id: u32, + source_geometry: Option, + started_at: Instant, + ) -> Self { + Self { + target_output_id, + 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) + } + + pub(super) fn output_matches(&self, current_output_id: Option) -> bool { + current_output_id == Some(self.target_output_id) + } +} + /// End-to-end controller for frozen mode capture and image storage. #[allow(clippy::type_complexity)] pub struct FrozenState { pub(super) manager: Option, + pub(super) ext_managers: Option, + pub(super) portal_available: bool, pub(super) active_output: Option, pub(super) active_output_id: Option, pub(super) active_geometry: Option, - pub(super) capture: Option, + pub(super) direct_capture: Option, pub(super) image: Option, image_target_dimensions: Option<(u32, u32)>, image_generation: u64, @@ -41,7 +124,7 @@ pub struct FrozenState { pub(super) portal_target_output_id: Option, pub(super) runtime_wake: Option, pub(super) preflight_pending: bool, - pub(super) preflight_use_fallback: bool, + pub(super) preflight_backend: Option, pub(super) capture_done: bool, pending_image: Option, } @@ -49,26 +132,40 @@ pub struct FrozenState { impl FrozenState { #[cfg(test)] pub fn new(manager: Option) -> Self { - Self::new_inner(manager, None) + Self::new_inner(manager, None, false, None) } + #[cfg(test)] pub(in crate::backend::wayland) fn new_with_runtime_wake( manager: Option, runtime_wake: RuntimeWakeHandle, ) -> Self { - Self::new_inner(manager, Some(runtime_wake)) + Self::new_inner(manager, None, true, Some(runtime_wake)) + } + + pub(in crate::backend::wayland) fn new_with_backends( + manager: Option, + ext_managers: Option, + portal_available: bool, + runtime_wake: RuntimeWakeHandle, + ) -> Self { + Self::new_inner(manager, ext_managers, portal_available, Some(runtime_wake)) } fn new_inner( manager: Option, + ext_managers: Option, + portal_available: bool, runtime_wake: Option, ) -> Self { Self { manager, + ext_managers, + portal_available, active_output: None, active_output_id: None, active_geometry: None, - capture: None, + direct_capture: None, image: None, image_target_dimensions: None, image_generation: 0, @@ -77,14 +174,25 @@ impl FrozenState { portal_target_output_id: None, runtime_wake, preflight_pending: false, - preflight_use_fallback: false, + preflight_backend: None, capture_done: false, pending_image: None, } } - pub fn manager_available(&self) -> bool { - self.manager.is_some() + pub(in crate::backend::wayland) fn preferred_backend(&self) -> Option { + select_capture_backend( + self.manager.is_some(), + self.ext_managers.is_some(), + self.portal_available, + ) + } + + pub(super) fn next_backend_after( + &self, + failed: FrozenCaptureBackend, + ) -> Option { + next_capture_backend_after(failed, self.ext_managers.is_some(), self.portal_available) } pub fn set_active_output(&mut self, output: Option, id: Option) { @@ -118,11 +226,31 @@ impl FrozenState { pub fn set_pending_output_image( &mut self, image: FrozenImage, + target_output_id: u32, source_geometry: Option, ) { self.pending_image = Some(PendingFrozenImage { image, + target_output_id: Some(target_output_id), source_geometry, + output_transform: None, + needs_output_transform: true, + source: FrozenCaptureSource::ActiveOutput, + }); + } + + pub(super) fn set_pending_output_image_with_transform( + &mut self, + image: FrozenImage, + target_output_id: u32, + source_geometry: Option, + output_transform: Option, + ) { + self.pending_image = Some(PendingFrozenImage { + image, + target_output_id: Some(target_output_id), + source_geometry, + output_transform, needs_output_transform: true, source: FrozenCaptureSource::ActiveOutput, }); @@ -131,11 +259,14 @@ impl FrozenState { 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, source_geometry, + output_transform: None, needs_output_transform: false, source: FrozenCaptureSource::Desktop, }); @@ -146,20 +277,20 @@ impl FrozenState { } pub fn is_in_progress(&self) -> bool { - self.capture.is_some() + self.direct_capture.is_some() || self.portal_in_progress || self.preflight_pending || self.pending_image.is_some() } - pub fn take_preflight_pending(&mut self) -> Option { + pub(in crate::backend::wayland) fn take_preflight_pending( + &mut self, + ) -> Option { if !self.preflight_pending { return None; } - let use_fallback = self.preflight_use_fallback; self.preflight_pending = false; - self.preflight_use_fallback = false; - Some(use_fallback) + self.preflight_backend.take() } pub fn take_capture_done(&mut self) -> bool { @@ -168,6 +299,29 @@ impl FrozenState { done } + pub(in crate::backend::wayland) fn direct_capture_timeout( + &self, + now: Instant, + ) -> Option { + self.direct_capture + .as_ref() + .map(|capture| capture.context().timeout(now)) + } + + pub(in crate::backend::wayland) fn take_timed_out_direct_capture( + &mut self, + now: Instant, + ) -> Option { + let capture = self.direct_capture.as_ref()?; + if !capture.context().timeout(now).is_zero() { + return None; + } + let backend = capture.backend(); + let capture = self.direct_capture.take()?; + capture.destroy(); + Some(backend) + } + pub fn activate_pending_image( &mut self, phys_width: u32, @@ -177,15 +331,27 @@ impl FrozenState { let Some(pending) = self.pending_image.take() else { return Ok(false); }; + if !crate::backend::wayland::portal_capture::portal_output_matches( + pending.target_output_id, + self.active_output_id, + ) { + info!("Pending frozen capture discarded after the active output changed"); + self.capture_done = true; + input_state.set_frozen_active(false); + input_state.needs_redraw = true; + return Ok(false); + } let mut image = pending.image; if pending.needs_output_transform { - let output_transform = pending - .source_geometry - .as_ref() - .or(self.active_geometry.as_ref()) - .map(|geo| geo.transform) - .unwrap_or(wl_output::Transform::Normal); + let output_transform = pending.output_transform.unwrap_or_else(|| { + pending + .source_geometry + .as_ref() + .or(self.active_geometry.as_ref()) + .map(|geo| geo.transform) + .unwrap_or(wl_output::Transform::Normal) + }); image = image.with_output_transform(output_transform); } @@ -275,11 +441,11 @@ impl FrozenState { } pub fn cancel(&mut self, input_state: &mut InputState) { - if let Some(capture) = self.capture.take() { - capture.frame.destroy(); + if let Some(capture) = self.direct_capture.take() { + capture.destroy(); } self.preflight_pending = false; - self.preflight_use_fallback = false; + self.preflight_backend = None; self.portal_in_progress = false; if let Some(mut task) = self.portal_task.take() { task.cancel(); @@ -305,15 +471,98 @@ impl FrozenState { } } +fn select_capture_backend( + wlr_screencopy: bool, + ext_image_copy: bool, + portal: bool, +) -> Option { + if wlr_screencopy { + Some(FrozenCaptureBackend::WlrScreencopy) + } else if ext_image_copy { + Some(FrozenCaptureBackend::ExtImageCopy) + } else if portal { + Some(FrozenCaptureBackend::Portal) + } else { + None + } +} + +fn next_capture_backend_after( + failed: FrozenCaptureBackend, + ext_image_copy: bool, + portal: bool, +) -> Option { + match failed { + FrozenCaptureBackend::WlrScreencopy if ext_image_copy => { + Some(FrozenCaptureBackend::ExtImageCopy) + } + FrozenCaptureBackend::WlrScreencopy | FrozenCaptureBackend::ExtImageCopy if portal => { + Some(FrozenCaptureBackend::Portal) + } + FrozenCaptureBackend::WlrScreencopy + | FrozenCaptureBackend::ExtImageCopy + | FrozenCaptureBackend::Portal => None, + } +} + #[cfg(test)] mod tests { use super::*; use crate::input::state::test_support::make_test_input_state; + #[test] + fn capture_backend_priority_is_wlr_then_ext_then_portal() { + assert_eq!( + select_capture_backend(true, true, true), + Some(FrozenCaptureBackend::WlrScreencopy) + ); + assert_eq!( + select_capture_backend(false, true, true), + Some(FrozenCaptureBackend::ExtImageCopy) + ); + assert_eq!( + select_capture_backend(false, false, true), + Some(FrozenCaptureBackend::Portal) + ); + assert_eq!(select_capture_backend(false, false, false), None); + assert_eq!( + next_capture_backend_after(FrozenCaptureBackend::WlrScreencopy, true, true), + Some(FrozenCaptureBackend::ExtImageCopy) + ); + assert_eq!( + next_capture_backend_after(FrozenCaptureBackend::WlrScreencopy, false, true), + Some(FrozenCaptureBackend::Portal) + ); + assert_eq!( + next_capture_backend_after(FrozenCaptureBackend::ExtImageCopy, true, true), + Some(FrozenCaptureBackend::Portal) + ); + assert_eq!( + next_capture_backend_after(FrozenCaptureBackend::Portal, true, true), + None + ); + } + + #[test] + fn direct_capture_context_tracks_its_deadline_and_output_identity() { + let started_at = Instant::now(); + let capture = DirectCaptureContext::new_at(7, None, started_at); + + assert_eq!(capture.timeout(started_at), DIRECT_CAPTURE_TIMEOUT); + assert_eq!( + capture.timeout(started_at + DIRECT_CAPTURE_TIMEOUT), + Duration::ZERO + ); + assert!(capture.output_matches(Some(7))); + assert!(!capture.output_matches(Some(8))); + assert!(!capture.output_matches(None)); + } + #[test] fn active_output_capture_accepts_native_fractional_scale_dimensions() { let mut state = FrozenState::new(None); let mut input_state = make_test_input_state(); + state.set_active_output(None, Some(7)); state.set_pending_output_image( FrozenImage { width: 10, @@ -321,6 +570,7 @@ mod tests { stride: 40, data: vec![0; 10 * 10 * 4], }, + 7, None, ); @@ -340,6 +590,31 @@ mod tests { assert!(!input_state.frozen_active()); } + #[test] + fn active_output_capture_uses_protocol_transform_without_output_geometry() { + let mut state = FrozenState::new(None); + let mut input_state = make_test_input_state(); + state.set_active_output(None, Some(7)); + state.set_pending_output_image_with_transform( + FrozenImage { + width: 2, + height: 1, + stride: 8, + data: vec![1, 0, 0, 255, 2, 0, 0, 255], + }, + 7, + None, + Some(wl_output::Transform::_90), + ); + + state + .activate_pending_image(1, 2, &mut input_state) + .expect("capture transform should orient the frozen image"); + + let image = state.image().expect("the frozen image should be active"); + assert_eq!((image.width, image.height), (1, 2)); + } + #[test] fn desktop_capture_still_requires_a_crop_covering_the_target() { let mut state = FrozenState::new(None); @@ -352,6 +627,7 @@ mod tests { data: vec![0; 4 * 3 * 4], }, None, + None, ); assert!( @@ -363,6 +639,34 @@ mod tests { assert!(!input_state.frozen_active()); } + #[test] + fn pending_capture_is_discarded_if_output_changes_before_activation() { + let mut state = FrozenState::new(None); + let mut input_state = make_test_input_state(); + state.set_active_output(None, Some(7)); + state.set_pending_output_image( + FrozenImage { + width: 1, + height: 1, + stride: 4, + data: vec![0; 4], + }, + 7, + None, + ); + + state.set_active_output(None, Some(8)); + let activated = state + .activate_pending_image(1, 1, &mut input_state) + .expect("the stale-output path is a handled non-error outcome"); + + assert!(!activated); + assert!(state.image().is_none()); + assert!(!state.has_pending_image()); + assert!(state.take_capture_done()); + assert!(!input_state.frozen_active()); + } + #[test] fn cancel_clears_an_in_flight_portal_capture() { let mut state = FrozenState::new(None); diff --git a/src/backend/wayland/handlers/ext_image_copy.rs b/src/backend/wayland/handlers/ext_image_copy.rs new file mode 100644 index 00000000..3014d11d --- /dev/null +++ b/src/backend/wayland/handlers/ext_image_copy.rs @@ -0,0 +1,92 @@ +// Dispatch handlers for ext-image-copy-capture objects used by frozen mode. +use log::debug; +use wayland_client::{Connection, Dispatch, QueueHandle}; +use wayland_protocols::ext::{ + image_capture_source::v1::client::{ + ext_image_capture_source_v1::{Event as SourceEvent, ExtImageCaptureSourceV1}, + ext_output_image_capture_source_manager_v1::{ + Event as OutputSourceManagerEvent, ExtOutputImageCaptureSourceManagerV1, + }, + }, + image_copy_capture::v1::client::{ + ext_image_copy_capture_frame_v1::{Event as FrameEvent, ExtImageCopyCaptureFrameV1}, + ext_image_copy_capture_manager_v1::{Event as ManagerEvent, ExtImageCopyCaptureManagerV1}, + ext_image_copy_capture_session_v1::{Event as SessionEvent, ExtImageCopyCaptureSessionV1}, + }, +}; + +use super::super::frozen::FrozenCaptureBackend; +use super::super::state::WaylandState; + +impl Dispatch for WaylandState { + fn event( + _state: &mut Self, + _proxy: &ExtImageCopyCaptureManagerV1, + event: ManagerEvent, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + debug!("Ext-image-copy manager event ignored: {event:?}"); + } +} + +impl Dispatch for WaylandState { + fn event( + _state: &mut Self, + _proxy: &ExtOutputImageCaptureSourceManagerV1, + event: OutputSourceManagerEvent, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + debug!("Ext output capture source manager event ignored: {event:?}"); + } +} + +impl Dispatch for WaylandState { + fn event( + _state: &mut Self, + _proxy: &ExtImageCaptureSourceV1, + event: SourceEvent, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + debug!("Ext image capture source event ignored: {event:?}"); + } +} + +impl Dispatch for WaylandState { + fn event( + state: &mut Self, + _proxy: &ExtImageCopyCaptureSessionV1, + event: SessionEvent, + _data: &(), + _conn: &Connection, + qh: &QueueHandle, + ) { + let should_fallback = state.frozen.handle_ext_session_event(event, qh); + if should_fallback { + state.continue_frozen_capture_after_failure(FrozenCaptureBackend::ExtImageCopy, qh); + } + } +} + +impl Dispatch for WaylandState { + fn event( + state: &mut Self, + _proxy: &ExtImageCopyCaptureFrameV1, + event: FrameEvent, + _data: &(), + _conn: &Connection, + qh: &QueueHandle, + ) { + if state + .frozen + .handle_ext_frame_event(event, qh, &mut state.input_state) + { + state.continue_frozen_capture_after_failure(FrozenCaptureBackend::ExtImageCopy, qh); + } + } +} diff --git a/src/backend/wayland/handlers/mod.rs b/src/backend/wayland/handlers/mod.rs index 25dcca2e..fafdf7ac 100644 --- a/src/backend/wayland/handlers/mod.rs +++ b/src/backend/wayland/handlers/mod.rs @@ -25,6 +25,7 @@ delegate_xdg_window!(WaylandState); mod activation; mod buffer; mod compositor; +mod ext_image_copy; pub(in crate::backend::wayland) mod keyboard; mod layer; mod output; diff --git a/src/backend/wayland/handlers/screencopy.rs b/src/backend/wayland/handlers/screencopy.rs index 7397307d..ba4fd827 100644 --- a/src/backend/wayland/handlers/screencopy.rs +++ b/src/backend/wayland/handlers/screencopy.rs @@ -6,6 +6,7 @@ use wayland_protocols_wlr::screencopy::v1::client::{ zwlr_screencopy_manager_v1::{Event as ManagerEvent, ZwlrScreencopyManagerV1}, }; +use super::super::frozen::FrozenCaptureBackend; use super::super::state::WaylandState; impl Dispatch for WaylandState { @@ -28,14 +29,15 @@ impl Dispatch for WaylandState { event: FrameEvent, _data: &(), _conn: &Connection, - _qh: &QueueHandle, + qh: &QueueHandle, ) { if state.zoom.is_in_progress() { state.zoom.handle_frame_event(event, &mut state.input_state); - } else { - state - .frozen - .handle_frame_event(event, &mut state.input_state); + } else if state + .frozen + .handle_frame_event(event, &mut state.input_state) + { + state.continue_frozen_capture_after_failure(FrozenCaptureBackend::WlrScreencopy, qh); } } } diff --git a/src/backend/wayland/portal_capture.rs b/src/backend/wayland/portal_capture.rs index 81b30dbf..42ac6131 100644 --- a/src/backend/wayland/portal_capture.rs +++ b/src/backend/wayland/portal_capture.rs @@ -22,18 +22,18 @@ pub(crate) fn screenshot_portal_available(_runtime: &tokio::runtime::Runtime) -> } #[cfg(feature = "portal")] -pub(crate) async fn capture_via_portal_fullscreen_bytes() -> Result, String> { +pub(crate) async fn capture_via_portal_fullscreen_bytes() +-> Result, crate::capture::types::CaptureError> { use crate::capture::sources::portal::capture_via_portal_bytes; use crate::capture::types::CaptureType; - capture_via_portal_bytes(CaptureType::FullScreen) - .await - .map_err(|error| format!("Portal capture failed: {error}")) + capture_via_portal_bytes(CaptureType::FullScreen).await } #[cfg(not(feature = "portal"))] -pub(crate) async fn capture_via_portal_fullscreen_bytes() -> Result, String> { - Err("Portal capture is disabled (feature flag)".to_string()) +pub(crate) async fn capture_via_portal_fullscreen_bytes() +-> Result, crate::capture::types::CaptureError> { + Err(crate::capture::types::CaptureError::PortalUnavailable) } pub(crate) const fn portal_output_matches(target: Option, current: Option) -> bool { diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 0552f901..79a60fee 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -78,7 +78,7 @@ use super::{ ClipboardOperationController, ClipboardOperationIdSource, ClipboardPasteCompletion, ClipboardPublishCompletion, }, - frozen::FrozenState, + frozen::{ExtImageCopyManagers, FrozenState}, overlay_passthrough::set_surface_clickthrough, session::SessionState, surface::SurfaceState, @@ -170,6 +170,8 @@ pub(in crate::backend::wayland) struct WaylandStateInit { pub main_surface_uses_overlay_layer: bool, pub pending_freeze_on_start: bool, pub screencopy_manager: Option, + pub ext_image_copy_managers: Option, + pub portal_freeze_supported: bool, pub text_input_manager: Option, #[cfg(feature = "tablet-input")] pub tablet_manager: Option, diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index 5f19e3d2..a5b88550 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -1,4 +1,5 @@ use super::*; +use crate::backend::wayland::frozen::FrozenCaptureBackend; use crate::capture::{CaptureRequest, CaptureRequestId, CaptureSubmitError}; use crate::input::state::{Toast, ToastPriority}; @@ -18,6 +19,20 @@ fn should_exit_after_capture(mode: ExitAfterCaptureMode, destination: CaptureDes } impl WaylandState { + pub(in crate::backend::wayland) fn continue_frozen_capture_after_failure( + &mut self, + failed_backend: FrozenCaptureBackend, + qh: &QueueHandle, + ) { + if let Err(error) = + self.frozen + .begin_fallback_capture(failed_backend, &self.shm, qh, &self.tokio_handle) + { + log::warn!("No frozen capture fallback succeeded after {failed_backend:?}: {error:#}"); + self.frozen.cancel(&mut self.input_state); + } + } + fn should_exit_after_capture(&self, destination: CaptureDestination) -> bool { should_exit_after_capture(self.exit_after_capture_mode, destination) } diff --git a/src/backend/wayland/state/capture/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index bc6b5f78..9b10dd7a 100644 --- a/src/backend/wayland/state/capture/barrier.rs +++ b/src/backend/wayland/state/capture/barrier.rs @@ -319,17 +319,15 @@ impl WaylandState { match reason { OverlaySuppression::Frozen => { - let Some(use_fallback) = self.frozen.take_preflight_pending() else { + let Some(backend) = self.frozen.take_preflight_pending() else { log::warn!("Frozen capture barrier completed without a pending preflight"); self.cancel_overlay_capture_preflight(reason); return; }; - if let Err(err) = self.frozen.begin_preflight_capture( - use_fallback, - &self.shm, - qh, - &self.tokio_handle, - ) { + if let Err(err) = + self.frozen + .begin_preflight_capture(backend, &self.shm, qh, &self.tokio_handle) + { log::warn!("Frozen preflight capture failed: {err}"); self.frozen.cancel(&mut self.input_state); } diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index 61f2a018..7dd07fab 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -25,6 +25,8 @@ impl WaylandState { main_surface_uses_overlay_layer, pending_freeze_on_start, screencopy_manager, + ext_image_copy_managers, + portal_freeze_supported, text_input_manager, #[cfg(feature = "tablet-input")] tablet_manager, @@ -167,7 +169,12 @@ impl WaylandState { ui_animation_next_tick: None, ui_animation_interval, capture: CaptureState::new(capture_manager), - frozen: FrozenState::new_with_runtime_wake(screencopy_manager, runtime_wake.clone()), + frozen: FrozenState::new_with_backends( + screencopy_manager, + ext_image_copy_managers, + portal_freeze_supported, + runtime_wake.clone(), + ), zoom: ZoomState::new_with_runtime_wake(zoom_manager, runtime_wake.clone()), perf: perf::PerfMetrics::from_env(), exit_after_capture_mode, diff --git a/src/backend/wayland/zoom/mod.rs b/src/backend/wayland/zoom/mod.rs index 1bec1d31..ac649571 100644 --- a/src/backend/wayland/zoom/mod.rs +++ b/src/backend/wayland/zoom/mod.rs @@ -8,5 +8,7 @@ pub use state::ZoomState; const MIN_ZOOM_SCALE: f64 = 1.0; const MAX_ZOOM_SCALE: f64 = 8.0; -type PortalCaptureResult = - Result<(Option, crate::backend::wayland::frozen::FrozenImage), String>; +type PortalCaptureResult = Result< + (Option, crate::backend::wayland::frozen::FrozenImage), + crate::capture::types::CaptureError, +>; diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index 2944a863..f7c7e453 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -8,6 +8,7 @@ use crate::backend::wayland::portal_capture::{ }; use crate::backend::wayland::portal_task::{PortalPoll, PortalTask}; use crate::capture::sources::frozen::decode_image_to_argb; +use crate::capture::types::CaptureError; use crate::input::InputState; use super::state::ZoomState; @@ -41,8 +42,8 @@ impl ZoomState { async { let bytes = capture_via_portal_fullscreen_bytes().await?; - let (mut data, mut width, mut height) = - decode_image_to_argb(&bytes).map_err(|e| format!("Decode failed: {}", e))?; + let (mut data, mut width, mut height) = decode_image_to_argb(&bytes) + .map_err(|error| CaptureError::ImageError(format!("Decode failed: {error}")))?; if let Some(geo) = geo { let (phys_w, phys_h) = geo.physical_size(); @@ -136,10 +137,18 @@ impl ZoomState { input_state.needs_redraw = true; self.capture_done = true; } - PortalPoll::Ready(Err(err)) | PortalPoll::Failed(err) => { + PortalPoll::Ready(Err(CaptureError::Cancelled(reason))) => { + log::info!("Portal zoom capture cancelled: {reason}"); + self.finish_failed_portal_task(input_state); + } + PortalPoll::Ready(Err(err)) => { warn!("Portal zoom capture failed: {err}"); self.finish_failed_portal_task(input_state); } + PortalPoll::Failed(err) => { + warn!("Portal zoom capture task failed: {err}"); + self.finish_failed_portal_task(input_state); + } PortalPoll::Pending => {} PortalPoll::Disconnected => { warn!("Portal zoom capture channel disconnected"); @@ -230,7 +239,7 @@ mod tests { }) } else { PortalTask::spawn(&tokio::runtime::Handle::current(), wake.handle(), async { - Err("portal denied".to_string()) + Err(CaptureError::PermissionDenied) }) }); zoom.portal_in_progress = true; diff --git a/src/capture/portal.rs b/src/capture/portal.rs index 3118152d..2cfe6ba2 100644 --- a/src/capture/portal.rs +++ b/src/capture/portal.rs @@ -2,9 +2,15 @@ use super::types::{CaptureError, CaptureType}; use std::collections::HashMap; -use zbus::zvariant::OwnedValue; +use zbus::zvariant::{OwnedObjectPath, OwnedValue}; use zbus::{Connection, proxy}; +const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop"; +const PORTAL_REQUEST_PATH_PREFIX: &str = "/org/freedesktop/portal/desktop/request"; +const PORTAL_OPTION_HANDLE_TOKEN_KEY: &str = "handle_token"; +const PORTAL_HANDLE_RANDOM_BYTES: usize = 16; +const LOWERCASE_HEX: &[u8; 16] = b"0123456789abcdef"; + /// D-Bus proxy for the xdg-desktop-portal Screenshot interface. #[proxy( interface = "org.freedesktop.portal.Screenshot", @@ -12,6 +18,10 @@ use zbus::{Connection, proxy}; default_path = "/org/freedesktop/portal/desktop" )] trait Screenshot { + /// Maximum Screenshot interface version supported by the selected portal backend. + #[zbus(property, name = "version")] + fn version(&self) -> zbus::Result; + /// Take a screenshot. /// /// # Arguments @@ -87,6 +97,7 @@ pub async fn capture_via_portal(capture_type: CaptureType) -> Result return Ok(uri), + Err(err @ CaptureError::Cancelled(_)) => return Err(err), Err(err) => { log::warn!("Portal capture attempt '{}' failed: {}", attempt.label, err); last_error = Some(err); @@ -117,52 +128,129 @@ fn portal_attempts(capture_type: CaptureType) -> Vec { async fn capture_once( connection: &Connection, proxy: &ScreenshotProxy<'_>, - options: HashMap>, + mut options: HashMap>, ) -> Result { + let handle_token = next_handle_token()?; + let request_path = portal_request_path(connection, &handle_token)?; + options.insert( + PORTAL_OPTION_HANDLE_TOKEN_KEY.to_string(), + handle_token.into(), + ); log::debug!("Calling portal screenshot with options: {:?}", options); - // Call screenshot method - this returns a Request object path. - let request_path = proxy - .screenshot("", options) - .await - .map_err(map_portal_call_error)?; - - log::info!("Screenshot request created: {:?}", request_path); - - // Create a proxy for the Request object to receive Response signal. + // Portal backends may complete non-interactive screenshots before the + // Screenshot method reply reaches us. Subscribe at the predicted request + // path first so that fast Response signals cannot be lost. let request_proxy = RequestProxy::builder(connection) - .path(request_path) + .destination(PORTAL_DESTINATION) + .map_err(CaptureError::DBusError)? + .path(request_path.clone()) .map_err(CaptureError::DBusError)? .build() .await .map_err(CaptureError::DBusError)?; - - // Wait for the Response signal. let mut response_stream = request_proxy .receive_response() .await .map_err(CaptureError::DBusError)?; + let returned_path = proxy + .screenshot("", options) + .await + .map_err(map_portal_call_error)?; + + log::info!("Screenshot request created: {:?}", returned_path); log::debug!("Waiting for Response signal..."); - // Get the first (and only) response. - let response_signal = crate::zbus_stream::next(&mut response_stream) - .await - .ok_or_else(|| CaptureError::InvalidResponse("No Response signal received".to_string()))?; + // Most portals honor handle_token, which lets us install the signal match + // before calling Screenshot. Older implementations may return a different + // path; switch to that path as required by the Request compatibility + // contract instead of rejecting an otherwise valid request. + let response_signal = if returned_path == request_path { + crate::zbus_stream::next(&mut response_stream).await + } else { + log::warn!( + "Screenshot portal returned a different request path; updating Response subscription" + ); + let returned_request_proxy = RequestProxy::builder(connection) + .destination(PORTAL_DESTINATION) + .map_err(CaptureError::DBusError)? + .path(returned_path) + .map_err(CaptureError::DBusError)? + .build() + .await + .map_err(CaptureError::DBusError)?; + let mut returned_response_stream = returned_request_proxy + .receive_response() + .await + .map_err(CaptureError::DBusError)?; + crate::zbus_stream::next(&mut returned_response_stream).await + } + .ok_or_else(|| CaptureError::InvalidResponse("No Response signal received".to_string()))?; let args = response_signal.args().map_err(|e| { CaptureError::InvalidResponse(format!("Failed to parse response args: {}", e)) })?; log::debug!( - "Response signal received: code={}, results={:?}", + "Response signal received: code={}, result_keys={:?}", args.response, - args.results + args.results.keys().collect::>() ); parse_response(args.response, &args.results) } +fn next_handle_token() -> Result { + let mut random = [0_u8; PORTAL_HANDLE_RANDOM_BYTES]; + getrandom::fill(&mut random).map_err(|error| { + CaptureError::InvalidResponse(format!( + "Failed to generate a secure portal handle token: {error}" + )) + })?; + Ok(handle_token_from_random(&random)) +} + +fn handle_token_from_random(random: &[u8; PORTAL_HANDLE_RANDOM_BYTES]) -> String { + let mut token = String::with_capacity("wayscriber_".len() + random.len() * 2); + token.push_str("wayscriber_"); + for byte in random { + token.push(char::from(LOWERCASE_HEX[usize::from(byte >> 4)])); + token.push(char::from(LOWERCASE_HEX[usize::from(byte & 0x0f)])); + } + token +} + +fn portal_request_path( + connection: &Connection, + handle_token: &str, +) -> Result { + let unique_name = connection.unique_name().ok_or_else(|| { + CaptureError::InvalidResponse("Session bus connection has no unique D-Bus name".to_string()) + })?; + portal_request_path_for_unique_name(unique_name.as_str(), handle_token) +} + +fn portal_request_path_for_unique_name( + unique_name: &str, + handle_token: &str, +) -> Result { + if handle_token.is_empty() + || !handle_token + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(CaptureError::InvalidResponse( + "Portal handle token is not a valid D-Bus object-path element".to_string(), + )); + } + let sender = unique_name.trim_start_matches(':').replace('.', "_"); + OwnedObjectPath::try_from(format!( + "{PORTAL_REQUEST_PATH_PREFIX}/{sender}/{handle_token}" + )) + .map_err(|error| CaptureError::InvalidResponse(format!("Invalid portal request path: {error}"))) +} + fn parse_response( response_code: u32, results: &HashMap, @@ -182,12 +270,14 @@ fn parse_response( CaptureError::InvalidResponse(format!("URI is not a string: {}", e)) })?; - log::info!("Screenshot captured successfully: {}", uri_str); + log::info!("Screenshot captured successfully"); Ok(uri_str.to_string()) } 1 => { - log::warn!("Screenshot cancelled by user"); - Err(CaptureError::PermissionDenied) + log::info!("Screenshot cancelled by user"); + Err(CaptureError::Cancelled( + "portal screenshot request was cancelled by the user".to_string(), + )) } code => { log::error!("Screenshot failed with code {}", code); @@ -200,11 +290,16 @@ fn parse_response( } fn map_portal_call_error(err: zbus::Error) -> CaptureError { - log::error!("Portal screenshot call failed: {}", err); let message = err.to_string(); - if message.contains("Cancelled") || message.contains("denied") { + let lowercase_message = message.to_ascii_lowercase(); + if lowercase_message.contains("cancelled") || lowercase_message.contains("canceled") { + log::info!("Portal screenshot call was cancelled"); + CaptureError::Cancelled("portal screenshot request was cancelled".to_string()) + } else if lowercase_message.contains("denied") { + log::warn!("Portal screenshot permission was denied"); CaptureError::PermissionDenied } else { + log::error!("Portal screenshot call failed: {err}"); CaptureError::DBusError(err) } } @@ -239,12 +334,13 @@ fn build_active_window_interactive_options() -> HashMap bool { match Connection::session().await { Ok(connection) => { - // Try to create the proxy. - ScreenshotProxy::new(&connection).await.is_ok() + let Ok(proxy) = ScreenshotProxy::new(&connection).await else { + return false; + }; + proxy.version().await.is_ok() } Err(_) => false, } @@ -302,4 +398,56 @@ mod tests { Some(&zbus::zvariant::Value::from(true)) ); } + + #[test] + fn portal_request_path_uses_the_dbus_unique_name_and_handle_token() { + assert_eq!( + portal_request_path_for_unique_name(":1.42", "wayscriber_7_9") + .expect("valid request path") + .as_str(), + "/org/freedesktop/portal/desktop/request/1_42/wayscriber_7_9" + ); + } + + #[test] + fn portal_request_path_rejects_an_invalid_handle_token() { + assert!( + portal_request_path_for_unique_name(":1.42", "invalid/token").is_err(), + "a handle token must remain one D-Bus object-path element" + ); + } + + #[test] + fn portal_handle_token_is_a_128_bit_random_object_path_element() { + let token = handle_token_from_random(&[ + 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, + 0x54, 0x32, + ]); + + assert_eq!(token, "wayscriber_000123456789abcdeffedcba98765432"); + assert!(portal_request_path_for_unique_name(":1.42", &token).is_ok()); + } + + #[test] + fn generated_portal_handle_tokens_have_independent_random_suffixes() -> Result<(), CaptureError> + { + let first = next_handle_token()?; + let second = next_handle_token()?; + + assert_ne!(first, second); + assert_eq!( + first.len(), + "wayscriber_".len() + PORTAL_HANDLE_RANDOM_BYTES * 2 + ); + assert_eq!(second.len(), first.len()); + assert!(portal_request_path_for_unique_name(":1.42", &first).is_ok()); + assert!(portal_request_path_for_unique_name(":1.42", &second).is_ok()); + Ok(()) + } + + #[test] + fn portal_response_code_one_preserves_user_cancellation() { + let error = parse_response(1, &HashMap::new()).expect_err("response 1 must cancel"); + assert!(matches!(error, CaptureError::Cancelled(_))); + } } diff --git a/src/capture/sources/portal.rs b/src/capture/sources/portal.rs index 840d00ed..b9d6f8c9 100644 --- a/src/capture/sources/portal.rs +++ b/src/capture/sources/portal.rs @@ -8,7 +8,7 @@ use super::reader::read_image_from_uri; /// Capture using xdg-desktop-portal and return image bytes without blocking the Tokio runtime. pub async fn capture_via_portal_bytes(capture_type: CaptureType) -> Result, CaptureError> { let uri = portal::capture_via_portal(capture_type).await?; - log::info!("Portal returned URI: {}", uri); + log::info!("Portal returned a screenshot URI"); tokio::task::spawn_blocking(move || read_image_from_uri(&uri)) .await diff --git a/src/capture/sources/reader.rs b/src/capture/sources/reader.rs index 88a5ceaa..1046ce30 100644 --- a/src/capture/sources/reader.rs +++ b/src/capture/sources/reader.rs @@ -12,7 +12,7 @@ use std::{fs, thread, time::Duration}; pub fn read_image_from_uri(uri: &str) -> Result, CaptureError> { let path = decode_file_uri(uri)?; - log::debug!("Reading screenshot from: {}", path.display()); + log::debug!("Reading screenshot from the portal temporary file"); // Wait briefly for portal to flush the file to disk (some portals write asynchronously) const MAX_ATTEMPTS: usize = 60; // up to 3 seconds total @@ -27,16 +27,14 @@ pub fn read_image_from_uri(uri: &str) -> Result, CaptureError> { } Ok(_) => { log::trace!( - "Portal screenshot file {} still empty (attempt {}/{})", - path.display(), + "Portal screenshot file still empty (attempt {}/{})", attempt + 1, MAX_ATTEMPTS ); } Err(e) => { log::trace!( - "Portal screenshot file {} not ready yet (attempt {}/{}): {}", - path.display(), + "Portal screenshot file not ready yet (attempt {}/{}): {}", attempt + 1, MAX_ATTEMPTS, e @@ -46,9 +44,7 @@ pub fn read_image_from_uri(uri: &str) -> Result, CaptureError> { if attempt + 1 == MAX_ATTEMPTS { return Err(CaptureError::ImageError(format!( - "Portal screenshot file {} not ready after {} attempts", - path.display(), - MAX_ATTEMPTS + "Portal screenshot file was not ready after {MAX_ATTEMPTS} attempts" ))); } @@ -62,13 +58,9 @@ pub fn read_image_from_uri(uri: &str) -> Result, CaptureError> { // Clean up portal temp file to prevent accumulation if let Err(e) = fs::remove_file(&path) { - log::warn!( - "Failed to remove portal temp file {}: {}", - path.display(), - e - ); + log::warn!("Failed to remove portal screenshot temporary file: {e}"); } else { - log::debug!("Removed portal temp file: {}", path.display()); + log::debug!("Removed portal screenshot temporary file"); } Ok(data) diff --git a/src/file_uri.rs b/src/file_uri.rs index 502ec6ce..16094929 100644 --- a/src/file_uri.rs +++ b/src/file_uri.rs @@ -6,18 +6,18 @@ use std::os::unix::ffi::OsStringExt; pub(crate) fn decode_file_uri(uri: &str) -> Result { let raw = uri .strip_prefix("file://") - .ok_or_else(|| format!("Invalid file URI '{uri}'"))?; + .ok_or_else(|| "Invalid file URI scheme".to_string())?; let path_part = if raw.starts_with("localhost/") { &raw["localhost".len()..] } else if raw.starts_with('/') { raw } else { - return Err(format!("Unsupported file URI host in '{uri}'")); + return Err("Unsupported file URI host".to_string()); }; let decoded = percent_decode(path_part) - .map_err(|err| format!("Invalid percent-encoding in '{uri}': {err}"))?; + .map_err(|err| format!("Invalid percent-encoding in file URI: {err}"))?; #[cfg(unix)] { @@ -28,7 +28,7 @@ pub(crate) fn decode_file_uri(uri: &str) -> Result { #[cfg(not(unix))] { let path = String::from_utf8(decoded) - .map_err(|err| format!("Non-UTF8 path in URI '{uri}': {err}"))?; + .map_err(|err| format!("Non-UTF8 path in file URI: {err}"))?; Ok(PathBuf::from(path)) } } @@ -72,14 +72,27 @@ mod tests { #[test] fn decode_file_uri_rejects_non_file_schemes() { - let err = decode_file_uri("http://example.com/file.png").expect_err("expected error"); + let uri = "http://private.example/screenshot.png"; + let err = decode_file_uri(uri).expect_err("expected error"); assert!(err.contains("Invalid file URI")); + assert!(!err.contains(uri)); } #[test] fn decode_file_uri_rejects_unsupported_hosts() { - let err = decode_file_uri("file://example.com/path.png").expect_err("expected error"); + let uri = "file://private.example/screenshot.png"; + let err = decode_file_uri(uri).expect_err("expected error"); assert!(err.contains("Unsupported file URI host")); + assert!(!err.contains(uri)); + } + + #[test] + fn decode_file_uri_redacts_invalid_percent_encoded_locations() { + let uri = "file:///home/private-user/%ZZ.png"; + let err = decode_file_uri(uri).expect_err("expected error"); + assert!(err.contains("Invalid percent-encoding in file URI")); + assert!(!err.contains(uri)); + assert!(!err.contains("private-user")); } #[test] diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 8a80f6f9..99d8214f 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -377,6 +377,7 @@ pub(crate) enum HistoryMode { pub struct CompositorCapabilities { pub layer_shell: bool, pub screencopy: bool, + pub image_copy_capture: bool, pub freeze_capture: bool, pub pointer_constraints: bool, pub desktop_environment: DesktopEnvironment, @@ -399,8 +400,12 @@ pub enum ShellMode { } impl CompositorCapabilities { + pub fn direct_capture_available(&self) -> bool { + self.screencopy || self.image_copy_capture + } + pub fn all_available(&self) -> bool { - self.layer_shell && self.screencopy && self.pointer_constraints + self.layer_shell && self.direct_capture_available() && self.pointer_constraints } pub fn limitations_summary(&self) -> Option { @@ -410,7 +415,7 @@ impl CompositorCapabilities { } if !self.freeze_capture { issues.push("Freeze unavailable"); - } else if !self.screencopy { + } else if !self.direct_capture_available() { issues.push("Freeze uses portal capture"); } if !self.pointer_constraints { diff --git a/src/input/state/core/base/types/tests.rs b/src/input/state/core/base/types/tests.rs index 3a09b86f..3b1e4f4c 100644 --- a/src/input/state/core/base/types/tests.rs +++ b/src/input/state/core/base/types/tests.rs @@ -6,6 +6,7 @@ fn compositor_capabilities_limitations_summary_returns_none_when_fully_available CompositorCapabilities { layer_shell: true, screencopy: true, + image_copy_capture: false, freeze_capture: true, pointer_constraints: true, desktop_environment: Default::default(), @@ -22,6 +23,7 @@ fn compositor_capabilities_limitations_summary_lists_missing_features_in_order() CompositorCapabilities { layer_shell: false, screencopy: true, + image_copy_capture: false, freeze_capture: true, pointer_constraints: false, desktop_environment: Default::default(), @@ -39,6 +41,7 @@ fn compositor_capabilities_reports_portal_freeze_without_hiding_limitations() { let caps = CompositorCapabilities { layer_shell: true, screencopy: false, + image_copy_capture: false, freeze_capture: true, pointer_constraints: true, desktop_environment: Default::default(), @@ -51,3 +54,20 @@ fn compositor_capabilities_reports_portal_freeze_without_hiding_limitations() { Some("Freeze uses portal capture".to_string()) ); } + +#[test] +fn compositor_capabilities_accepts_ext_image_copy_as_direct_capture() { + let caps = CompositorCapabilities { + layer_shell: true, + screencopy: false, + image_copy_capture: true, + freeze_capture: true, + pointer_constraints: true, + desktop_environment: Default::default(), + shell_mode: Default::default(), + }; + + assert!(caps.direct_capture_available()); + assert!(caps.all_available()); + assert_eq!(caps.limitations_summary(), None); +} diff --git a/tools/check-nixpkgs-recipe.py b/tools/check-nixpkgs-recipe.py index 9ced443c..07d83b17 100755 --- a/tools/check-nixpkgs-recipe.py +++ b/tools/check-nixpkgs-recipe.py @@ -40,6 +40,7 @@ "anyhow": (), "cairo-rs": ("cairo",), "flate2": (), + "getrandom": (), "glib": (), "gtk4": ("gtk4",), "gtk4-layer-shell": ("gtk4-layer-shell",),