From 0ab5f2e05bcc54f64c2b3c64a743ec5caa20a85d Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 29 Sep 2026 23:52:27 +0100 Subject: [PATCH] Add isolated window screenshot targets with native selection --- compositor/src/backends/direct.rs | 24 ++ compositor/src/handlers/screenshot.rs | 12 +- compositor/src/handlers/screenshot_worker.rs | 1 + compositor/src/ipc.rs | 123 +++++++- compositor/src/winit.rs | 53 +++- docs/portals.md | 28 +- feresectl/src/main.rs | 11 +- portal/src/backend.rs | 1 + portal/src/desktop.rs | 272 +++++++++++++----- portal/src/picker.rs | 69 ++++- scripts/tests/fixtures/window-capture.c | 91 ++++++ scripts/tests/test_window_capture_isolated.py | 91 ++++++ 12 files changed, 681 insertions(+), 95 deletions(-) create mode 100644 scripts/tests/fixtures/window-capture.c create mode 100644 scripts/tests/test_window_capture_isolated.py diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 67af6ae..1824b56 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -85,6 +85,30 @@ impl DirectBackendState { remaining, ) } + + pub(crate) fn capture_window_buffer( + &mut self, + window: &smithay::desktop::Window, + geometry: smithay::utils::Rectangle, + output: &Output, + ) -> Result { + let device = self + .devices + .values_mut() + .find(|device| { + device + .outputs + .values() + .any(|candidate| &candidate.output == output) + }) + .ok_or("Window output is unavailable")?; + crate::winit::capture_window_buffer( + &mut device.renderer, + window, + geometry, + output.current_scale().fractional_scale(), + ) + } } #[derive(Clone, Debug)] diff --git a/compositor/src/handlers/screenshot.rs b/compositor/src/handlers/screenshot.rs index 301982d..9874677 100644 --- a/compositor/src/handlers/screenshot.rs +++ b/compositor/src/handlers/screenshot.rs @@ -10,6 +10,7 @@ use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Size, Transfor const BYTES_PER_PIXEL: usize = 4; pub(crate) struct OutputFrame { + pub(crate) preserve_alpha: bool, pub(crate) width: i32, pub(crate) height: i32, pub(crate) stride: usize, @@ -114,7 +115,7 @@ fn scale_and_convert( out[to] = pixel[2]; out[to + 1] = pixel[1]; out[to + 2] = pixel[0]; - out[to + 3] = 255; + out[to + 3] = if frame.preserve_alpha { pixel[3] } else { 255 }; } } return Some(out); @@ -147,7 +148,7 @@ fn scale_and_convert( out[to] = pixel[2]; out[to + 1] = pixel[1]; out[to + 2] = pixel[0]; - out[to + 3] = 255; + out[to + 3] = if frame.preserve_alpha { pixel[3] } else { 255 }; } } Some(out) @@ -362,6 +363,7 @@ pub(crate) type PartSender = smithay::reexports::calloop::channel::Sender PartSpec { PartSpec { + preserve_alpha: false, transform: output.transform, scale: output.scale, location: (self.logical.loc.x, self.logical.loc.y), @@ -984,6 +988,7 @@ mod completion { fn spec(width: i32, height: i32) -> PartSpec { PartSpec { + preserve_alpha: false, transform: Transform::Normal, scale: 1.0, location: (0, 0), @@ -1276,6 +1281,7 @@ mod completion { assert!(overflow.is_err(), "outstanding requests are bounded"); let huge = PartSpec { + preserve_alpha: false, buffer_width: 1 << 20, buffer_height: 1 << 20, ..spec(2, 2) @@ -1351,6 +1357,7 @@ mod tests { pixels.extend_from_slice(&[b'a' + index as u8, 0, 0, 255]); } OutputFrame { + preserve_alpha: false, width, height, stride: width as usize * BYTES_PER_PIXEL, @@ -1377,6 +1384,7 @@ mod tests { pixels.extend_from_slice(&[(index % 251) as u8, 0, 0, 255]); } OutputFrame { + preserve_alpha: false, width, height, stride: width as usize * BYTES_PER_PIXEL, diff --git a/compositor/src/handlers/screenshot_worker.rs b/compositor/src/handlers/screenshot_worker.rs index 9c8e737..dcdecc8 100644 --- a/compositor/src/handlers/screenshot_worker.rs +++ b/compositor/src/handlers/screenshot_worker.rs @@ -200,6 +200,7 @@ mod tests { pixel[3] = 255; } OutputFrame { + preserve_alpha: false, width, height, stride, diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 1a4323a..0852dd5 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -125,7 +125,9 @@ pub(crate) fn init( return; } }; - if call.request.command == "screenshot" { + if call.request.command == "screenshot-window" { + state.start_window_screenshot(call.request, call.response); + } else 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); @@ -309,6 +311,8 @@ fn serve_connection( }; let (response, receiver) = sync_channel(1); let exit_requested = request.command == "exit"; + let screenshot_requested = + matches!(request.command.as_str(), "screenshot" | "screenshot-window"); let call = IpcEvent::Call(IpcCall { owner, request, @@ -323,6 +327,9 @@ fn serve_connection( }; let exit_accepted = exit_requested && response.error.is_none(); if let Err(error) = write_frame(&mut stream, &response) { + if screenshot_requested { + discard_undelivered_screenshot(&response); + } tracing::debug!(%error, "IPC client disconnected before receiving its response"); return; } @@ -335,6 +342,19 @@ fn serve_connection( } } +fn discard_undelivered_screenshot(response: &Response) { + if response.error.is_none() + && let Some(path) = response + .result + .as_ref() + .and_then(|result| result["path"].as_str()) + && let Err(error) = fs::remove_file(path) + && error.kind() != io::ErrorKind::NotFound + { + tracing::debug!(%error, "failed to remove an undelivered screenshot"); + } +} + impl Ferese { fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response { if self.session_lock.active && request.command != "portal-shortcuts-poll" { @@ -520,6 +540,93 @@ impl Ferese { crate::backends::direct::render_all(self); } + fn start_window_screenshot(&mut self, request: Request, response: SyncSender) { + let reject = |message: String| { + let _ = response.try_send(Response::error( + request.id, + "window_capture_failed", + message, + )); + }; + if self.session_lock.active || !screencopy::capture_allowed() { + reject("Screen capture is unavailable".into()); + return; + } + if let Err(error) = validate_request(&request) { + reject(error.message); + return; + } + let Some(id) = request + .args + .get("window") + .and_then(Value::as_u64) + .map(ferese_layout::WindowId) + else { + reject("Expected a window ID".into()); + return; + }; + let Some(window) = self + .window_ids + .iter() + .find(|(_, candidate)| **candidate == id) + .map(|(window, _)| window.clone()) + else { + reject("Window no longer exists".into()); + return; + }; + let Some(output) = self + .space + .outputs() + .find(|output| self.window_belongs_to_output(id, output)) + .cloned() + else { + reject("Window output is unavailable".into()); + return; + }; + let geometry = window.geometry(); + let scale = output.current_scale().fractional_scale(); + let size = geometry.size.to_physical_precise_round(scale); + let spec = crate::handlers::screenshot::PartSpec { + preserve_alpha: true, + transform: Transform::Normal, + scale, + location: (0, 0), + logical_width: geometry.size.w, + logical_height: geometry.size.h, + buffer_width: size.w, + buffer_height: size.h, + }; + let fallback = response.clone(); + let capture = match self.screenshot.admit(request.id, response, vec![spec]) { + Ok(capture) => capture, + Err(error) => { + let _ = + fallback.try_send(Response::error(request.id, "screenshot_rejected", error)); + return; + } + }; + let result = if let Some(backend) = &self.nested_backend { + match backend.try_borrow_mut() { + Ok(mut backend) => crate::winit::capture_window_buffer( + backend.renderer(), + &window, + geometry, + scale, + ), + Err(_) => Err("Window renderer is busy".into()), + } + } else if let Some(backend) = &mut self.direct_backend { + backend.capture_window_buffer(&window, geometry, &output) + } else { + Err("Window renderer is unavailable".into()) + }; + self.on_screenshot_part(PartOutcome { + request: capture, + part: 0, + result, + }); + } + pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) { let action = self .screenshot @@ -1030,6 +1137,20 @@ mod tests { worker.join().unwrap(); } + #[test] + fn failed_screenshot_delivery_removes_only_its_staging_file() { + let root = unique_test_directory("undelivered-screenshot"); + fs::create_dir(&root).unwrap(); + let path = root.join("capture.png"); + fs::write(&path, b"capture").unwrap(); + let response = Response::success(1, serde_json::json!({"path": path})); + + discard_undelivered_screenshot(&response); + assert!(!path.exists()); + discard_undelivered_screenshot(&response); + fs::remove_dir(root).unwrap(); + } + #[test] fn runtime_socket_directory_is_private() { let root = unique_test_directory("private-parent"); diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index f9f4079..666a26e 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -14,7 +14,8 @@ use smithay::{ backend::{ allocator::Fourcc, renderer::{ - Bind, Color32F, ErasedContextId, Frame, ImportDma, Offscreen, Renderer, Texture, + Bind, Color32F, ErasedContextId, ExportMem, Frame, ImportDma, Offscreen, Renderer, + Texture, damage::OutputDamageTracker, element::{ Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage, @@ -169,6 +170,56 @@ pub(crate) fn capture_resize_snapshot( })) } +pub(crate) fn capture_window_buffer( + renderer: &mut GlesRenderer, + window: &smithay::desktop::Window, + geometry: Rectangle, + scale: f64, +) -> Result { + let snapshot = capture_resize_snapshot(renderer, window, geometry, scale, 128 * 1024 * 1024) + .map_err(|error| error.to_string())? + .ok_or("Window has no capturable content")?; + let size = snapshot.texture.size(); + let mapping = renderer + .copy_texture( + &snapshot.texture, + Rectangle::from_size(size), + Fourcc::Abgr8888, + ) + .map_err(|error| error.to_string())?; + if mapping.format() != Some(Fourcc::Abgr8888) { + return Err("Unsupported window readback format".into()); + } + let bytes = (size.w as usize) + .checked_mul(size.h as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or("Window is too large")?; + let source = renderer + .map_texture(&mapping) + .map_err(|error| error.to_string())?; + if source.len() < bytes { + return Err("Incomplete window readback".into()); + } + let mut pixels = source[..bytes].to_vec(); + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + let alpha = pixel[3] as u16; + for channel in &mut pixel[..3] { + *channel = if alpha == 0 { + 0 + } else { + ((*channel as u16 * 255 + alpha / 2) / alpha).min(255) as u8 + }; + } + } + Ok(crate::handlers::screenshot::CaptureBuffer { + width: size.w, + height: size.h, + stride: size.w as usize * 4, + pixels, + }) +} + render_elements! { WindowContentRenderElement<=GlesRenderer>; Rounded=RoundedSurfaceRenderElement, diff --git a/docs/portals.md b/docs/portals.md index 752dc9e..60a26e3 100644 --- a/docs/portals.md +++ b/docs/portals.md @@ -9,9 +9,13 @@ standard file, print and application dialogs. Install both backends. - **Settings:** Ferese's color scheme, accent color, contrast preference and reduced-motion setting. Changes are signalled after valid KDL appearance changes; malformed saves leave the last valid preferences intact. -- **Screenshot:** whole-screen and selected-area PNG captures, plus a screen - color picker. Every capture asks for consent. Interactive requests offer area - selection. Selection uses `slurp`; captures use Ferese's bounded screenshot IPC. +- **Screenshot:** screen, selected-area, selected-window and active-window PNG + captures, plus a screen color picker. Window captures render the selected + toplevel and its subsurfaces in isolation, preserving transparency; occluding + windows, separate popups, shell layers and compositor borders are excluded. + The native window picker lists window titles and application IDs. Every capture + asks for consent. Interactive requests offer area selection; area and color + selection use `slurp`. Captures use Ferese's bounded screenshot IPC. The helper must disappear from the compositor before capture starts. - **Wallpaper:** a preview and confirmation, then an atomic configuration update. Desktop and lock-screen wallpaper can be set independently, or together. @@ -59,7 +63,7 @@ Matching D-Bus signatures does not establish complete feature parity. | Interface | Ferese integration | | --- | --- | | Settings | Native, including appearance change signals | -| Screenshot | Native screen/area capture and PickColor; true window capture remains missing | +| Screenshot | Native screen/area/window/active-window capture and PickColor | | Wallpaper | Native desktop, lock-screen and combined targets | | ScreenCast | Native monitors with logical geometry metadata; window sources and persistence remain missing | | Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK | @@ -76,9 +80,9 @@ The frontend supplies additional APIs such as OpenURI, network monitoring, document export and permission storage. Adding a similarly named backend service is neither required nor sufficient for those APIs. -Do not advertise window capture or input services until the compositor provides -the corresponding operation. Screen crops include occluding windows and cannot -serve as isolated window captures. GNOME's remote-input features require native +Do not advertise window sources for ScreenCast or input services until the +compositor provides the corresponding operation. Screen crops include occluding +windows and cannot serve as isolated window streams. GNOME's remote-input features require native compositor/session work, rather than substituting successful empty replies. ## Validation @@ -98,6 +102,16 @@ and configuration conflicts in an isolated nested compositor: FERESE_TEST_SHORTCUTS=1 python3 scripts/tests/test_shortcuts_isolated.py ``` +Check window capture isolation, transparency and orientation with overlapping +clients in a temporary nested compositor: + +```sh +FERESE_TEST_WINDOW_CAPTURE=1 python3 scripts/tests/test_window_capture_isolated.py +``` + +Capture a managed window directly from the CLI with `feresectl screenshot-window + > capture.png`; `feresectl get-windows` lists IDs. + The contract test compares introspection against the installed official backend XML, verifies appearance changes and invalid-save retention, and checks that sensitive methods reject callers outside the portal frontend. It does not open diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 4d7c736..47c3695 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -33,7 +33,7 @@ fn main() -> Result<(), Box> { return Err(format!("{}: {}", error.code, error.message).into()); } - if command == "screenshot" { + if matches!(command.as_str(), "screenshot" | "screenshot-window") { write_png(&response) } else { println!( @@ -71,6 +71,13 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), let command = args.next().ok_or_else(usage)?; let positional = args.collect::>(); let payload = match command.as_str() { + "screenshot-window" => { + exactly_one(&command, &positional, "window ID")?; + let window = positional[0] + .parse::() + .map_err(|_| "Expected an unsigned window ID")?; + json!({"window":window}) + } "focus" | "move" | "resize" => { exactly_one(&command, &positional, "direction")?; json!({ "direction": positional[0] }) @@ -126,7 +133,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 012943a..d745f1b 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -503,6 +503,7 @@ async fn start_streams( sources, multiple, parent: parent.into(), + window_capture: false, }; let picker = picker(&prompt).await?; let picker_pid = picker.id().ok_or("Missing picker process ID")?; diff --git a/portal/src/desktop.rs b/portal/src/desktop.rs index 37e7e1d..21746d2 100644 --- a/portal/src/desktop.rs +++ b/portal/src/desktop.rs @@ -91,7 +91,7 @@ impl Screenshot { #[zbus(property)] fn available_targets(&self) -> u32 { - 1 | 4 + 1 | 2 | 4 | 8 } async fn screenshot( @@ -225,65 +225,52 @@ pub(crate) async fn ipc( command: &'static str, args: serde_json::Value, ) -> Result { - tokio::task::spawn_blocking(move || { - let path = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .ok_or("Missing runtime directory")? - .join("ferese/control.sock"); - let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?; - stream - .set_read_timeout(Some(Duration::from_secs(15))) - .map_err(|error| error.to_string())?; - stream - .set_write_timeout(Some(Duration::from_secs(2))) - .map_err(|error| error.to_string())?; - let request = ferese_ipc::Request { - version: ferese_ipc::VERSION, - id: 1, - kind: "command".into(), - command: command.into(), - args, - }; - ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?; - let response: ferese_ipc::Response = - ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?; - if let Some(error) = response.error { - return Err(error.message); - } - response.result.ok_or("Missing IPC result".into()) - }) - .await - .map_err(|error| error.to_string())? + tokio::task::spawn_blocking(move || ipc_sync(command, args)) + .await + .map_err(|error| error.to_string())? } -async fn select_geometry(point: bool, windows: Option) -> Result, String> { +fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result { + let path = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .ok_or("Missing runtime directory")? + .join("ferese/control.sock"); + let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(15))) + .map_err(|error| error.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + let request = ferese_ipc::Request { + version: ferese_ipc::VERSION, + id: 1, + kind: "command".into(), + command: command.into(), + args, + }; + ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?; + let response: ferese_ipc::Response = + ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?; + if let Some(error) = response.error { + return Err(error.message); + } + response.result.ok_or("Missing IPC result".into()) +} + +async fn select_geometry(point: bool) -> Result, String> { let mut command = crate::backend::child_command_for("slurp")?; if point { command.arg("-p"); } - if windows.is_some() { - command.arg("-r"); - } - command.stdin(if windows.is_some() { - Stdio::piped() - } else { - Stdio::null() - }); - let mut child = command + command.stdin(Stdio::null()); + let child = command .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .kill_on_drop(true) .spawn() .map_err(|error| error.to_string())?; let pid = child.id().ok_or("Missing selection process ID")?; - if let Some(windows) = windows { - let mut input = child.stdin.take().unwrap(); - input - .write_all(windows.as_bytes()) - .await - .map_err(|error| error.to_string())?; - drop(input); - } let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output()) .await .map_err(|_| "Selection timed out")? @@ -313,27 +300,41 @@ pub(crate) async fn wait_for_surface_removal(pid: u32) -> Result<(), String> { } async fn capture(geometry: Option) -> Result, String> { - let result = ipc("screenshot", serde_json::json!({"geometry": geometry})).await?; - let path = result["path"] - .as_str() - .ok_or("Missing screenshot path")? - .to_owned(); + capture_command("screenshot", serde_json::json!({"geometry": geometry})).await +} + +async fn capture_command( + command: &'static str, + args: serde_json::Value, +) -> Result, String> { tokio::task::spawn_blocking(move || { - let file = std::fs::File::open(&path).map_err(|error| error.to_string())?; - let _ = std::fs::remove_file(path); - let mut bytes = Vec::new(); - file.take(MAX_IMAGE_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|error| error.to_string())?; - if bytes.len() as u64 > MAX_IMAGE_BYTES { - return Err("Screenshot is too large".into()); - } - Ok(bytes) + let result = ipc_sync(command, args)?; + let path = result["path"].as_str().ok_or("Missing screenshot path")?; + read_screenshot(path) }) .await .map_err(|error| error.to_string())? } +fn read_screenshot(path: &str) -> Result, String> { + struct Cleanup<'a>(&'a str); + impl Drop for Cleanup<'_> { + fn drop(&mut self) { + let _ = std::fs::remove_file(self.0); + } + } + let _cleanup = Cleanup(path); + let file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let mut bytes = Vec::new(); + file.take(MAX_IMAGE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_IMAGE_BYTES { + return Err("Screenshot is too large".into()); + } + Ok(bytes) +} + async fn screenshot(app: &str, parent: &str, options: &Options) -> Result, String> { let target = options .get("target") @@ -353,9 +354,18 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result