From eda453dabdcaf501125317299f1251b8fd88c685 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 29 Sep 2026 15:51:56 +0100 Subject: [PATCH] Capture screenshots in the compositor instead of grim --- compositor/src/handlers/mod.rs | 2 + compositor/src/handlers/screencopy.rs | 257 ++- compositor/src/handlers/screenshot.rs | 1701 ++++++++++++++++++ compositor/src/handlers/screenshot_worker.rs | 292 +++ compositor/src/ipc.rs | 229 ++- compositor/src/session_lock.rs | 11 +- compositor/src/state.rs | 16 +- compositor/src/window_rules.rs | 3 +- docs/configuration.md | 30 +- docs/installation.md | 15 +- feresectl/src/main.rs | 85 +- packaging/ferese-screenshot | 34 +- scripts/tests/test_floating_isolated.py | 25 +- scripts/tests/test_lock_isolated.py | 20 +- scripts/tests/test_screenshot.py | 221 ++- 15 files changed, 2770 insertions(+), 171 deletions(-) create mode 100644 compositor/src/handlers/screenshot.rs create mode 100644 compositor/src/handlers/screenshot_worker.rs diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 611f12c..8ab80ce 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -3,6 +3,8 @@ mod compositor; mod input_method; mod layer_shell; pub(crate) mod screencopy; +pub(crate) mod screenshot; +pub(crate) mod screenshot_worker; mod xdg_shell; use smithay::{ diff --git a/compositor/src/handlers/screencopy.rs b/compositor/src/handlers/screencopy.rs index 91a50e7..0467851 100644 --- a/compositor/src/handlers/screencopy.rs +++ b/compositor/src/handlers/screencopy.rs @@ -1,7 +1,12 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use super::screenshot::{CaptureBuffer, PartOutcome, PartSender}; use smithay::{ - backend::{allocator::Fourcc, renderer::ExportMem}, + backend::{ + allocator::Fourcc, + renderer::{ExportMem, TextureMapping}, + }, output::Output, reexports::{ wayland_protocols_wlr::screencopy::v1::server::{ @@ -31,18 +36,120 @@ pub(crate) struct FrameData { #[derive(Debug)] pub(crate) struct PendingScreencopy { - pub(crate) frame: ZwlrScreencopyFrameV1, - buffer: WlBuffer, + sink: CaptureSink, pub(crate) output: Output, region: Rectangle, overlay_cursor: bool, with_damage: bool, } -pub(crate) fn init_global(display: &DisplayHandle) { - let enabled = std::env::var_os("FERESE_ENABLE_SCREENCOPY").is_some_and(|value| value == "1"); +#[derive(Debug)] +pub(crate) enum CaptureSink { + Shm { + frame: ZwlrScreencopyFrameV1, + buffer: WlBuffer, + }, + Owned { + request: u64, + part: usize, + complete: PartSender, + published: AtomicBool, + }, +} - if enabled { +impl CaptureSink { + pub(crate) fn owned(request: u64, part: usize, complete: PartSender) -> Self { + Self::Owned { + request, + part, + complete, + published: AtomicBool::new(false), + } + } + + // Every part reaches exactly one terminal state: pixels or an error. A + // cancelled part is still answered, otherwise its request would wait + // forever for a part that can no longer arrive. + pub(crate) fn fail(&self) { + match self { + CaptureSink::Shm { frame, .. } => frame.failed(), + CaptureSink::Owned { + request, + part, + complete, + published, + } => { + if !published.swap(true, Ordering::AcqRel) { + let _ = complete.send(PartOutcome { + request: *request, + part: *part, + result: Err("Screenshot part failed".to_string()), + }); + } + } + } + } + + // One-shot: a part publishes at most once, so a second completion can + // never overwrite pixels that were already handed off. + pub(crate) fn publish(&self, buffer: CaptureBuffer) -> bool { + let CaptureSink::Owned { + request, + part, + complete, + published, + } = self + else { + return false; + }; + if published.swap(true, Ordering::AcqRel) { + return false; + } + complete + .send(PartOutcome { + request: *request, + part: *part, + result: Ok(buffer), + }) + .is_ok() + } +} + +impl PendingScreencopy { + pub(crate) fn owned( + request: u64, + part: usize, + complete: PartSender, + output: Output, + region: Rectangle, + ) -> Self { + Self { + sink: CaptureSink::owned(request, part, complete), + output, + region, + // The compositor-owned path deliberately omits the cursor. + overlay_cursor: false, + with_damage: false, + } + } + + pub(crate) fn fail(&self) { + self.sink.fail(); + } +} + +pub(crate) fn capture_allowed() -> bool { + static ALLOWED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ALLOWED + .get_or_init(|| capture_opted_in(std::env::var_os("FERESE_ENABLE_SCREENCOPY").as_deref())) +} + +fn capture_opted_in(value: Option<&std::ffi::OsStr>) -> bool { + value.is_some_and(|value| value == "1") +} + +pub(crate) fn init_global(display: &DisplayHandle) { + if capture_allowed() { display.create_global::(3, ()); tracing::info!("authorized screencopy is enabled for this session"); } @@ -161,8 +268,10 @@ impl Ferese { }; self.pending_screencopies.push(PendingScreencopy { - frame: frame.clone(), - buffer, + sink: CaptureSink::Shm { + frame: frame.clone(), + buffer, + }, output, region: data.region, overlay_cursor: data.overlay_cursor, @@ -192,14 +301,16 @@ impl Ferese { remaining.push(capture); continue; } - if !capture.frame.is_alive() || !capture.buffer.is_alive() { + if let CaptureSink::Shm { frame, buffer } = &capture.sink + && (!frame.is_alive() || !buffer.is_alive()) + { continue; } framebuffer_binding_changed = true; if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err() { - capture.frame.failed(); + capture.fail(); } } @@ -283,17 +394,25 @@ fn capture_region( capture_region_for_geometry(mode.size, scale, transform, requested) } -fn capture_region_for_geometry( +pub(crate) fn output_logical_size( + mode_size: Size, + scale: f64, + transform: smithay::utils::Transform, +) -> Size { + transform + .transform_size(mode_size) + .to_f64() + .to_logical(scale) + .to_i32_round() +} + +pub(crate) fn capture_region_for_geometry( mode_size: Size, scale: f64, transform: smithay::utils::Transform, requested: Option>, ) -> Option> { - let logical_size = transform - .transform_size(mode_size) - .to_f64() - .to_logical(scale) - .to_i32_round(); + let logical_size = output_logical_size(mode_size, scale, transform); let output_region = Rectangle::from_size(logical_size); let logical_region = requested .unwrap_or(output_region) @@ -342,50 +461,79 @@ where .map_err(|error| { tracing::warn!(?error, "failed to copy output framebuffer"); })?; + if mapping.format() != Fourcc::Argb8888 { + return Err(()); + } let source = renderer.map_texture(&mapping).map_err(|error| { tracing::warn!(?error, "failed to map output framebuffer copy"); })?; let expected = capture.region.size; - let copied = with_buffer_contents_mut(&capture.buffer, |destination, length, data| { - let bytes = expected.w as usize * expected.h as usize * BYTES_PER_PIXEL; - if source.len() < bytes || data.offset < 0 || data.offset as usize + bytes > length { - return false; - } + let stride = (expected.w as usize) + .checked_mul(BYTES_PER_PIXEL) + .ok_or(())?; + let bytes = stride.checked_mul(expected.h as usize).ok_or(())?; + if source.len() < bytes { + return Err(()); + } + // Smithay's output projection already accounts for OpenGL's Y axis, so these + // rows must not be flipped vertically again. They are in output-buffer order + // including the output transform, which the conversion stage normalizes. + let copied = match &capture.sink { + CaptureSink::Shm { buffer, .. } => { + with_buffer_contents_mut(buffer, |destination, length, data| { + let Ok(offset) = usize::try_from(data.offset) else { + return false; + }; + if offset > length || bytes > length - offset { + return false; + } - // Smithay's output projection already accounts for OpenGL's Y axis. - // These rows are in output-buffer order, including its output transform. - unsafe { - std::ptr::copy_nonoverlapping( - source.as_ptr(), - destination.add(data.offset as usize), - bytes, - ); + // SAFETY: the mapped slice is at least `bytes` long, and the + // checks above establish `bytes` writable bytes at `offset` + // within the shm buffer. The regions cannot overlap: one is + // renderer-owned and the other client-owned. + unsafe { + std::ptr::copy_nonoverlapping(source.as_ptr(), destination.add(offset), bytes); + } + true + }) + .unwrap_or(false) } - true - }) - .map_err(|_| ())?; + CaptureSink::Owned { .. } => { + let mut pixels = Vec::new(); + if pixels.try_reserve_exact(bytes).is_err() { + false + } else { + pixels.extend_from_slice(&source[..bytes]); + capture.sink.publish(CaptureBuffer { + width: expected.w, + height: expected.h, + stride, + pixels, + }) + } + } + }; if !copied { return Err(()); } - if capture.with_damage { - capture - .frame - .damage(0, 0, expected.w as u32, expected.h as u32); - } - // TextureMapping::flipped describes importing the mapping as a texture; - // using it here would make clients flip the already-correct output rows. - capture - .frame - .flags(zwlr_screencopy_frame_v1::Flags::empty()); + if let CaptureSink::Shm { frame, .. } = &capture.sink { + if capture.with_damage { + frame.damage(0, 0, expected.w as u32, expected.h as u32); + } + // TextureMapping::flipped describes importing the mapping as a texture; + // using it here would make clients flip the already-correct output rows. + frame.flags(zwlr_screencopy_frame_v1::Flags::empty()); - let seconds = timestamp.as_secs(); - capture.frame.ready( - (seconds >> 32) as u32, - seconds as u32, - timestamp.subsec_nanos(), - ); + let seconds = timestamp.as_secs(); + frame.ready( + (seconds >> 32) as u32, + seconds as u32, + timestamp.subsec_nanos(), + ); + } Ok(()) } @@ -394,6 +542,19 @@ mod tests { use super::*; use smithay::utils::{Physical, Transform}; + #[test] + fn capture_is_off_unless_the_opt_in_is_exactly_one() { + use std::ffi::OsStr; + + assert!(!capture_opted_in(None)); + assert!(!capture_opted_in(Some(OsStr::new("0")))); + assert!(!capture_opted_in(Some(OsStr::new("")))); + assert!(!capture_opted_in(Some(OsStr::new("true")))); + assert!(!capture_opted_in(Some(OsStr::new("01")))); + assert!(!capture_opted_in(Some(OsStr::new(" 1")))); + assert!(capture_opted_in(Some(OsStr::new("1")))); + } + #[test] fn capture_region_clips_then_scales_to_buffer_coordinates() { let mode = Size::::from((1_920, 1_080)); diff --git a/compositor/src/handlers/screenshot.rs b/compositor/src/handlers/screenshot.rs new file mode 100644 index 0000000..e5cc2ad --- /dev/null +++ b/compositor/src/handlers/screenshot.rs @@ -0,0 +1,1701 @@ +//! Converts output-buffer readback into upright image coordinates. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc::SyncSender as ReplySender; + +use ferese_ipc::Response; +use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Size, Transform}; + +const BYTES_PER_PIXEL: usize = 4; + +pub(crate) struct OutputFrame { + pub(crate) width: i32, + pub(crate) height: i32, + pub(crate) stride: usize, + pub(crate) pixels: Vec, + pub(crate) transform: Transform, + pub(crate) location: (i32, i32), + pub(crate) scale: f64, + // The clipped logical extent this readback was taken for, in upright + // orientation. Dividing the readback dimensions by `scale` would not + // recover it exactly at fractional scales, so it is carried through + // unchanged rather than recomputed on the worker. + pub(crate) logical_width: i32, + pub(crate) logical_height: i32, +} + +pub(crate) struct Canvas { + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) pixels: Vec, +} + +const MAX_CANVAS_BYTES: usize = 128 * 1024 * 1024; + +impl Canvas { + fn new(width: u32, height: u32) -> Option { + let length = (width as usize) + .checked_mul(height as usize)? + .checked_mul(4)?; + if width == 0 || height == 0 || length > MAX_CANVAS_BYTES { + return None; + } + Some(Self { + width, + height, + pixels: vec![0; length], + }) + } + + fn blit(&mut self, source: &[u8], source_width: usize, source_height: usize, x: i32, y: i32) { + let canvas_width = self.width as usize; + let source_row_bytes = source_width.checked_mul(BYTES_PER_PIXEL); + let (Some(source_row_bytes), Some(canvas_row_bytes)) = + (source_row_bytes, canvas_width.checked_mul(BYTES_PER_PIXEL)) + else { + return; + }; + + let first_row = y.clamp(0, self.height as i32) as usize; + let last_row = (y + source_height as i32).clamp(0, self.height as i32) as usize; + if first_row >= last_row { + return; + } + let first_column = x.clamp(0, self.width as i32) as usize; + let last_column = (x + source_width as i32).clamp(0, self.width as i32) as usize; + if first_column >= last_column { + return; + } + + let width = (last_column - first_column) * BYTES_PER_PIXEL; + let source_column_offset = (first_column as i32 - x) as usize * BYTES_PER_PIXEL; + for row in first_row..last_row { + let from = (row - y as usize) * source_row_bytes + source_column_offset; + let to = row * canvas_row_bytes + first_column * BYTES_PER_PIXEL; + if from + width <= source.len() && to + width <= self.pixels.len() { + self.pixels[to..to + width].copy_from_slice(&source[from..from + width]); + } + } + } +} + +fn scale_and_convert( + frame: &OutputFrame, + target_width: usize, + target_height: usize, +) -> Option> { + let (upright_width, upright_height) = upright_size(frame); + if upright_width <= 0 || upright_height <= 0 || target_width == 0 || target_height == 0 { + return None; + } + + let mut out = vec![0_u8; target_width.checked_mul(target_height)?.checked_mul(4)?]; + let row_bytes = target_width.checked_mul(BYTES_PER_PIXEL)?; + + if upright_width as usize == target_width && upright_height as usize == target_height { + for row in 0..target_height { + for column in 0..target_width { + let (x, y) = upright_source_pixel( + column as i32, + row as i32, + frame.width, + frame.height, + frame.transform, + ); + let from = (y as usize) + .checked_mul(frame.stride)? + .checked_add(x as usize * BYTES_PER_PIXEL)?; + if from + BYTES_PER_PIXEL > frame.pixels.len() { + return None; + } + let to = row * row_bytes + column * BYTES_PER_PIXEL; + let pixel = &frame.pixels[from..from + BYTES_PER_PIXEL]; + out[to] = pixel[2]; + out[to + 1] = pixel[1]; + out[to + 2] = pixel[0]; + out[to + 3] = 255; + } + } + return Some(out); + } + + // Destination columns map to source columns, and only the row varies within + // a destination row, so resolve each column once. + let columns: Vec = (0..target_width) + .map(|column| (column * upright_width as usize / target_width) as i32) + .collect(); + + for row in 0..target_height { + let source_row = row * upright_height as usize / target_height; + for (column, source_column) in columns.iter().enumerate() { + let (x, y) = upright_source_pixel( + *source_column, + source_row as i32, + frame.width, + frame.height, + frame.transform, + ); + let from = (y as usize) + .checked_mul(frame.stride)? + .checked_add(x as usize * BYTES_PER_PIXEL)?; + if from + BYTES_PER_PIXEL > frame.pixels.len() { + return None; + } + let to = row * row_bytes + column * BYTES_PER_PIXEL; + let pixel = &frame.pixels[from..from + BYTES_PER_PIXEL]; + out[to] = pixel[2]; + out[to + 1] = pixel[1]; + out[to + 2] = pixel[0]; + out[to + 3] = 255; + } + } + Some(out) +} + +fn upright_source_pixel( + x: i32, + y: i32, + width: i32, + height: i32, + transform: Transform, +) -> (i32, i32) { + match transform { + Transform::_90 => (y, height - 1 - x), + Transform::_180 => (width - 1 - x, height - 1 - y), + Transform::_270 => (width - 1 - y, x), + Transform::Flipped => (width - 1 - x, y), + Transform::Flipped90 => (y, x), + Transform::Flipped180 => (x, height - 1 - y), + Transform::Flipped270 => (width - 1 - y, height - 1 - x), + Transform::Normal => (x, y), + } +} + +pub(crate) fn swaps_axes(transform: Transform) -> bool { + matches!( + transform, + Transform::_90 | Transform::_270 | Transform::Flipped90 | Transform::Flipped270 + ) +} + +pub(crate) fn compose(frames: &[OutputFrame]) -> Result { + for frame in frames { + validate(frame)?; + } + + let (left, top) = logical_origin(frames).ok_or("Screenshot region is empty")?; + let scale = frames + .iter() + .map(|frame| frame.scale) + .fold(0.0_f64, f64::max); + if !(scale.is_finite() && scale > 0.0) { + return Err("Screenshot outputs have an invalid scale".into()); + } + + let mut placement = Vec::with_capacity(frames.len()); + let mut width = 0_i32; + let mut height = 0_i32; + for frame in frames { + // Placement is rounded from the canvas origin, and the far edge is + // derived from the near edge plus the physical extent, so a frame at the + // maximum scale keeps its exact pixel dimensions and adjacent frames + // share their boundary. + let x0 = ((frame.location.0 - left) as f64 * scale).round() as i32; + let y0 = ((frame.location.1 - top) as f64 * scale).round() as i32; + let target_width = canvas_extent(frame.logical_width, scale); + let target_height = canvas_extent(frame.logical_height, scale); + let x1 = x0.saturating_add(target_width); + let y1 = y0.saturating_add(target_height); + width = width.max(x1); + height = height.max(y1); + placement.push((x0, y0, target_width, target_height)); + } + + if width <= 0 || height <= 0 { + return Err("Screenshot region is empty".into()); + } + let mut canvas = Canvas::new(width as u32, height as u32) + .ok_or("Screenshot image is too large to encode")?; + + for (frame, (x0, y0, target_width, target_height)) in frames.iter().zip(placement) { + if target_width == 0 || target_height == 0 { + continue; + } + let scaled = scale_and_convert(frame, target_width as usize, target_height as usize) + .ok_or("Invalid capture size")?; + canvas.blit( + &scaled, + target_width as usize, + target_height as usize, + x0, + y0, + ); + } + + Ok(canvas) +} + +fn canvas_extent(logical: i32, scale: f64) -> i32 { + if logical <= 0 || !(scale.is_finite() && scale > 0.0) { + return 0; + } + ((logical as f64 * scale).round() as i32).max(0) +} + +fn validate(frame: &OutputFrame) -> Result<(), String> { + let row = (frame.width as usize) + .checked_mul(BYTES_PER_PIXEL) + .ok_or("Capture dimensions overflow")?; + if frame.width <= 0 + || frame.height <= 0 + || frame.logical_width <= 0 + || frame.logical_height <= 0 + || frame.stride < row + || !(frame.scale.is_finite() && frame.scale > 0.0) + { + return Err("Capture has invalid dimensions or scale".into()); + } + let length = frame + .stride + .checked_mul(frame.height as usize) + .ok_or("Capture dimensions overflow")?; + if frame.pixels.len() < length { + return Err("Capture buffer is shorter than its dimensions".into()); + } + Ok(()) +} + +fn upright_size(frame: &OutputFrame) -> (i32, i32) { + if swaps_axes(frame.transform) { + (frame.height, frame.width) + } else { + (frame.width, frame.height) + } +} + +fn logical_origin(frames: &[OutputFrame]) -> Option<(i32, i32)> { + let mut origin: Option<(i32, i32)> = None; + for frame in frames { + if frame.width <= 0 || frame.height <= 0 { + continue; + } + origin = Some(match origin { + None => (frame.location.0, frame.location.1), + Some((x, y)) => (x.min(frame.location.0), y.min(frame.location.1)), + }); + } + origin +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Geometry { + All, + Region { + x: i32, + y: i32, + width: i32, + height: i32, + }, +} + +impl Geometry { + // IPC arguments arrive as JSON numbers, so this is the gate that keeps a + // negative or zero extent from reaching rectangle arithmetic. + pub(crate) fn new_region(x: i32, y: i32, width: i32, height: i32) -> Result { + if width <= 0 || height <= 0 { + return Err("Screenshot region must have a positive size".into()); + } + x.checked_add(width) + .ok_or("screenshot geometry overflows")?; + y.checked_add(height) + .ok_or("screenshot geometry overflows")?; + Ok(Geometry::Region { + x, + y, + width, + height, + }) + } +} + +pub(crate) fn parse_geometry(raw: &str) -> Result { + let raw = raw.trim(); + let invalid = || format!("screenshot geometry must be \"x,y WxH\": {raw:?}"); + if raw.is_empty() { + return Err(invalid()); + } + let (position, size) = raw.split_once(' ').ok_or_else(invalid)?; + let (x, y) = position.split_once(',').ok_or_else(invalid)?; + let (width, height) = size.split_once('x').ok_or_else(invalid)?; + + let x: i32 = x.trim().parse().map_err(|_| invalid())?; + let y: i32 = y.trim().parse().map_err(|_| invalid())?; + let width: i32 = width.trim().parse().map_err(|_| invalid())?; + let height: i32 = height.trim().parse().map_err(|_| invalid())?; + + Geometry::new_region(x, y, width, height) +} + +#[derive(Debug)] +pub(crate) struct CaptureBuffer { + pub(crate) width: i32, + pub(crate) height: i32, + pub(crate) stride: usize, + pub(crate) pixels: Vec, +} + +// A capture part completes exactly once, with either pixels or an error. The +// The compositor thread sends it over a channel that never fills while it is +// rendering; a dropped part would leave its request waiting indefinitely. +#[derive(Debug)] +pub(crate) struct PartOutcome { + pub(crate) request: u64, + pub(crate) part: usize, + pub(crate) result: Result, +} + +// A calloop channel so publishing a part also wakes the event loop, whichever +// backend produced the pixels. +pub(crate) type PartSender = smithay::reexports::calloop::channel::Sender; + +// Geometry snapshotted when the request was admitted, so the encoding worker +// never re-queries an output whose transform or scale has since changed. +pub(crate) struct PartSpec { + pub(crate) transform: Transform, + pub(crate) scale: f64, + pub(crate) location: (i32, i32), + pub(crate) logical_width: i32, + pub(crate) logical_height: i32, + pub(crate) buffer_width: i32, + pub(crate) buffer_height: i32, +} + +enum PartState { + Pending, + Ready(OutputFrame), +} + +enum RequestState { + Collecting, + Encoding, +} + +struct Request { + response_id: u64, + reply: ReplySender, + state: RequestState, + parts: Vec<(PartSpec, PartState)>, + received: usize, +} + +pub(crate) enum Action { + None, + Encode { + request: u64, + frames: Vec, + }, + // The path was already sent to the caller; it must not be cleaned up. + Delivered(PathBuf), + // The request is gone, so this file is ours to remove. + DiscardFile(PathBuf), +} + +const MAX_OUTSTANDING: usize = 8; +const MAX_PENDING_BYTES: usize = 256 * 1024 * 1024; + +#[derive(Default)] +pub(crate) struct Coordinator { + requests: std::collections::HashMap, + next_id: u64, +} + +impl Coordinator { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn admit( + &mut self, + response_id: u64, + reply: ReplySender, + specs: Vec, + ) -> Result { + if specs.is_empty() { + return Err("No outputs are enabled".into()); + } + if self.requests.len() >= MAX_OUTSTANDING { + return Err("Too many screenshot requests are in flight".into()); + } + // Budget every participating readback before the first one allocates. + let mut pending = 0_usize; + for spec in &specs { + if spec.buffer_width <= 0 || spec.buffer_height <= 0 { + return Err("Screenshot region is too large".into()); + } + let Some(bytes) = (spec.buffer_width as usize) + .checked_mul(BYTES_PER_PIXEL) + .and_then(|row| row.checked_mul(spec.buffer_height as usize)) + else { + return Err("Screenshot region is too large".into()); + }; + pending = pending.saturating_add(bytes); + } + if pending > MAX_PENDING_BYTES { + return Err("Screenshot region is too large".into()); + } + + self.next_id = self.next_id.wrapping_add(1); + let id = self.next_id; + self.requests.insert( + id, + Request { + response_id, + reply, + state: RequestState::Collecting, + parts: specs + .into_iter() + .map(|spec| (spec, PartState::Pending)) + .collect(), + received: 0, + }, + ); + Ok(id) + } + + // Used by the completion tests to assert that a request is still tracked. + #[allow(dead_code)] + pub(crate) fn is_live(&self, request: u64) -> bool { + self.requests.contains_key(&request) + } + + pub(crate) fn is_encoding(&self, request: u64) -> bool { + matches!( + self.requests.get(&request).map(|entry| &entry.state), + Some(RequestState::Encoding) + ) + } + + pub(crate) fn on_part( + &mut self, + request: u64, + part: usize, + result: Result, + ) -> Action { + let Some(entry) = self.requests.get_mut(&request) else { + return Action::None; + }; + if !matches!(entry.state, RequestState::Collecting) { + return Action::None; + } + let Some((spec, slot)) = entry.parts.get_mut(part) else { + return Action::None; + }; + if !matches!(slot, PartState::Pending) { + return Action::None; + } + + let frame = match result { + Ok(ref buffer) => { + let mut pixels = Vec::new(); + if pixels.try_reserve_exact(buffer.pixels.len()).is_err() { + None + } else { + pixels.extend_from_slice(&buffer.pixels); + Some(OutputFrame { + width: buffer.width, + height: buffer.height, + stride: buffer.stride, + pixels, + transform: spec.transform, + location: spec.location, + scale: spec.scale, + logical_width: spec.logical_width, + logical_height: spec.logical_height, + }) + } + } + Err(_) => None, + }; + + let Some(frame) = frame else { + let error = match result { + Err(error) => error, + Ok(_) => "Screenshot capture could not be allocated".to_string(), + }; + self.reject_inner(request, &error); + return Action::None; + }; + + let Some(entry) = self.requests.get_mut(&request) else { + return Action::None; + }; + entry.parts[part].1 = PartState::Ready(frame); + entry.received += 1; + if entry.received < entry.parts.len() { + return Action::None; + } + + let frames: Vec = std::mem::take(&mut entry.parts) + .into_iter() + .filter_map(|(_, state)| match state { + PartState::Ready(frame) => Some(frame), + PartState::Pending => None, + }) + .collect(); + entry.state = RequestState::Encoding; + Action::Encode { request, frames } + } + + pub(crate) fn on_encoded(&mut self, request: u64, result: Result) -> Action { + if !self.is_encoding(request) { + // Terminated while encoding: the file is ours to clean up, and a + // late result must not revive the request. + return match result { + Ok(path) => Action::DiscardFile(path), + Err(_) => Action::None, + }; + } + let entry = self.requests.remove(&request).expect("checked above"); + match result { + Ok(path) => { + reply(&entry, Ok(&path)); + Action::Delivered(path) + } + Err(error) => { + reply(&entry, Err(&error)); + Action::None + } + } + } + + pub(crate) fn reject(&mut self, request: u64, error: &str) { + self.reject_inner(request, error); + } + + pub(crate) fn terminate_all(&mut self) -> Vec { + let ids: Vec = self.requests.keys().copied().collect(); + for id in &ids { + self.reject_inner(*id, "Screenshot cancelled: the session was locked"); + } + ids + } + + fn reject_inner(&mut self, request: u64, error: &str) { + if let Some(entry) = self.requests.remove(&request) { + reply(&entry, Err(error)); + } + } +} + +fn reply(entry: &Request, result: Result<&Path, &str>) { + let response = match result { + Ok(path) => Response::success(entry.response_id, serde_json::json!({ "path": path })), + Err(error) => Response::error(entry.response_id, "screenshot_failed", error), + }; + // The IPC worker is already blocked in recv(), so this never blocks. A + // disconnected client simply drops the receiver. + let _ = entry.reply.try_send(response); +} + +// The layout facts the planner needs, snapshotted from a live output. +#[derive(Clone)] +pub(crate) struct OutputLayout { + pub(crate) mode_size: Size, + pub(crate) scale: f64, + pub(crate) transform: Transform, + // Global logical position of the output, as the layout sees it. + pub(crate) location: Point, +} + +impl OutputLayout { + fn logical_size(&self) -> Size { + crate::handlers::screencopy::output_logical_size(self.mode_size, self.scale, self.transform) + } +} + +pub(crate) struct PlannedPart { + // Index of the contributing output in the caller's list. + pub(crate) index: usize, + // The clipped global logical rectangle, in the upright orientation the + // caller sees. Composition places by this, so it is never recovered from + // the readback dimensions. + pub(crate) logical: Rectangle, + // The readback rectangle, relative to this output's buffer origin. + pub(crate) buffer: Rectangle, +} + +impl PlannedPart { + pub(crate) fn spec(&self, output: &OutputLayout) -> PartSpec { + PartSpec { + transform: output.transform, + scale: output.scale, + location: (self.logical.loc.x, self.logical.loc.y), + logical_width: self.logical.size.w, + logical_height: self.logical.size.h, + buffer_width: self.buffer.size.w, + buffer_height: self.buffer.size.h, + } + } +} + +// Turns a requested logical region into the per-output reads that cover it. +// The logical-to-buffer conversion is shared with the screencopy path so both +// agree on rounding at fractional scales. +pub(crate) fn plan( + geometry: &Geometry, + outputs: &[OutputLayout], +) -> Result, String> { + if outputs.is_empty() { + return Err("No outputs are enabled".into()); + } + // Validate before building any Rectangle: Size construction panics on a + // negative extent, and this input arrives over IPC. + if let Geometry::Region { width, height, .. } = geometry + && (*width <= 0 || *height <= 0) + { + return Err("Screenshot region must have a positive size".into()); + } + let logical_sizes: Vec> = + outputs.iter().map(OutputLayout::logical_size).collect(); + + let requested = match geometry { + Geometry::All => { + let mut union: Option> = None; + for (index, output) in outputs.iter().enumerate() { + let rect = Rectangle { + loc: output.location, + size: logical_sizes[index], + }; + union = Some(match union { + Some(current) => { + let top_left = Point::::new( + current.loc.x.min(rect.loc.x), + current.loc.y.min(rect.loc.y), + ); + let bottom_right = Point::::new( + (current.loc.x + current.size.w).max(rect.loc.x + rect.size.w), + (current.loc.y + current.size.h).max(rect.loc.y + rect.size.h), + ); + Rectangle::from_extremities(top_left, bottom_right) + } + None => rect, + }); + } + union.expect("outputs is not empty") + } + Geometry::Region { + x, + y, + width, + height, + } => Rectangle { + loc: (*x, *y).into(), + size: (*width, *height).into(), + }, + }; + if requested.size.w <= 0 || requested.size.h <= 0 { + return Err("Screenshot region must have a positive size".into()); + } + + let mut parts = Vec::new(); + for (index, output) in outputs.iter().enumerate() { + if !(output.scale.is_finite() && output.scale > 0.0) { + return Err("Output scale is not usable".into()); + } + let output_rect = Rectangle { + loc: output.location, + size: logical_sizes[index], + }; + let Some(clipped) = requested.intersection(output_rect) else { + continue; + }; + // capture_region_for_geometry works in output-local logical space. + let local = Rectangle { + loc: ( + clipped.loc.x - output.location.x, + clipped.loc.y - output.location.y, + ) + .into(), + size: clipped.size, + }; + let Some(buffer) = crate::handlers::screencopy::capture_region_for_geometry( + output.mode_size, + output.scale, + output.transform, + Some(local), + ) else { + continue; + }; + parts.push(PlannedPart { + index, + logical: clipped, + buffer, + }); + } + if parts.is_empty() { + return Err("The requested region does not intersect any enabled output".into()); + } + Ok(parts) +} + +#[cfg(test)] +mod planning { + use super::*; + + fn output(x: i32, y: i32, width: i32, height: i32, scale: f64) -> OutputLayout { + let mode = Size::::new( + (f64::from(width) * scale).round() as i32, + (f64::from(height) * scale).round() as i32, + ); + OutputLayout { + mode_size: mode, + scale, + transform: Transform::Normal, + location: Point::new(x, y), + } + } + + fn region(x: i32, y: i32, width: i32, height: i32) -> Geometry { + Geometry::Region { + x, + y, + width, + height, + } + } + + #[test] + fn a_crop_crossing_a_seam_splits_into_one_part_per_output() { + let outputs = [ + output(0, 0, 1920, 1080, 1.0), + output(1920, 0, 1920, 1080, 1.0), + ]; + let parts = plan(®ion(1800, 0, 200, 100), &outputs).unwrap(); + assert_eq!(parts.len(), 2); + + assert_eq!(parts[0].index, 0); + assert_eq!( + (parts[0].logical.loc.x, parts[0].logical.size.w), + (1800, 120) + ); + assert_eq!( + ( + parts[1].index, + parts[1].logical.loc.x, + parts[1].logical.size.w + ), + (1, 1920, 80) + ); + // The clipped widths must add back up to the request. + assert_eq!(parts[0].logical.size.w + parts[1].logical.size.w, 200); + + // Readbacks are rebased onto each output's own buffer origin. + assert_eq!((parts[0].buffer.loc.x, parts[0].buffer.size.w), (1800, 120)); + assert_eq!((parts[1].buffer.loc.x, parts[1].buffer.size.w), (0, 80)); + } + + #[test] + fn a_spec_carries_the_clipped_logical_rect_not_the_readback() { + // A fractional-scale output readbacks more pixels than logical*scale + // exactly, so the spec must not be rebuilt from the buffer size. + let outputs = [output(0, 0, 3, 3, 1.5)]; + let parts = plan(®ion(0, 0, 3, 3), &outputs).unwrap(); + let spec = parts[0].spec(&outputs[0]); + assert_eq!((spec.logical_width, spec.logical_height), (3, 3)); + assert_eq!(spec.location, (0, 0)); + assert_eq!(spec.scale, 1.5); + assert_ne!( + (spec.buffer_width, spec.buffer_height), + (spec.logical_width, spec.logical_height), + "this fixture only makes sense if the readback is scaled up" + ); + } + + #[test] + fn mixed_scale_keeps_both_clips_in_logical_space() { + let outputs = [output(0, 0, 800, 600, 1.0), output(800, 0, 1600, 1200, 1.5)]; + let parts = plan(®ion(600, 0, 400, 300), &outputs).unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!( + (parts[0].logical.loc.x, parts[0].logical.size.w), + (600, 200) + ); + assert_eq!( + (parts[1].logical.loc.x, parts[1].logical.size.w), + (800, 200) + ); + // The 1.5 output reads back at 1.5x while still reporting logical 200. + let spec = parts[1].spec(&outputs[1]); + assert_eq!(spec.logical_width, 200); + assert_eq!(spec.buffer_width, 300); + assert_eq!(spec.buffer_height, 450); + } + + #[test] + fn all_geometry_unions_every_output() { + let outputs = [ + output(0, 0, 1920, 1080, 1.0), + output(-1280, -400, 1280, 800, 1.0), + ]; + let parts = plan(&Geometry::All, &outputs).unwrap(); + assert_eq!(parts.len(), 2); + // Neither output is moved into a normalised origin at plan time. + assert_eq!(parts[0].logical.loc.x, 0); + assert_eq!(parts[1].logical.loc.x, -1280); + assert_eq!(parts[1].logical.loc.y, -400); + } + + #[test] + fn a_crop_misses_outputs_that_do_not_intersect() { + let outputs = [ + output(0, 0, 1920, 1080, 1.0), + output(1920, 0, 1920, 1080, 1.0), + ]; + let parts = plan(®ion(4000, 0, 100, 100), &outputs); + assert!(parts.is_err(), "a region off the desktop is reported"); + } + + #[test] + fn a_rotated_output_reads_back_with_swapped_axes() { + let mut rotated = output(0, 0, 1920, 1080, 1.0); + rotated.transform = Transform::_90; + // A 90-degree transform turns the 1920x1080 mode into a 1080x1920 + // logical area, and the readback follows the same rotation. + let parts = plan(®ion(0, 0, 1080, 400), std::slice::from_ref(&rotated)).unwrap(); + assert_eq!( + (parts[0].logical.size.w, parts[0].logical.size.h), + (1080, 400) + ); + let spec = parts[0].spec(&rotated); + assert_eq!(spec.transform, Transform::_90); + assert!( + spec.buffer_height > spec.buffer_width, + "buffer axes are swapped" + ); + } + + #[test] + fn the_region_constructor_validates_instead_of_panicking() { + // Size construction panics on a negative extent, so this must never + // reach rectangle arithmetic. + assert!(Geometry::new_region(0, 0, 100, -5).is_err()); + assert!(Geometry::new_region(0, 0, 0, 100).is_err()); + assert!(Geometry::new_region(i32::MAX, 0, 2, 2).is_err()); + assert!(Geometry::new_region(0, i32::MAX, 2, 2).is_err()); + assert!( + Geometry::new_region(0, i32::MIN, 2, 2).is_ok(), + "a large negative origin is representable" + ); + let ok = Geometry::new_region(-5, -7, 10, 20).expect("negative origins are fine"); + assert_eq!( + ok, + Geometry::Region { + x: -5, + y: -7, + width: 10, + height: 20 + } + ); + // The parser goes through the same gate. + assert!(parse_geometry("0,0 100x-5").is_err()); + } + + #[test] + fn a_non_positive_or_empty_request_is_rejected() { + let outputs = [output(0, 0, 1920, 1080, 1.0)]; + assert!(plan(®ion(0, 0, 0, 100), &outputs).is_err()); + assert!(plan(®ion(0, 0, 100, -5), &outputs).is_err()); + assert!(plan(&Geometry::All, &[]).is_err()); + } +} + +#[cfg(test)] +mod completion { + use super::*; + use crate::handlers::screencopy::CaptureSink; + use smithay::reexports::calloop::channel::channel as loop_channel; + use std::sync::mpsc::sync_channel; + + fn spec(width: i32, height: i32) -> PartSpec { + PartSpec { + transform: Transform::Normal, + scale: 1.0, + location: (0, 0), + logical_width: width, + logical_height: height, + buffer_width: width, + buffer_height: height, + } + } + + fn buffer(width: i32, height: i32) -> CaptureBuffer { + let stride = width as usize * BYTES_PER_PIXEL; + CaptureBuffer { + width, + height, + stride, + pixels: vec![0u8; stride * height as usize], + } + } + + struct Harness { + coordinator: Coordinator, + received: std::sync::mpsc::Receiver, + } + + impl Harness { + fn new(parts: usize) -> Self { + let (reply, received) = sync_channel(8); + let mut coordinator = Coordinator::new(); + coordinator + .admit(42, reply.clone(), (0..parts).map(|_| spec(2, 2)).collect()) + .expect("admitted"); + Self { + coordinator, + received, + } + } + + fn response(&self) -> Option { + self.received.try_recv().ok() + } + } + + #[test] + fn part_failure_terminates_the_request_with_an_error() { + let mut harness = Harness::new(2); + let id = harness.coordinator.next_id; + + assert!(matches!( + harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))), + Action::None + )); + assert!( + harness.coordinator.is_live(id), + "still collecting after part 0" + ); + + assert!(matches!( + harness + .coordinator + .on_part(id, 1, Err("readback failed".into())), + Action::None + )); + assert!( + !harness.coordinator.is_live(id), + "one failed part ends the request" + ); + + let response = harness.response().expect("caller is always answered"); + assert_eq!(response.id, 42); + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, "screenshot_failed"); + } + + #[test] + fn a_disappearing_output_fails_the_request_and_discards_late_results() { + let mut harness = Harness::new(2); + let id = harness.coordinator.next_id; + + harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); + // Output 1 is removed from the layout, so its pending capture is failed. + harness + .coordinator + .on_part(id, 1, Err("output was removed".into())); + assert!(!harness.coordinator.is_live(id)); + assert!(harness.response().unwrap().error.is_some()); + + // A late readback for the sibling must not revive the request. + assert!(matches!( + harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))), + Action::None + )); + assert!(harness.response().is_none(), "no second response is sent"); + } + + #[test] + fn a_part_completes_at_most_once() { + let mut harness = Harness::new(2); + let id = harness.coordinator.next_id; + + harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); + // A duplicate publication for the same part is ignored rather than + // overwriting pixels that were already accepted. + assert!(matches!( + harness.coordinator.on_part(id, 0, Ok(buffer(64, 64))), + Action::None + )); + assert!(matches!( + harness.coordinator.on_part(id, 1, Ok(buffer(2, 2))), + Action::Encode { .. } + )); + // An unknown part index is ignored entirely. + assert!(matches!( + harness.coordinator.on_part(id, 99, Ok(buffer(2, 2))), + Action::None + )); + } + + #[test] + fn all_parts_ready_hands_frames_to_the_encoder() { + let mut harness = Harness::new(2); + let id = harness.coordinator.next_id; + + assert!(matches!( + harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))), + Action::None + )); + let action = harness.coordinator.on_part(id, 1, Ok(buffer(2, 2))); + let Action::Encode { request, frames } = action else { + panic!("expected the request to move to encoding"); + }; + assert_eq!(request, id); + assert_eq!(frames.len(), 2); + assert!(harness.coordinator.is_encoding(id)); + assert!(harness.response().is_none(), "no reply until encoding ends"); + } + + #[test] + fn lock_during_encoding_cancels_and_the_late_file_is_removed() { + let mut harness = Harness::new(1); + let id = harness.coordinator.next_id; + harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); + assert!(harness.coordinator.is_encoding(id)); + + // The session locks while the worker is still encoding. + let terminated = harness.coordinator.terminate_all(); + assert_eq!(terminated, vec![id]); + let response = harness.response().expect("cancellation is answered"); + assert!(response.error.is_some()); + + // Unlocking does not resurrect the request; the file the worker + // eventually produces is ours to delete. + assert!(!harness.coordinator.is_live(id)); + let action = harness + .coordinator + .on_encoded(id, Ok(PathBuf::from("/tmp/late.png"))); + assert!(matches!(action, Action::DiscardFile(ref path) if path.ends_with("late.png"))); + assert!(harness.response().is_none(), "no late success is delivered"); + } + + #[test] + fn encoded_result_is_delivered_once() { + let mut harness = Harness::new(1); + let id = harness.coordinator.next_id; + harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); + + harness + .coordinator + .on_encoded(id, Ok(PathBuf::from("/tmp/shot.png"))); + let response = harness.response().expect("success is answered"); + assert_eq!(response.id, 42); + assert!(response.error.is_none()); + assert_eq!(response.result.unwrap()["path"], "/tmp/shot.png"); + + // A duplicate completion for the same request has nowhere to go, and + // its file is an orphan rather than a second delivery. + let action = harness + .coordinator + .on_encoded(id, Ok(PathBuf::from("/tmp/again.png"))); + assert!(matches!(action, Action::DiscardFile(_))); + + // A delivered path is reported separately so the caller cannot + // mistake it for a file to delete. + let mut other = Harness::new(1); + let other_id = other.coordinator.next_id; + other.coordinator.on_part(other_id, 0, Ok(buffer(2, 2))); + assert!(matches!( + other + .coordinator + .on_encoded(other_id, Ok(PathBuf::from("/tmp/live.png"))), + Action::Delivered(ref path) if path.ends_with("live.png") + )); + assert!(harness.response().is_none()); + } + + #[test] + fn encoder_failure_is_reported_to_the_caller() { + let mut harness = Harness::new(1); + let id = harness.coordinator.next_id; + harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); + + harness + .coordinator + .on_encoded(id, Err("encoding failed".into())); + let response = harness.response().expect("failure is answered"); + assert_eq!(response.error.unwrap().message, "encoding failed"); + assert!(!harness.coordinator.is_live(id)); + } + + #[test] + fn admission_is_bounded_by_requests_and_bytes() { + let (reply, _received) = sync_channel(1); + let mut coordinator = Coordinator::new(); + for _ in 0..MAX_OUTSTANDING { + coordinator + .admit(42, reply.clone(), vec![spec(2, 2)]) + .expect("within the request cap"); + } + let overflow = coordinator.admit(42, reply.clone(), vec![spec(2, 2)]); + assert!(overflow.is_err(), "outstanding requests are bounded"); + + let huge = PartSpec { + buffer_width: 1 << 20, + buffer_height: 1 << 20, + ..spec(2, 2) + }; + let mut fresh = Coordinator::new(); + assert!( + fresh.admit(42, reply.clone(), vec![huge]).is_err(), + "the byte budget is checked before any readback allocates" + ); + } + + #[test] + fn a_sink_publishes_once_and_then_rejects_duplicates() { + let (sender, receiver) = loop_channel(); + let sink = CaptureSink::owned(7, 1, PartSender::clone(&sender)); + assert!(sink.publish(buffer(2, 2))); + // A second publication for the same part is refused, so the first + // buffer that was handed off is never replaced. + assert!(!sink.publish(buffer(64, 64))); + + let PartOutcome { + request, + part, + result, + } = receiver.try_recv().expect("published once"); + assert_eq!((request, part), (7, 1)); + assert_eq!(result.unwrap().width, 2); + assert!(receiver.try_recv().is_err(), "no second buffer was sent"); + } + + #[test] + fn failing_an_owned_sink_answers_the_part_with_an_error() { + let (sender, receiver) = loop_channel(); + let sink = CaptureSink::owned(3, 0, PartSender::clone(&sender)); + sink.fail(); + // A capture that later completes cannot publish after the terminal + // failure has already been reported. + assert!(!sink.publish(buffer(2, 2))); + + let PartOutcome { + request, + part, + result, + } = receiver.try_recv().expect("answered"); + assert_eq!((request, part), (3, 0)); + assert!(result.is_err()); + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn completion_channel_keeps_more_parts_than_the_old_capacity() { + let (sender, receiver) = loop_channel(); + for part in 0..65 { + let sink = CaptureSink::owned(1, part, sender.clone()); + assert!(sink.publish(buffer(2, 2))); + } + for part in 0..65 { + let outcome = receiver.try_recv().expect("every part was queued"); + assert_eq!((outcome.request, outcome.part), (1, part)); + } + assert!(receiver.try_recv().is_err()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn labelled_frame(transform: Transform, width: i32, height: i32) -> OutputFrame { + let mut pixels = Vec::new(); + for index in 0..(width * height) { + // First byte of each pixel is a unique row-major index. + pixels.extend_from_slice(&[b'a' + index as u8, 0, 0, 255]); + } + OutputFrame { + width, + height, + stride: width as usize * BYTES_PER_PIXEL, + pixels, + transform, + location: (0, 0), + scale: 1.0, + logical_width: width, + logical_height: height, + } + } + + fn decode(frame: &OutputFrame, from: usize) -> &'static str { + const CELLS: [&str; 6] = ["a", "b", "c", "d", "e", "f"]; + let index = (frame.pixels[from] - b'a') as usize; + CELLS.get(index).copied().unwrap_or("?") + } + + fn frame(transform: Transform, width: i32, height: i32) -> OutputFrame { + // Row-major fill with a distinct byte per pixel; the fixture is + // asymmetric so a missing rotation cannot cancel out. + let mut pixels = Vec::new(); + for index in 0..(width * height) { + pixels.extend_from_slice(&[(index % 251) as u8, 0, 0, 255]); + } + OutputFrame { + width, + height, + stride: width as usize * BYTES_PER_PIXEL, + pixels, + transform, + location: (0, 0), + scale: 1.0, + logical_width: width, + logical_height: height, + } + } + + #[test] + fn upright_orientation_matches_hand_derived_layouts() { + let cases: [(Transform, Vec<&str>); 8] = [ + (Transform::Normal, vec!["a", "b", "c", "d", "e", "f"]), + (Transform::_90, vec!["d", "a", "e", "b", "f", "c"]), + (Transform::_180, vec!["f", "e", "d", "c", "b", "a"]), + (Transform::_270, vec!["c", "f", "b", "e", "a", "d"]), + (Transform::Flipped, vec!["c", "b", "a", "f", "e", "d"]), + (Transform::Flipped90, vec!["a", "d", "b", "e", "c", "f"]), + (Transform::Flipped180, vec!["d", "e", "f", "a", "b", "c"]), + (Transform::Flipped270, vec!["f", "c", "e", "b", "d", "a"]), + ]; + + for (transform, expected) in cases { + let f = labelled_frame(transform, 3, 2); + let (upright_width, upright_height) = upright_size(&f); + let mut got = vec![""; (upright_width * upright_height) as usize]; + for y in 0..upright_height { + for x in 0..upright_width { + let (sx, sy) = upright_source_pixel(x, y, f.width, f.height, f.transform); + assert!( + (0..f.width).contains(&sx) && (0..f.height).contains(&sy), + "{transform:?} maps ({x},{y}) outside the source" + ); + let from = (sy * f.width + sx) as usize * BYTES_PER_PIXEL; + got[(y * upright_width + x) as usize] = decode(&f, from); + } + } + assert_eq!(got, expected, "wrong upright layout for {transform:?}"); + } + } + + #[test] + fn upright_orientation_holds_for_the_transposed_fixture() { + // The 2x3 fixture distinguishes a rotation from a transposition, which + // the 3x2 fixture cannot. + let cases: [(Transform, Vec<&str>); 8] = [ + (Transform::Normal, vec!["a", "b", "c", "d", "e", "f"]), + (Transform::_90, vec!["e", "c", "a", "f", "d", "b"]), + (Transform::_180, vec!["f", "e", "d", "c", "b", "a"]), + (Transform::_270, vec!["b", "d", "f", "a", "c", "e"]), + (Transform::Flipped, vec!["b", "a", "d", "c", "f", "e"]), + (Transform::Flipped90, vec!["a", "c", "e", "b", "d", "f"]), + (Transform::Flipped180, vec!["e", "f", "c", "d", "a", "b"]), + (Transform::Flipped270, vec!["f", "d", "b", "e", "c", "a"]), + ]; + + for (transform, expected) in cases { + let f = labelled_frame(transform, 2, 3); + let (upright_width, upright_height) = upright_size(&f); + let mut got = vec![""; (upright_width * upright_height) as usize]; + for y in 0..upright_height { + for x in 0..upright_width { + let (sx, sy) = upright_source_pixel(x, y, f.width, f.height, f.transform); + assert!( + (0..f.width).contains(&sx) && (0..f.height).contains(&sy), + "{transform:?} maps ({x},{y}) outside the source" + ); + let from = (sy * f.width + sx) as usize * BYTES_PER_PIXEL; + got[(y * upright_width + x) as usize] = decode(&f, from); + } + } + assert_eq!(got, expected, "wrong upright layout for {transform:?}"); + } + } + + #[test] + fn every_transform_is_a_bijection_over_the_source() { + for transform in [ + Transform::Normal, + Transform::_90, + Transform::_180, + Transform::_270, + Transform::Flipped, + Transform::Flipped90, + Transform::Flipped180, + Transform::Flipped270, + ] { + let f = frame(transform, 3, 2); + let (upright_width, upright_height) = upright_size(&f); + let mut seen = vec![false; (f.width * f.height) as usize]; + for y in 0..upright_height { + for x in 0..upright_width { + let (sx, sy) = upright_source_pixel(x, y, f.width, f.height, f.transform); + let index = (sy * f.width + sx) as usize; + assert!(!seen[index], "{transform:?} reused source pixel {index}"); + seen[index] = true; + } + } + assert!(seen.iter().all(|hit| *hit), "{transform:?} dropped pixels"); + } + } + + #[test] + fn rotated_output_swaps_axis_but_keeps_size_shape() { + let f = frame(Transform::_90, 3, 2); + assert_eq!(upright_size(&f), (2, 3)); + } + + #[test] + fn conversion_swaps_red_and_blue_and_forces_opaque_alpha() { + let mut f = frame(Transform::Normal, 1, 1); + f.pixels = vec![0x11, 0x22, 0x33, 0x00]; // B, G, R, A + let out = scale_and_convert(&f, 1, 1).unwrap(); + assert_eq!(out, vec![0x33, 0x22, 0x11, 255]); + } + + #[test] + fn conversion_does_not_flip_vertically() { + // Top row red, bottom row blue. An extra Y flip would swap them. + let mut f = frame(Transform::Normal, 1, 2); + f.pixels = vec![0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0x00]; + let out = scale_and_convert(&f, 1, 2).unwrap(); + assert_eq!(&out[0..4], &[0xFF, 0x00, 0x00, 255], "top row"); + assert_eq!(&out[4..8], &[0x00, 0x00, 0xFF, 255], "bottom row"); + } + + #[test] + fn conversion_scales_to_target_and_handles_stride() { + let mut f = frame(Transform::Normal, 2, 2); + // Padded stride, as an owned buffer would carry. + f.stride = 2 * BYTES_PER_PIXEL + 4; + let mut padded = Vec::new(); + for row in 0..2 { + for column in 0..2 { + let value = (row * 2 + column) as u8; + padded.extend_from_slice(&[value, value, value, 255]); + } + padded.extend_from_slice(&[0xEE; 4]); + } + f.pixels = padded; + + let out = scale_and_convert(&f, 4, 4).unwrap(); + assert_eq!(out.len(), 4 * 4 * 4); + // Upscaling 2x must not pick up the padding bytes; source indices are + // 0,1,2,3 in row-major order and the first byte of each pixel is red. + for row in 0..4 { + for column in 0..4 { + let source_row = row / 2; + let source_column = column / 2; + let expected = (source_row * 2 + source_column) as u8; + assert_eq!( + out[(row * 4 + column) * 4], + expected, + "row {row} column {column} should sample source index {}", + expected + ); + } + } + } + + #[test] + fn negative_origin_places_output_at_origin_of_canvas() { + let mut f = frame(Transform::Normal, 2, 2); + f.location = (-10, -20); + let canvas = compose(&[f]).unwrap(); + assert_eq!((canvas.width, canvas.height), (2, 2)); + } + + #[test] + fn mixed_scale_uses_highest_scale_and_preserves_logical_layout() { + // 200x100 logical at scale 1, then 200x200 logical at scale 2. + let mut left = frame(Transform::Normal, 200, 100); + left.location = (0, 0); + left.scale = 1.0; + let mut right = frame(Transform::Normal, 400, 400); + right.location = (200, 0); + right.scale = 2.0; + right.logical_width = 200; + right.logical_height = 200; + + let canvas = compose(&[left, right]).unwrap(); + // Logical extent is 400x200; the highest participating scale is 2.0. + assert_eq!((canvas.width, canvas.height), (800, 400)); + } + + #[test] + fn adjacent_outputs_leave_no_seam() { + // Two 1x1 logical outputs side by side at scale 1, with a gap in the + // canvas only if rounding is done independently per output. + let mut left = frame(Transform::Normal, 1, 1); + left.location = (0, 0); + left.scale = 1.0; + let mut right = frame(Transform::Normal, 1, 1); + right.location = (1, 0); + right.scale = 1.0; + + let canvas = compose(&[left, right]).unwrap(); + assert_eq!((canvas.width, canvas.height), (2, 1)); + assert_eq!(canvas.pixels[3], 255, "left pixel opaque"); + assert_eq!(canvas.pixels[7], 255, "right pixel opaque"); + } + + #[test] + fn fractional_scale_leaves_no_gaps_between_outputs() { + // 3 + 2 logical pixels at scale 1.5 must total 8 device pixels exactly + // when boundaries are rounded from a shared origin. + // 2x1 and 2x1 logical, side by side, at scale 1.5. + let mut left = frame(Transform::Normal, 3, 2); + left.location = (0, 0); + left.scale = 1.5; + left.logical_width = 2; + left.logical_height = 1; + let mut right = frame(Transform::Normal, 3, 2); + right.location = (2, 0); + right.scale = 1.5; + right.logical_width = 2; + right.logical_height = 1; + + let canvas = compose(&[left, right]).unwrap(); + assert_eq!((canvas.width, canvas.height), (6, 2), "4x1 logical at 1.5"); + // Every pixel in the top row is covered by some output. + for column in 0..canvas.width as usize { + let index = column * 4 + 3; + assert_eq!(canvas.pixels[index], 255, "column {column} has a seam"); + } + } + + #[test] + fn rejects_canvas_beyond_the_allocation_cap() { + assert!(Canvas::new(20_000, 20_000).is_none()); + assert!(Canvas::new(0, 100).is_none()); + assert!(Canvas::new(100, 0).is_none()); + } + + #[test] + fn rejects_frames_whose_buffer_is_shorter_than_their_dimensions() { + let mut short = frame(Transform::Normal, 4, 4); + short.pixels.truncate(short.pixels.len() - 4); + assert!(compose(&[short]).is_err()); + + let mut narrow = frame(Transform::Normal, 4, 4); + narrow.stride = 4 * BYTES_PER_PIXEL - 1; + assert!(compose(&[narrow]).is_err()); + + let mut zero = frame(Transform::Normal, 4, 4); + zero.width = 0; + assert!(compose(&[zero]).is_err()); + + let mut bad_scale = frame(Transform::Normal, 4, 4); + bad_scale.scale = 0.0; + assert!(compose(&[bad_scale]).is_err()); + + let mut nan_scale = frame(Transform::Normal, 4, 4); + nan_scale.scale = f64::NAN; + assert!(compose(&[nan_scale]).is_err()); + } + + #[test] + fn fractional_scale_keeps_physical_dimensions_exact() { + // A single output at the maximum scale must keep its pixel dimensions. + // 2560x1440 at 1.25 is exactly 2048x1152 logical. + let mut f = frame(Transform::Normal, 2560, 1440); + f.scale = 1.25; + f.logical_width = 2048; + f.logical_height = 1152; + let canvas = compose(&[f]).unwrap(); + assert_eq!((canvas.width, canvas.height), (2560, 1440)); + } + + #[test] + fn fractional_scale_upsamples_a_lower_scale_output() { + // 1600x900 physical at scale 1.0 is 1600x900 logical, beside + // 3200x1800 physical at scale 2.0 which is also 1600x900 logical. + let mut low = frame(Transform::Normal, 1600, 900); + low.location = (0, 0); + low.scale = 1.0; + let mut high = frame(Transform::Normal, 3200, 1800); + high.location = (1600, 0); + high.scale = 2.0; + high.logical_width = 1600; + high.logical_height = 900; + + let canvas = compose(&[low, high]).unwrap(); + // 3200x900 logical composed at the highest participating scale of 2.0. + assert_eq!((canvas.width, canvas.height), (6400, 1800)); + } + + #[test] + fn fractional_scale_crop_uses_the_original_logical_rect() { + // 3 logical pixels at scale 1.5 read back as 5 physical pixels, so + // recovering the logical extent by dividing would give 5/1.5 = 3.33 and + // then 3.33*2.0 = 6.67 -> 7 on a 2.0 canvas, instead of 6. + let mut low = frame(Transform::Normal, 5, 5); + low.location = (0, 0); + low.scale = 1.5; + low.logical_width = 3; + low.logical_height = 3; + + let mut high = frame(Transform::Normal, 4, 4); + high.location = (3, 0); + high.scale = 2.0; + high.logical_width = 2; + high.logical_height = 2; + + let canvas = compose(&[low, high]).unwrap(); + // 3 + 2 = 5 logical, composed at the highest participating scale of 2.0. + assert_eq!((canvas.width, canvas.height), (10, 6)); + } + + #[test] + fn adjacent_outputs_do_not_drift_across_repeated_rounding() { + // Three fractional-scale outputs side by side. Boundaries are derived + // from the logical rectangles, so accumulated rounding cannot open a + // gap or overlap between them. + let mut frames = Vec::new(); + for index in 0..3 { + let mut f = frame(Transform::Normal, 5, 4); + f.location = (index * 3, 0); + f.scale = 1.5; + f.logical_width = 3; + f.logical_height = 3; + frames.push(f); + } + let canvas = compose(&frames).unwrap(); + // 9 logical at scale 1.5. + assert_eq!((canvas.width, canvas.height), (14, 5)); + for column in 0..canvas.width as usize { + assert_eq!( + canvas.pixels[column * 4 + 3], + 255, + "column {column} has a gap" + ); + } + } + + #[test] + fn outputs_below_negative_locations_share_a_seam() { + let mut left = frame(Transform::Normal, 2, 2); + left.location = (-4, -4); + left.scale = 1.0; + let mut right = frame(Transform::Normal, 2, 2); + right.location = (-2, -4); + right.scale = 1.0; + + let canvas = compose(&[left, right]).unwrap(); + assert_eq!((canvas.width, canvas.height), (4, 2)); + for column in 0..canvas.width as usize { + assert_eq!(canvas.pixels[column * 4 + 3], 255, "column {column} seam"); + } + } + + #[test] + fn gap_between_outputs_stays_transparent() { + // Two outputs with a one-pixel logical gap between them. + let mut left = frame(Transform::Normal, 2, 2); + left.location = (0, 0); + left.scale = 1.0; + let mut right = frame(Transform::Normal, 2, 2); + right.location = (3, 0); + right.scale = 1.0; + + let canvas = compose(&[left, right]).unwrap(); + assert_eq!(canvas.width, 5); + assert_eq!( + &canvas.pixels[2 * 4..3 * 4], + &[0, 0, 0, 0], + "gap is transparent" + ); + assert_eq!(canvas.pixels[3], 255); + assert_eq!(canvas.pixels[3 * 4 + 3], 255); + } + + #[test] + fn parses_slurp_geometry_with_negative_origin() { + assert_eq!( + parse_geometry("100,200 800x600").unwrap(), + Geometry::Region { + x: 100, + y: 200, + width: 800, + height: 600 + } + ); + assert_eq!( + parse_geometry("-1920,-40 1280x1024").unwrap(), + Geometry::Region { + x: -1920, + y: -40, + width: 1280, + height: 1024 + } + ); + } + + #[test] + fn geometry_tolerates_surrounding_whitespace_from_slurp() { + for raw in [ + "100,200 800x600\n", + " 100,200 800x600 ", + "100,200 800x600\r\n", + ] { + assert_eq!( + parse_geometry(raw).unwrap(), + Geometry::Region { + x: 100, + y: 200, + width: 800, + height: 600 + }, + "should accept {raw:?}" + ); + } + } + + #[test] + fn rejects_empty_and_malformed_geometry() { + for raw in [ + "", + " ", + "\n", + "100,200", + "100 200 300", + "100,200 800", + "100,200 800x", + "x,200 800x600", + "100,y 800x600", + "100,200 WxH", + "100,200 800xH", + "100.5,200 800x600", + "1e3,200 800x600", + "100,200,300 800x600", + ] { + assert!(parse_geometry(raw).is_err(), "should reject {raw:?}"); + } + } + + #[test] + fn rejects_non_positive_and_overflowing_geometry() { + for raw in [ + "0,0 0x100", + "0,0 100x0", + "0,0 -100x100", + "0,0 100x-1", + "2147483647,0 2x100", + "0,2147483647 100x2", + ] { + assert!(parse_geometry(raw).is_err(), "should reject {raw:?}"); + } + } + + #[test] + fn rejects_empty_frame_set() { + assert!(compose(&[]).is_err()); + } +} diff --git a/compositor/src/handlers/screenshot_worker.rs b/compositor/src/handlers/screenshot_worker.rs new file mode 100644 index 0000000..b74453d --- /dev/null +++ b/compositor/src/handlers/screenshot_worker.rs @@ -0,0 +1,292 @@ +use std::fs; +use std::io; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; +use std::thread; +use std::time::{Duration, SystemTime}; + +use smithay::reexports::calloop::channel; + +use super::screenshot::{Canvas, OutputFrame, compose}; + +const QUEUE_CAPACITY: usize = 4; +/// A client normally opens and unlinks its screenshot immediately. If it dies +/// first, the file is left behind, so staged files are swept well after any +/// realistic request could still be reading one. +const STAGE_TTL: Duration = Duration::from_secs(60 * 60); +pub(crate) const SWEEP_INTERVAL: Duration = Duration::from_secs(5 * 60); +const DIR_MODE: u32 = 0o700; +const FILE_MODE: u32 = 0o600; + +pub(crate) struct Job { + pub(crate) request: u64, + pub(crate) frames: Vec, +} + +pub(crate) struct Encoded { + pub(crate) request: u64, + pub(crate) result: Result, +} + +pub(crate) struct Worker { + queue: SyncSender, +} + +impl Worker { + pub(crate) fn spawn(results: channel::SyncSender) -> Option { + let (queue, receiver) = sync_channel::(QUEUE_CAPACITY); + thread::Builder::new() + .name("ferese-screenshot".to_owned()) + .spawn(move || run(receiver, results)) + .ok() + .map(|_| Self { queue }) + } + + // The compositor thread must never block, so a full queue is reported back + // to the caller instead of waiting. + pub(crate) fn submit(&self, job: Job) -> Result<(), Job> { + match self.queue.try_send(job) { + Ok(()) => Ok(()), + Err(TrySendError::Full(job)) | Err(TrySendError::Disconnected(job)) => Err(job), + } + } +} + +fn run(receiver: Receiver, results: channel::SyncSender) { + while let Ok(job) = receiver.recv() { + let result = encode(&job); + // The loop drains this channel, so a full queue only delays the worker. + if results + .send(Encoded { + request: job.request, + result, + }) + .is_err() + { + return; + } + } +} + +// Removes staged screenshots whose client never collected them. Only files +// older than the TTL are touched, so an in-flight request is never disturbed. +pub(crate) fn sweep_stale_files() { + let Ok(directory) = private_directory() else { + return; + }; + let Ok(entries) = fs::read_dir(&directory) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_none_or(|extension| extension != "png") { + continue; + } + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_file() { + continue; + } + // A file dated in the future is treated as fresh rather than swept. + let stale = metadata + .modified() + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age > STAGE_TTL); + if stale && fs::remove_file(&path).is_ok() { + tracing::debug!(path = %path.display(), "removed an uncollected screenshot"); + } + } +} + +fn encode(job: &Job) -> Result { + let canvas = compose(&job.frames)?; + let png = encode_png(&canvas)?; + write_private_file(&png) +} + +fn encode_png(canvas: &Canvas) -> Result, String> { + let (width, height) = (canvas.width, canvas.height); + // A final sanity check on the composed size before handing it to the + // encoder, which would otherwise panic on a zero dimension. + if width == 0 || height == 0 { + return Err("Screenshot is empty".into()); + } + let image = image::RgbaImage::from_raw(width, height, canvas.pixels.clone()) + .ok_or("Screenshot buffer does not match its dimensions")?; + let mut out = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new_with_quality( + std::io::Cursor::new(&mut out), + image::codecs::png::CompressionType::Fast, + image::codecs::png::FilterType::Adaptive, + ); + image::ImageEncoder::write_image( + encoder, + image.as_raw(), + width, + height, + image::ExtendedColorType::Rgba8, + ) + .map_err(|error| format!("Could not encode screenshot: {error}"))?; + tracing::debug!(encoded = out.len(), "encoded screenshot"); + Ok(out) +} + +// The client is told to open this path and unlink it immediately, so the +// directory is ours, private, and files are created with the final mode. +fn write_private_file(png: &[u8]) -> Result { + let directory = private_directory()?; + for attempt in 0..64u32 { + let path = directory.join(format!("shot-{}-{attempt}.png", std::process::id())); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true).mode(FILE_MODE); + match options.open(&path) { + Ok(mut file) => { + use io::Write; + file.write_all(png) + .and_then(|()| file.sync_all()) + .map_err(|error| format!("Could not write screenshot: {error}"))?; + return Ok(path); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(format!("Could not write screenshot: {error}")), + } + } + Err("Could not find an unused screenshot filename".into()) +} + +fn private_directory() -> Result { + let runtime = std::env::var_os("XDG_RUNTIME_DIR") + .ok_or("XDG_RUNTIME_DIR is not set, so screenshots cannot be staged")?; + let root = PathBuf::from(runtime); + let directory = root.join("ferese-screenshots"); + match fs::symlink_metadata(&directory) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("Refusing to stage screenshots in a symlinked directory".into()); + } + Ok(metadata) => { + use std::os::unix::fs::MetadataExt; + if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } { + return Err("Refusing to stage screenshots in an untrusted directory".into()); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(&directory) + .and_then(|()| { + fs::set_permissions(&directory, fs::Permissions::from_mode(DIR_MODE)) + }) + .map_err(|error| format!("Could not create screenshot directory: {error}"))?; + } + Err(error) => return Err(format!("Could not inspect screenshot directory: {error}")), + } + Ok(directory) +} + +#[cfg(test)] +mod tests { + use super::*; + use smithay::utils::Transform; + + fn frame(width: i32, height: i32) -> OutputFrame { + let stride = width as usize * 4; + let mut pixels = vec![0u8; stride * height as usize]; + let (quads, _) = pixels.as_chunks_mut::<4>(); + for pixel in quads { + pixel[3] = 255; + } + OutputFrame { + width, + height, + stride, + pixels, + transform: Transform::Normal, + location: (0, 0), + scale: 1.0, + logical_width: width, + logical_height: height, + } + } + + #[test] + fn encode_produces_a_decodable_png() { + let job = Job { + request: 1, + frames: vec![frame(4, 3)], + }; + let canvas = compose(&job.frames).unwrap(); + let png = encode_png(&canvas).expect("png"); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "png signature"); + + let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png) + .expect("decodes back") + .to_rgba8(); + assert_eq!(decoded.dimensions(), (4, 3)); + // The opaque alpha we force is preserved through the round trip. + assert_eq!(decoded.get_pixel(0, 0)[3], 255); + } + + #[test] + fn staged_files_are_owner_only() { + let png = b"\x89PNG\r\n\x1a\nnot really a png".to_vec(); + let path = write_private_file(&png).expect("staged"); + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, FILE_MODE, + "staged screenshots are not group/world readable" + ); + fs::remove_file(&path).unwrap(); + } + + #[test] + fn the_sweep_keeps_fresh_files_and_ignores_other_types() { + let png = b"\x89PNG\r\n\x1a\n".to_vec(); + let fresh = write_private_file(&png).expect("staged"); + + let directory = private_directory().unwrap(); + let other = directory.join("keep-me.txt"); + fs::write(&other, b"not a screenshot").unwrap(); + + sweep_stale_files(); + assert!( + fresh.exists(), + "a freshly staged screenshot is still in use" + ); + assert!(other.exists(), "unrelated files are left alone"); + + fs::remove_file(&fresh).unwrap(); + fs::remove_file(&other).unwrap(); + } + + #[test] + fn the_sweep_removes_files_past_the_ttl() { + let png = b"\x89PNG\r\n\x1a\n".to_vec(); + let path = write_private_file(&png).expect("staged"); + // Backdate it past the TTL without waiting an hour. + let aged = SystemTime::now() - STAGE_TTL - Duration::from_secs(60); + fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(aged) + .expect("backdate"); + + sweep_stale_files(); + assert!(!path.exists(), "an uncollected screenshot is reclaimed"); + } + + #[test] + fn a_missing_runtime_dir_is_reported_rather_than_ignored() { + // private_directory must never fall back to /tmp or the cwd. + let result = private_directory(); + match result { + Ok(path) => assert!( + path.ends_with("ferese-screenshots"), + "screenshots are staged in the runtime dir, not a fallback location" + ), + Err(error) => assert!(error.contains("XDG_RUNTIME_DIR") || error.contains("untrusted")), + } + } +} diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 9808be4..f010735 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -11,18 +11,27 @@ use std::sync::{ mpsc::{SyncSender, sync_channel}, }; use std::thread; +use std::time::Duration; use ferese_core::LayoutMode; use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; use ferese_layout::Direction; use serde_json::{Value, json}; -use smithay::reexports::calloop::{EventLoop, LoopSignal, channel}; +use smithay::reexports::calloop::{EventLoop, LoopSignal, channel, timer}; use smithay::utils::Transform; +use crate::handlers::screencopy; +use crate::handlers::screenshot::{ + Action, Geometry, OutputLayout, PartOutcome, PartSender, parse_geometry, plan, +}; +use crate::handlers::screenshot_worker::{self, Encoded, Job, Worker}; use crate::{Ferese, config::OutputTransform}; +use smithay::output::Output; const REQUEST_QUEUE_CAPACITY: usize = 128; const MAX_CONNECTIONS: usize = 64; +const RESULT_QUEUE_CAPACITY: usize = 8; +const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL; #[derive(Debug)] struct IpcCall { @@ -61,7 +70,7 @@ impl Drop for IpcSocketGuard { pub(crate) fn init( event_loop: &mut EventLoop<'static, Ferese>, -) -> Result> { +) -> Result> { let path = socket_path()?; prepare_parent(&path)?; let listener = bind_listener(&path)?; @@ -79,18 +88,70 @@ pub(crate) fn init( .handle() .insert_source(receiver, |event, _, state| { if let channel::Event::Msg(call) = event { - let response = state.handle_ipc_request(call.request); - let _ = call.response.send(response); + if call.request.command == "screenshot" { + // Deferred: this path answers the caller itself, exactly + // once, whenever the request finishes or is terminated. + state.start_screenshot(call.request, call.response); + } else { + let response = state.handle_ipc_request(call.request); + let _ = call.response.send(response); + } } })?; + // Screenshot readbacks publish parts here, so the loop wakes as soon as a + // readback lands, whichever backend produced it. + let (parts, part_events) = channel::channel::(); + event_loop + .handle() + .insert_source(part_events, |event, _, state| { + if let channel::Event::Msg(outcome) = event { + state.on_screenshot_part(outcome); + } + })?; + + let (encoded, encoded_events) = channel::sync_channel::(RESULT_QUEUE_CAPACITY); + event_loop + .handle() + .insert_source(encoded_events, |event, _, state| { + if let channel::Event::Msg(result) = event { + state.on_screenshot_encoded(result); + } + })?; + let worker = Worker::spawn(encoded); + let signal = event_loop.get_signal(); thread::Builder::new() .name("ferese-ipc-listener".to_owned()) .spawn(move || accept_connections(listener, sender, signal))?; + // A client that dies between receiving a screenshot path and unlinking it + // leaves the file behind, so staged files are reclaimed at startup and then + // periodically. The TTL keeps this from disturbing a live request. + screenshot_worker::sweep_stale_files(); + event_loop + .handle() + .insert_source(timer::Timer::from_duration(SWEEP_INTERVAL), |_, _, _| { + screenshot_worker::sweep_stale_files(); + timer::TimeoutAction::ToDuration(SWEEP_INTERVAL) + })?; + tracing::info!(path = %path.display(), "Ferese IPC is accepting connections"); - Ok(guard) + + let Some(worker) = worker else { + return Err("Could not start the screenshot worker".into()); + }; + Ok(ScreenshotInit { + parts, + worker, + _guard: guard, + }) +} + +pub(crate) struct ScreenshotInit { + pub(crate) parts: PartSender, + pub(crate) worker: Worker, + pub(crate) _guard: IpcSocketGuard, } fn accept_connections( @@ -261,6 +322,152 @@ impl Ferese { Ok(json!({})) } + // Screenshot replies are deferred: this either answers the caller now, or + // hands the reply to the coordinator, which answers exactly once. + pub(crate) fn start_screenshot(&mut self, request: Request, response: SyncSender) { + // A macro rather than a closure: the message may be a borrowed str or an + // owned String, and Response::error already accepts either. + macro_rules! reject { + ($code:expr, $message:expr) => { + let _ = response.try_send(Response::error(request.id, $code, $message)); + }; + } + if self.session_lock.active { + reject!("session_locked", "IPC unavailable while session is locked"); + return; + } + if !screencopy::capture_allowed() { + reject!( + "capture_disabled", + "Screen capture is disabled for this session; start it with \ + FERESE_ENABLE_SCREENCOPY=1 to enable screenshots" + ); + return; + } + if let Err(error) = validate_request(&request) { + reject!(error.code, error.message); + return; + } + let geometry = match geometry_arg(&request.args) { + Ok(geometry) => geometry, + Err(message) => { + reject!("invalid_argument", &message); + return; + } + }; + if self.screenshot_parts.is_none() || self.screenshot_worker.is_none() { + reject!("unavailable", "Screenshot capture is not available"); + return; + } + let Some(parts) = self.screenshot_parts.clone() else { + reject!("unavailable", "Screenshot capture is not available"); + return; + }; + + // Snapshot the layout, so a later move, rescale, or transform cannot + // change what this request captures. + let targets: Vec<(Output, OutputLayout)> = self + .space + .outputs() + .filter_map(|output| { + let mode = output.current_mode()?; + let geometry = self.space.output_geometry(output)?; + Some(( + output.clone(), + OutputLayout { + mode_size: mode.size, + scale: output.current_scale().fractional_scale(), + transform: output.current_transform(), + location: geometry.loc, + }, + )) + }) + .collect(); + let layouts: Vec = targets.iter().map(|(_, layout)| layout.clone()).collect(); + let planned = match plan(&geometry, &layouts) { + Ok(planned) => planned, + Err(message) => { + reject!("invalid_request", &message); + return; + } + }; + let specs = planned + .iter() + .map(|part| part.spec(&layouts[part.index])) + .collect(); + + // admit stores the reply only on success, so a clone survives the + // rejection path and every caller is answered exactly once. + let fallback = response.clone(); + let id = match self.screenshot.admit(request.id, response, specs) { + Ok(id) => id, + Err(message) => { + let _ = + fallback.try_send(Response::error(request.id, "screenshot_rejected", message)); + return; + } + }; + + for (position, part) in planned.iter().enumerate() { + let (output, _) = &targets[part.index]; + self.pending_screencopies + .push(crate::handlers::screencopy::PendingScreencopy::owned( + id, + position, + parts.clone(), + output.clone(), + part.buffer, + )); + } + + // The nested backend redraws on its refresh timer; this drives the + // direct backend immediately and is a no-op otherwise. + crate::backends::direct::render_all(self); + } + + pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) { + let action = self + .screenshot + .on_part(outcome.request, outcome.part, outcome.result); + self.apply_screenshot_action(action); + } + + pub(crate) fn on_screenshot_encoded(&mut self, result: Encoded) { + let action = self.screenshot.on_encoded(result.request, result.result); + self.apply_screenshot_action(action); + } + + fn apply_screenshot_action(&mut self, action: Action) { + match action { + Action::None => {} + Action::Encode { request, frames } => { + let submitted = self + .screenshot_worker + .as_ref() + .is_some_and(|worker| worker.submit(Job { request, frames }).is_ok()); + if !submitted { + self.screenshot + .reject(request, "Screenshot encoding queue is full"); + } + } + Action::Delivered(path) => { + // The caller opens and unlinks it; the path was already sent. + tracing::debug!(path = %path.display(), "delivered a screenshot"); + } + Action::DiscardFile(path) => { + if let Err(error) = fs::remove_file(&path) + && error.kind() != io::ErrorKind::NotFound + { + tracing::debug!( + %error, + path = %path.display(), + "failed to remove a staged screenshot" + ); + } + } + } + } + fn focused_window_json(&self) -> Value { self.focused_window .map(|window| json!({ "id": window.0 })) @@ -413,6 +620,18 @@ impl Ferese { } } +fn geometry_arg(args: &Value) -> Result { + match args.get("geometry") { + None | Some(Value::Null) => Ok(Geometry::All), + Some(value) => { + let text = value + .as_str() + .ok_or("geometry must be a string like \"x,y WxH\"")?; + parse_geometry(text) + } + } +} + fn validate_request(request: &Request) -> Result<(), CommandError> { if request.version != VERSION { return Err(CommandError::new( diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 4b659dc..fdc143c 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -125,7 +125,16 @@ impl SessionLockHandler for Ferese { self.session_lock.owner = Some(confirmation.ext_session_lock().clone()); self.session_lock.confirmation = Some(confirmation); for capture in self.pending_screencopies.drain(..) { - capture.frame.failed(); + capture.fail(); + } + // Draining only covers requests that had not read back yet. One that is + // already encoding has no pending capture left, so it is terminated + // here and its staged file is discarded when the worker reports back. + for request in self.screenshot.terminate_all() { + tracing::debug!( + request, + "cancelled an in-flight screenshot for the session lock" + ); } self.set_overview_active(false); let serial = SERIAL_COUNTER.next_serial(); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 7c4852f..3ca51d8 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -174,6 +174,12 @@ pub struct Ferese { _ipc_socket: Option, pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>, pub(crate) pending_screencopies: Vec, + // Screenshot requests outlive the readback that filled them: the + // coordinator owns them until encoding finishes and the caller is + // answered, or the request is terminated. + pub(crate) screenshot: crate::handlers::screenshot::Coordinator, + pub(crate) screenshot_parts: Option, + pub(crate) screenshot_worker: Option, pub(crate) shell_resources: Vec< smithay::reexports::wayland_server::Weak< ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1, @@ -385,6 +391,9 @@ impl Ferese { _ipc_socket: None, pending_dmabuf_imports: Vec::new(), pending_screencopies: Vec::new(), + screenshot: crate::handlers::screenshot::Coordinator::new(), + screenshot_parts: None, + screenshot_worker: None, shell_resources: Vec::new(), shell_snapshot_serial: 0, last_shell_snapshot: None, @@ -422,7 +431,10 @@ impl Ferese { xdg_shell_state, xdg_toplevel_icon_manager, }; - state._ipc_socket = Some(crate::ipc::init(event_loop)?); + let screenshot = crate::ipc::init(event_loop)?; + state.screenshot_parts = Some(screenshot.parts); + state.screenshot_worker = Some(screenshot.worker); + state._ipc_socket = Some(screenshot._guard); Ok(state) } @@ -572,7 +584,7 @@ impl Ferese { self.overview_scrims.remove(&output_id); self.pending_screencopies.retain(|capture| { if capture.output == *output { - capture.frame.failed(); + capture.fail(); false } else { true diff --git a/compositor/src/window_rules.rs b/compositor/src/window_rules.rs index 2fec2a6..cd8b1f1 100644 --- a/compositor/src/window_rules.rs +++ b/compositor/src/window_rules.rs @@ -73,7 +73,7 @@ pub fn resolve( let mut result = WindowRuleResult::default(); if matches!( app_id.as_deref(), - Some("dev.ferese.authentication" | "dev.ferese.screenshare") + Some("dev.ferese.authentication" | "dev.ferese.screenshare" | "dev.ferese.screenshot") ) { result.floating = Some(true); } @@ -220,6 +220,7 @@ mod tests { "dev.ferese.ScreenShare", "dev.ferese.ScreenShare.desktop", "dev.ferese.Authentication", + "dev.ferese.Screenshot", ] { assert_eq!(resolve(&[], Some(app_id), None, false).floating, Some(true)); let rules = validate(&[WindowRuleConfig { diff --git a/docs/configuration.md b/docs/configuration.md index 514f171..6e2a782 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -294,16 +294,26 @@ Super+M layout; Super+Shift+Space floating; Super+Tab overview; Super+Shift+S area screenshot; Print Screen whole-screen screenshot; Super+Shift+E immediate logout. +Hold Super and drag a floating window with the left mouse button to move it. +Hold Super and drag with the right mouse button to resize it from the nearest +corner. These work even when an app has no title bar or resize border. + The screenshot shortcut runs `ferese-screenshot`: drag to select an area, or press Escape to cancel. Captures open in Satty for annotation. Press Enter to save the edited PNG under your Pictures directory in `Screenshots` and copy it -to the clipboard; Escape discards the capture. It requires `grim`, `slurp`, -`satty`, and `wl-copy` (from `wl-clipboard`). Override the `screenshot` command -to use another screenshot tool. -Print Screen runs `ferese-screenshot --full` and captures all enabled outputs -without a selector. Fn+PrtSc works when the keyboard emits the Print Screen key; -Fn is handled by the keyboard firmware. Override `screenshot-full` to customize -this command. Both modes open Satty with the same save and copy workflow. +to the clipboard; Escape discards the capture. It requires `slurp`, `satty`, and +`wl-copy` (from `wl-clipboard`); the capture itself is done by the compositor +through `feresectl screenshot`. Override the `screenshot` command to use another +screenshot tool. +Print Screen runs `ferese-screenshot --full` and captures the active monitor +without a selector, falling back to the sole enabled output when focus is +unavailable. It never silently captures every output. Fn+PrtSc works when the +keyboard emits the Print Screen key; Fn is handled by the keyboard firmware. +Override `screenshot-full` to customize this command. Both modes open Satty with +the same save and copy workflow. + +`ferese-screenshot --all` captures every enabled output as one image. + ## Window rules @@ -406,7 +416,11 @@ These are launch-time environment switches, not KDL keys. Enable only for trusted clients: `FERESE_ENABLE_INPUT_METHOD=1`, `FERESE_ENABLE_SHORTCUT_INHIBIT=1`, `FERESE_ENABLE_SCREENCOPY=1`. The installed session launcher enables screencopy for screenshots unless -`FERESE_ENABLE_SCREENCOPY=0` is explicitly set in its environment. +`FERESE_ENABLE_SCREENCOPY=0` is explicitly set in its environment. Setting it +to `0` disables every path that can read the screen, including the built-in +screenshot command, not just the Wayland protocol. A session launched by hand +rather than through `ferese-session` needs `FERESE_ENABLE_SCREENCOPY=1` in its +environment for screenshots to work. Text input is available by default; Ctrl+Alt+Escape releases an active shortcut inhibitor. Use `feresectl --help` for runtime control commands. diff --git a/docs/installation.md b/docs/installation.md index 8a9048a..f15d301 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -203,7 +203,7 @@ Install these through your distribution when you want the corresponding features | Feature | Tools or services | | --- | --- | | Wallpaper file picker | `zenity` | -| Screenshots and editing | `grim`, `slurp`, `satty`, `wl-clipboard`, Python 3 | +| Screenshots and editing | `slurp`, `satty`, `wl-clipboard`, Python 3 | | Idle locking | `swayidle` with the included `ferese-lock` | | Network controls | NetworkManager and its running service | | Audio controls | PipeWire, WirePlumber, and `wpctl` | @@ -244,9 +244,16 @@ A directly launched source build writes to its terminal. Keep another desktop available while testing scaling, monitor hotplug, suspend, and locking. The launcher enables the screencopy protocol for screenshot tools. Set -`FERESE_ENABLE_SCREENCOPY=0` in the session environment to disable it. When -enabled, Wayland clients can capture the unlocked desktop. Capture remains -blocked while the session is locked. Launcher changes take effect at next login. +`FERESE_ENABLE_SCREENCOPY=0` in the session environment to disable it. This +turns off every path that can read the screen, including the built-in +screenshot command, not only the Wayland protocol. When enabled, Wayland clients +can capture the unlocked desktop, and `feresectl screenshot` is available. +Capture remains blocked while the session is locked. Launcher changes take +effect at next login. + +Starting the compositor directly instead of through `ferese-session` leaves +`FERESE_ENABLE_SCREENCOPY` unset, which disables screenshots. Export +`FERESE_ENABLE_SCREENCOPY=1` to use them. ## Updating diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index b9f85d9..1e88f4e 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -1,6 +1,7 @@ use std::env; use std::error::Error; -use std::io; +use std::fs; +use std::io::{self, Write}; use std::os::unix::net::UnixStream; use std::path::PathBuf; @@ -13,7 +14,7 @@ fn main() -> Result<(), Box> { version: VERSION, id: 1, kind: "command".to_owned(), - command, + command: command.clone(), args, }; let mut stream = UnixStream::connect(socket_path()?)?; @@ -24,10 +25,36 @@ fn main() -> Result<(), Box> { return Err(format!("{}: {}", error.code, error.message).into()); } - println!( - "{}", - serde_json::to_string_pretty(&response.result.unwrap_or(Value::Null))? - ); + if command == "screenshot" { + write_png(&response) + } else { + println!( + "{}", + serde_json::to_string_pretty(&response.result.unwrap_or(Value::Null))? + ); + Ok(()) + } +} + +// The compositor stages the PNG in a private directory and replies with its +// path. Open it before unlinking so a failure never destroys the only copy, and +// stream the bytes with explicit writes: println! would corrupt binary output. +fn write_png(response: &Response) -> Result<(), Box> { + let path = response + .result + .as_ref() + .and_then(|result| result.get("path")) + .and_then(Value::as_str) + .ok_or("the compositor did not return a screenshot path")?; + let mut file = fs::File::open(path)?; + if let Err(error) = fs::remove_file(path) { + // The compositor sweeps anything left behind, so a failure here is not + // worth losing the capture over. + eprintln!("feresectl: could not remove {path}: {error}"); + } + let mut stdout = io::stdout().lock(); + io::copy(&mut file, &mut stdout)?; + stdout.flush()?; Ok(()) } @@ -47,6 +74,18 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), .map_err(|_| format!("{} requires a positive workspace index", command))?; json!({ "index": index }) } + "screenshot" => match positional.as_slice() { + [] => json!({}), + [flag, geometry] if flag == "--geometry" || flag == "-g" => { + json!({ "geometry": geometry }) + } + _ => { + return Err(format!( + "{command} accepts an optional --geometry \"x,y WxH\" ({})", + usage() + )); + } + }, "toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout" | "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel" | "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "reload-config" @@ -79,7 +118,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] @@ -115,6 +154,38 @@ mod tests { ); } + #[test] + fn screenshot_takes_an_optional_geometry() { + assert_eq!( + parse_args(["screenshot".into()]).unwrap(), + ("screenshot".into(), json!({})), + "no geometry captures every enabled output" + ); + assert_eq!( + parse_args(["screenshot".into(), "-g".into(), "10,-20 300x200".into()]) + .unwrap() + .1, + json!({ "geometry": "10,-20 300x200" }), + "negative origins survive argument parsing" + ); + assert_eq!( + parse_args(["screenshot".into(), "--geometry".into(), "0,0 8x8".into()]) + .unwrap() + .1, + json!({ "geometry": "0,0 8x8" }) + ); + } + + #[test] + fn screenshot_rejects_stray_arguments() { + assert!(parse_args(["screenshot".into(), "extra".into()]).is_err()); + assert!(parse_args(["screenshot".into(), "-g".into()]).is_err()); + assert!( + parse_args(["screenshot".into(), "0,0 8x8".into()]).is_err(), + "a bare geometry is not accepted without the flag" + ); + } + #[test] fn rejects_unknown_or_missing_arguments() { assert_eq!( diff --git a/packaging/ferese-screenshot b/packaging/ferese-screenshot index b97953f..288a351 100755 --- a/packaging/ferese-screenshot +++ b/packaging/ferese-screenshot @@ -25,7 +25,7 @@ case ${1:-} in esac (($# == 0)) || fail 'Usage: ferese-screenshot [--full | --all]' -for tool in grim satty wl-copy flock; do +for tool in feresectl satty wl-copy flock python3; do command -v "$tool" >/dev/null || fail "Required command not found: $tool" done : "${XDG_RUNTIME_DIR:?A Wayland login session is required}" @@ -40,21 +40,18 @@ if [[ $mode == area ]]; then [[ -n $geometry ]] || exit 0 capture_args=(-g "$geometry") elif [[ $mode == full ]]; then - for tool in feresectl python3; do - command -v "$tool" >/dev/null || fail "Required command not found: $tool" - done # Resolve the active monitor before Satty can change keyboard focus. - # Never fall back to grim's all-monitor capture when IPC is unavailable. - output=$(feresectl get-outputs | python3 -c ' + # Never fall back to an all-monitor capture when IPC is unavailable. + geometry=$(feresectl get-outputs | python3 -c ' import json, sys outputs = [output for output in json.load(sys.stdin) if output.get("enabled")] focused = [output for output in outputs if output.get("focused")] selected = focused if len(focused) == 1 else outputs if len(outputs) == 1 else [] -if len(selected) != 1 or not selected[0].get("name"): +if len(selected) != 1 or any(selected[0].get(k) is None for k in ("x", "y", "width", "height")): sys.exit(1) -print(selected[0]["name"]) +print("%d,%d %dx%d" % (selected[0]["x"], selected[0]["y"], selected[0]["width"], selected[0]["height"])) ') || fail 'Could not determine the active monitor. Use area selection or --all.' - capture_args=(-o "$output") + capture_args=(-g "$geometry") fi pictures=$(xdg-user-dir PICTURES 2>/dev/null) || pictures="$HOME/Pictures" directory=${pictures:-$HOME/Pictures}/Screenshots @@ -63,12 +60,27 @@ file=$(mktemp "$directory/Screenshot-$(date +%Y-%m-%d_%H-%M-%S)-XXXXXX.png") || input='' trap '[[ -z $input ]] || rm -f -- "$input"; [[ -s $file ]] || rm -f -- "$file"' EXIT input=$(mktemp "$XDG_RUNTIME_DIR/ferese-screenshot-XXXXXX.png") || fail 'Could not create the temporary capture.' -grim "${capture_args[@]}" "$input" || fail 'Could not capture the screen. Log back into Ferese after enabling screen capture.' +feresectl screenshot "${capture_args[@]}" >"$input" || fail 'Could not capture the screen. Ferese must be running, unlocked, and reachable over its socket.' +editor_size=$(feresectl get-outputs | python3 -c ' +import json, sys + +outputs = [output for output in json.load(sys.stdin) if output.get("enabled")] +focused = [output for output in outputs if output.get("focused")] +selected = focused if len(focused) == 1 else outputs if len(outputs) == 1 else [] +if len(selected) != 1 or any(selected[0].get(key) is None for key in ("width", "height")): + sys.exit(1) + +width = max(1, min(1500, round(selected[0]["width"] * 0.8))) +height = max(1, min(920, round(selected[0]["height"] * 0.8), round(width * 0.68))) +print(f"{width}x{height}") +') || editor_size=1000x700 # Enter saves the annotated image and copies it; Escape discards the capture. # Keep the parent lock through editing. Close it only in Satty so clipboard # helpers cannot inherit the lock and keep later screenshots blocked. satty --filename "$input" --output-filename "$file" \ - --fullscreen current-screen \ + --app-id dev.ferese.Screenshot \ + --resize "$editor_size" \ + --no-window-decoration \ --copy-command 'wl-copy --type image/png' \ --save-after-copy \ --actions-on-enter save-to-file,save-to-clipboard,exit \ diff --git a/scripts/tests/test_floating_isolated.py b/scripts/tests/test_floating_isolated.py index bead2cc..21bf79d 100644 --- a/scripts/tests/test_floating_isolated.py +++ b/scripts/tests/test_floating_isolated.py @@ -1,7 +1,10 @@ """Opt-in rendered regression test; opens only a temporary nested compositor. FERESE_TEST_FLOATING=1 FERESE_TEST_BINARY=target/debug/ferese python3 scripts/tests/test_floating_isolated.py -Requires cc, pkg-config, wayland-scanner, wayland-protocols, grim, and Pillow. +Requires cc, pkg-config, wayland-scanner, wayland-protocols, and Pillow. +The compositor and feresectl must be built; set FERESE_TEST_BINARY to point at +the compositor and FERESE_TEST_CTL at feresectl when they are not siblings. """ +import json import os from pathlib import Path import subprocess @@ -42,6 +45,9 @@ class FloatingSizeTest(unittest.TestCase): env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=display, FERESE_ENABLE_SCREENCOPY="1") binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") + ctl = repo / os.environ.get("FERESE_TEST_CTL", str(binary.parent / "feresectl")) + if not ctl.is_file(): + self.fail(f"missing {ctl}; build it with cargo build -p feresectl") with (root / "compositor.log").open("w") as log: process = subprocess.Popen([str(binary), "--backend", "nested", "--", str(root / "client")], env=env, stdout=log, stderr=subprocess.STDOUT) @@ -56,8 +62,23 @@ class FloatingSizeTest(unittest.TestCase): self.assertTrue(sockets, "nested Wayland socket did not appear") childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0])) time.sleep(2) + listed = subprocess.run([str(ctl), "get-outputs"], env=childenv, + check=True, capture_output=True, timeout=10, text=True) + enabled = [o for o in json.loads(listed.stdout) if o.get("enabled")] + if not enabled: + self.fail("compositor reported no enabled output to capture") + first = enabled[0] + if any(first.get(k) is None for k in ("x", "y", "width", "height")): + self.fail(f"enabled output {first.get('name')!r} has no logical geometry") + geometry = "%d,%d %dx%d" % (first["x"], first["y"], first["width"], first["height"]) screenshot = root / "frame.png" - subprocess.run(["grim", "-s", "1", str(screenshot)], env=childenv, check=True, timeout=15) + with screenshot.open("wb") as png: + captured = subprocess.run([str(ctl), "screenshot", "-g", geometry], + env=childenv, stdout=png, stderr=subprocess.PIPE, timeout=15) + if captured.returncode != 0: + self.fail(f"capture of {geometry} failed: {captured.stderr.decode()}") + if screenshot.stat().st_size == 0: + self.fail(f"capture of {geometry} produced an empty file") with Image.open(screenshot) as frame: rgb = frame.convert("RGB") red, green, blue = rgb.split() diff --git a/scripts/tests/test_lock_isolated.py b/scripts/tests/test_lock_isolated.py index 09e6dfc..6381598 100644 --- a/scripts/tests/test_lock_isolated.py +++ b/scripts/tests/test_lock_isolated.py @@ -1,6 +1,6 @@ """Opt-in lock protocol smoke test. Opens a temporary nested compositor. Run: FERESE_TEST_LOCK=1 python3 scripts/tests/test_lock_isolated.py -Requires release binaries, bwrap and grim. Host PAM is never modified. +Requires release binaries and bwrap. Host PAM is never modified. """ import os import unittest @@ -23,6 +23,8 @@ class NativeLockTest(unittest.TestCase): env=dict(os.environ,XDG_RUNTIME_DIR=str(runtime),XDG_CONFIG_HOME=str(root/'config'),WAYLAND_DISPLAY=host_display,FERESE_ENABLE_SCREENCOPY='1') log=(root/'compositor.log').open('w') compositor=subprocess.Popen(['target/release/ferese','--backend','nested'],env=env,stdout=log,stderr=subprocess.STDOUT) + ctl=pathlib.Path('target/release/feresectl') + assert ctl.is_file(), f'missing {ctl}; build it with cargo build --release -p feresectl' locker=None locker_log=(root/'locker.log').open('w') try: @@ -32,19 +34,25 @@ class NativeLockTest(unittest.TestCase): if sockets:break time.sleep(.1) childenv=dict(env,WAYLAND_DISPLAY=str(sockets[0]),FERESE_LOCK_READY='1') + def capture(destination, timeout=10): + with open(destination,'wb') as png: + return subprocess.run([str(ctl),'screenshot'],env=childenv,stdout=png,stderr=subprocess.PIPE,timeout=timeout) time.sleep(.6) - subprocess.run(['grim',str(root/'unlocked.png')],env=childenv,check=True,timeout=10,capture_output=True) + result=capture(root/'unlocked.png') + assert result.returncode == 0, f'Capture failed while unlocked: {result.stderr.decode()}' + assert (root/'unlocked.png').stat().st_size > 0, 'Capture produced an empty file' + print('Native capture succeeded while unlocked',flush=True) locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--','target/release/ferese-lock'],env=childenv,stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True) assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text() print('Native locker received compositor confirmation',flush=True) - result=subprocess.run(['grim',str(root/'locked.png')],env=childenv,timeout=10,capture_output=True) + result=capture(root/'locked.png') assert result.returncode != 0, 'Capture unexpectedly succeeded during lock' - print('Screencopy denied while native locker is running',flush=True) + print('Capture denied while native locker is running',flush=True) os.killpg(locker.pid, signal.SIGKILL) time.sleep(.3) - result=subprocess.run(['grim',str(root/'after-crash.png')],env=childenv,timeout=10,capture_output=True) + result=capture(root/'after-crash.png') assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash' - print('Screencopy remains denied after locker crash',flush=True) + print('Capture remains denied after locker crash',flush=True) finally: if locker is not None: try: os.killpg(locker.pid, signal.SIGKILL) diff --git a/scripts/tests/test_screenshot.py b/scripts/tests/test_screenshot.py index e50b43a..8dd6254 100644 --- a/scripts/tests/test_screenshot.py +++ b/scripts/tests/test_screenshot.py @@ -10,87 +10,156 @@ import unittest SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-screenshot' +OUTPUTS = [ + {'name': 'eDP-1', 'enabled': True, 'focused': False, + 'x': 0, 'y': 0, 'width': 1920, 'height': 1080}, + {'name': 'HDMI-A-1', 'enabled': True, 'focused': True, + 'x': 1920, 'y': 0, 'width': 2560, 'height': 1440}, +] +FOCUSED_GEOMETRY = '1920,0 2560x1440' -class ScreenshotTest(unittest.TestCase): - def test_active_output_all_outputs_and_editor_lock(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - bins = root / 'bin' - bins.mkdir() - env = dict(os.environ, XDG_RUNTIME_DIR=directory, TEST_ROOT=directory, - PATH=f'{bins}:{os.environ["PATH"]}') - (root / 'outputs.json').write_text(json.dumps([ - {'name': 'eDP-1', 'enabled': True, 'focused': False}, - {'name': 'HDMI-A-1', 'enabled': True, 'focused': True}, - ])) - scripts = { - 'grim': '''#!/bin/bash -printf '%s\\n' "$@" >> "$TEST_ROOT/captures" -printf image > "${@: -1}" -''', - 'feresectl': '#!/bin/bash\ncat "$TEST_ROOT/outputs.json"\n', - 'notify-send': '#!/bin/bash\nexit 0\n', - 'wl-copy': '#!/bin/bash\nexit 0\n', - 'xdg-user-dir': '#!/bin/bash\nprintf "%s" "$TEST_ROOT/Pictures"\n', - 'satty': '''#!/bin/bash +FARESECTL = '''#!/bin/bash +case "$1" in + get-outputs) cat "$TEST_ROOT/outputs.json" ;; + screenshot) + printf '%s\\n' "$@" >> "$TEST_ROOT/captures" + printf image + ;; + *) printf 'unexpected feresectl command: %s\\n' "$1" >&2; exit 1 ;; +esac +''' + +SATTY = '''#!/bin/bash if [[ -e /proc/$$/fd/9 ]]; then touch "$TEST_ROOT/inherited-lock"; fi +printf '%s\\n' "$@" > "$TEST_ROOT/satty-args" printf 'editor\\n' >> "$TEST_ROOT/editors" touch "$TEST_ROOT/ready" while [[ ! -e "$TEST_ROOT/release" ]]; do sleep 0.02; done -''', - } - for name, contents in scripts.items(): - path = bins / name - path.write_text(contents) - path.chmod(0o755) - def run(mode): - return subprocess.run(['bash', str(SCRIPT), mode], env=env, - capture_output=True, timeout=3) - first = subprocess.Popen(['bash', str(SCRIPT), '--full'], env=env, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - try: - deadline = time.monotonic() + 5 - while not (root / 'ready').exists(): - if time.monotonic() > deadline or first.poll() is not None: - self.fail('First screenshot did not open its editor') - time.sleep(0.02) - second = run('--full') - self.assertEqual(second.returncode, 0, second.stderr) - self.assertEqual((root / 'editors').read_text().splitlines(), ['editor']) - self.assertFalse((root / 'inherited-lock').exists()) - (root / 'release').touch() - _, stderr = first.communicate(timeout=3) - self.assertEqual(first.returncode, 0, stderr) - third = run('--full') - self.assertEqual(third.returncode, 0, third.stderr) - self.assertEqual((root / 'editors').read_text().splitlines(), ['editor', 'editor']) - self.assertFalse(list((root / 'Pictures/Screenshots').glob('*.png'))) - self.assertFalse(list(root.glob('ferese-screenshot-*.png'))) - captures = (root / 'captures').read_text().splitlines() - self.assertEqual(captures[0:2], ['-o', 'HDMI-A-1']) - self.assertEqual(captures[3:5], ['-o', 'HDMI-A-1']) - all_monitors = run('--all') - self.assertEqual(all_monitors.returncode, 0, all_monitors.stderr) - captures = (root / 'captures').read_text().splitlines() - self.assertEqual(len(captures), 7) # --all passes only the filename - for outputs in [[], [{'enabled': True, 'name': 'A'}, {'enabled': True, 'name': 'B'}]]: - (root / 'outputs.json').write_text(json.dumps(outputs)) - unavailable = run('--full') - self.assertNotEqual(unavailable.returncode, 0) - self.assertIn(b'Could not determine the active monitor', unavailable.stderr) - self.assertEqual((root / 'captures').read_text().splitlines(), captures) - (root / 'outputs.json').write_text(json.dumps([ - {'name': 'eDP-1', 'enabled': False, 'focused': True}, - {'name': 'HDMI-A-1', 'enabled': True, 'focused': False}, - ])) - single = run('--full') - self.assertEqual(single.returncode, 0, single.stderr) - self.assertEqual((root / 'captures').read_text().splitlines()[-3:-1], ['-o', 'HDMI-A-1']) - finally: - (root / 'release').touch() - if first.poll() is None: - first.terminate() - first.communicate(timeout=3) +''' + + +def fakes(): + return { + 'feresectl': FARESECTL, + 'notify-send': '#!/bin/bash\nexit 0\n', + 'wl-copy': '#!/bin/bash\nexit 0\n', + 'xdg-user-dir': '#!/bin/bash\nprintf "%s" "$TEST_ROOT/Pictures"\n', + 'satty': SATTY, + } + + +class ScreenshotTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.bins = self.root / 'bin' + self.bins.mkdir() + self.env = dict(os.environ, XDG_RUNTIME_DIR=self.directory.name, + TEST_ROOT=str(self.root), + PATH=f'{self.bins}:{os.environ["PATH"]}') + self.write_outputs(OUTPUTS) + for name, contents in fakes().items(): + path = self.bins / name + path.write_text(contents) + path.chmod(0o755) + + def write_outputs(self, outputs): + (self.root / 'outputs.json').write_text(json.dumps(outputs)) + + def captures(self): + log = self.root / 'captures' + if not log.exists(): + return [] + return log.read_text().splitlines() + + def run_script(self, *args, timeout=3): + return subprocess.run(['bash', str(SCRIPT), *args], env=self.env, + capture_output=True, timeout=timeout) + + def test_active_output_all_outputs_and_editor_lock(self): + first = subprocess.Popen(['bash', str(SCRIPT), '--full'], env=self.env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 5 + while not (self.root / 'ready').exists(): + if time.monotonic() > deadline or first.poll() is not None: + self.fail('First screenshot did not open its editor') + time.sleep(0.02) + second = self.run_script('--full') + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual((self.root / 'editors').read_text().splitlines(), ['editor']) + editor_args = (self.root / 'satty-args').read_text().splitlines() + self.assertEqual(editor_args[editor_args.index('--app-id') + 1], + 'dev.ferese.Screenshot') + self.assertEqual(editor_args[editor_args.index('--resize') + 1], + '1500x920') + self.assertIn('--no-window-decoration', editor_args) + self.assertNotIn('--fullscreen', editor_args) + self.assertFalse((self.root / 'inherited-lock').exists()) + (self.root / 'release').touch() + _, stderr = first.communicate(timeout=3) + self.assertEqual(first.returncode, 0, stderr) + third = self.run_script('--full') + self.assertEqual(third.returncode, 0, third.stderr) + self.assertEqual((self.root / 'editors').read_text().splitlines(), ['editor', 'editor']) + self.assertFalse(list((self.root / 'Pictures/Screenshots').glob('*.png'))) + self.assertFalse(list(self.root.glob('ferese-screenshot-*.png'))) + # Each --full capture asked for the focused output by geometry, not + # by name, because native capture has no output-name selection. + self.assertEqual(self.captures()[0:3], ['screenshot', '-g', FOCUSED_GEOMETRY]) + self.assertEqual(self.captures()[3:6], ['screenshot', '-g', FOCUSED_GEOMETRY]) + all_monitors = self.run_script('--all') + self.assertEqual(all_monitors.returncode, 0, all_monitors.stderr) + self.assertEqual(len(self.captures()), 7) # --all passes only "screenshot" + self.assertEqual(self.captures()[6], 'screenshot') + captured = self.captures() + for outputs in [[], [dict(OUTPUTS[0], focused=False), dict(OUTPUTS[1], focused=False)]]: + self.write_outputs(outputs) + unavailable = self.run_script('--full') + self.assertNotEqual(unavailable.returncode, 0) + self.assertIn(b'Could not determine the active monitor', unavailable.stderr) + self.assertEqual(self.captures(), captured) + # An enabled output with no geometry is ambiguous, not a reason to + # capture everything. + self.write_outputs([dict(OUTPUTS[1], focused=True, x=None)]) + unknown = self.run_script('--full') + self.assertNotEqual(unknown.returncode, 0) + self.assertIn(b'Could not determine the active monitor', unknown.stderr) + self.assertEqual(self.captures(), captured) + # Disabled outputs are ignored, so the sole remaining one is used. + self.write_outputs([dict(OUTPUTS[0], enabled=False, focused=True), + dict(OUTPUTS[1], focused=False)]) + single = self.run_script('--full') + self.assertEqual(single.returncode, 0, single.stderr) + self.assertEqual(self.captures()[-2:], ['-g', FOCUSED_GEOMETRY]) + finally: + (self.root / 'release').touch() + if first.poll() is None: + first.terminate() + first.communicate(timeout=3) + + def test_area_selection_is_passed_through(self): + self.bins.joinpath('slurp').write_text( + '#!/bin/bash\nprintf "10,-20 300x200"\n') + self.bins.joinpath('slurp').chmod(0o755) + (self.root / 'release').touch() + area = self.run_script() + self.assertEqual(area.returncode, 0, area.stderr) + self.assertEqual(self.captures(), ['screenshot', '-g', '10,-20 300x200']) + + def test_every_mode_captures_through_the_native_command(self): + (self.root / 'release').touch() + cases = [(['--all'], ['screenshot']), + (['--full'], ['screenshot', '-g', FOCUSED_GEOMETRY])] + for args, expected in cases: + with self.subTest(args=args): + before = len(self.captures()) + result = self.run_script(*args) + self.assertEqual(result.returncode, 0, result.stderr) + # The fake only records these when the compositor command runs, + # so a capture taken any other way leaves the log short. + self.assertEqual(self.captures()[before:], expected) if __name__ == '__main__':