From 20b468c6acbd8006c3e09130eb4f2eecebd3fea4 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 01:36:07 +0100 Subject: [PATCH] feat: support authorized screencopy --- README.md | 12 + compositor/src/backends/direct.rs | 19 +- compositor/src/handlers/mod.rs | 1 + compositor/src/handlers/screencopy.rs | 395 ++++++++++++++++++++++++++ compositor/src/state.rs | 3 + compositor/src/winit.rs | 11 +- 6 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 compositor/src/handlers/screencopy.rs diff --git a/README.md b/README.md index bc0ed29..c281d75 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,18 @@ FERESE_ENABLE_INPUT_METHOD=1 cargo run -p ferese `Ctrl+Alt+Escape` always releases an active client shortcut inhibitor. +Screen capture is hidden by default. A session that has already made an +explicit capture-policy decision can expose screencopy-v1 to its clients with: + +```bash +FERESE_ENABLE_SCREENCOPY=1 cargo run -p ferese +``` + +The current M5 implementation supports one-shot output and region capture into +ARGB8888 shared-memory buffers. Captures currently include Ferese's software +cursor even when a client does not request a cursor overlay; cursor-free +capture remains part of the M5 interoperability acceptance work. + ### Direct DRM session Run the hardware backend from a spare TTY, outside another graphical session: diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index fc12e2c..20206ba 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -9,7 +9,10 @@ use smithay::{ drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface}, egl::{EGLContext, EGLDisplay}, libinput::{LibinputInputBackend, LibinputSessionInterface}, - renderer::{Bind, ImportDma, ImportMemWl, damage::OutputDamageTracker, gles::GlesRenderer}, + renderer::{ + Bind, Frame, ImportDma, ImportMemWl, Renderer, damage::OutputDamageTracker, + gles::GlesRenderer, + }, session::{Event as SessionEvent, Session, libseat::LibSeatSession}, udev::{UdevBackend, UdevEvent, primary_gpu}, }, @@ -315,6 +318,20 @@ fn render_device(state: &mut Ferese, node: DrmNode) { &elements, [0.035, 0.04, 0.055, 1.0], )?; + if state.process_screencopies(&mut device.renderer, &framebuffer, &device.output) { + let _ = device + .renderer + .render( + &mut framebuffer, + device + .output + .current_mode() + .expect("output has a mode") + .size, + device.output.current_transform(), + )? + .finish()?; + } let Some(damage) = result.damage.cloned() else { return Ok(false); }; diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 766c7f1..cf17087 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -2,6 +2,7 @@ mod activation; mod compositor; mod input_method; mod layer_shell; +pub(crate) mod screencopy; mod xdg_shell; use smithay::{ diff --git a/compositor/src/handlers/screencopy.rs b/compositor/src/handlers/screencopy.rs new file mode 100644 index 0000000..97dd37e --- /dev/null +++ b/compositor/src/handlers/screencopy.rs @@ -0,0 +1,395 @@ +use std::sync::{Arc, Mutex}; + +use smithay::{ + backend::{ + allocator::Fourcc, + renderer::{ExportMem, TextureMapping}, + }, + output::Output, + reexports::{ + wayland_protocols_wlr::screencopy::v1::server::{ + zwlr_screencopy_frame_v1::{self, ZwlrScreencopyFrameV1}, + zwlr_screencopy_manager_v1::{self, ZwlrScreencopyManagerV1}, + }, + wayland_server::{ + Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, + protocol::{wl_buffer::WlBuffer, wl_shm}, + }, + }, + utils::{Buffer, Logical, Rectangle, Size}, + wayland::shm::with_buffer_contents_mut, +}; + +use crate::Ferese; + +const BYTES_PER_PIXEL: usize = 4; + +#[derive(Debug)] +pub(crate) struct FrameData { + output: Option, + region: Rectangle, + used: Mutex, +} + +#[derive(Debug)] +pub(crate) struct PendingScreencopy { + frame: ZwlrScreencopyFrameV1, + buffer: WlBuffer, + output: Output, + region: Rectangle, + with_damage: bool, +} + +pub(crate) fn init_global(display: &DisplayHandle) { + let enabled = std::env::var_os("FERESE_ENABLE_SCREENCOPY").is_some_and(|value| value == "1"); + + if enabled { + display.create_global::(3, ()); + tracing::info!("authorized screencopy is enabled for this session"); + } +} + +impl GlobalDispatch for Ferese { + fn bind( + _state: &mut Self, + _display: &DisplayHandle, + _client: &Client, + resource: New, + _global_data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + data_init.init(resource, ()); + } +} + +impl Dispatch for Ferese { + fn request( + _state: &mut Self, + _client: &Client, + _manager: &ZwlrScreencopyManagerV1, + request: zwlr_screencopy_manager_v1::Request, + _data: &(), + _display: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + zwlr_screencopy_manager_v1::Request::CaptureOutput { + frame, + overlay_cursor: _, + output, + } => { + create_frame(data_init, frame, output, None); + } + zwlr_screencopy_manager_v1::Request::CaptureOutputRegion { + frame, + overlay_cursor: _, + output, + x, + y, + width, + height, + } => { + create_frame( + data_init, + frame, + output, + Some(Rectangle::new((x, y).into(), (width, height).into())), + ); + } + zwlr_screencopy_manager_v1::Request::Destroy => {} + _ => unreachable!(), + } + } +} + +impl Dispatch> for Ferese { + fn request( + state: &mut Self, + _client: &Client, + frame: &ZwlrScreencopyFrameV1, + request: zwlr_screencopy_frame_v1::Request, + data: &Arc, + _display: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + match request { + zwlr_screencopy_frame_v1::Request::Copy { buffer } => { + state.queue_screencopy(frame, data, buffer, false); + } + zwlr_screencopy_frame_v1::Request::CopyWithDamage { buffer } => { + state.queue_screencopy(frame, data, buffer, true); + } + zwlr_screencopy_frame_v1::Request::Destroy => {} + _ => unreachable!(), + } + } +} + +impl Ferese { + fn queue_screencopy( + &mut self, + frame: &ZwlrScreencopyFrameV1, + data: &Arc, + buffer: WlBuffer, + with_damage: bool, + ) { + let mut used = data.used.lock().unwrap(); + if *used { + frame.post_error( + zwlr_screencopy_frame_v1::Error::AlreadyUsed, + "screencopy frame has already been used", + ); + return; + } + *used = true; + + if !valid_shm_buffer(&buffer, data.region.size) { + frame.post_error( + zwlr_screencopy_frame_v1::Error::InvalidBuffer, + "screencopy requires the advertised ARGB8888 SHM buffer", + ); + return; + } + + let Some(output) = data.output.clone() else { + frame.failed(); + return; + }; + + self.pending_screencopies.push(PendingScreencopy { + frame: frame.clone(), + buffer, + output, + region: data.region, + with_damage, + }); + crate::backends::direct::render_all(self); + } + + pub(crate) fn process_screencopies( + &mut self, + renderer: &mut R, + framebuffer: &R::Framebuffer<'_>, + output: &Output, + ) -> bool + where + R: ExportMem, + { + let mut remaining = Vec::new(); + let mut framebuffer_binding_changed = false; + let captures = std::mem::take(&mut self.pending_screencopies); + + for capture in captures { + if &capture.output != output { + remaining.push(capture); + continue; + } + if !capture.frame.is_alive() || !capture.buffer.is_alive() { + continue; + } + + framebuffer_binding_changed = true; + if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err() + { + capture.frame.failed(); + } + } + + self.pending_screencopies = remaining; + framebuffer_binding_changed + } +} + +fn create_frame( + data_init: &mut DataInit<'_, Ferese>, + frame: New, + wl_output: smithay::reexports::wayland_server::protocol::wl_output::WlOutput, + requested: Option>, +) { + let Some(output) = Output::from_resource(&wl_output) else { + let frame = data_init.init( + frame, + Arc::new(FrameData { + output: None, + region: Rectangle::from_size((1, 1).into()), + used: Mutex::new(true), + }), + ); + frame.failed(); + return; + }; + let Some(region) = capture_region(&output, requested) else { + let frame = data_init.init( + frame, + Arc::new(FrameData { + output: Some(output), + region: Rectangle::from_size((1, 1).into()), + used: Mutex::new(true), + }), + ); + frame.failed(); + return; + }; + + let size = region.size; + let data = Arc::new(FrameData { + output: Some(output), + region, + used: Mutex::new(false), + }); + let frame = data_init.init(frame, data); + + frame.buffer( + wl_shm::Format::Argb8888, + size.w as u32, + size.h as u32, + (size.w as usize * BYTES_PER_PIXEL) as u32, + ); + if frame.version() >= 3 { + frame.buffer_done(); + } +} + +fn capture_region( + output: &Output, + requested: Option>, +) -> Option> { + let mode = output.current_mode()?; + let scale = output.current_scale().fractional_scale(); + let transform = output.current_transform(); + capture_region_for_geometry(mode.size, scale, transform, requested) +} + +fn capture_region_for_geometry( + mode_size: Size, + scale: f64, + transform: smithay::utils::Transform, + requested: Option>, +) -> Option> { + let logical_size = transform + .transform_size(mode_size) + .to_f64() + .to_logical(scale) + .to_i32_round(); + let output_region = Rectangle::from_size(logical_size); + let logical_region = requested + .unwrap_or(output_region) + .intersection(output_region)?; + + if logical_region.size.w <= 0 || logical_region.size.h <= 0 { + return None; + } + + Some( + logical_region + .to_f64() + .to_buffer(scale, transform, &logical_size.to_f64()) + .to_i32_round(), + ) +} + +fn valid_shm_buffer(buffer: &WlBuffer, expected: Size) -> bool { + with_buffer_contents_mut(buffer, |_, length, data| { + let stride = expected.w.checked_mul(BYTES_PER_PIXEL as i32); + let required = data + .stride + .checked_mul(data.height) + .and_then(|bytes| data.offset.checked_add(bytes)); + + data.format == wl_shm::Format::Argb8888 + && data.width == expected.w + && data.height == expected.h + && Some(data.stride) == stride + && required.is_some_and(|required| required >= 0 && required as usize <= length) + }) + .unwrap_or(false) +} + +fn complete_capture( + renderer: &mut R, + framebuffer: &R::Framebuffer<'_>, + capture: &PendingScreencopy, + timestamp: std::time::Duration, +) -> Result<(), ()> +where + R: ExportMem, +{ + let mapping = renderer + .copy_framebuffer(framebuffer, capture.region, Fourcc::Argb8888) + .map_err(|error| { + tracing::warn!(?error, "failed to copy output framebuffer"); + })?; + let source = renderer.map_texture(&mapping).map_err(|error| { + tracing::warn!(?error, "failed to map output framebuffer copy"); + })?; + let expected = capture.region.size; + let copied = with_buffer_contents_mut(&capture.buffer, |destination, length, data| { + let bytes = expected.w as usize * expected.h as usize * BYTES_PER_PIXEL; + if source.len() < bytes || data.offset < 0 || data.offset as usize + bytes > length { + return false; + } + + // Preserve the renderer's row order and report it through the protocol + // instead of making another full-frame copy on the compositor thread. + unsafe { + std::ptr::copy_nonoverlapping( + source.as_ptr(), + destination.add(data.offset as usize), + bytes, + ); + } + true + }) + .map_err(|_| ())?; + + if !copied { + return Err(()); + } + + if capture.with_damage { + capture + .frame + .damage(0, 0, expected.w as u32, expected.h as u32); + } + let flags = if mapping.flipped() { + zwlr_screencopy_frame_v1::Flags::YInvert + } else { + zwlr_screencopy_frame_v1::Flags::empty() + }; + capture.frame.flags(flags); + + let seconds = timestamp.as_secs(); + capture.frame.ready( + (seconds >> 32) as u32, + seconds as u32, + timestamp.subsec_nanos(), + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use smithay::utils::{Physical, Transform}; + + #[test] + fn capture_region_clips_then_scales_to_buffer_coordinates() { + let mode = Size::::from((1_920, 1_080)); + let requested = Rectangle::new((-10, 20).into(), (110, 50).into()); + + let region = + capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested)).unwrap(); + + assert_eq!(region, Rectangle::new((0, 40).into(), (200, 100).into())); + } + + #[test] + fn capture_region_rejects_regions_outside_the_output() { + let mode = Size::::from((1_920, 1_080)); + let requested = Rectangle::new((1_000, 600).into(), (100, 100).into()); + + assert!( + capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested),).is_none() + ); + } +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 2a909c9..3c09faf 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -80,6 +80,7 @@ pub struct Ferese { pub active_shortcuts_inhibitor: Option, pub direct_backend: Option, pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>, + pub(crate) pending_screencopies: Vec, next_window_id: u64, last_animation_tick: Instant, pub popups: PopupManager, @@ -135,6 +136,7 @@ impl Ferese { display: Display, ) -> Result> { let display_handle = display.handle(); + crate::handlers::screencopy::init_global(&display_handle); let compositor_state = CompositorState::new::(&display_handle); let cursor_shape_state = CursorShapeManagerState::new::(&display_handle); let xdg_shell_state = XdgShellState::new::(&display_handle); @@ -192,6 +194,7 @@ impl Ferese { active_shortcuts_inhibitor: None, direct_backend: None, pending_dmabuf_imports: Vec::new(), + pending_screencopies: Vec::new(), next_window_id: 1, last_animation_tick: start_time, popups: PopupManager::default(), diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index e65fd51..5254728 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -6,7 +6,7 @@ use std::{ use smithay::{ backend::{ renderer::{ - ImportDma, + Frame, ImportDma, Renderer, damage::OutputDamageTracker, element::{ AsRenderElements, Kind as RenderElementKind, @@ -142,6 +142,15 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() &elements, [0.035, 0.04, 0.055, 1.0], )?; + if state.process_screencopies(renderer, &framebuffer, &output) { + let _ = renderer + .render( + &mut framebuffer, + output.current_mode().expect("output has a mode").size, + output.current_transform(), + )? + .finish()?; + } } backend.submit(Some(&[damage]))?; Ok(())