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

+15 -8
View File
@@ -5,7 +5,7 @@ use std::os::fd::AsFd;
use std::path::Path;
use std::time::{Duration, Instant};
mod capture;
pub(crate) mod capture;
mod lid;
mod mirror;
mod output_power;
@@ -251,6 +251,7 @@ struct DirectOutput {
identity: String,
mirror_source: Option<String>,
mirror_texture: Option<GlesTexture>,
mirror_capture_texture: Option<GlesTexture>,
mirror_canvas: Option<Output>,
mirror_id: smithay::backend::renderer::element::Id,
mirror_commit: CommitCounter,
@@ -1267,13 +1268,17 @@ fn render_output(
primary.sync.wait()?;
}
capture::capture_output(
state,
&mut device.renderer,
&mut output.capture_texture,
&output.output,
&frame,
)?;
if output.mirror_source.is_some() {
capture::capture_mirror(state, &mut device.renderer, &scene_output, &frame, &mut output)?;
} else {
capture::capture_output(
state,
&mut device.renderer,
&mut output.capture_texture,
&output.output,
&frame,
)?;
}
if result.is_empty {
output
@@ -1511,6 +1516,7 @@ fn remove_device(state: &mut Ferese, node: DrmNode) {
let context = device.renderer.context_id().erased();
state.wallpaper.forget_context(&context);
state.render.forget_context(&context);
state.capture_render.forget_context(&context);
for (crtc, mut output) in device.outputs {
cancel_output_timers(&state.loop_handle, &mut output);
if let Some(global) = output.global.take() {
@@ -2027,6 +2033,7 @@ fn create_direct_output(
identity,
mirror_source,
mirror_texture: None,
mirror_capture_texture: None,
mirror_canvas: None,
mirror_id: smithay::backend::renderer::element::Id::new(),
mirror_commit: CommitCounter::default(),
+30 -3
View File
@@ -5,10 +5,10 @@ use smithay::backend::renderer::{Bind, Offscreen, Texture};
use smithay::output::Output;
use crate::Ferese;
use crate::render::{redraw_output, sampled_output_elements};
use crate::render::{capture_output_elements, redraw_output};
use crate::state::FrameScene;
pub(super) fn capture_output(
pub(crate) fn capture_output(
state: &mut Ferese,
renderer: &mut GlesRenderer,
texture: &mut Option<GlesTexture>,
@@ -22,7 +22,7 @@ pub(super) fn capture_output(
// Sample the same scene into an independent target: the displayed
// primary buffer may omit both a scanned-out client and hardware cursor.
let elements = sampled_output_elements(state, renderer, output, include_cursor, scene);
let elements = capture_output_elements(state, renderer, output, include_cursor, scene);
let target = render_capture(renderer, texture, output, &elements)?;
state.process_screencopies(renderer, &target, output, include_cursor);
}
@@ -30,6 +30,33 @@ pub(super) fn capture_output(
Ok(())
}
pub(super) fn capture_mirror(
state: &mut Ferese,
renderer: &mut GlesRenderer,
source: &Output,
scene: &FrameScene,
output: &mut super::DirectOutput,
) -> Result<(), GlesError> {
for include_cursor in [true, false] {
if !state.has_pending_screencopy(&output.output, include_cursor) {
continue;
}
let elements = capture_output_elements(state, renderer, source, include_cursor, scene);
// Never sample mirror_texture: it contains the unfiltered display.
let canvas = output.mirror_canvas.as_ref().expect("mirror display composed");
super::mirror::compose(renderer, &mut output.mirror_capture_texture, canvas, source, &elements)?;
let element = super::mirror::fitted_texture(
output.mirror_capture_texture.as_ref().unwrap().clone(),
&output.output,
smithay::backend::renderer::element::Id::new(),
Default::default(),
);
let target = render_capture(renderer, &mut output.capture_texture, &output.output, &[element])?;
state.process_screencopies(renderer, &target, &output.output, include_cursor);
}
Ok(())
}
pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer,
texture: &'a mut Option<GlesTexture>,
+2 -2
View File
@@ -47,7 +47,7 @@ pub(super) fn elements(
])
}
fn compose(
pub(super) fn compose(
renderer: &mut GlesRenderer,
texture: &mut Option<GlesTexture>,
canvas: &Output,
@@ -69,7 +69,7 @@ fn compose(
Ok(())
}
fn fitted_texture(
pub(super) fn fitted_texture(
texture: GlesTexture,
target: &Output,
id: smithay::backend::renderer::element::Id,
+2 -1
View File
@@ -1016,7 +1016,7 @@ mod tests {
#[test]
fn parses_and_validates_window_rules() {
let config = parse(
"window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 fullscreen=#false\n",
"window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 fullscreen=#false block-out-from-screencasts=#true\n",
);
let rules = config.window_rules().unwrap();
let result = window_rules::resolve(&rules, Some("org.example.editor.desktop"), Some("Document"), false);
@@ -1026,6 +1026,7 @@ mod tests {
assert_eq!(result.width, Some(900.0));
assert_eq!(result.height, Some(600.0));
assert_eq!(result.fullscreen, Some(false));
assert_eq!(result.block_out_from_screencasts, Some(true));
}
#[test]
+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;
};
+4
View File
@@ -898,6 +898,10 @@ impl Ferese {
reject("Window no longer exists".into());
return;
};
if self.capture_protected(&window) {
reject("Window is protected from capture".into());
return;
}
let Some(output) = self
.space
.outputs()
-1
View File
@@ -1046,7 +1046,6 @@ impl OverviewMotion {
#[cfg(test)]
pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect {
self.presentations.get(&id).map_or(normal, |p| p.current)
}
+1 -1
View File
@@ -23,7 +23,7 @@ use overview::*;
pub(crate) use resources::RenderResources;
use scene::cursor_elements;
pub(crate) use scene::{
animated_window_elements, frame_effect_metrics, layer_surfaces, output_elements, sampled_output_elements,
animated_window_elements, capture_output_elements, frame_effect_metrics, layer_surfaces, sampled_output_elements,
};
pub(crate) use shader::SharedPixelShaderElement;
use smithay::backend::allocator::Fourcc;
+11 -1
View File
@@ -91,6 +91,7 @@ pub(super) fn overview_strip_elements(
output_geometry: Rectangle<i32, Logical>,
scale: f64,
frame: &crate::state::FrameScene,
capture: bool,
) -> Vec<AnimatedWindowRenderElement> {
let alpha = frame.overview.opacity();
if alpha <= 0.001 {
@@ -139,7 +140,13 @@ pub(super) fn overview_strip_elements(
// has a fresh seed; iterating it changes z indices and damages still captions.
let caption_ids = state.windows.ordered_ids().rev().collect::<Vec<_>>();
for id in caption_ids {
if !state.window_belongs_to_output(id, output) {
if (capture
&& state
.windows
.window(id)
.is_none_or(|window| state.capture_protected(window)))
|| !state.window_belongs_to_output(id, output)
{
continue;
}
let Some(rect) = frame.windows.get(&id).map(|sample| sample.presentation.bounds.current) else {
@@ -222,6 +229,9 @@ pub(super) fn overview_strip_elements(
}
for (id, rect) in &card.windows {
if let Some(window) = state.windows.window(*id) {
if capture && state.capture_protected(window) {
continue;
}
let shape = window_corner_shape(window);
if let Some(programs) = corner_program(&mut state.render, renderer, shape) {
+30 -2
View File
@@ -31,6 +31,32 @@ pub(crate) fn sampled_output_elements(
output: &Output,
include_cursor: bool,
frame: &crate::state::FrameScene,
) -> Vec<AnimatedWindowRenderElement> {
scene_elements(state, renderer, output, include_cursor, frame, false)
}
pub(crate) fn capture_output_elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
include_cursor: bool,
frame: &crate::state::FrameScene,
) -> Vec<AnimatedWindowRenderElement> {
// Blur textures and resize handoff snapshots must never be shared with
// display rendering. Only the capture scene can populate these caches.
std::mem::swap(&mut state.render, &mut state.capture_render);
let elements = scene_elements(state, renderer, output, include_cursor, frame, true);
std::mem::swap(&mut state.render, &mut state.capture_render);
elements
}
fn scene_elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
include_cursor: bool,
frame: &crate::state::FrameScene,
capture: bool,
) -> Vec<AnimatedWindowRenderElement> {
if state.session_lock.active() {
let Some(geometry) = state.space.output_geometry(output) else {
@@ -89,7 +115,8 @@ pub(crate) fn sampled_output_elements(
};
let scale = output.current_scale().fractional_scale();
let mut elements = if include_cursor {
let protected_cursor = capture && state.capture_cursor_protected();
let mut elements = if include_cursor && !protected_cursor {
cursor_elements(state, renderer, output_geometry, scale)
} else {
Vec::new()
@@ -108,12 +135,13 @@ pub(crate) fn sampled_output_elements(
output_geometry,
scale,
frame,
capture,
));
}
let prepare_window = |window: &Window| {
// Configure immediately, but present the frame/shadow only after
// the first buffer commit has settled the actual client geometry.
if !state.window_content_ready(window) {
if (capture && state.capture_protected(window)) || !state.window_content_ready(window) {
return None;
}
let id = *state.windows.ids().get(window)?;
+6
View File
@@ -333,6 +333,7 @@ impl SessionLockHandler for Ferese {
// Replacing an orphan never passes through Unlocked. Existing protected
// presentation facts remain valid, but old surfaces must not own input.
self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation);
self.capture_epoch = self.capture_epoch.wrapping_add(1);
self.session_lock.surfaces.clear();
self.cancel_logout_confirmation();
self.refresh_idle_inhibition();
@@ -473,6 +474,7 @@ mod tests {
let mut event_loop = EventLoop::try_new().unwrap();
let config = crate::config::Config::default().runtime_config().unwrap();
let mut state = Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap();
let capture_epoch = state.capture_epoch;
let acquire = |state: &mut Ferese| {
let (server, mut wire) = UnixStream::pair().unwrap();
@@ -496,6 +498,10 @@ mod tests {
let mut first = acquire(&mut state);
dispatch(&mut event_loop, &mut state);
assert_ne!(
state.capture_epoch, capture_epoch,
"lock revokes deferred window snapshots"
);
assert!(
matches!(state.session_lock.lifecycle, Lifecycle::Locked(_)),
"no outputs must confirm immediately"
+10
View File
@@ -1,4 +1,5 @@
mod animation;
mod capture_privacy;
mod floating;
mod focus;
mod hit_testing;
@@ -409,6 +410,10 @@ pub struct Ferese {
output_identity_ids: HashMap<String, OutputId>,
pub(crate) windows: WindowRegistry<Window>,
pub(crate) render: crate::render::RenderResources,
pub(crate) capture_render: crate::render::RenderResources,
pub(crate) capture_epoch: u64,
capture_protected_windows: HashSet<WindowId>,
capture_protected_cursor: Option<WlSurface>,
pub(crate) daemons: Option<std::rc::Rc<std::cell::RefCell<crate::daemon::Runner>>>,
pub(crate) display_presentation: crate::display_presentation::DisplayPresentation,
pub(crate) nested_backend: Option<NestedBackend>,
@@ -642,6 +647,10 @@ impl Ferese {
output_identity_ids: HashMap::new(),
windows: WindowRegistry::default(),
render: Default::default(),
capture_render: Default::default(),
capture_epoch: 0,
capture_protected_windows: HashSet::new(),
capture_protected_cursor: None,
display_presentation: Default::default(),
daemons: None,
nested_backend: None,
@@ -872,6 +881,7 @@ impl Ferese {
}
if old_rules != self.window_rules {
self.refresh_capture_privacy();
self.reapply_window_rules(&old_rules);
}
+141
View File
@@ -0,0 +1,141 @@
use smithay::desktop::Window;
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
use crate::Ferese;
impl Ferese {
pub(crate) fn has_capture_exclusions(&self) -> bool {
// Detached close snapshots outlive the registry's privacy metadata.
// Keep them on the display and use the filtered scene for captures.
!self.capture_protected_windows.is_empty()
|| self.capture_protected_cursor.is_some()
|| !self.render.closing.is_empty()
}
/// Resolve at capture time: metadata changes must not wait for placement rules.
/// Transient children inherit protection, including children on other outputs.
pub(crate) fn capture_protected(&self, window: &Window) -> bool {
let Some(toplevel) = window.toplevel() else {
return true;
};
let mut surface = Some(toplevel.wl_surface().clone());
let mut visited = Vec::new();
while let Some(current) = surface {
if visited.contains(&current) {
return true;
}
visited.push(current.clone());
let (blocked, parent) = with_states(&current, |states| {
let Some(data) = states.data_map.get::<XdgToplevelSurfaceData>() else {
return (true, None);
};
let data = data.lock().unwrap();
let rule = crate::window_rules::resolve(
&self.window_rules,
data.app_id.as_deref(),
data.title.as_deref(),
data.parent.is_some(),
);
(rule.block_out_from_screencasts == Some(true), data.parent.clone())
});
if blocked {
return true;
}
surface = parent;
}
false
}
// Called from cursor_image while the pointer mutex is held: do not query
// pointer focus here. A cursor surface can outlive its protected window.
pub(crate) fn update_capture_cursor_privacy(&mut self) {
use smithay::input::pointer::CursorImageStatus;
use smithay::reexports::wayland_server::Resource;
let CursorImageStatus::Surface(cursor) = &self.cursor_status else {
self.capture_protected_cursor = None;
return;
};
if self.capture_protected_cursor.as_ref() == Some(cursor) {
return;
}
let protected = self.windows.ids().iter().any(|(window, id)| {
window
.toplevel()
.is_some_and(|top| top.wl_surface().id().same_client_as(&cursor.id()))
&& (self.capture_protected_windows.contains(id) || self.capture_protected(window))
});
self.capture_protected_cursor = protected.then(|| cursor.clone());
}
pub(crate) fn capture_cursor_protected(&self) -> bool {
use smithay::input::pointer::CursorImageStatus;
use smithay::reexports::wayland_server::Resource;
use smithay::wayland::compositor::get_parent;
// A cursor image is supplied by a client and can outlive pointer focus.
// Conservatively conceal that client's custom cursor while any of its
// windows is protected, including popup grabs outside the parent rect.
if let CursorImageStatus::Surface(cursor) = &self.cursor_status {
return self.capture_protected_cursor.as_ref() == Some(cursor)
|| self.windows.ids().keys().any(|window| {
window
.toplevel()
.is_some_and(|top| top.wl_surface().id().same_client_as(&cursor.id()))
&& self.capture_protected(window)
});
}
let Some(mut focus) = self.seat.get_pointer().and_then(|pointer| pointer.current_focus()) else {
return false;
};
while let Some(parent) = get_parent(&focus) {
focus = parent;
}
if let Some(popup) = self.popups.find_popup(&focus)
&& let Ok(root) = smithay::desktop::find_popup_root_surface(&popup)
{
focus = root;
}
self.windows
.id_for_surface(&focus)
.and_then(|id| self.windows.window(id))
.is_some_and(|window| self.capture_protected(window))
}
pub(crate) fn capture_window_allowed(&self, id: ferese_layout::WindowId, epoch: u64) -> bool {
epoch == self.capture_epoch
&& self
.windows
.window(id)
.is_some_and(|window| !self.capture_protected(window))
}
/// Deferred readbacks and encoders must not publish across a policy change,
/// even if a subsequent change restores the original policy.
pub(crate) fn refresh_capture_privacy(&mut self) {
self.update_capture_cursor_privacy();
let protected = self
.windows
.ids()
.iter()
.filter(|(window, _)| self.capture_protected(window))
.map(|(_, id)| *id)
.collect();
if self.capture_protected_windows == protected {
return;
}
self.capture_protected_windows = protected;
self.update_capture_cursor_privacy();
self.capture_epoch = self.capture_epoch.wrapping_add(1);
self.screenshot
.terminate_all_with_reason("Screenshot cancelled: capture privacy changed");
// Drop cached blur pixels as well as their damage history.
self.capture_render = Default::default();
for output in self.space.outputs().cloned().collect::<Vec<_>>() {
self.defer_output_redraw(output);
}
}
}
#[cfg(test)]
#[path = "capture_privacy_tests.rs"]
mod tests;
@@ -0,0 +1,595 @@
//! Private protocol clients and offscreen EGL; no host desktop or DRM required.
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, RawFd};
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::Duration;
use smithay::backend::allocator::Fourcc;
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
use smithay::backend::renderer::{Bind, ExportMem, Offscreen};
use smithay::desktop::Window;
use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase;
use smithay::reexports::wayland_server::protocol::{wl_compositor::WlCompositor, wl_shm::WlShm};
use smithay::reexports::wayland_server::{Display, Resource};
use smithay::utils::{Rectangle, Transform};
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::{XdgShellHandler, XdgToplevelSurfaceData, XdgWmBaseUserData};
use crate::Ferese;
use crate::state::{ClientState, DesktopOutput};
fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32], fd: Option<RawFd>) {
let bytes = [object, (((args.len() + 2) * 4) as u32) << 16 | opcode]
.into_iter()
.chain(args.iter().copied())
.flat_map(u32::to_ne_bytes)
.collect::<Vec<_>>();
if let Some(fd) = fd {
let mut iov = libc::iovec {
iov_base: bytes.as_ptr().cast_mut().cast(),
iov_len: bytes.len(),
};
let mut control = [0usize; 8];
// SAFETY: aligned control storage and iovec remain live for sendmsg;
// the one SCM_RIGHTS entry contains an owned, open tempfile descriptor.
unsafe {
let mut msg: libc::msghdr = std::mem::zeroed();
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = control.as_mut_ptr().cast();
msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::<RawFd>() as u32) as usize;
let header = libc::CMSG_FIRSTHDR(&msg);
(*header).cmsg_level = libc::SOL_SOCKET;
(*header).cmsg_type = libc::SCM_RIGHTS;
(*header).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as usize;
libc::CMSG_DATA(header).cast::<RawFd>().write(fd);
assert_eq!(libc::sendmsg(wire.as_raw_fd(), &msg, 0), bytes.len() as isize);
}
} else {
wire.write_all(&bytes).unwrap();
}
}
fn dispatch(events: &mut EventLoop<'static, Ferese>, state: &mut Ferese) {
events.dispatch(Duration::from_millis(1), state).unwrap();
state.display_handle.flush_clients().unwrap();
}
fn ack_configure(wire: &mut UnixStream, xdg: u32) {
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
wire.set_nonblocking(false).unwrap();
let mut serial = None;
let mut offset = 0;
while offset < bytes.len() {
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
if object == xdg && header & 0xffff == 0 {
serial = Some(u32::from_ne_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()));
}
offset += (header >> 16) as usize;
}
assert!(serial.is_some(), "missing configure for {xdg}: {bytes:?}");
request(wire, xdg, 4, &[serial.unwrap()], None);
}
fn window(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, color: u32) -> (Window, UnixStream) {
let (server, mut wire) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(ClientState::default()))
.unwrap();
let compositor = client
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
.unwrap();
let shell = client
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(&state.display_handle, 6, Default::default())
.unwrap();
let shm = client
.create_resource::<WlShm, (), Ferese>(&state.display_handle, 1, ())
.unwrap();
request(&mut wire, compositor.id().protocol_id(), 0, &[2], None);
request(&mut wire, shell.id().protocol_id(), 2, &[3, 2], None);
request(&mut wire, 3, 1, &[4], None);
request(&mut wire, 2, 6, &[], None);
dispatch(events, state);
ack_configure(&mut wire, 3);
let mut file = tempfile::tempfile().unwrap();
file.write_all(&color.to_ne_bytes().repeat(64 * 48)).unwrap();
request(
&mut wire,
shm.id().protocol_id(),
0,
&[5, 64 * 48 * 4],
Some(file.as_raw_fd()),
);
request(&mut wire, 5, 0, &[6, 0, 64, 48, 64 * 4, 0], None);
request(&mut wire, 3, 3, &[0, 0, 64, 48], None);
request(&mut wire, 2, 1, &[6, 0, 0], None);
request(&mut wire, 2, 2, &[0, 0, 64, 48], None);
request(&mut wire, 2, 6, &[], None);
dispatch(events, state);
let window = state
.windows
.ids()
.keys()
.find(|window| {
window
.toplevel()
.unwrap()
.wl_surface()
.client()
.is_some_and(|owner| owner.id() == client.id())
})
.expect("mapped window")
.clone();
(window, wire)
}
fn attach_children(
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
window: &Window,
wire: &mut UnixStream,
) {
use smithay::reexports::wayland_server::protocol::wl_subcompositor::WlSubcompositor;
let client = window.toplevel().unwrap().wl_surface().client().unwrap();
let compositor = client
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
.unwrap();
let subcompositor = client
.create_resource::<WlSubcompositor, (), Ferese>(&state.display_handle, 1, ())
.unwrap();
request(wire, compositor.id().protocol_id(), 0, &[7], None);
request(wire, subcompositor.id().protocol_id(), 1, &[8, 7, 2], None);
request(wire, 8, 1, &[11, 11], None);
request(wire, 7, 1, &[6, 0, 0], None);
request(wire, 7, 6, &[], None);
request(wire, 2, 6, &[], None);
let shell = client
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(&state.display_handle, 6, Default::default())
.unwrap();
request(wire, compositor.id().protocol_id(), 0, &[9], None);
request(wire, shell.id().protocol_id(), 2, &[10, 9], None);
request(wire, shell.id().protocol_id(), 1, &[11], None);
request(wire, 11, 1, &[32, 16], None);
request(wire, 11, 2, &[32, 16, 1, 1], None);
request(wire, 10, 2, &[12, 3, 11], None);
request(wire, 9, 6, &[], None);
dispatch(events, state);
ack_configure(wire, 10);
request(wire, 10, 3, &[0, 0, 32, 16], None);
request(wire, 9, 1, &[6, 0, 0], None);
request(wire, 9, 6, &[], None);
dispatch(events, state);
assert_eq!(
smithay::desktop::PopupManager::popups_for_surface(window.toplevel().unwrap().wl_surface()).count(),
1
);
}
fn app_id(state: &mut Ferese, window: &Window, app: &str) {
let top = window.toplevel().unwrap();
with_states(top.wl_surface(), |states| {
states
.data_map
.get::<XdgToplevelSurfaceData>()
.unwrap()
.lock()
.unwrap()
.app_id = Some(app.into());
});
state.app_id_changed(top.clone());
}
fn pixels(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
capture: bool,
texture: &mut GlesTexture,
) -> Vec<u8> {
let scene = state.sample_frame(output, Duration::ZERO);
if capture {
let (sender, receiver) = smithay::reexports::calloop::channel::channel();
state
.pending_screencopies
.push(crate::handlers::screencopy::PendingScreencopy::owned(
1,
0,
sender,
output.clone(),
Rectangle::from_size((320, 240).into()),
));
let mut target = Some(texture.clone());
crate::backends::direct::capture::capture_output(state, renderer, &mut target, output, &scene).unwrap();
let mut result = receiver.try_recv().unwrap().result.unwrap().pixels;
for pixel in result.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
return result;
}
let elements = crate::render::sampled_output_elements(state, renderer, output, false, &scene);
let mut target = renderer.bind(texture).unwrap();
crate::render::redraw_output(renderer, &mut target, output, &elements).unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size((320, 240).into()), Fourcc::Abgr8888)
.unwrap();
renderer.map_texture(&mapping).unwrap().to_vec()
}
#[test]
#[ignore = "requires an EGL device; uses a disposable runtime and private protocol clients"]
fn capture_privacy_pixels_and_policy_transitions() {
if std::env::var_os("FERESE_PRIVACY_TEST_CHILD").is_none() {
let runtime = tempfile::tempdir().unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"state::capture_privacy::tests::capture_privacy_pixels_and_policy_transitions",
"--ignored",
"--nocapture",
])
.env("FERESE_PRIVACY_TEST_CHILD", "1")
.env("FERESE_ENABLE_SCREENCOPY", "1")
.env("XDG_RUNTIME_DIR", runtime.path())
.env("XDG_CONFIG_HOME", runtime.path())
.env("XDG_STATE_HOME", runtime.path())
.env_remove("FERESE_SOCKET")
.output()
.unwrap();
assert!(
result.status.success(),
"{}\n{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
return;
}
let mut events = EventLoop::try_new().unwrap();
let config = crate::config::Config::parse_source(
"animations { reduced-motion #true; }\nwindow-rule transient=#false floating=#true\n",
)
.unwrap()
.runtime_config()
.unwrap();
let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap();
let output = Output::new(
"privacy".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
let desktop = DesktopOutput {
output: output.clone(),
identity: "privacy".into(),
mode: Mode {
size: (320, 240).into(),
refresh: 60_000,
},
transform: Transform::Normal,
scale: Scale::Fractional(1.0),
position: (0, 0).into(),
};
state.begin_desktop_transition();
let changes = state.publish_desktop(vec![desktop]).unwrap();
state.finish_desktop_transition(changes);
let (secret, mut secret_wire) = window(&mut state, &mut events, 0xffff0000);
attach_children(&mut state, &mut events, &secret, &mut secret_wire);
let id = state.windows.ids()[&secret];
assert!(state.window_content_ready(&secret));
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let mut display_texture =
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap();
let mut capture_texture =
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap();
let has_red = |bytes: &[u8]| bytes.chunks_exact(4).any(|pixel| pixel == [255, 0, 0, 255]);
assert!(has_red(&pixels(
&mut state,
&mut renderer,
&output,
false,
&mut display_texture
)));
assert!(has_red(&pixels(
&mut state,
&mut renderer,
&output,
true,
&mut capture_texture
)));
let epoch = state.capture_epoch;
assert!(!state.has_capture_exclusions());
crate::handlers::window_capture::tests::check_deferred_snapshot(&state, &secret, epoch, true);
app_id(&mut state, &secret, "ordinary");
assert_eq!(
state.capture_epoch, epoch,
"unmatched metadata must not interrupt capture"
);
app_id(&mut state, &secret, "dev.ferese.Authentication");
assert!(state.capture_protected(&secret));
assert!(state.has_capture_exclusions());
assert!(!state.capture_window_allowed(id, epoch));
crate::handlers::window_capture::tests::check_deferred_snapshot(&state, &secret, epoch, false);
assert!(
!has_red(&pixels(&mut state, &mut renderer, &output, true, &mut capture_texture)),
"reused capture target leaked previous pixels"
);
assert!(
has_red(&pixels(&mut state, &mut renderer, &output, false, &mut display_texture)),
"display lost protected content"
);
app_id(&mut state, &secret, "ordinary");
assert!(
!state.capture_window_allowed(id, epoch),
"protect then unprotect must invalidate deferred frames"
);
assert!(state.capture_window_allowed(id, state.capture_epoch));
crate::handlers::window_capture::tests::check_deferred_snapshot(&state, &secret, epoch, false);
assert!(has_red(&pixels(
&mut state,
&mut renderer,
&output,
true,
&mut capture_texture
)));
let rules =
crate::config::Config::parse_source("window-rule app-id=\"ordinary\" block-out-from-screencasts=#true\n")
.unwrap()
.window_rules()
.unwrap();
state.window_rules = rules;
state.refresh_capture_privacy();
assert!(state.capture_protected(&secret));
assert!(!has_red(&pixels(
&mut state,
&mut renderer,
&output,
true,
&mut capture_texture
)));
for transform in [
Transform::Normal,
Transform::_90,
Transform::_180,
Transform::_270,
Transform::Flipped,
Transform::Flipped90,
Transform::Flipped180,
Transform::Flipped270,
] {
for scale in [1.0, 1.25] {
output.change_current_state(None, Some(transform), Some(Scale::Fractional(scale)), None);
assert!(
has_red(&pixels(&mut state, &mut renderer, &output, false, &mut display_texture)),
"display {transform:?}/{scale}"
);
assert!(
!has_red(&pixels(&mut state, &mut renderer, &output, true, &mut capture_texture)),
"capture {transform:?}/{scale}"
);
}
}
output.change_current_state(None, Some(Transform::Normal), Some(Scale::Fractional(1.0)), None);
// An otherwise unprotected child still inherits its parent's protection.
let (child, _child_wire) = window(&mut state, &mut events, 0xff00ff00);
with_states(child.toplevel().unwrap().wl_surface(), |states| {
states
.data_map
.get::<XdgToplevelSurfaceData>()
.unwrap()
.lock()
.unwrap()
.parent = Some(secret.toplevel().unwrap().wl_surface().clone());
});
state.parent_changed(child.toplevel().unwrap().clone());
assert!(state.capture_protected(&child));
let captured = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
assert!(!has_red(&captured));
assert!(!captured.chunks_exact(4).any(|pixel| pixel == [0, 255, 0, 255]));
state.set_overview_active(true);
let overview = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
assert!(!has_red(&overview));
assert!(!overview.chunks_exact(4).any(|pixel| pixel == [0, 255, 0, 255]));
state.set_overview_active(false);
// Region requests go through the same filtered output readback.
let (sender, receiver) = smithay::reexports::calloop::channel::channel();
state
.pending_screencopies
.push(crate::handlers::screencopy::PendingScreencopy::owned(
2,
0,
sender,
output.clone(),
Rectangle::new((32, 24).into(), (160, 120).into()),
));
let scene = state.sample_frame(&output, Duration::ZERO);
crate::backends::direct::capture::capture_output(
&mut state,
&mut renderer,
&mut Some(capture_texture.clone()),
&output,
&scene,
)
.unwrap();
let region = receiver.try_recv().unwrap().result.unwrap();
assert_eq!((region.width, region.height), (160, 120));
assert!(
!region
.pixels
.chunks_exact(4)
.any(|pixel| pixel == [0, 0, 255, 255] || pixel == [0, 255, 0, 255])
);
// A transient remains protected when moved to a different monitor.
let second = Output::new(
"second".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
let mut desktop = state.current_desktop_outputs();
desktop.push(DesktopOutput {
output: second.clone(),
identity: "second".into(),
mode: Mode {
size: (320, 240).into(),
refresh: 60_000,
},
transform: Transform::Normal,
scale: Scale::Fractional(1.0),
position: (400, 0).into(),
});
state.begin_desktop_transition();
let changes = state.publish_desktop(desktop).unwrap();
state.finish_desktop_transition(changes);
let destination = state
.output_workspaces
.active_workspace(state.output_ids[&second])
.unwrap();
let child_id = state.windows.ids()[&child];
state
.workspaces
.move_window_to_workspace(child_id, destination, ferese_layout::Axis::Horizontal, 0.5)
.unwrap();
state.relayout();
let rect = ferese_layout::Rect::new(440.0, 40.0, 64.0, 48.0);
state.workspaces.set_floating_rect(child_id, rect).unwrap();
let mut geometry = ferese_animation::WindowGeometry::new(rect, None);
geometry.client.committed_size = Some(ferese_animation::ClientSize::from_rect(rect));
state.windows.set_geometry(child_id, geometry);
state.space.map_element(child.clone(), (440, 40), false);
assert!(
pixels(&mut state, &mut renderer, &second, false, &mut display_texture)
.chunks_exact(4)
.any(|pixel| pixel == [0, 255, 0, 255])
);
assert!(
!pixels(&mut state, &mut renderer, &second, true, &mut capture_texture)
.chunks_exact(4)
.any(|pixel| pixel == [0, 255, 0, 255])
);
// Unprotected content still captures, including translucent pixels.
let (public, mut public_wire) = window(&mut state, &mut events, 0x80000080);
let captured = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
assert!(captured.chunks_exact(4).any(|pixel| pixel[2] > 100));
assert!(!has_red(&captured));
// A translucent material over the secret must never reuse display blur.
use ferese_protocols::material::v1::server::ferese_material_manager_v1::FereseMaterialManagerV1;
let client = public.toplevel().unwrap().wl_surface().client().unwrap();
let manager = client
.create_resource::<FereseMaterialManagerV1, (), Ferese>(&state.display_handle, 1, ())
.unwrap();
request(&mut public_wire, manager.id().protocol_id(), 1, &[7, 2], None);
dispatch(&mut events, &mut state);
state.theme_settings.material_style = crate::config::MaterialStyle::Translucent;
state.theme_settings.backdrop_blur = 12.0;
// Place the glass over the secret, preserving the protocol-owned buffer.
let public_id = state.windows.ids()[&public];
let secret_geometry = *state.windows.geometry(&id).unwrap();
state.windows.set_geometry(public_id, secret_geometry);
state
.space
.map_element(public.clone(), state.space.element_location(&secret).unwrap(), true);
let before = pixels(&mut state, &mut renderer, &output, false, &mut display_texture);
let protected = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
state.space.unmap_elem(&secret);
state.space.unmap_elem(&child);
let absent = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
assert_eq!(protected, absent, "protected content leaked through a cached material");
let without = pixels(&mut state, &mut renderer, &output, false, &mut display_texture);
assert_ne!(before, without, "fixture must place protected content behind glass");
// A stationary client's cursor is a separate surface and can outlive its
// protected toplevel. Never reveal that retained image after an opt-out or close.
use smithay::input::{SeatHandler, pointer::CursorImageStatus};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
let client = secret.toplevel().unwrap().wl_surface().client().unwrap();
let compositor = client
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
.unwrap();
request(&mut secret_wire, compositor.id().protocol_id(), 0, &[13], None);
request(&mut secret_wire, 13, 1, &[6, 0, 0], None);
request(&mut secret_wire, 13, 6, &[], None);
dispatch(&mut events, &mut state);
let cursor = client
.object_from_protocol_id::<WlSurface>(&state.display_handle, 13)
.unwrap();
let seat = state.seat.clone();
state.cursor_image(&seat, CursorImageStatus::Surface(cursor.clone()));
assert!(state.capture_cursor_protected());
app_id(&mut state, &secret, "public-again");
state.cursor_image(&seat, CursorImageStatus::Surface(cursor.clone()));
assert!(state.capture_cursor_protected());
let mut presentation = state.current_window_presentation(id).unwrap();
presentation.close();
let snapshot = crate::render::capture_resize_snapshot(
&mut renderer,
&secret,
secret.geometry(),
1.0,
crate::presentation::SNAPSHOT_BUDGET,
)
.unwrap()
.unwrap();
state.remove_tiled_window(&secret);
assert!(state.capture_cursor_protected());
assert!(matches!(&state.cursor_status, CursorImageStatus::Surface(current) if current == &cursor));
state.cursor_image(&seat, CursorImageStatus::Hidden);
assert!(!state.capture_cursor_protected());
assert!(!state.has_capture_exclusions());
// A close snapshot remains visible after the protected client leaves the
// registry. The nested readback shortcut must still use the filtered scene.
state.space.unmap_elem(&public);
state.space.unmap_elem(&child);
state.render.closing.push(crate::render::ClosedWindow {
presentation,
output: state.output_ids[&output],
below: None,
snapshot,
handoff: None,
radius: 0.0,
shape: crate::presentation::CornerShape::Continuous,
decorations: 0.0,
dim: 0.0,
fill: None,
material: None,
});
assert!(
state.has_capture_exclusions(),
"close snapshot bypassed capture filtering"
);
assert!(has_red(&pixels(
&mut state,
&mut renderer,
&output,
false,
&mut display_texture
)));
assert!(!has_red(&pixels(
&mut state,
&mut renderer,
&output,
true,
&mut capture_texture
)));
}
+1
View File
@@ -101,6 +101,7 @@ impl Ferese {
self.output_redraw_pending.retain(|pending| pending != output);
if let Some(id) = self.output_ids.get(output) {
self.render.remove_output(*id);
self.capture_render.remove_output(*id);
}
self.pending_screencopies.retain(|capture| {
if capture.output == *output {
+6
View File
@@ -116,6 +116,7 @@ impl Ferese {
}
self.windows.register_window(window.clone(), id);
self.refresh_capture_privacy();
self.windows.update(id, |w| w.placement_anchor = Some(anchor));
if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() && remembered_work.is_none()
@@ -157,6 +158,7 @@ impl Ferese {
}
self.windows.register_window(window.clone(), id);
self.refresh_capture_privacy();
self.window_stack.insert(id);
if focus_new_window {
self.focused_window = Some(id);
@@ -197,6 +199,7 @@ impl Ferese {
}
self.windows.register_window(window.clone(), id);
self.refresh_capture_privacy();
self.window_stack.insert(id);
if client_size(&window).is_none() {
self.windows.update(id, |w| w.natural_floating_pending = true);
@@ -417,6 +420,7 @@ impl Ferese {
}
pub fn remove_tiled_window(&mut self, window: &Window) {
self.update_capture_cursor_privacy();
self.retain_closed_window(window);
// Uncommitted toplevels have not entered a workspace yet.
self.space.unmap_elem(window);
@@ -424,11 +428,13 @@ impl Ferese {
return;
};
self.refresh_capture_privacy();
self.focus_history.remove(id);
#[cfg(feature = "resize-metrics")]
self.resize_metrics
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled);
self.render.remove_window(id);
self.capture_render.remove_window(id);
self.window_stack.remove(id);
self.floating_above_fullscreen.remove(&id);
if let Err(error) = self.workspaces.remove_window(id) {
+68 -1
View File
@@ -10,6 +10,7 @@ pub(crate) struct WindowRuleConfig {
pub width: Option<f64>,
pub height: Option<f64>,
pub fullscreen: Option<bool>,
pub block_out_from_screencasts: Option<bool>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -22,6 +23,7 @@ pub struct WindowRule {
width: Option<f64>,
height: Option<f64>,
fullscreen: Option<bool>,
block_out_from_screencasts: Option<bool>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
@@ -31,14 +33,18 @@ pub struct WindowRuleResult {
pub width: Option<f64>,
pub height: Option<f64>,
pub fullscreen: Option<bool>,
pub block_out_from_screencasts: Option<bool>,
}
/// Only changed matches should override an existing window's manual state.
pub(crate) fn live_result(
old: WindowRuleResult,
mut old: WindowRuleResult,
mut new: WindowRuleResult,
transient: bool,
) -> Option<WindowRuleResult> {
// Capture policy is resolved independently of placement at capture time.
old.block_out_from_screencasts = None;
new.block_out_from_screencasts = None;
if old == new {
return None;
}
@@ -70,6 +76,10 @@ pub fn resolve(rules: &[WindowRule], app_id: Option<&str>, title: Option<&str>,
result.floating = Some(true);
}
if app_id.as_deref() == Some("dev.ferese.authentication") {
result.block_out_from_screencasts = Some(true);
}
for rule in rules {
let app_id_matches = rule
.app_id
@@ -86,6 +96,7 @@ pub fn resolve(rules: &[WindowRule], app_id: Option<&str>, title: Option<&str>,
result.width = rule.width.or(result.width);
result.height = rule.height.or(result.height);
result.fullscreen = rule.fullscreen.or(result.fullscreen);
result.block_out_from_screencasts = rule.block_out_from_screencasts.or(result.block_out_from_screencasts);
}
result
@@ -125,6 +136,7 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result<WindowRule, St
width,
height,
fullscreen: rule.fullscreen,
block_out_from_screencasts: rule.block_out_from_screencasts,
})
}
@@ -202,9 +214,63 @@ mod tests {
width: None,
height: None,
fullscreen: None,
block_out_from_screencasts: None,
}
}
#[test]
fn capture_privacy_defaults_overrides_and_placement_are_independent() {
for app_id in ["dev.ferese.Authentication", "dev.ferese.Authentication.desktop"] {
assert_eq!(
resolve(&[], Some(app_id), None, false).block_out_from_screencasts,
Some(true)
);
let rules = validate(&[WindowRuleConfig {
block_out_from_screencasts: Some(false),
..config(app_id)
}])
.unwrap();
assert_eq!(
resolve(&rules, Some(app_id), None, false).block_out_from_screencasts,
Some(false)
);
}
assert_eq!(
resolve(&[], Some("editor"), None, false).block_out_from_screencasts,
None
);
let rules = validate(&[
WindowRuleConfig {
block_out_from_screencasts: Some(true),
..config("editor")
},
WindowRuleConfig {
title: Some("Public".into()),
block_out_from_screencasts: Some(false),
..config("editor")
},
])
.unwrap();
assert_eq!(
resolve(&rules, Some("editor"), Some("Secret"), false).block_out_from_screencasts,
Some(true)
);
assert_eq!(
resolve(&rules, Some("editor"), Some("Public"), false).block_out_from_screencasts,
Some(false)
);
let old = WindowRuleResult {
width: Some(800.),
floating: Some(true),
..Default::default()
};
let new = WindowRuleResult {
block_out_from_screencasts: Some(true),
..old
};
assert_eq!(live_result(old, new, false), None);
}
#[test]
fn native_dialogs_float_by_default_and_allow_user_overrides() {
for app_id in [
@@ -279,6 +345,7 @@ mod tests {
width: None,
height: None,
fullscreen: None,
block_out_from_screencasts: None,
};
let invalid_size = WindowRuleConfig {
width: Some(f64::NAN),
+21 -11
View File
@@ -18,7 +18,7 @@ use smithay::wayland::presentation::Refresh;
use crate::Ferese;
use crate::metrics::{FrameEffectMetrics, RenderMetrics};
use crate::render::{animated_window_elements, frame_effect_metrics, layer_surfaces, output_elements, redraw_output};
use crate::render::{animated_window_elements, frame_effect_metrics, layer_surfaces};
pub(crate) type NestedBackend = Rc<RefCell<winit::WinitGraphicsBackend<GlesRenderer>>>;
type DamageRenderResult = Result<
@@ -89,6 +89,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
TimeoutAction::ToDuration(redraw_refresh.get())
})?;
let mut capture_texture = None;
event_loop
.handle()
.insert_source(event_source, move |event, _, state| {
@@ -140,16 +141,25 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
&elements,
[0.035, 0.04, 0.055, 1.0],
)?;
let cursorless_capture = state.has_pending_screencopy(&output, false);
let captured_with_cursor =
state.process_screencopies(renderer, &framebuffer, &output, true);
if cursorless_capture {
let cursorless_elements = output_elements(state, renderer, &output, false);
redraw_output(renderer, &mut framebuffer, &output, &cursorless_elements)?;
state.process_screencopies(renderer, &framebuffer, &output, false);
redraw_output(renderer, &mut framebuffer, &output, &elements)?;
} else if captured_with_cursor {
// Keep the existing readback-only path when the
// displayed scene has no privacy exclusions.
let mut capture_changed_binding = !state.has_capture_exclusions()
&& state.process_screencopies(renderer, &framebuffer, &output, true);
if state.has_pending_screencopy(&output, false)
|| state.has_pending_screencopy(&output, true)
{
let scene = state.sample_frame(&output, Duration::ZERO);
crate::backends::direct::capture::capture_output(
state,
renderer,
&mut capture_texture,
&output,
&scene,
)?;
capture_changed_binding = true;
}
if capture_changed_binding {
// Restore the display framebuffer binding after offscreen readback.
let _ = renderer
.render(
&mut framebuffer,
+18
View File
@@ -533,6 +533,24 @@ window rules can override that behavior.
| `floating` | boolean | Leave placement unchanged |
| `width`, `height` | numbers > 0 | Application-chosen floating size |
| `fullscreen` | boolean | Leave fullscreen state unchanged |
| `block-out-from-screencasts` | boolean | Exclude from captures; enabled by default for Ferese authentication dialogs |
`window-rule app-id="org.example.Private" block-out-from-screencasts=#true`
keeps matching windows visible on the display but omits them (including their
popups, subsurfaces, shadows and overview previews) from monitor and region
captures. The background or windows behind them remain visible in the capture.
Transient children inherit protection even on another output. Direct window
capture fails, ending an active window stream. Custom cursors from a client with
a protected window are also omitted until the cursor image is replaced.
Detached close-animation snapshots are omitted from captures.
This applies to portal sharing, recording, native capture protocols and screenshots:
these routes share capture buffers. The rule is checked against current app-ID,
title and parent metadata, and updates on config reload. Other placement rules
retain their existing lifecycle. Later matching rules may set the field to `#false`,
including for `dev.ferese.Authentication`; a protected parent still protects its
children. A protection change cancels deferred screenshots and window frames.
Frames already delivered to a capture client cannot be recalled.
Every rule needs at least one matcher, and supplied matchers must all match.
Rules apply in order; later fields override earlier ones. Dimensions imply
+11
View File
@@ -72,6 +72,17 @@ Encoding needs the recorder's GStreamer plugins. The transport test needs libei
and uses synthetic input over a private socket pair. Hardware pointer barriers,
multiple monitors, display sleep, and live authentication still need session tests.
## Capture privacy checks
The capture privacy regression uses disposable Wayland clients and offscreen EGL
(including software Mesa); it does not capture the host desktop or validate DRM:
```sh
cargo test --locked -p ferese capture_privacy_pixels_and_policy_transitions -- --ignored
cargo test --locked -p ferese backends::direct::capture::tests -- --ignored
cargo test --locked -p ferese mirrored_pixels_fit -- --ignored
```
## Report a bug
Include the commit or installed release, reproduction steps, backend (nested or
+35 -5
View File
@@ -3,6 +3,7 @@
FERESE_TEST_WINDOW_CAPTURE=1 python3 scripts/tests/test_window_capture_isolated.py
Requires built debug binaries, a Wayland host, PipeWire, gst-launch-1.0,
cc, wayland-scanner and Pillow.
Override FERESE_TEST_BINARY, FERESE_TEST_CTL and FERESE_TEST_PORTAL to test a release build.
"""
import io
import json
@@ -16,6 +17,9 @@ import time
import unittest
REPO = Path(__file__).resolve().parents[2]
COMPOSITOR = Path(os.environ.get("FERESE_TEST_BINARY", REPO / "target/debug/ferese"))
CTL = Path(os.environ.get("FERESE_TEST_CTL", REPO / "target/debug/feresectl"))
PORTAL = Path(os.environ.get("FERESE_TEST_PORTAL", REPO / "target/debug/xdg-desktop-portal-ferese"))
@unittest.skipUnless(os.environ.get("FERESE_TEST_WINDOW_CAPTURE") == "1", "requires a Wayland host")
@@ -54,7 +58,7 @@ class WindowCapture(unittest.TestCase):
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)
compositor = subprocess.Popen([str(COMPOSITOR), "--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():
@@ -65,7 +69,7 @@ class WindowCapture(unittest.TestCase):
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)
return subprocess.check_output([str(CTL), *args], env=env, timeout=10)
def windows(count):
deadline = time.monotonic() + 10
@@ -94,7 +98,7 @@ class WindowCapture(unittest.TestCase):
# Exercise the asynchronous conversion worker and reuse its render
# target across a stream, not just the synchronous screenshot path.
stream_env = dict(env, PIPEWIRE_REMOTE=str(Path(os.environ["XDG_RUNTIME_DIR"]) / "pipewire-0"))
worker = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"),
worker = subprocess.Popen([str(PORTAL),
"--stream-window", str(target["id"]), "hidden"],
env=stream_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=log, start_new_session=True)
@@ -105,7 +109,7 @@ class WindowCapture(unittest.TestCase):
ready = json.loads(worker.stdout.readline())
self.assertEqual((ready["width"], ready["height"]), (round(640 * scale), round(480 * scale)))
raw = root / "frames.bgrx"
subprocess.run(["gst-launch-1.0", "-q", "pipewiresrc", f'path={ready["node"]}',
subprocess.run(["gst-launch-1.0", "-q", "pipewiresrc", "min-buffers=4", f'path={ready["node"]}',
"num-buffers=15", "!", "video/x-raw,format=BGRx", "!", "filesink",
f"location={raw}"], env=stream_env, check=True, timeout=20)
pixels = raw.read_bytes()
@@ -118,9 +122,35 @@ class WindowCapture(unittest.TestCase):
blue = start + ((height * 3 // 4) * width + width // 2) * 4
self.assertEqual(pixels[red:red + 3], bytes([0, 0, 255]))
self.assertEqual(pixels[blue:blue + 3], bytes([255, 0, 0]))
worker.stdin.close()
# A live rule change revokes the window stream and synchronous
# window screenshot route, while normal display mapping remains.
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n'
'window-rule title="Capture target" block-out-from-screencasts=#true\n')
call("reload-config")
self.assertEqual(worker.wait(timeout=7), 0)
worker.stdin.close()
worker.stdout.close()
rejected = subprocess.run([str(CTL), "screenshot-window", str(target["id"])],
env=env, capture_output=True, timeout=10)
self.assertNotEqual(rejected.returncode, 0)
self.assertIn(b"protected", rejected.stderr)
self.assertTrue(next(window for window in windows(2) if window["id"] == target["id"])["mapped"])
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))
os.killpg(processes[2].pid, signal.SIGTERM)
processes[2].wait(timeout=5)
protected = windows(1)[0]
self.assertTrue(protected["mapped"])
for args in [("screenshot",), ("screenshot", "--geometry",
f'{protected["x"]},{protected["y"]} {protected["width"]}x{protected["height"]}')]:
with Image.open(io.BytesIO(call(*args))) as image:
self.assertFalse(any(pixel in ((255, 0, 0), (0, 0, 255))
for pixel in image.convert("RGB").getdata()))
# Reusing the capture target after opt-out must produce a fresh frame.
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n')
call("reload-config")
with Image.open(io.BytesIO(call("screenshot-window", str(target["id"])))) as image:
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 4)), (255, 0, 0))
finally:
for process in reversed(processes):
if process.poll() is None: