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,
)
}
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)]
+10 -2
View File
@@ -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<PartOu
// 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) preserve_alpha: bool,
pub(crate) transform: Transform,
pub(crate) scale: f64,
pub(crate) location: (i32, i32),
@@ -510,6 +512,7 @@ impl Coordinator {
} else {
pixels.extend_from_slice(&buffer.pixels);
Some(OutputFrame {
preserve_alpha: spec.preserve_alpha,
width: buffer.width,
height: buffer.height,
stride: buffer.stride,
@@ -664,6 +667,7 @@ pub(crate) struct PlannedPart {
impl PlannedPart {
pub(crate) fn spec(&self, output: &OutputLayout) -> 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,
@@ -200,6 +200,7 @@ mod tests {
pixel[3] = 255;
}
OutputFrame {
preserve_alpha: false,
width,
height,
stride,
+122 -1
View File
@@ -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<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) {
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");
+52 -1
View File
@@ -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<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! {
WindowContentRenderElement<=GlesRenderer>;
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
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
<window-id> > 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
+9 -2
View File
@@ -33,7 +33,7 @@ fn main() -> Result<(), Box<dyn Error>> {
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<Item = String>) -> Result<(String, Value),
let command = args.next().ok_or_else(usage)?;
let positional = args.collect::<Vec<_>>();
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" => {
exactly_one(&command, &positional, "direction")?;
json!({ "direction": positional[0] })
@@ -126,7 +133,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
}
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)]
+1
View File
@@ -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")?;
+202 -70
View File
@@ -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<serde_json::Value, String> {
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<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")?;
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<String>) -> Result<Vec<u8>, 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<Vec<u8>, 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<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> {
let target = options
.get("target")
@@ -353,9 +354,18 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
1
},
);
if !matches!(target, 1 | 4) {
if !matches!(target, 1 | 2 | 4 | 8) {
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(
app,
parent,
@@ -368,17 +378,28 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
{
return Ok(None);
}
let geometry = match target {
1 => None,
let bytes = match target {
1 => capture(None).await?,
4 => {
let Some(geometry) = select_geometry(false, None).await? else {
let Some(geometry) = select_geometry(false).await? else {
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!(),
};
let bytes = capture(geometry).await?;
let directory = private_directory("screenshots")?;
let mut file = tempfile::Builder::new()
.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> {
if !consent(
app,
@@ -408,7 +511,7 @@ async fn pick_color(app: &str, parent: &str) -> Result<Option<Options>, String>
{
return Ok(None);
}
let Some(geometry) = select_geometry(true, None).await? else {
let Some(geometry) = select_geometry(true).await? else {
return Ok(None);
};
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]
fn pixel_color_is_normalized_rgb() {
let mut bytes = Vec::new();
+57 -12
View File
@@ -16,6 +16,8 @@ pub struct Prompt {
pub multiple: bool,
#[serde(default)]
pub parent: String,
#[serde(default)]
pub window_capture: bool,
}
#[derive(Clone, Debug)]
@@ -49,11 +51,16 @@ struct Picker {
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
std::io::stdin()
.take(64 * 1024)
.take(256 * 1024 + 1)
.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)?;
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());
}
@@ -114,7 +121,16 @@ impl cosmic::Application for Picker {
let task = app
.core
.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);
(app, task)
@@ -199,8 +215,21 @@ impl cosmic::Application for Picker {
row![
glyph(ferese_theme::icons::DISPLAY, 24),
column![
self.text("Choose a display").size(18),
self.text(format!("Share with {app}")).size(12),
self.text(if self.prompt.window_capture {
"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)
.width(Length::Fill),
@@ -214,7 +243,12 @@ impl cosmic::Application for Picker {
let mut entry = row![
glyph(ferese_theme::icons::DISPLAY, 24),
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))
.size(12)
]
@@ -242,14 +276,25 @@ impl cosmic::Application for Picker {
(self.prompt.sources.len().min(4) * 56) as f32,
)));
content = content.push(
self.text("Everything on this display, including notifications, is visible.")
.size(12)
.width(Length::Fill),
self.text(if self.prompt.window_capture {
"Only the selected window will be captured."
} else {
"Everything on this display, including notifications, is visible."
})
.size(12)
.width(Length::Fill),
);
let mut share = button::custom(
row![self.text("Share"), glyph(ferese_theme::icons::ARROW, 16)]
.spacing(8)
.align_y(Alignment::Center),
row![
self.text(if self.prompt.window_capture {
"Capture"
} else {
"Share"
}),
glyph(ferese_theme::icons::ARROW, 16)
]
.spacing(8)
.align_y(Alignment::Center),
)
.class(ferese_theme::accent_button())
.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()