diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index a09043c..b403787 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -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, mirror_texture: Option, + mirror_capture_texture: Option, mirror_canvas: Option, 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(), diff --git a/compositor/src/backends/direct/capture.rs b/compositor/src/backends/direct/capture.rs index f9b833f..a3e8e31 100644 --- a/compositor/src/backends/direct/capture.rs +++ b/compositor/src/backends/direct/capture.rs @@ -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, @@ -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>( renderer: &mut GlesRenderer, texture: &'a mut Option, diff --git a/compositor/src/backends/direct/mirror.rs b/compositor/src/backends/direct/mirror.rs index e1a0a96..05e6362 100644 --- a/compositor/src/backends/direct/mirror.rs +++ b/compositor/src/backends/direct/mirror.rs @@ -47,7 +47,7 @@ pub(super) fn elements( ]) } -fn compose( +pub(super) fn compose( renderer: &mut GlesRenderer, texture: &mut Option, 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, diff --git a/compositor/src/config.rs b/compositor/src/config.rs index b4b8be3..cfda30b 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -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] diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 2903401..608bec9 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -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. diff --git a/compositor/src/handlers/screenshot.rs b/compositor/src/handlers/screenshot.rs index 94ab920..84f0e4d 100644 --- a/compositor/src/handlers/screenshot.rs +++ b/compositor/src/handlers/screenshot.rs @@ -560,9 +560,13 @@ impl Coordinator { } pub(crate) fn terminate_all(&mut self) -> Vec { + self.terminate_all_with_reason("Screenshot cancelled: the session was locked") + } + + pub(crate) fn terminate_all_with_reason(&mut self, reason: &str) -> Vec { let ids: Vec = 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); diff --git a/compositor/src/handlers/window_capture.rs b/compositor/src/handlers/window_capture.rs index e332a62..65561cc 100644 --- a/compositor/src/handlers/window_capture.rs +++ b/compositor/src/handlers/window_capture.rs @@ -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::( + &state.display_handle, + 1, + Global { + budget: budget.clone(), + worker, + }, + ) + .unwrap(); + let data = frame_data(); + let frame = client + .create_resource::, 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::, Ferese>(&state.display_handle, 3, data.clone()) + .unwrap(); + let buffer = client + .object_from_protocol_id::(&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(); diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index c9d6ef6..e32b258 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -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; }; diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index ba37797..4f810cf 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -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() diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index f8d067b..a918860 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -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) } diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index b518d1c..b882e6c 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -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; diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index 156d945..bdef7ac 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -91,6 +91,7 @@ pub(super) fn overview_strip_elements( output_geometry: Rectangle, scale: f64, frame: &crate::state::FrameScene, + capture: bool, ) -> Vec { 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::>(); 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) { diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index f482c8e..338fa5b 100644 --- a/compositor/src/render/scene.rs +++ b/compositor/src/render/scene.rs @@ -31,6 +31,32 @@ pub(crate) fn sampled_output_elements( output: &Output, include_cursor: bool, frame: &crate::state::FrameScene, +) -> Vec { + 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 { + // 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 { 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)?; diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 68de25e..37445d0 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -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" diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 7ba65ed..0a741eb 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -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, pub(crate) windows: WindowRegistry, pub(crate) render: crate::render::RenderResources, + pub(crate) capture_render: crate::render::RenderResources, + pub(crate) capture_epoch: u64, + capture_protected_windows: HashSet, + capture_protected_cursor: Option, pub(crate) daemons: Option>>, pub(crate) display_presentation: crate::display_presentation::DisplayPresentation, pub(crate) nested_backend: Option, @@ -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); } diff --git a/compositor/src/state/capture_privacy.rs b/compositor/src/state/capture_privacy.rs new file mode 100644 index 0000000..33d93a6 --- /dev/null +++ b/compositor/src/state/capture_privacy.rs @@ -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(¤t) { + return true; + } + visited.push(current.clone()); + let (blocked, parent) = with_states(¤t, |states| { + let Some(data) = states.data_map.get::() 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::>() { + self.defer_output_redraw(output); + } + } +} + +#[cfg(test)] +#[path = "capture_privacy_tests.rs"] +mod tests; diff --git a/compositor/src/state/capture_privacy_tests.rs b/compositor/src/state/capture_privacy_tests.rs new file mode 100644 index 0000000..381c76f --- /dev/null +++ b/compositor/src/state/capture_privacy_tests.rs @@ -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) { + let bytes = [object, (((args.len() + 2) * 4) as u32) << 16 | opcode] + .into_iter() + .chain(args.iter().copied()) + .flat_map(u32::to_ne_bytes) + .collect::>(); + 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::() 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::() as u32) as usize; + libc::CMSG_DATA(header).cast::().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::(&state.display_handle, 6, ()) + .unwrap(); + let shell = client + .create_resource::(&state.display_handle, 6, Default::default()) + .unwrap(); + let shm = client + .create_resource::(&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::(&state.display_handle, 6, ()) + .unwrap(); + let subcompositor = client + .create_resource::(&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::(&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::() + .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 { + 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::::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap(); + let mut capture_texture = + Offscreen::::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::() + .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::(&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::(&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::(&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 + ))); +} diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index e5f3ffa..3edead8 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -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 { diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index 3ba02c4..9fdd427 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -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) { diff --git a/compositor/src/window_rules.rs b/compositor/src/window_rules.rs index 805e73b..34670c8 100644 --- a/compositor/src/window_rules.rs +++ b/compositor/src/window_rules.rs @@ -10,6 +10,7 @@ pub(crate) struct WindowRuleConfig { pub width: Option, pub height: Option, pub fullscreen: Option, + pub block_out_from_screencasts: Option, } #[derive(Clone, Debug, PartialEq)] @@ -22,6 +23,7 @@ pub struct WindowRule { width: Option, height: Option, fullscreen: Option, + block_out_from_screencasts: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -31,14 +33,18 @@ pub struct WindowRuleResult { pub width: Option, pub height: Option, pub fullscreen: Option, + pub block_out_from_screencasts: Option, } /// 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 { + // 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>>; type DamageRenderResult = Result< @@ -89,6 +89,7 @@ pub fn init(event_loop: &mut EventLoop, 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, 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, diff --git a/docs/configuration.md b/docs/configuration.md index 3b7bf0b..d210a7e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/development.md b/docs/development.md index 51b74d2..2346152 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/scripts/tests/test_window_capture_isolated.py b/scripts/tests/test_window_capture_isolated.py index 9d61909..9659b03 100644 --- a/scripts/tests/test_window_capture_isolated.py +++ b/scripts/tests/test_window_capture_isolated.py @@ -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: