Add isolated window screenshot targets with native selection

This commit is contained in:
Samuel Onoja committed 2026-09-29 23:54:32 +01:00
1 parent c8ed8e6c30
commit 0ab5f2e05b
12 files changed
+681 -95

No files matched your search

+24
View File
@@ -85,6 +85,30 @@ impl DirectBackendState {
remaining, remaining,
) )
} }
pub(crate) fn capture_window_buffer(
&mut self,
window: &smithay::desktop::Window,
geometry: smithay::utils::Rectangle<i32, smithay::utils::Logical>,
output: &Output,
) -> Result<crate::handlers::screenshot::CaptureBuffer, String> {
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)] #[derive(Clone, Debug)]
+10 -2
View File
@@ -10,6 +10,7 @@ use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Size, Transfor
const BYTES_PER_PIXEL: usize = 4; const BYTES_PER_PIXEL: usize = 4;
pub(crate) struct OutputFrame { pub(crate) struct OutputFrame {
pub(crate) preserve_alpha: bool,
pub(crate) width: i32, pub(crate) width: i32,
pub(crate) height: i32, pub(crate) height: i32,
pub(crate) stride: usize, pub(crate) stride: usize,
@@ -114,7 +115,7 @@ fn scale_and_convert(
out[to] = pixel[2]; out[to] = pixel[2];
out[to + 1] = pixel[1]; out[to + 1] = pixel[1];
out[to + 2] = pixel[0]; out[to + 2] = pixel[0];
out[to + 3] = 255; out[to + 3] = if frame.preserve_alpha { pixel[3] } else { 255 };
} }
} }
return Some(out); return Some(out);
@@ -147,7 +148,7 @@ fn scale_and_convert(
out[to] = pixel[2]; out[to] = pixel[2];
out[to + 1] = pixel[1]; out[to + 1] = pixel[1];
out[to + 2] = pixel[0]; out[to + 2] = pixel[0];
out[to + 3] = 255; out[to + 3] = if frame.preserve_alpha { pixel[3] } else { 255 };
} }
} }
Some(out) Some(out)
@@ -362,6 +363,7 @@ pub(crate) type PartSender = smithay::reexports::calloop::channel::Sender<PartOu
// Geometry snapshotted when the request was admitted, so the encoding worker // Geometry snapshotted when the request was admitted, so the encoding worker
// never re-queries an output whose transform or scale has since changed. // never re-queries an output whose transform or scale has since changed.
pub(crate) struct PartSpec { pub(crate) struct PartSpec {
pub(crate) preserve_alpha: bool,
pub(crate) transform: Transform, pub(crate) transform: Transform,
pub(crate) scale: f64, pub(crate) scale: f64,
pub(crate) location: (i32, i32), pub(crate) location: (i32, i32),
@@ -510,6 +512,7 @@ impl Coordinator {
} else { } else {
pixels.extend_from_slice(&buffer.pixels); pixels.extend_from_slice(&buffer.pixels);
Some(OutputFrame { Some(OutputFrame {
preserve_alpha: spec.preserve_alpha,
width: buffer.width, width: buffer.width,
height: buffer.height, height: buffer.height,
stride: buffer.stride, stride: buffer.stride,
@@ -664,6 +667,7 @@ pub(crate) struct PlannedPart {
impl PlannedPart { impl PlannedPart {
pub(crate) fn spec(&self, output: &OutputLayout) -> PartSpec { pub(crate) fn spec(&self, output: &OutputLayout) -> PartSpec {
PartSpec { PartSpec {
preserve_alpha: false,
transform: output.transform, transform: output.transform,
scale: output.scale, scale: output.scale,
location: (self.logical.loc.x, self.logical.loc.y), location: (self.logical.loc.x, self.logical.loc.y),
@@ -984,6 +988,7 @@ mod completion {
fn spec(width: i32, height: i32) -> PartSpec { fn spec(width: i32, height: i32) -> PartSpec {
PartSpec { PartSpec {
preserve_alpha: false,
transform: Transform::Normal, transform: Transform::Normal,
scale: 1.0, scale: 1.0,
location: (0, 0), location: (0, 0),
@@ -1276,6 +1281,7 @@ mod completion {
assert!(overflow.is_err(), "outstanding requests are bounded"); assert!(overflow.is_err(), "outstanding requests are bounded");
let huge = PartSpec { let huge = PartSpec {
preserve_alpha: false,
buffer_width: 1 << 20, buffer_width: 1 << 20,
buffer_height: 1 << 20, buffer_height: 1 << 20,
..spec(2, 2) ..spec(2, 2)
@@ -1351,6 +1357,7 @@ mod tests {
pixels.extend_from_slice(&[b'a' + index as u8, 0, 0, 255]); pixels.extend_from_slice(&[b'a' + index as u8, 0, 0, 255]);
} }
OutputFrame { OutputFrame {
preserve_alpha: false,
width, width,
height, height,
stride: width as usize * BYTES_PER_PIXEL, stride: width as usize * BYTES_PER_PIXEL,
@@ -1377,6 +1384,7 @@ mod tests {
pixels.extend_from_slice(&[(index % 251) as u8, 0, 0, 255]); pixels.extend_from_slice(&[(index % 251) as u8, 0, 0, 255]);
} }
OutputFrame { OutputFrame {
preserve_alpha: false,
width, width,
height, height,
stride: width as usize * BYTES_PER_PIXEL, stride: width as usize * BYTES_PER_PIXEL,
@@ -200,6 +200,7 @@ mod tests {
pixel[3] = 255; pixel[3] = 255;
} }
OutputFrame { OutputFrame {
preserve_alpha: false,
width, width,
height, height,
stride, stride,
+122 -1
View File
@@ -125,7 +125,9 @@ pub(crate) fn init(
return; 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 // Deferred: this path answers the caller itself, exactly
// once, whenever the request finishes or is terminated. // once, whenever the request finishes or is terminated.
state.start_screenshot(call.request, call.response); state.start_screenshot(call.request, call.response);
@@ -309,6 +311,8 @@ fn serve_connection(
}; };
let (response, receiver) = sync_channel(1); let (response, receiver) = sync_channel(1);
let exit_requested = request.command == "exit"; let exit_requested = request.command == "exit";
let screenshot_requested =
matches!(request.command.as_str(), "screenshot" | "screenshot-window");
let call = IpcEvent::Call(IpcCall { let call = IpcEvent::Call(IpcCall {
owner, owner,
request, request,
@@ -323,6 +327,9 @@ fn serve_connection(
}; };
let exit_accepted = exit_requested && response.error.is_none(); let exit_accepted = exit_requested && response.error.is_none();
if let Err(error) = write_frame(&mut stream, &response) { 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"); tracing::debug!(%error, "IPC client disconnected before receiving its response");
return; 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 { impl Ferese {
fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response { fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response {
if self.session_lock.active && request.command != "portal-shortcuts-poll" { if self.session_lock.active && request.command != "portal-shortcuts-poll" {
@@ -520,6 +540,93 @@ impl Ferese {
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
} }
fn start_window_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
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) { pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) {
let action = self let action = self
.screenshot .screenshot
@@ -1030,6 +1137,20 @@ mod tests {
worker.join().unwrap(); 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] #[test]
fn runtime_socket_directory_is_private() { fn runtime_socket_directory_is_private() {
let root = unique_test_directory("private-parent"); let root = unique_test_directory("private-parent");
+52 -1
View File
@@ -14,7 +14,8 @@ use smithay::{
backend::{ backend::{
allocator::Fourcc, allocator::Fourcc,
renderer::{ renderer::{
Bind, Color32F, ErasedContextId, Frame, ImportDma, Offscreen, Renderer, Texture, Bind, Color32F, ErasedContextId, ExportMem, Frame, ImportDma, Offscreen, Renderer,
Texture,
damage::OutputDamageTracker, damage::OutputDamageTracker,
element::{ element::{
Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage, 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<i32, Logical>,
scale: f64,
) -> Result<crate::handlers::screenshot::CaptureBuffer, String> {
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! { render_elements! {
WindowContentRenderElement<=GlesRenderer>; WindowContentRenderElement<=GlesRenderer>;
Rounded=RoundedSurfaceRenderElement, Rounded=RoundedSurfaceRenderElement,
+21 -7
View File
@@ -9,9 +9,13 @@ standard file, print and application dialogs. Install both backends.
- **Settings:** Ferese's color scheme, accent color, contrast preference and - **Settings:** Ferese's color scheme, accent color, contrast preference and
reduced-motion setting. Changes are signalled after valid KDL appearance reduced-motion setting. Changes are signalled after valid KDL appearance
changes; malformed saves leave the last valid preferences intact. changes; malformed saves leave the last valid preferences intact.
- **Screenshot:** whole-screen and selected-area PNG captures, plus a screen - **Screenshot:** screen, selected-area, selected-window and active-window PNG
color picker. Every capture asks for consent. Interactive requests offer area captures, plus a screen color picker. Window captures render the selected
selection. Selection uses `slurp`; captures use Ferese's bounded screenshot IPC. 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. The helper must disappear from the compositor before capture starts.
- **Wallpaper:** a preview and confirmation, then an atomic configuration update. - **Wallpaper:** a preview and confirmation, then an atomic configuration update.
Desktop and lock-screen wallpaper can be set independently, or together. 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 | | Interface | Ferese integration |
| --- | --- | | --- | --- |
| Settings | Native, including appearance change signals | | 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 | | Wallpaper | Native desktop, lock-screen and combined targets |
| ScreenCast | Native monitors with logical geometry metadata; window sources and persistence remain missing | | ScreenCast | Native monitors with logical geometry metadata; window sources and persistence remain missing |
| Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK | | 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 document export and permission storage. Adding a similarly named backend service
is neither required nor sufficient for those APIs. is neither required nor sufficient for those APIs.
Do not advertise window capture or input services until the compositor provides Do not advertise window sources for ScreenCast or input services until the
the corresponding operation. Screen crops include occluding windows and cannot compositor provides the corresponding operation. Screen crops include occluding
serve as isolated window captures. GNOME's remote-input features require native windows and cannot serve as isolated window streams. GNOME's remote-input features require native
compositor/session work, rather than substituting successful empty replies. compositor/session work, rather than substituting successful empty replies.
## Validation ## Validation
@@ -98,6 +102,16 @@ and configuration conflicts in an isolated nested compositor:
FERESE_TEST_SHORTCUTS=1 python3 scripts/tests/test_shortcuts_isolated.py 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
<window-id> > capture.png`; `feresectl get-windows` lists IDs.
The contract test compares introspection against the installed official backend The contract test compares introspection against the installed official backend
XML, verifies appearance changes and invalid-save retention, and checks that XML, verifies appearance changes and invalid-save retention, and checks that
sensitive methods reject callers outside the portal frontend. It does not open sensitive methods reject callers outside the portal frontend. It does not open
+9 -2
View File
@@ -33,7 +33,7 @@ fn main() -> Result<(), Box<dyn Error>> {
return Err(format!("{}: {}", error.code, error.message).into()); return Err(format!("{}: {}", error.code, error.message).into());
} }
if command == "screenshot" { if matches!(command.as_str(), "screenshot" | "screenshot-window") {
write_png(&response) write_png(&response)
} else { } else {
println!( println!(
@@ -71,6 +71,13 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
let command = args.next().ok_or_else(usage)?; let command = args.next().ok_or_else(usage)?;
let positional = args.collect::<Vec<_>>(); let positional = args.collect::<Vec<_>>();
let payload = match command.as_str() { let payload = match command.as_str() {
"screenshot-window" => {
exactly_one(&command, &positional, "window ID")?;
let window = positional[0]
.parse::<u64>()
.map_err(|_| "Expected an unsigned window ID")?;
json!({"window":window})
}
"focus" | "move" | "resize" => { "focus" | "move" | "resize" => {
exactly_one(&command, &positional, "direction")?; exactly_one(&command, &positional, "direction")?;
json!({ "direction": positional[0] }) json!({ "direction": positional[0] })
@@ -126,7 +133,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
} }
fn usage() -> String { fn usage() -> String {
"usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned() "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
} }
#[cfg(test)] #[cfg(test)]
+1
View File
@@ -503,6 +503,7 @@ async fn start_streams(
sources, sources,
multiple, multiple,
parent: parent.into(), parent: parent.into(),
window_capture: false,
}; };
let picker = picker(&prompt).await?; let picker = picker(&prompt).await?;
let picker_pid = picker.id().ok_or("Missing picker process ID")?; let picker_pid = picker.id().ok_or("Missing picker process ID")?;
+202 -70
View File
@@ -91,7 +91,7 @@ impl Screenshot {
#[zbus(property)] #[zbus(property)]
fn available_targets(&self) -> u32 { fn available_targets(&self) -> u32 {
1 | 4 1 | 2 | 4 | 8
} }
async fn screenshot( async fn screenshot(
@@ -225,65 +225,52 @@ pub(crate) async fn ipc(
command: &'static str, command: &'static str,
args: serde_json::Value, args: serde_json::Value,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || ipc_sync(command, args))
let path = std::env::var_os("XDG_RUNTIME_DIR") .await
.map(PathBuf::from) .map_err(|error| error.to_string())?
.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())?
} }
async fn select_geometry(point: bool, windows: Option<String>) -> Result<Option<String>, String> { fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result<serde_json::Value, String> {
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<Option<String>, String> {
let mut command = crate::backend::child_command_for("slurp")?; let mut command = crate::backend::child_command_for("slurp")?;
if point { if point {
command.arg("-p"); command.arg("-p");
} }
if windows.is_some() { command.stdin(Stdio::null());
command.arg("-r"); let child = command
}
command.stdin(if windows.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
let mut child = command
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
.kill_on_drop(true) .kill_on_drop(true)
.spawn() .spawn()
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let pid = child.id().ok_or("Missing selection process ID")?; 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()) let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output())
.await .await
.map_err(|_| "Selection timed out")? .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<String>) -> Result<Vec<u8>, String> { async fn capture(geometry: Option<String>) -> Result<Vec<u8>, String> {
let result = ipc("screenshot", serde_json::json!({"geometry": geometry})).await?; capture_command("screenshot", serde_json::json!({"geometry": geometry})).await
let path = result["path"] }
.as_str()
.ok_or("Missing screenshot path")? async fn capture_command(
.to_owned(); command: &'static str,
args: serde_json::Value,
) -> Result<Vec<u8>, String> {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&path).map_err(|error| error.to_string())?; let result = ipc_sync(command, args)?;
let _ = std::fs::remove_file(path); let path = result["path"].as_str().ok_or("Missing screenshot path")?;
let mut bytes = Vec::new(); read_screenshot(path)
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)
}) })
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
} }
fn read_screenshot(path: &str) -> Result<Vec<u8>, 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<Option<Options>, String> { async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option<Options>, String> {
let target = options let target = options
.get("target") .get("target")
@@ -353,9 +354,18 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
1 1
}, },
); );
if !matches!(target, 1 | 4) { if !matches!(target, 1 | 2 | 4 | 8) {
return Err("Unsupported screenshot target".into()); return Err("Unsupported screenshot target".into());
} }
let active_window = if target == 8 {
Some(
ipc("get-focused-window", serde_json::json!({})).await?["id"]
.as_u64()
.ok_or("No active window")?,
)
} else {
None
};
if !consent( if !consent(
app, app,
parent, parent,
@@ -368,17 +378,28 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
{ {
return Ok(None); return Ok(None);
} }
let geometry = match target { let bytes = match target {
1 => None, 1 => capture(None).await?,
4 => { 4 => {
let Some(geometry) = select_geometry(false, None).await? else { let Some(geometry) = select_geometry(false).await? else {
return Ok(None); return Ok(None);
}; };
Some(geometry) capture(Some(geometry)).await?
}
2 | 8 => {
let id = match active_window {
Some(id) => id,
None => {
let Some(id) = select_window(app, parent).await? else {
return Ok(None);
};
id
}
};
capture_command("screenshot-window", serde_json::json!({"window":id})).await?
} }
_ => unreachable!(), _ => unreachable!(),
}; };
let bytes = capture(geometry).await?;
let directory = private_directory("screenshots")?; let directory = private_directory("screenshots")?;
let mut file = tempfile::Builder::new() let mut file = tempfile::Builder::new()
.prefix("screenshot-") .prefix("screenshot-")
@@ -395,6 +416,88 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
)]))) )])))
} }
fn window_sources(windows: &serde_json::Value) -> Result<Vec<crate::capture::Source>, String> {
let windows = windows.as_array().ok_or("Invalid window list")?;
if windows.len() > 128 {
return Err("Too many windows to choose from".into());
}
let mut sources = Vec::new();
for window in windows.iter().filter(|window| window["mapped"] == true) {
let Some(id) = window["id"].as_u64() else {
continue;
};
let title = window["title"].as_str().unwrap_or("Untitled window");
let app = window["app_id"].as_str().unwrap_or("");
let label = format!("{title} — {app}")
.chars()
.filter(|c| !c.is_control())
.take(256)
.collect();
sources.push(crate::capture::Source {
name: id.to_string(),
label,
width: window["width"]
.as_i64()
.and_then(|w| i32::try_from(w).ok())
.unwrap_or(0),
height: window["height"]
.as_i64()
.and_then(|h| i32::try_from(h).ok())
.unwrap_or(0),
x: 0,
y: 0,
scale: 1,
});
}
Ok(sources)
}
async fn select_window(app: &str, parent: &str) -> Result<Option<u64>, String> {
let windows = ipc("get-windows", serde_json::json!({})).await?;
let sources = window_sources(&windows)?;
if sources.is_empty() {
return Err("No windows are available".into());
}
let prompt = crate::picker::Prompt {
app: app.into(),
sources,
multiple: false,
parent: parent.into(),
window_capture: true,
};
let mut child = crate::backend::child_command()?
.arg("--picker")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|error| error.to_string())?;
let pid = child.id().ok_or("Missing window selector PID")?;
let mut stdin = child.stdin.take().unwrap();
stdin
.write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?)
.await
.map_err(|error| error.to_string())?;
drop(stdin);
let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output())
.await
.map_err(|_| "Window selection timed out")?
.map_err(|error| error.to_string())?;
if !output.status.success() {
return Ok(None);
}
let names: Vec<String> =
serde_json::from_slice(&output.stdout).map_err(|_| "Invalid window selection")?;
if names.len() != 1 || !prompt.sources.iter().any(|source| source.name == names[0]) {
return Err("Unknown selected window".into());
}
wait_for_surface_removal(pid).await?;
names[0]
.parse::<u64>()
.map(Some)
.map_err(|_| "Invalid window ID".into())
}
async fn pick_color(app: &str, parent: &str) -> Result<Option<Options>, String> { async fn pick_color(app: &str, parent: &str) -> Result<Option<Options>, String> {
if !consent( if !consent(
app, app,
@@ -408,7 +511,7 @@ async fn pick_color(app: &str, parent: &str) -> Result<Option<Options>, String>
{ {
return Ok(None); return Ok(None);
} }
let Some(geometry) = select_geometry(true, None).await? else { let Some(geometry) = select_geometry(true).await? else {
return Ok(None); return Ok(None);
}; };
let bytes = capture(Some(geometry)).await?; let bytes = capture(Some(geometry)).await?;
@@ -654,6 +757,35 @@ mod tests {
} }
} }
#[test]
fn screenshot_reads_remove_staging_files_on_success_and_failure() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("capture.png");
std::fs::write(&path, b"capture").unwrap();
assert_eq!(read_screenshot(path.to_str().unwrap()).unwrap(), b"capture");
assert!(!path.exists());
let file = std::fs::File::create(&path).unwrap();
file.set_len(MAX_IMAGE_BYTES + 1).unwrap();
assert!(read_screenshot(path.to_str().unwrap()).is_err());
assert!(!path.exists());
}
#[test]
fn window_choices_preserve_identity_and_bound_untrusted_titles() {
let windows = serde_json::json!([
{"id": 1, "mapped": true, "title": "Editor\n", "app_id": "zed"},
{"id": 2, "mapped": false, "title": "Hidden", "app_id": "foot"},
{"id": 3, "mapped": true, "title": "x".repeat(1024)}
]);
let sources = window_sources(&windows).unwrap();
assert_eq!(sources.len(), 2);
assert_eq!(sources[0].name, "1");
assert_eq!(sources[0].label, "Editor — zed");
assert_eq!(sources[1].label.chars().count(), 256);
assert!(window_sources(&serde_json::json!({})).is_err());
}
#[test] #[test]
fn pixel_color_is_normalized_rgb() { fn pixel_color_is_normalized_rgb() {
let mut bytes = Vec::new(); let mut bytes = Vec::new();
+57 -12
View File
@@ -16,6 +16,8 @@ pub struct Prompt {
pub multiple: bool, pub multiple: bool,
#[serde(default)] #[serde(default)]
pub parent: String, pub parent: String,
#[serde(default)]
pub window_capture: bool,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -49,11 +51,16 @@ struct Picker {
pub fn run() -> Result<(), Box<dyn std::error::Error>> { pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new(); let mut input = String::new();
std::io::stdin() std::io::stdin()
.take(64 * 1024) .take(256 * 1024 + 1)
.read_to_string(&mut input)?; .read_to_string(&mut input)?;
if input.len() > 256 * 1024 {
return Err("Picker request is too large".into());
}
let prompt: Prompt = serde_json::from_str(&input)?; let prompt: Prompt = serde_json::from_str(&input)?;
if prompt.sources.is_empty() || prompt.sources.len() > 16 { if prompt.sources.is_empty()
|| prompt.sources.len() > if prompt.window_capture { 128 } else { 16 }
{
return Err("No shareable displays".into()); return Err("No shareable displays".into());
} }
@@ -114,7 +121,16 @@ impl cosmic::Application for Picker {
let task = app let task = app
.core .core
.main_window_id() .main_window_id()
.map(|id| app.set_window_title("Ferese — share your screen".into(), id)) .map(|id| {
app.set_window_title(
if app.prompt.window_capture {
"Ferese — capture a window".into()
} else {
"Ferese — share your screen".into()
},
id,
)
})
.unwrap_or_else(Task::none); .unwrap_or_else(Task::none);
(app, task) (app, task)
@@ -199,8 +215,21 @@ impl cosmic::Application for Picker {
row![ row![
glyph(ferese_theme::icons::DISPLAY, 24), glyph(ferese_theme::icons::DISPLAY, 24),
column![ column![
self.text("Choose a display").size(18), self.text(if self.prompt.window_capture {
self.text(format!("Share with {app}")).size(12), "Choose a window"
} else {
"Choose a display"
})
.size(18),
self.text(format!(
"{} {app}",
if self.prompt.window_capture {
"Screenshot for"
} else {
"Share with"
}
))
.size(12),
] ]
.spacing(2) .spacing(2)
.width(Length::Fill), .width(Length::Fill),
@@ -214,7 +243,12 @@ impl cosmic::Application for Picker {
let mut entry = row![ let mut entry = row![
glyph(ferese_theme::icons::DISPLAY, 24), glyph(ferese_theme::icons::DISPLAY, 24),
column![ column![
self.text(&source.name).size(14), self.text(if self.prompt.window_capture {
&source.label
} else {
&source.name
})
.size(14),
self.text(format!("{} × {}", source.width, source.height)) self.text(format!("{} × {}", source.width, source.height))
.size(12) .size(12)
] ]
@@ -242,14 +276,25 @@ impl cosmic::Application for Picker {
(self.prompt.sources.len().min(4) * 56) as f32, (self.prompt.sources.len().min(4) * 56) as f32,
))); )));
content = content.push( content = content.push(
self.text("Everything on this display, including notifications, is visible.") self.text(if self.prompt.window_capture {
.size(12) "Only the selected window will be captured."
.width(Length::Fill), } else {
"Everything on this display, including notifications, is visible."
})
.size(12)
.width(Length::Fill),
); );
let mut share = button::custom( let mut share = button::custom(
row![self.text("Share"), glyph(ferese_theme::icons::ARROW, 16)] row![
.spacing(8) self.text(if self.prompt.window_capture {
.align_y(Alignment::Center), "Capture"
} else {
"Share"
}),
glyph(ferese_theme::icons::ARROW, 16)
]
.spacing(8)
.align_y(Alignment::Center),
) )
.class(ferese_theme::accent_button()) .class(ferese_theme::accent_button())
.padding([6, 16]); .padding([6, 16]);
+91
View File
@@ -0,0 +1,91 @@
/* Two overlapping clients with distinct colors for isolated capture tests. */
#define _GNU_SOURCE
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#include <wayland-client.h>
#include "xdg-shell-client-protocol.h"
static struct wl_compositor *compositor;
static struct wl_shm *shm;
static struct xdg_wm_base *wm;
static struct wl_surface *surface;
static struct wl_buffer *buffer;
static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) {
(void)data;
xdg_wm_base_pong(base, serial);
}
static const struct xdg_wm_base_listener wm_listener = {.ping = ping};
static void global(void *data, struct wl_registry *registry, uint32_t name,
const char *interface, uint32_t version) {
(void)data; (void)version;
if (!strcmp(interface, "wl_compositor"))
compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4);
else if (!strcmp(interface, "wl_shm"))
shm = wl_registry_bind(registry, name, &wl_shm_interface, 1);
else if (!strcmp(interface, "xdg_wm_base")) {
wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1);
xdg_wm_base_add_listener(wm, &wm_listener, NULL);
}
}
static void removed(void *data, struct wl_registry *registry, uint32_t name) {
(void)data; (void)registry; (void)name;
}
static const struct wl_registry_listener registry_listener = {global, removed};
static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) {
(void)data;
xdg_surface_ack_configure(xdg, serial);
xdg_surface_set_window_geometry(xdg, 0, 0, 640, 480);
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage(surface, 0, 0, 640, 480);
wl_surface_commit(surface);
}
static const struct xdg_surface_listener surface_listener = {.configure = configure};
static void size(void *data, struct xdg_toplevel *top, int32_t w, int32_t h,
struct wl_array *states) {
(void)data; (void)top; (void)w; (void)h; (void)states;
}
static void close_window(void *data, struct xdg_toplevel *top) {
(void)data; (void)top; exit(0);
}
static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window};
int main(int argc, char **argv) {
struct wl_display *display = wl_display_connect(NULL);
if (!display) return 1;
struct wl_registry *registry = wl_display_get_registry(display);
wl_registry_add_listener(registry, &registry_listener, NULL);
if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm) return 2;
int fd = memfd_create("floating-size-test", MFD_CLOEXEC);
size_t bytes = 640 * 480 * 4;
if (fd < 0 || ftruncate(fd, bytes)) return 3;
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (pixels == MAP_FAILED) return 4;
int covered = argc > 1 && !strcmp(argv[1], "cover");
for (size_t i = 0; i < 640 * 480; i++) {
pixels[i] = covered ? 0xff00ff00 : (i % 640 < 80 ? 0x80800000 : (i / 640 < 240 ? 0xffff0000 : 0xff0000ff));
}
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
buffer = wl_shm_pool_create_buffer(pool, 0, 640, 480, 640 * 4, WL_SHM_FORMAT_ARGB8888);
wl_shm_pool_destroy(pool);
close(fd);
surface = wl_compositor_create_surface(compositor);
struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface);
xdg_surface_add_listener(xdg, &surface_listener, NULL);
struct xdg_toplevel *top = xdg_surface_get_toplevel(xdg);
xdg_toplevel_add_listener(top, &top_listener, NULL);
xdg_toplevel_set_app_id(top, "ferese.test.window-capture");
xdg_toplevel_set_title(top, covered ? "Occluding window" : "Capture target");
wl_surface_commit(surface);
while (wl_display_dispatch(display) != -1) {}
return 0;
}
@@ -0,0 +1,91 @@
"""Verify isolated window capture without exposing host desktop content.
FERESE_TEST_WINDOW_CAPTURE=1 python3 scripts/tests/test_window_capture_isolated.py
Requires built debug binaries, a Wayland host, cc, wayland-scanner and Pillow.
"""
import io
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
import unittest
REPO = Path(__file__).resolve().parents[2]
@unittest.skipUnless(os.environ.get("FERESE_TEST_WINDOW_CAPTURE") == "1", "requires a Wayland host")
class WindowCapture(unittest.TestCase):
def test_occluding_window_does_not_appear_and_rows_keep_orientation(self):
from PIL import Image
processes = []
with tempfile.TemporaryDirectory(prefix="ferese-window-capture-") as directory:
root = Path(directory)
protocols = subprocess.check_output(["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip()
xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml")
for kind, output in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]:
subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True)
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split()
subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(REPO / "scripts/tests/fixtures/window-capture.c"),
str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese/config.kdl"
config.parent.mkdir(parents=True)
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n')
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1")
env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log = (root / "compositor.log").open("w")
try:
compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(compositor)
deadline = time.monotonic() + 10
while not (runtime / "ferese/control.sock").exists():
if compositor.poll() is not None or time.monotonic() > deadline:
self.fail((root / "compositor.log").read_text())
time.sleep(0.02)
sockets = list(runtime.glob("wayland-*"))
env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock")))
def call(*args):
return subprocess.check_output([str(REPO / "target/debug/feresectl"), *args], env=env, timeout=10)
def windows(count):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
result = json.loads(call("get-windows"))
if len(result) == count and all(window["mapped"] and window["width"] == 640 for window in result):
return result
time.sleep(0.02)
self.fail((root / "compositor.log").read_text())
processes.append(subprocess.Popen([str(root / "client")], env=env, stdout=log, stderr=log, start_new_session=True))
target = windows(1)[0]
processes.append(subprocess.Popen([str(root / "client"), "cover"], env=env, stdout=log, stderr=log, start_new_session=True))
current = windows(2)
cover = next(window for window in current if window["title"] == "Occluding window")
self.assertEqual((target["x"], target["y"], target["width"], target["height"]),
(cover["x"], cover["y"], cover["width"], cover["height"]))
scale = json.loads(call("get-outputs"))[0]["scale"]
with Image.open(io.BytesIO(call("screenshot-window", str(target["id"])))) as image:
self.assertEqual(image.size, (round(640 * scale), round(480 * scale)))
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 4)), (255, 0, 0))
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height * 3 // 4)), (0, 0, 255))
self.assertEqual(image.convert("RGBA").getpixel((round(40 * scale), round(80 * scale))), (255, 0, 0, 128))
with Image.open(io.BytesIO(call("screenshot-window", str(cover["id"])))) as image:
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 2)), (0, 255, 0))
finally:
for process in reversed(processes):
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
log.close()
if __name__ == "__main__":
unittest.main()