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::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::Rectangle; 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; #[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().unwrap(); 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().unwrap(); 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::( 1, 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().unwrap() { return; } if state.session_lock.active() || !super::screencopy::capture_allowed() || !state.capture_window_allowed(snapshot.id, snapshot.epoch) { *data.used.lock().unwrap() = 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().unwrap() = 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::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), }), ); if failed { if busy { manager.busy(&resource); } resource.failed(); } else if global .worker .try_send(Conversion { snapshot: snapshot.unwrap(), frame: resource.clone(), manager: manager.clone(), }) .is_err() { *resource .data::>() .unwrap() .used .lock() .unwrap() = true; manager.busy(&resource); resource.failed(); } } } } impl Ferese { fn window_snapshot( &mut self, id: WindowId, overlay_cursor: bool, budget: &Arc>, busy: &mut bool, ) -> 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 scale = output.current_scale().fractional_scale(); 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; } let Some(permit) = Permit::reserve(budget, bytes) else { *busy = true; return None; }; 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) { 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 }; 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().unwrap(); if *used { resource.post_error( frame::Error::AlreadyUsed, "window capture frame has already been used", ); return; } *used = true; let Some(snapshot) = data.snapshot.lock().unwrap().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::*; /// 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. // 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); } }