use std::os::unix::fs::MetadataExt; use std::sync::{Arc, Mutex}; use ferese_layout::WindowId; use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1 as manager; use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1::FereseWindowCaptureManagerV1; use smithay::backend::renderer::utils::{RendererSurfaceStateUserData, SurfaceView}; use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1 as frame; use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1; use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer; use smithay::reexports::wayland_server::protocol::wl_shm; use smithay::reexports::wayland_server::{ Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, }; use smithay::utils::{Logical, Point, Rectangle}; use smithay::wayland::compositor::{TraversalAction, with_surface_tree_downward}; use smithay::wayland::shm::with_buffer_contents_mut; use super::screencopy::FrameData; use super::screenshot::CaptureBuffer; use crate::Ferese; const MAX_BYTES: usize = 128 * 1024 * 1024; const MAX_FRAMES: usize = 8; fn capture_scale(output_scale: f64, buffer_scale: i32, view: SurfaceView) -> f64 { if view.dst.w <= 0 || view.dst.h <= 0 { return output_scale; } let width = view.src.size.w * f64::from(buffer_scale); let height = view.src.size.h * f64::from(buffer_scale); let logical_width = f64::from(view.dst.w); let logical_height = f64::from(view.dst.h); let matches_native = |scale: f64| { (logical_width * scale).round() == width && (logical_height * scale).round() == height }; // Fractional clients round their backing dimensions independently. Taking // the larger density can add a pixel on the other axis and filter every // row or column, even though the client already rendered at this scale. if matches_native(output_scale) { return output_scale; } let scale = output_scale .max(width / logical_width) .max(height / logical_height); if matches_native(scale) { return scale; } // Prefer a common density whose rounded dimensions preserve both axes. // Stay strictly below the upper rounding boundary, including FP error. let upper = ((width + 0.5) / logical_width).min((height + 0.5) / logical_height); let adjusted = upper * (1.0 - 8.0 * f64::EPSILON); if adjusted >= output_scale && matches_native(adjusted) { adjusted } else { scale } } #[derive(Debug, Default)] pub(crate) struct Budget { bytes: usize, frames: usize, } #[derive(Debug)] struct Permit { budget: Arc>, bytes: usize, } impl Permit { fn reserve(budget: &Arc>, bytes: usize) -> Option { let mut available = budget .lock() .expect("surface rendering state must remain consistent across callbacks"); if bytes == 0 || bytes > MAX_BYTES.saturating_sub(available.bytes) || available.frames >= MAX_FRAMES { return None; } available.bytes += bytes; available.frames += 1; Some(Self { budget: budget.clone(), bytes, }) } } impl Drop for Permit { fn drop(&mut self) { let mut budget = self .budget .lock() .expect("surface rendering state must remain consistent across callbacks"); budget.bytes -= self.bytes; budget.frames -= 1; } } #[derive(Debug)] pub(super) struct Snapshot { id: WindowId, epoch: u64, logical_size: (u32, u32), pixels: CaptureBuffer, _permit: Permit, } #[derive(Clone)] pub(crate) struct Global { budget: Arc>, worker: std::sync::mpsc::SyncSender, } struct Conversion { snapshot: Snapshot, frame: ZwlrScreencopyFrameV1, manager: FereseWindowCaptureManagerV1, } pub(crate) fn is_portal(stream: &std::os::unix::net::UnixStream) -> bool { use std::os::fd::AsRawFd; let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0, }; let mut length = std::mem::size_of_val(&credentials) as libc::socklen_t; // SAFETY: the connected socket is live and both out pointers cover their // initialized stack allocations. SO_PEERCRED identifies the connecting process. let valid = unsafe { libc::getsockopt( stream.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, (&raw mut credentials).cast(), &raw mut length, ) == 0 }; if !valid || length as usize != std::mem::size_of_val(&credentials) || credentials.pid <= 0 { return false; } let expected = std::env::current_exe() .ok() .and_then(|exe| { exe.parent() .map(|dir| dir.join("xdg-desktop-portal-ferese")) }) .and_then(|exe| std::fs::metadata(exe).ok()); let actual = std::fs::metadata(format!("/proc/{}/exe", credentials.pid)).ok(); matches!((expected, actual), (Some(expected), Some(actual)) if expected.dev() == actual.dev() && expected.ino() == actual.ino()) } pub(super) fn init_global( display: &DisplayHandle, loop_handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>, ) { use smithay::reexports::calloop::channel; let (worker, requests) = std::sync::mpsc::sync_channel::(MAX_FRAMES); let (results, source) = channel::sync_channel::(MAX_FRAMES); let Ok(token) = loop_handle.insert_source(source, |event, _, state| { if let channel::Event::Msg(result) = event { finish_conversion(state, result); } }) else { tracing::warn!("window capture result source could not be registered"); return; }; if let Err(error) = std::thread::Builder::new() .name("ferese-capture".into()) .spawn(move || { while let Ok(mut job) = requests.recv() { if job.frame.is_alive() { crate::render::convert_window_pixels(&mut job.snapshot.pixels.pixels); } // Permits cap the total number of queued, working and completed // frames at MAX_FRAMES, so the result queue cannot fill here. if results.send(job).is_err() { break; } } }) { loop_handle.remove(token); tracing::warn!(%error, "window capture worker could not be started"); return; } display.create_global::( 3, Global { budget: Arc::default(), worker, }, ); } fn finish_conversion(state: &Ferese, result: Conversion) { let Conversion { snapshot, frame, manager, } = result; let Some(data) = frame.data::>() else { return; }; if !frame.is_alive() || *data .used .lock() .expect("surface rendering state must remain consistent across callbacks") { return; } if state.session_lock.active() || !super::screencopy::capture_allowed() || !state.capture_window_allowed(snapshot.id, snapshot.epoch) { *data .used .lock() .expect("surface rendering state must remain consistent across callbacks") = true; frame.failed(); return; } let size = (snapshot.pixels.width as u32, snapshot.pixels.height as u32); let logical_size = snapshot.logical_size; *data .snapshot .lock() .expect("surface rendering state must remain consistent across callbacks") = Some(snapshot); if manager.is_alive() { manager.geometry(&frame, logical_size.0, logical_size.1); } frame.buffer(wl_shm::Format::Argb8888, size.0, size.1, size.0 * 4); } impl GlobalDispatch for Ferese { fn can_view(client: Client, _: &Global) -> bool { client .get_data::() .is_some_and(|state| { state .capabilities .contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE) }) } fn bind( _: &mut Self, _: &DisplayHandle, _: &Client, resource: New, global: &Global, data_init: &mut DataInit<'_, Self>, ) { data_init.init(resource, global.clone()); } } impl Dispatch for Ferese { fn request( state: &mut Self, _: &Client, manager: &FereseWindowCaptureManagerV1, request: manager::Request, global: &Global, _: &DisplayHandle, data_init: &mut DataInit<'_, Self>, ) { if let manager::Request::CreateWorkspaceSource { workspace_hi, workspace_lo, source, } = request { use smithay::wayland::image_capture_source::{ ImageCaptureSource, ImageCaptureSourceData, }; let id = ferese_core::WorkspaceId((u64::from(workspace_hi) << 32) | u64::from(workspace_lo)); let capture = ImageCaptureSource::new(); let view = super::workspace_capture::WorkspaceSource::new(state, id); if let Some(view) = &view { capture .user_data() .insert_if_missing(|| super::image_capture::Source::Workspace(view.clone())); } let resource = data_init.init( source, ImageCaptureSourceData { source: capture.clone(), }, ); capture.add_instance(&resource); if let Some(view) = view { manager.source_geometry( &resource, view.geometry.size.w as u32, view.geometry.size.h as u32, ); } return; } if let manager::Request::SetSourceActive { source, active } = request { if let Some(source) = smithay::wayland::image_capture_source::ImageCaptureSource::from_resource(&source) { source .user_data() .get_or_insert_threadsafe(super::image_capture::SourcePaused::default) .0 .store(active != 1, std::sync::atomic::Ordering::Relaxed); } return; } if let manager::Request::RefreshSource { source } = request { if let Some(source) = smithay::wayland::image_capture_source::ImageCaptureSource::from_resource(&source) { state.refresh_capture_source(&source); } return; } if let manager::Request::CreateSource { window_hi, window_lo, source, } = request { use smithay::wayland::image_capture_source::{ ImageCaptureSource, ImageCaptureSourceData, }; let id = WindowId((u64::from(window_hi) << 32) | u64::from(window_lo)); let capture = ImageCaptureSource::new(); capture .user_data() .insert_if_missing(|| super::image_capture::Source::Window(id)); let resource = data_init.init( source, ImageCaptureSourceData { source: capture.clone(), }, ); capture.add_instance(&resource); if let Some(info) = state.window_capture_info(id) { manager.source_geometry( &resource, info.geometry.size.w as u32, info.geometry.size.h as u32, ); } return; } if let manager::Request::CaptureWindow { window_hi, window_lo, overlay_cursor, frame, } = request { let id = WindowId((u64::from(window_hi) << 32) | u64::from(window_lo)); let mut busy = false; let snapshot = (overlay_cursor <= 1) .then(|| state.window_snapshot(id, overlay_cursor == 1, &global.budget, &mut busy)) .flatten(); let size = snapshot .as_ref() .map(|snapshot| (snapshot.pixels.width, snapshot.pixels.height)) .unwrap_or((1, 1)); let failed = snapshot.is_none(); let resource = data_init.init( frame, Arc::new(FrameData { output: None, region: Rectangle::from_size(size.into()), overlay_cursor: false, used: Mutex::new(failed), snapshot: Mutex::new(None), history: None, }), ); if failed { if busy { manager.busy(&resource); } resource.failed(); } else if global .worker .try_send(Conversion { snapshot: snapshot .expect("conversion is queued only after obtaining a capture snapshot"), frame: resource.clone(), manager: manager.clone(), }) .is_err() { *resource .data::>() .expect("capture frame resources are initialized with their FrameData") .used .lock() .expect("surface rendering state must remain consistent across callbacks") = true; manager.busy(&resource); resource.failed(); } } } } pub(crate) struct WindowInfo { pub window: smithay::desktop::Window, pub output: smithay::output::Output, pub geometry: Rectangle, pub scale: f64, pub bytes: usize, } impl Ferese { pub(super) fn window_capture_info(&self, id: WindowId) -> Option { if self.session_lock.active() || !super::screencopy::capture_allowed() { return None; } let window = self .windows .ids() .iter() .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 .output_ids .iter() .find(|(_, candidate)| **candidate == owner)? .0 .clone(); let geometry = window.geometry(); let output_scale = output.current_scale().fractional_scale(); // A low-density parent can contain higher-density content. Match the // rendered surface tree, excluding buffers outside the captured geometry. let mut scale = output_scale; if let Some(toplevel) = window.toplevel() { with_surface_tree_downward( toplevel.wl_surface(), Point::::default(), |_, states, location| { let Some(data) = states.data_map.get::() else { return TraversalAction::SkipChildren; }; let data = data .lock() .expect("Smithay surface metadata must remain consistent across callbacks"); let Some(view) = data.view() else { return TraversalAction::SkipChildren; }; let location = *location + view.offset.to_f64(); if Rectangle::new(location, view.dst.to_f64()).overlaps(geometry.to_f64()) { scale = capture_scale(scale, data.buffer_scale(), view); } TraversalAction::DoChildren(location) }, |_, _, _| {}, |_, _, _| true, ); } let size: smithay::utils::Size = geometry.size.to_physical_precise_round(scale); let bytes = usize::try_from(size.w) .ok()? .checked_mul(usize::try_from(size.h).ok()?)? .checked_mul(4)?; if bytes == 0 || bytes > MAX_BYTES { return None; } Some(WindowInfo { window, output, geometry, scale, bytes, }) } pub(super) fn window_capture_cursor( &self, id: WindowId, window: &smithay::desktop::Window, geometry: Rectangle, ) -> Option> { if !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) { return None; } let (x, y) = self.inverse_presented_window_point(id, location.x, location.y)?; let surface_point = smithay::utils::Point::from((x, y)) + geometry.loc.to_f64(); let (surface, _) = window .surface_under(surface_point, smithay::desktop::WindowSurfaceType::ALL)?; if pointer.current_focus().as_ref() != Some(&surface) { return None; } Some(Rectangle::new( (location - smithay::utils::Point::from((x, y))).to_i32_round(), geometry.size, )) }) } else { None } } fn window_snapshot( &mut self, id: WindowId, overlay_cursor: bool, budget: &Arc>, busy: &mut bool, ) -> Option { let WindowInfo { window, output, geometry, scale, bytes, } = self.window_capture_info(id)?; let Some(permit) = Permit::reserve(budget, bytes) else { *busy = true; return None; }; let cursor = overlay_cursor .then(|| self.window_capture_cursor(id, &window, geometry)) .flatten(); let result = if let Some(backend) = self.nested_backend.clone() { let mut backend = backend.try_borrow_mut().ok()?; crate::render::capture_window_frame( backend.renderer(), &window, geometry, scale, cursor.map(|rect| (&*self, rect)), ) } else { let mut backend = self.direct_backend.take()?; let result = backend.capture_window_frame(self, &window, &output, cursor, scale); self.direct_backend = Some(backend); result }; let pixels = result.ok()?; if let Some(toplevel) = window.toplevel() { smithay::desktop::utils::send_frames_surface_tree( toplevel.wl_surface(), &output, self.start_time.elapsed(), Some(std::time::Duration::ZERO), |_, _| Some(output.clone()), ); } if pixels.pixels.len() != bytes { return None; } Some(Snapshot { id, epoch: self.capture_epoch, logical_size: (geometry.size.w as u32, geometry.size.h as u32), pixels, _permit: permit, }) } } pub(super) fn copy_snapshot( state: &Ferese, resource: &ZwlrScreencopyFrameV1, data: &FrameData, buffer: &WlBuffer, ) { let mut used = data .used .lock() .expect("surface rendering state must remain consistent across callbacks"); if *used { resource.post_error( frame::Error::AlreadyUsed, "window capture frame has already been used", ); return; } *used = true; let Some(snapshot) = data .snapshot .lock() .expect("surface rendering state must remain consistent across callbacks") .take() else { resource.failed(); return; }; if state.session_lock.active() || !super::screencopy::capture_allowed() || !state.capture_window_allowed(snapshot.id, snapshot.epoch) { resource.failed(); return; } if !super::screencopy::valid_shm_buffer(buffer, data.region.size) { resource.post_error( frame::Error::InvalidBuffer, "window capture requires the advertised ARGB8888 SHM buffer", ); return; } let copied = with_buffer_contents_mut(buffer, |destination, length, layout| { let bytes = snapshot.pixels.pixels.len(); let Ok(offset) = usize::try_from(layout.offset) else { return false; }; if offset.checked_add(bytes).is_none_or(|end| end > length) { return false; } // SAFETY: Smithay owns the writable mapping, validated format/stride and // checked offset cover the complete snapshot; the source is separately owned. unsafe { std::ptr::copy_nonoverlapping( snapshot.pixels.pixels.as_ptr(), destination.add(offset), bytes, ); } true }) .unwrap_or(false); if !copied { resource.failed(); return; } let timestamp = state.start_time.elapsed(); resource.flags(frame::Flags::empty()); resource.ready( (timestamp.as_secs() >> 32) as u32, timestamp.as_secs() as u32, timestamp.subsec_nanos(), ); } #[cfg(test)] pub(crate) mod tests { use super::*; #[test] fn sharing_preserves_backing_density_at_fractional_display_scales() { let integer = SurfaceView { src: Rectangle::from_size((640., 480.).into()), dst: (640, 480).into(), offset: (0, 0).into(), }; let viewport = SurfaceView { src: Rectangle::from_size((1280., 960.).into()), ..integer }; for output_scale in [1., 1.25, 1.5, 1.75, 2.] { assert_eq!(capture_scale(output_scale, 2, integer), 2.); assert_eq!(capture_scale(output_scale, 1, viewport), 2.); } assert_eq!(capture_scale(2.5, 2, integer), 2.5); let fractional = SurfaceView { src: Rectangle::from_size((1120., 840.).into()), ..integer }; assert_eq!(capture_scale(1.25, 1, fractional), 1.75); assert_eq!(capture_scale(1.75, 1, fractional), 1.75); } #[test] fn sharing_uses_the_viewport_crop_instead_of_the_whole_buffer() { let cropped = SurfaceView { src: Rectangle::new((320., 240.).into(), (640., 480.).into()), dst: (640, 480).into(), offset: (0, 0).into(), }; assert_eq!(capture_scale(1.75, 1, cropped), 1.75); } #[test] fn sharing_preserves_independently_rounded_fractional_buffers() { for (width, height) in [(3819., 2091.), (3819., 2092.)] { let view = SurfaceView { src: Rectangle::from_size((width, height).into()), dst: (2182, 1195).into(), offset: (0, 0).into(), }; for output_scale in [1.25, 1.5, 1.75] { let scale = capture_scale(output_scale, 1, view); assert!(scale >= output_scale); assert_eq!((2182. * scale).round(), width); assert_eq!((1195. * scale).round(), height); } } } /// 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), history: 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. // SAFETY: before was copied from this checked SHM layout, so the destination has // before.len() writable bytes and does not overlap the owned backup. 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(); let permits = (0..MAX_FRAMES) .map(|_| Permit::reserve(&budget, 4).unwrap()) .collect::>(); assert!(Permit::reserve(&budget, 4).is_none()); drop(permits); let full = Permit::reserve(&budget, MAX_BYTES).unwrap(); assert!(Permit::reserve(&budget, 4).is_none()); drop(full); assert!(Permit::reserve(&budget, MAX_BYTES).is_some()); assert!(Permit::reserve(&budget, MAX_BYTES + 1).is_none()); assert!(Permit::reserve(&budget, 0).is_none()); assert_eq!(budget.lock().unwrap().bytes, 0); assert_eq!(budget.lock().unwrap().frames, 0); } }