Protect selected windows from screen capture

Add capture privacy rules for windows, transient children, overview previews and client cursors, with separate capture rendering and deferred-frame invalidation. Keep detached close snapshots out of captures after clients leave the registry.

Validated with release workspace tests, private compositor/EGL regressions, and an isolated release PipeWire capture test.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-03 10:49:01 +01:00
1 parent bffd538dd4
commit 453ec16583
24 files changed
+1132 -41

No files matched your search

+1
View File
@@ -52,6 +52,7 @@ impl SeatHandler for Ferese {
.or_insert_with(|| crate::cursor::load_named_cursor(&self.cursor_theme, *icon));
}
self.cursor_status = image;
self.update_capture_cursor_privacy();
// Pointer focus transitions invoke this with the pointer lock held.
// render_all -> cursor_elements -> current_location would try to
// acquire that same lock, freezing the entire compositor thread.
+25 -1
View File
@@ -560,9 +560,13 @@ impl Coordinator {
}
pub(crate) fn terminate_all(&mut self) -> Vec<u64> {
self.terminate_all_with_reason("Screenshot cancelled: the session was locked")
}
pub(crate) fn terminate_all_with_reason(&mut self, reason: &str) -> Vec<u64> {
let ids: Vec<u64> = self.requests.keys().copied().collect();
for id in &ids {
self.reject_inner(*id, "Screenshot cancelled: the session was locked");
self.reject_inner(*id, reason);
}
ids
}
@@ -1029,6 +1033,26 @@ mod completion {
assert!(harness.response().is_none(), "no late success is delivered");
}
#[test]
fn privacy_change_cancels_encoding_and_discards_late_results() {
let mut harness = Harness::new(1);
let id = harness.coordinator.next_id;
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
assert!(harness.coordinator.is_encoding(id));
assert_eq!(
harness.coordinator.terminate_all_with_reason("Capture privacy changed"),
vec![id]
);
assert!(harness.response().unwrap().error.is_some());
assert!(matches!(
harness
.coordinator
.on_encoded(id, Ok(PathBuf::from("/tmp/private.png"))),
Action::DiscardFile(_)
));
assert!(harness.response().is_none());
}
#[test]
fn encoded_result_is_delivered_once() {
let mut harness = Harness::new(1);
+96 -4
View File
@@ -57,6 +57,7 @@ impl Drop for Permit {
#[derive(Debug)]
pub(super) struct Snapshot {
id: WindowId,
epoch: u64,
logical_size: (u32, u32),
pixels: CaptureBuffer,
_permit: Permit,
@@ -159,7 +160,7 @@ fn finish_conversion(state: &Ferese, result: Conversion) {
}
if state.session_lock.active()
|| !super::screencopy::capture_allowed()
|| !state.windows.ids().values().any(|id| *id == snapshot.id)
|| !state.capture_window_allowed(snapshot.id, snapshot.epoch)
{
*data.used.lock().unwrap() = true;
frame.failed();
@@ -273,6 +274,9 @@ impl Ferese {
.find(|(_, candidate)| **candidate == id)?
.0
.clone();
if self.capture_protected(&window) {
return None;
}
let workspace = self.workspaces.workspace_for_window(id)?;
let owner = self.output_workspaces.output_for_workspace(workspace)?;
let output = self
@@ -295,7 +299,7 @@ impl Ferese {
*busy = true;
return None;
};
let cursor = if overlay_cursor && !self.overview.is_presenting() {
let cursor = if overlay_cursor && !self.overview.is_presenting() && !self.capture_cursor_protected() {
self.seat.get_pointer().and_then(|pointer| {
let location = pointer.current_location();
if self.window_under_visual(location).as_ref() != Some(&window) {
@@ -345,6 +349,7 @@ impl Ferese {
}
Some(Snapshot {
id,
epoch: self.capture_epoch,
logical_size: (geometry.size.w as u32, geometry.size.h as u32),
pixels,
_permit: permit,
@@ -365,7 +370,7 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
};
if state.session_lock.active()
|| !super::screencopy::capture_allowed()
|| !state.windows.ids().values().any(|id| *id == snapshot.id)
|| !state.capture_window_allowed(snapshot.id, snapshot.epoch)
{
resource.failed();
return;
@@ -407,9 +412,96 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
/// Exercise both asynchronous publication boundaries using real resources.
pub(crate) fn check_deferred_snapshot(
state: &Ferese,
window: &smithay::desktop::Window,
epoch: u64,
allowed: bool,
) {
let client = window.toplevel().unwrap().wl_surface().client().unwrap();
let id = state.windows.ids()[window];
let budget = Arc::default();
let snapshot = || Snapshot {
id,
epoch,
logical_size: (64, 48),
pixels: CaptureBuffer {
width: 64,
height: 48,
stride: 256,
pixels: vec![0; 64 * 48 * 4],
},
_permit: Permit::reserve(&budget, 64 * 48 * 4).unwrap(),
};
let frame_data = || {
Arc::new(FrameData {
output: None,
region: Rectangle::from_size((64, 48).into()),
overlay_cursor: false,
used: Mutex::new(false),
snapshot: Mutex::new(None),
})
};
let (worker, _requests) = std::sync::mpsc::sync_channel(1);
let manager = client
.create_resource::<FereseWindowCaptureManagerV1, Global, Ferese>(
&state.display_handle,
1,
Global {
budget: budget.clone(),
worker,
},
)
.unwrap();
let data = frame_data();
let frame = client
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(&state.display_handle, 3, data.clone())
.unwrap();
finish_conversion(
state,
Conversion {
snapshot: snapshot(),
frame,
manager,
},
);
assert_eq!(data.snapshot.lock().unwrap().is_some(), allowed);
assert_eq!(*data.used.lock().unwrap(), !allowed);
let data = frame_data();
*data.snapshot.lock().unwrap() = Some(snapshot());
let frame = client
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(&state.display_handle, 3, data.clone())
.unwrap();
let buffer = client
.object_from_protocol_id::<WlBuffer>(&state.display_handle, 6)
.unwrap();
let before = with_buffer_contents_mut(&buffer, |ptr, _, layout| {
// SAFETY: the fixture allocates and validates this 64x48 SHM buffer.
unsafe { std::slice::from_raw_parts(ptr.add(layout.offset as usize), 64 * 48 * 4).to_vec() }
})
.unwrap();
copy_snapshot(state, &frame, &data, &buffer);
with_buffer_contents_mut(&buffer, |ptr, _, layout| {
// SAFETY: same fixture-owned buffer as above.
let after = unsafe { std::slice::from_raw_parts(ptr.add(layout.offset as usize), 64 * 48 * 4) };
if allowed {
assert!(after.iter().all(|byte| *byte == 0));
} else {
assert_eq!(after, before, "rejected snapshot wrote private pixels");
}
// Restore the client's colored buffer for subsequent renderer checks.
unsafe {
std::ptr::copy_nonoverlapping(before.as_ptr(), ptr.add(layout.offset as usize), before.len());
}
})
.unwrap();
}
#[test]
fn outstanding_snapshots_release_both_limits_when_dropped() {
let budget = Arc::default();
+3
View File
@@ -34,6 +34,7 @@ impl XdgShellHandler for Ferese {
}
fn title_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy();
if self.windows.refresh_metadata(surface.wl_surface()) {
self.send_shell_snapshots();
for output in self.surface_outputs(surface.wl_surface()) {
@@ -43,6 +44,7 @@ impl XdgShellHandler for Ferese {
}
fn app_id_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy();
if self.windows.refresh_metadata(surface.wl_surface()) {
self.send_shell_snapshots();
for output in self.surface_outputs(surface.wl_surface()) {
@@ -52,6 +54,7 @@ impl XdgShellHandler for Ferese {
}
fn parent_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy();
let Some(parent) = surface.parent() else {
return;
};