From ab11269246102e3b65c665735fc60a86eb71a0bc Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 25 Oct 2026 13:59:34 +0100 Subject: [PATCH] Add workspace screen sharing (#64) Add workspace selection to the screen-sharing picker with pinned capture, privacy filtering and bounded capture resources. Refresh captured tab labels when title or app-ID metadata changes. --- compositor/src/backends/direct.rs | 13 + compositor/src/backends/direct/capture.rs | 25 +- compositor/src/handlers/image_capture.rs | 248 +++++++++- compositor/src/handlers/mod.rs | 2 + compositor/src/handlers/window_capture.rs | 48 +- compositor/src/handlers/workspace_capture.rs | 450 ++++++++++++++++++ .../src/handlers/workspace_capture_tests.rs | 215 +++++++++ compositor/src/handlers/xdg_shell.rs | 14 + compositor/src/render/mod.rs | 3 +- compositor/src/render/overview.rs | 7 +- compositor/src/render/resources.rs | 12 + compositor/src/render/scene.rs | 110 ++++- compositor/src/render/tab_indicator.rs | 8 +- compositor/src/render/tabs.rs | 33 +- compositor/src/state/animation.rs | 14 + compositor/src/state/layout.rs | 162 +++++-- compositor/src/state/prediction.rs | 109 +++++ compositor/src/state/tab_indicator.rs | 48 +- docs/development.md | 2 + docs/screen-sharing.md | 54 ++- portal/src/backend.rs | 46 +- portal/src/capture.rs | 66 ++- portal/src/main.rs | 5 + portal/src/picker.rs | 33 +- portal/src/restore.rs | 1 + portal/src/stream.rs | 17 +- portal/src/stream/capture.rs | 76 ++- portal/src/workspace.rs | 177 +++++++ protocols/ferese-window-capture-v1.xml | 25 +- scripts/tests/fixtures/window-capture.c | 20 +- scripts/tests/test_window_capture_isolated.py | 6 +- .../tests/test_workspace_capture_isolated.py | 241 ++++++++++ 32 files changed, 2125 insertions(+), 165 deletions(-) create mode 100644 compositor/src/handlers/workspace_capture.rs create mode 100644 compositor/src/handlers/workspace_capture_tests.rs create mode 100644 portal/src/workspace.rs create mode 100644 scripts/tests/test_workspace_capture_isolated.py diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index e17a13f..486b063 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -1244,6 +1244,7 @@ pub(crate) fn wake_locked_outputs(state: &mut Ferese) { #[track_caller] pub fn render_all(state: &mut Ferese) { + state.image_capture.invalidate_workspaces(); for output in state.space.outputs() { capture::changes::content_changed(output); } @@ -1320,6 +1321,18 @@ pub(crate) fn render_surface( return; } + let capture_root = state + .popups + .find_popup(&root) + .and_then(|popup| smithay::desktop::find_popup_root_surface(&popup).ok()) + .unwrap_or_else(|| root.clone()); + if let Some(workspace) = state + .windows + .id_for_surface(&capture_root) + .and_then(|id| state.workspaces.workspace_for_window(id)) + { + state.image_capture.workspace_changed(workspace); + } let outputs = state.surface_outputs(&root); render_on(state, &outputs); } diff --git a/compositor/src/backends/direct/capture.rs b/compositor/src/backends/direct/capture.rs index 5838090..3ca46e8 100644 --- a/compositor/src/backends/direct/capture.rs +++ b/compositor/src/backends/direct/capture.rs @@ -18,6 +18,7 @@ pub(crate) mod changes; pub(crate) struct CaptureTarget { texture: GlesTexture, + visible: Option, damage: OutputDamageTracker, output: Output, revision: u64, @@ -32,9 +33,24 @@ pub(crate) struct CaptureTarget { } impl CaptureTarget { + pub(crate) fn surface_visible( + &self, + surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, + ) -> bool { + self.visible + .as_ref() + .and_then(|states| states.element_render_state(surface)) + .is_some_and(crate::display_presentation::is_visible) + } + + pub(crate) fn image(&self) -> (GlesTexture, u64) { + (self.texture.clone(), self.revision) + } + pub(crate) fn new(texture: GlesTexture, output: &Output) -> Self { Self { texture, + visible: None, damage: OutputDamageTracker::from_output(output), output: output.clone(), revision: 0, @@ -300,7 +316,7 @@ pub(super) fn render_capture<'a, E: RenderElement>( render_capture_with_revision(renderer, target, output, elements).map(|(target, _)| target) } -pub(super) fn render_capture_with_revision<'a, E: RenderElement>( +pub(crate) fn render_capture_with_revision<'a, E: RenderElement>( renderer: &mut GlesRenderer, target: &'a mut Option, output: &Output, @@ -349,6 +365,13 @@ pub(super) fn render_capture_with_revision<'a, E: RenderElement>( target.compositions += 1; target.revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } + if output + .user_data() + .get::() + .is_some() + { + target.visible = Some(result.states); + } Ok((framebuffer, target.revision)) } diff --git a/compositor/src/handlers/image_capture.rs b/compositor/src/handlers/image_capture.rs index 2b4727f..063b568 100644 --- a/compositor/src/handlers/image_capture.rs +++ b/compositor/src/handlers/image_capture.rs @@ -24,10 +24,32 @@ use smithay::wayland::image_copy_capture::{BufferConstraints, DmabufConstraints, use crate::Ferese; const MAX_SESSIONS: usize = 16; +// Reserve for the maximum eight portal pool buffers, a retained target and a +// fallback transfer texture. Imports of the same allocation are not extra pixels. +const MAX_STREAM_BYTES: u64 = 512 * 1024 * 1024; +fn stream_bytes(size: Size) -> u64 { + // The portal rejects DMA strides above this allowance. Driver bookkeeping + // and encoder memory are separate from this pixel-storage reservation. + let stride = (size.w.max(0) as u64 * 4 + 4095) & !4095; + stride + .saturating_mul(size.h.max(0) as u64) + .saturating_mul(10) +} pub(super) enum Source { Output(WeakOutput), Window(WindowId), + Workspace(super::workspace_capture::WorkspaceSource), +} + +#[derive(Default)] +pub(super) struct SourcePaused(pub std::sync::atomic::AtomicBool); + +fn source_active(source: &ImageCaptureSource) -> bool { + source + .user_data() + .get::() + .is_none_or(|paused| !paused.0.load(std::sync::atomic::Ordering::Relaxed)) } #[derive(PartialEq)] @@ -50,7 +72,7 @@ struct WindowStamp { } #[derive(PartialEq)] -struct CursorStamp { +pub(super) struct CursorStamp { location: Point, geometry: Rectangle, image: CursorImageStamp, @@ -141,10 +163,10 @@ fn pending_frame_callbacks( pending } -struct Active { - session: Session, - pending: Option, - revision: Option, +pub(super) struct Active { + pub(super) session: Session, + pub(super) pending: Option, + pub(super) revision: Option, window_stamp: Option, size: Size, dma_node: Option, @@ -165,6 +187,10 @@ pub(crate) struct CaptureSessions { in_flight: HashMap, next_fence: u64, callback_timer: Option, + callback_deadline: Option, + workspace_callbacks: HashMap, + workspaces: + HashMap<(ferese_core::WorkspaceId, bool), super::workspace_capture::WorkspaceCapture>, } fn authorized(client: &Client) -> bool { @@ -179,6 +205,43 @@ fn authorized(client: &Client) -> bool { } impl CaptureSessions { + pub(super) fn workspace_callback_deadline( + &self, + id: ferese_core::WorkspaceId, + now: Instant, + ) -> Instant { + self.workspace_callbacks.get(&id).copied().unwrap_or(now) + } + pub(super) fn set_workspace_callback_deadline( + &mut self, + id: ferese_core::WorkspaceId, + deadline: Instant, + ) { + self.workspace_callbacks.insert(id, deadline); + } + pub(crate) fn invalidate_workspaces(&mut self) { + for view in self.workspaces.values_mut() { + view.invalidate(); + } + } + pub(crate) fn workspace_changed(&mut self, id: ferese_core::WorkspaceId) { + for ((workspace, _), view) in &mut self.workspaces { + if *workspace == id { + view.content_changed(); + } + } + } + pub(crate) fn workspace_geometry_changed(&mut self, id: ferese_core::WorkspaceId) { + for ((workspace, _), view) in &mut self.workspaces { + if *workspace == id { + view.invalidate(); + } + } + } + pub(crate) fn captured_workspaces(&self) -> Vec { + self.workspaces.keys().map(|(id, _)| *id).collect() + } + pub(crate) fn new(display: &DisplayHandle) -> Self { Self { copy: ImageCopyCaptureState::new_with_filter::(display, authorized), @@ -187,6 +250,9 @@ impl CaptureSessions { in_flight: HashMap::new(), next_fence: 0, callback_timer: None, + callback_deadline: None, + workspace_callbacks: HashMap::new(), + workspaces: HashMap::new(), } } @@ -280,6 +346,15 @@ impl ImageCopyCaptureHandler for Ferese { let Some(constraints) = session.current_constraints() else { return; }; + let reserved: u64 = self + .image_capture + .active + .iter() + .map(|active| stream_bytes(active.size)) + .sum(); + if stream_bytes(constraints.size) > MAX_STREAM_BYTES.saturating_sub(reserved) { + return; + } self.image_capture.active.push(Active { session, pending: None, @@ -347,6 +422,36 @@ impl ImageCopyCaptureHandler for Ferese { } impl Ferese { + pub(super) fn capture_cursor_stamp( + &self, + geometry: Rectangle, + ) -> Option { + use smithay::input::pointer::CursorImageStatus; + let pointer = self.seat.get_pointer()?; + let image = match &self.cursor_status { + CursorImageStatus::Named(_) => { + let (icon, epoch, frame) = self.cursor_animation.capture_revision(); + CursorImageStamp::Named(icon, epoch, frame) + } + CursorImageStatus::Surface(surface) => { + let hotspot = smithay::wayland::compositor::with_states(surface, |states| { + states + .data_map + .get::() + .map(|data| data.lock().expect("cursor metadata mutex poisoned").hotspot) + .unwrap_or_default() + }); + CursorImageStamp::Surface(surface_stamp(surface, None), hotspot) + } + CursorImageStatus::Hidden => CursorImageStamp::Hidden, + }; + Some(CursorStamp { + location: pointer.current_location(), + geometry, + image, + }) + } + fn capture_source_geometry( &self, source: &ImageCaptureSource, @@ -355,6 +460,12 @@ impl Ferese { return None; } let (size, output) = match source.user_data().get::()? { + Source::Workspace(view) => { + if !view.valid(self) { + return None; + } + (view.size, view.output.clone()) + } Source::Window(id) => { let info = self.window_capture_info(*id)?; let size = info.geometry.size.to_physical_precise_round(info.scale); @@ -411,31 +522,48 @@ impl Ferese { outputs.push(output); } } - crate::backends::direct::schedule_capture(self, &outputs); + if !outputs.is_empty() { + crate::backends::direct::schedule_capture(self, &outputs); + } } pub(crate) fn refresh_capture_sessions(&mut self) { self.image_capture.copy.cleanup(); let mut callback_delay: Option = None; + // Yield between distinct workspace compositions so input and display + // deadlines can run even when several consumers request changed views. + let mut workspace_compositions = 1usize; let mut sessions = std::mem::take(&mut self.image_capture.active); + let mut workspaces = std::mem::take(&mut self.image_capture.workspaces); + let mut reserved: u64 = sessions + .iter() + .map(|active| stream_bytes(active.size)) + .sum(); sessions.retain_mut(|active| { let Some((size, output)) = self.capture_source_geometry(&active.session.source()) else { - let session = active.session.as_ref(); - let fences: Vec<_> = self - .image_capture - .in_flight - .iter() - .filter_map(|(id, pending)| (pending.session == session).then_some(*id)) - .collect(); - for id in fences { - self.cancel_capture_fence(id); - } + self.cancel_session_fences(active.session.as_ref()); + reserved = reserved.saturating_sub(stream_bytes(active.size)); return false; }; let dma = self.capture_dma_constraints(&output); let dma_node = dma.as_ref().map(|dma| dma.node); if size != active.size || dma_node != active.dma_node { + let next_reserved = reserved + .saturating_sub(stream_bytes(active.size)) + .saturating_add(stream_bytes(size)); + if next_reserved > MAX_STREAM_BYTES { + self.cancel_session_fences(active.session.as_ref()); + reserved = reserved.saturating_sub(stream_bytes(active.size)); + return false; + } + reserved = next_reserved; + if let Some(Source::Workspace(source)) = + active.session.source().user_data().get::() + { + // GPU resources belong to the renderer that created them. + workspaces.remove(&(source.id, active.session.draw_cursor())); + } active.size = size; active.dma_node = dma_node; active.revision = None; @@ -449,6 +577,37 @@ impl Ferese { frame.fail(FailureReason::BufferConstraints); } } + if let Some(Source::Workspace(source)) = + active.session.source().user_data().get::() + && source_active(&active.session.source()) + { + let source = source.clone(); + let key = (source.id, active.session.draw_cursor()); + if let Some(frame) = active.pending.take() { + let view = workspaces.entry(key).or_insert_with(|| { + super::workspace_capture::WorkspaceCapture::new( + source.clone(), + self.capture_epoch, + ) + }); + if !view.source.valid(self) { + *view = super::workspace_capture::WorkspaceCapture::new( + source.clone(), + self.capture_epoch, + ); + } + if let Some(delay) = super::workspace_capture::process( + view, + self, + active, + frame, + &mut workspace_compositions, + ) { + callback_delay = + Some(callback_delay.map_or(delay, |existing| existing.min(delay))); + } + } + } if let Some(Source::Window(id)) = active.session.source().user_data().get::() { let id = *id; if let Some(frame) = active.pending.take() { @@ -574,24 +733,59 @@ impl Ferese { } true }); + workspaces.retain(|(id, cursor), view| { + let matches = |active: &Active| { + active.session.draw_cursor() == *cursor + && matches!(active.session.source().user_data().get::(), Some(Source::Workspace(source)) if source.id == *id) + }; + if !sessions.iter().any(matches) { return false; } + let pending = sessions.iter().any(|active| matches(active) + && active.pending.is_some() && source_active(&active.session.source())); + if !pending && view.target.is_some() { + let idle = view.last_used.elapsed(); + if idle >= Duration::from_secs(2) { + view.release_idle_target(); + } else { + let delay = Duration::from_secs(2) - idle; + callback_delay = Some(callback_delay.map_or(delay, |existing| existing.min(delay))); + } + } + true + }); + self.image_capture + .workspace_callbacks + .retain(|id, _| workspaces.keys().any(|(workspace, _)| id == workspace)); + self.image_capture.workspaces = workspaces; self.image_capture.active = sessions; if let Some(delay) = callback_delay { - if self.image_capture.callback_timer.is_none() { + let deadline = Instant::now() + delay; + if self + .image_capture + .callback_deadline + .is_none_or(|previous| deadline < previous) + { + if let Some(timer) = self.image_capture.callback_timer.take() { + self.loop_handle.remove(timer); + } + self.image_capture.callback_deadline = Some(deadline); use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; match self .loop_handle .insert_source(Timer::from_duration(delay), |_, _, state| { state.image_capture.callback_timer = None; + state.image_capture.callback_deadline = None; TimeoutAction::Drop }) { Ok(token) => self.image_capture.callback_timer = Some(token), Err(error) => { - tracing::warn!(%error, "could not schedule captured window callbacks") + self.image_capture.callback_deadline = None; + tracing::warn!(%error, "could not schedule capture callbacks") } } } } else if let Some(timer) = self.image_capture.callback_timer.take() { self.loop_handle.remove(timer); + self.image_capture.callback_deadline = None; } } @@ -635,6 +829,18 @@ impl Ferese { self.image_capture.active = sessions; } + fn cancel_session_fences(&mut self, session: SessionRef) { + let fences: Vec<_> = self + .image_capture + .in_flight + .iter() + .filter_map(|(id, pending)| (pending.session == session).then_some(*id)) + .collect(); + for id in fences { + self.cancel_capture_fence(id); + } + } + fn cancel_capture_fence(&mut self, id: u64) { if let Some(pending) = self.image_capture.in_flight.remove(&id) { self.loop_handle.remove(pending.fence); @@ -642,7 +848,7 @@ impl Ferese { } } - fn finish_stream_frame( + pub(super) fn finish_stream_frame( &mut self, frame: Frame, session: SessionRef, @@ -740,3 +946,7 @@ impl Ferese { } } } + +#[cfg(test)] +#[path = "workspace_capture_tests.rs"] +mod workspace_tests; diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 2cc1af4..f7ea73f 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -386,3 +386,5 @@ impl XdgForeignHandler for Ferese { } impl XdgToplevelIconHandler for Ferese {} + +pub(crate) mod workspace_capture; diff --git a/compositor/src/handlers/window_capture.rs b/compositor/src/handlers/window_capture.rs index b9b94be..87a1d65 100644 --- a/compositor/src/handlers/window_capture.rs +++ b/compositor/src/handlers/window_capture.rs @@ -191,7 +191,7 @@ pub(super) fn init_global( } display.create_global::( - 2, + 3, Global { budget: Arc::default(), worker, @@ -273,6 +273,52 @@ impl Dispatch for Ferese { _: &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) diff --git a/compositor/src/handlers/workspace_capture.rs b/compositor/src/handlers/workspace_capture.rs new file mode 100644 index 0000000..3306c27 --- /dev/null +++ b/compositor/src/handlers/workspace_capture.rs @@ -0,0 +1,450 @@ +//! Workspace capture owns a view, not a second desktop. Targets exist only for +//! subscribed sources; surface commits invalidate only their owning workspace. +use std::time::{Duration, Instant}; + +use ferese_core::WorkspaceId; +use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture}; +use smithay::output::Output; +use smithay::utils::{Buffer, Logical, Rectangle, Size, Transform}; + +use crate::Ferese; +use crate::backends::direct::capture::CaptureTarget; + +#[derive(Clone)] +pub(crate) struct WorkspaceSource { + pub id: WorkspaceId, + pub output: Output, + pub render_output: Output, + native_size: Size, + pub geometry: Rectangle, + pub size: Size, + scale: f64, + transform: Transform, +} + +impl WorkspaceSource { + pub fn new(state: &Ferese, id: WorkspaceId) -> Option { + state.workspaces.workspace(id)?; + let owner = state.output_workspaces.output_for_workspace(id)?; + let output = state + .output_ids + .iter() + .find(|(_, id)| **id == owner)? + .0 + .clone(); + let native_size = output.current_mode()?.size; + let geometry = state.space.output_geometry(&output)?; + if native_size.w <= 0 || native_size.h <= 0 || geometry.size.w <= 0 || geometry.size.h <= 0 + { + return None; + } + let scale = output.current_scale().fractional_scale(); + let (size, capture_scale) = capture_size(geometry.size, scale); + // A render descriptor only: never registered, mapped, advertised, or + // attached to input/layout. It gives shaders a bounded upright canvas. + let render_output = Output::new( + format!("workspace-capture-{}", id.0), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: String::new(), + model: String::new(), + serial_number: String::new(), + }, + ); + render_output.change_current_state( + Some(smithay::output::Mode { + size: (size.w, size.h).into(), + refresh: 60_000, + }), + Some(Transform::Normal), + Some(smithay::output::Scale::Fractional(capture_scale)), + None, + ); + render_output + .user_data() + .insert_if_missing(|| CaptureOutputId(owner)); + Some(Self { + id, + output: output.clone(), + render_output, + geometry, + size, + native_size, + scale, + transform: output.current_transform(), + }) + } + + pub fn valid(&self, state: &Ferese) -> bool { + state.workspaces.workspace(self.id).is_some() + && state.output_id(&self.output).is_some_and(|owner| { + state.output_workspaces.output_for_workspace(self.id) == Some(owner) + }) + && state.space.output_geometry(&self.output) == Some(self.geometry) + && self + .output + .current_mode() + .is_some_and(|mode| mode.size == self.native_size) + && self.output.current_scale().fractional_scale() == self.scale + && self.output.current_transform() == self.transform + } + + pub fn cursor_visible(&self, state: &Ferese) -> bool { + !state.overview.is_presenting() + && !state.input_capture.active() + && state + .output_id(&self.output) + .is_some_and(|output| state.workspace_capture_visible(self.id, output)) + && state.seat.get_pointer().is_some_and(|pointer| { + self.geometry + .contains(pointer.current_location().to_i32_floor()) + }) + && !state.capture_cursor_protected() + } +} + +pub(super) struct WorkspaceCapture { + pub source: WorkspaceSource, + pub content: u64, + pub prepared: Option, + pub cursor: Option, + pub epoch: u64, + pub next_frame: Instant, + pub callbacks_checked: Option, + pub callbacks_pending: bool, + pub target: Option, + pub resources: crate::render::RenderResources, + pub scene: Option, + pub scene_dirty: bool, + pub last_used: Instant, + trace: bool, + preparations: u64, + compositions: u64, + last_revision: Option, +} + +impl WorkspaceCapture { + pub fn new(source: WorkspaceSource, epoch: u64) -> Self { + Self { + source, + content: 0, + prepared: None, + cursor: None, + epoch, + next_frame: Instant::now(), + callbacks_checked: None, + callbacks_pending: false, + target: None, + resources: Default::default(), + scene: None, + scene_dirty: true, + last_used: Instant::now(), + trace: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(), + preparations: 0, + compositions: 0, + last_revision: None, + } + } + + pub fn invalidate(&mut self) { + self.content_changed(); + self.scene_dirty = true; + } + + pub fn release_idle_target(&mut self) { + self.target = None; + self.resources = Default::default(); + self.scene = None; + self.prepared = None; + if self.trace { + tracing::info!(target: "ferese::render", workspace = self.source.id.0, "released idle workspace capture target"); + } + } + + pub fn content_changed(&mut self) { + self.content = self.content.wrapping_add(1); + } + + fn callbacks(&self, state: &Ferese, send: bool) -> bool { + let (Some(scene), Some(target)) = (&self.scene, &self.target) else { + return false; + }; + let mut pending = false; + for id in scene.windows.keys() { + let Some(window) = state + .windows + .window(*id) + .filter(|window| !state.capture_protected(window)) + else { + continue; + }; + let mut visible = false; + window.with_surfaces(|surface, _| visible |= target.surface_visible(surface)); + if !visible { + continue; + } + // Normal display callbacks may already have drained these. Capture + // never generates presentation feedback or changes scanout ownership. + if send { + window.send_frame( + &self.source.output, + state.start_time.elapsed(), + Some(interval()), + |_, _| None, + ); + } + window.with_surfaces(|_, states| { + pending |= !states + .cached_state + .get::() + .current() + .frame_callbacks + .is_empty(); + }); + } + pending + } + + pub fn render( + &mut self, + state: &mut Ferese, + renderer: &mut GlesRenderer, + cursor: bool, + ) -> Result<(GlesTexture, u64), String> { + if self.epoch != state.capture_epoch { + self.resources = Default::default(); + self.target = None; + self.epoch = state.capture_epoch; + self.callbacks_checked = None; + } + if self.scene_dirty || self.scene.is_none() { + let scene = state.sample_workspace_frame(self.source.id, &self.source.output); + let members = state + .workspaces + .workspace(self.source.id) + .into_iter() + .flat_map(|workspace| { + workspace + .layout + .window_ids() + .chain(workspace.floating.iter().copied()) + }) + .collect(); + self.resources.retain_workspace_windows(&members); + self.scene = Some(scene); + self.scene_dirty = false; + } + let scene = self.scene.as_ref().expect("capture scene sampled"); + let elements = crate::render::capture_workspace_elements( + state, + renderer, + &self.source, + cursor, + scene, + &mut self.resources, + ); + let result = crate::backends::direct::capture::render_capture_with_revision( + renderer, + &mut self.target, + &self.source.render_output, + &elements, + ) + .map(|_| ()) + .map_err(|error| error.to_string()); + if let Err(error) = result { + self.target = None; + self.prepared = None; + return Err(error); + } + self.preparations += 1; + self.prepared = Some(self.content); + let target = self + .target + .as_ref() + .expect("successful capture has a target"); + let image = target.image(); + if self.last_revision != Some(image.1) { + self.compositions += 1; + } + self.last_revision = Some(image.1); + Ok(image) + } +} + +impl Drop for WorkspaceCapture { + fn drop(&mut self) { + if self.trace { + tracing::info!(target: "ferese::render", workspace = self.source.id.0, preparations = self.preparations, compositions = self.compositions, "workspace capture outcomes"); + } + } +} + +/// The compositor enforces a ceiling even for a client that bypasses PipeWire's +/// pacing. A shared deadline is used for both hidden-client callbacks and frames. +pub(super) fn interval() -> Duration { + static INTERVAL: std::sync::OnceLock = std::sync::OnceLock::new(); + *INTERVAL.get_or_init(|| { + let fps = std::env::var("FERESE_CAPTURE_MAX_FPS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(60) + .clamp(1, 240); + Duration::from_nanos(1_000_000_000 / fps) + }) +} + +/// Shader cache identity; this does not grant the descriptor a desktop output. +pub(crate) struct CaptureOutputId(pub ferese_core::OutputId); + +fn capture_size(logical: Size, native_scale: f64) -> (Size, f64) { + let (width, height) = if logical.w >= logical.h { + (1920.0, 1080.0) + } else { + (1080.0, 1920.0) + }; + let scale = native_scale + .min(width / f64::from(logical.w.max(1))) + .min(height / f64::from(logical.h.max(1))); + ( + ( + (f64::from(logical.w) * scale).round().max(1.0) as i32, + (f64::from(logical.h) * scale).round().max(1.0) as i32, + ) + .into(), + scale, + ) +} + +fn with_renderer( + state: &mut Ferese, + output: &Output, + render: impl FnOnce(&mut Ferese, &mut GlesRenderer) -> Result, +) -> Result { + if let Some(backend) = state.nested_backend.clone() { + let mut backend = backend + .try_borrow_mut() + .map_err(|error| error.to_string())?; + render(state, backend.renderer()) + } else if let Some(mut backend) = state.direct_backend.take() { + let result = backend + .capture_renderer(output) + .ok_or_else(|| "Capture output is unavailable".to_string()) + .and_then(|renderer| render(state, renderer)); + state.direct_backend = Some(backend); + result + } else { + Err("Capture renderer is unavailable".into()) + } +} + +pub(super) fn process( + view: &mut WorkspaceCapture, + state: &mut Ferese, + active: &mut super::image_capture::Active, + frame: smithay::wayland::image_copy_capture::Frame, + compositions_left: &mut usize, +) -> Option { + use smithay::reexports::wayland_protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::FailureReason; + let now = Instant::now(); + view.last_used = now; + let cursor = active.session.draw_cursor() && view.source.cursor_visible(state); + let cursor_stamp = cursor + .then(|| state.capture_cursor_stamp(view.source.geometry)) + .flatten(); + let changed = view.prepared != Some(view.content) + || view.epoch != state.capture_epoch + || view.cursor != cursor_stamp; + let interval = super::workspace_capture::interval(); + let mut retry = None; + let image = if changed && (now < view.next_frame || *compositions_left == 0) { + retry = Some( + view.next_frame + .saturating_duration_since(now) + .max(Duration::from_millis(1)), + ); + None + } else if changed { + *compositions_left -= 1; + let output = view.source.output.clone(); + let result = with_renderer(state, &output, |state, renderer| { + view.render(state, renderer, cursor) + }); + match result { + Ok(image) => { + view.cursor = cursor_stamp; + view.next_frame = now + interval; + Some(image) + } + Err(error) => { + tracing::warn!(%error, "workspace capture failed"); + frame.fail(FailureReason::Unknown); + return None; + } + } + } else { + view.target.as_ref().map(|target| target.image()) + }; + if let Some((texture, revision)) = + image.filter(|(_, revision)| active.revision != Some(*revision)) + { + crate::backends::direct::capture::touch_cache(state); + let result = with_renderer(state, &view.source.output, |_, renderer| { + crate::render::capture_stream_texture( + renderer, + &texture, + &frame.buffer(), + Transform::Normal, + false, + ) + }); + active.revision = result.as_ref().ok().map(|_| revision); + state.finish_stream_frame(frame, active.session.as_ref(), result); + } else { + active.pending = Some(frame); + } + + let deadline = state + .image_capture + .workspace_callback_deadline(view.source.id, now); + if view.prepared == Some(view.content) + && (view.callbacks_checked != Some(view.content) + || (view.callbacks_pending && now >= deadline)) + { + view.callbacks_pending = view.callbacks(state, now >= deadline); + view.callbacks_checked = Some(view.content); + if now >= deadline { + state + .image_capture + .set_workspace_callback_deadline(view.source.id, now + interval); + } + } + if view.callbacks_pending { + let delay = state + .image_capture + .workspace_callback_deadline(view.source.id, now) + .saturating_duration_since(now); + retry = Some(retry.map_or(delay, |existing| existing.min(delay))); + } + retry +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_canvas_is_bounded_and_preserves_fractional_and_portrait_geometry() { + for (logical, native, expected) in [ + ((1920, 1080), 2.0, (1920, 1080)), + ((1080, 1920), 2.0, (1080, 1920)), + ((800, 600), 1.25, (1000, 750)), + ((3440, 1440), 1.0, (1920, 804)), + ] { + let (size, scale) = capture_size(logical.into(), native); + assert_eq!(size, expected.into()); + assert!(scale <= native); + assert!(size.w as u64 * size.h as u64 <= 1920 * 1080); + } + } +} diff --git a/compositor/src/handlers/workspace_capture_tests.rs b/compositor/src/handlers/workspace_capture_tests.rs new file mode 100644 index 0000000..29c77fd --- /dev/null +++ b/compositor/src/handlers/workspace_capture_tests.rs @@ -0,0 +1,215 @@ +use super::super::workspace_capture::{WorkspaceCapture, WorkspaceSource}; +use super::*; +use smithay::output::{Mode, PhysicalProperties, Scale, Subpixel}; + +fn fixture() -> ( + smithay::reexports::calloop::EventLoop<'static, Ferese>, + Ferese, + Output, +) { + let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap(); + let mut config = crate::config::Config::default().runtime_config().unwrap(); + config.animations_enabled = false; + config.wallpaper.path = None; + let mut state = Ferese::new( + &mut events, + smithay::reexports::wayland_server::Display::new().unwrap(), + config, + ) + .unwrap(); + let output = Output::new( + "workspace-test".into(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: String::new(), + model: String::new(), + serial_number: String::new(), + }, + ); + output.change_current_state( + Some(Mode { + size: (1600, 1200).into(), + refresh: 60_000, + }), + None, + Some(Scale::Fractional(2.0)), + None, + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, "workspace-test".into()); + (events, state, output) +} + +#[test] +fn workspace_capture_samples_hidden_layout_without_switching_or_configuring_clients() { + if !crate::startup_tests::private_runtime( + "handlers::image_capture::workspace_tests::workspace_capture_samples_hidden_layout_without_switching_or_configuring_clients", + ) { + return; + } + let (mut events, mut state, output) = fixture(); + let (window, _client) = crate::state::test_window(&mut state, &mut events, 0xffff0000); + let id = state.windows.ids()[&window]; + let workspace = state.workspaces.workspace_for_window(id).unwrap(); + let source = WorkspaceSource::new(&state, workspace).unwrap(); + state.switch_workspace(2); + let (private, _private_client) = crate::state::test_window(&mut state, &mut events, 0xff00ff00); + let private_id = state.windows.ids()[&private]; + let focused = state.focused_window; + let active = state.workspaces.active_id(); + let geometry = *state.windows.geometry(&id).unwrap(); + let sample = state.sample_workspace_frame(workspace, &output); + assert!(sample.windows.contains_key(&id)); + assert!(!sample.windows.contains_key(&private_id)); + assert!(!sample.overview.is_presenting()); + assert!(sample.capture_tabs.is_some()); + assert_eq!(state.workspaces.active_id(), active); + assert_eq!(state.focused_window, focused); + assert_eq!(*state.windows.geometry(&id).unwrap(), geometry); + assert!(state.space.element_location(&window).is_none()); + assert!(source.valid(&state)); + // The descriptor is never part of the desktop or advertised to clients. + assert!(state.output_id(&source.render_output).is_none()); + assert!(state.space.output_geometry(&source.render_output).is_none()); + // A newly mapped hidden client need not have acquired a display sample. + state.windows.record_mut(id).unwrap().geometry = None; + assert!( + state + .sample_workspace_frame(workspace, &output) + .windows + .contains_key(&id) + ); + assert!(state.windows.record(id).unwrap().geometry.is_none()); + output.change_current_state(None, None, Some(Scale::Fractional(1.5)), None); + assert!(!source.valid(&state)); +} + +#[test] +fn workspace_commits_invalidate_only_subscribed_source_and_allocate_no_targets() { + if !crate::startup_tests::private_runtime( + "handlers::image_capture::workspace_tests::workspace_commits_invalidate_only_subscribed_source_and_allocate_no_targets", + ) { + return; + } + let (mut events, mut state, _) = fixture(); + let (first, _first_client) = crate::state::test_window(&mut state, &mut events, 0xffff0000); + let first_id = state.windows.ids()[&first]; + let workspace = state.workspaces.workspace_for_window(first_id).unwrap(); + let source = WorkspaceSource::new(&state, workspace).unwrap(); + state.switch_workspace(2); + let (second, _second_client) = crate::state::test_window(&mut state, &mut events, 0xff00ff00); + for cursor in [false, true] { + state.image_capture.workspaces.insert( + (workspace, cursor), + WorkspaceCapture::new(source.clone(), state.capture_epoch), + ); + } + crate::backends::direct::render_surface(&mut state, second.toplevel().unwrap().wl_surface()); + for view in state.image_capture.workspaces.values() { + assert_eq!(view.content, 0); + assert!(view.target.is_none()); + } + crate::backends::direct::render_surface(&mut state, first.toplevel().unwrap().wl_surface()); + for view in state.image_capture.workspaces.values() { + assert_eq!(view.content, 1); + } + state + .image_capture + .workspace_callbacks + .insert(workspace, Instant::now()); + state.refresh_capture_sessions(); + assert!(state.image_capture.workspaces.is_empty()); + assert!(state.image_capture.workspace_callbacks.is_empty()); + assert!(state.image_capture.callback_timer.is_none()); +} + +#[test] +fn capture_admission_accounts_for_the_pool_and_transfer_targets() { + assert_eq!(stream_bytes((1920, 1080).into()), 8192 * 1080 * 10); + assert!(stream_bytes((3840, 2160).into()) < MAX_STREAM_BYTES); + assert!(stream_bytes((i32::MAX, i32::MAX).into()) > MAX_STREAM_BYTES); +} + +#[test] +fn workspace_tab_metadata_invalidates_only_owning_capture() { + if !crate::startup_tests::private_runtime( + "handlers::image_capture::workspace_tests::workspace_tab_metadata_invalidates_only_owning_capture", + ) { + return; + } + use smithay::wayland::compositor::with_states; + use smithay::wayland::shell::xdg::{XdgShellHandler, XdgToplevelSurfaceData}; + + for hidden in [false, true] { + for title in [false, true] { + let (mut events, mut state, output) = fixture(); + let (first, _first_client) = + crate::state::test_window(&mut state, &mut events, 0xffff0000); + state.toggle_column_tabbed(); + let (_second, _second_client) = + crate::state::test_window(&mut state, &mut events, 0xff00ff00); + state.consume_focused_window(); + let workspace = state.workspaces.active_id(); + let scene = state.sample_workspace_frame(workspace, &output); + assert!(!scene.capture_tabs.as_ref().unwrap().is_empty()); + assert!(state.space.element_location(&first).is_none()); + + state.switch_workspace(2); + let (_other, _other_client) = + crate::state::test_window(&mut state, &mut events, 0xff0000ff); + let other_workspace = state.workspaces.active_id(); + if !hidden { + state.switch_workspace(1); + } + for id in [workspace, other_workspace] { + for cursor in [false, true] { + let mut view = WorkspaceCapture::new( + WorkspaceSource::new(&state, id).unwrap(), + state.capture_epoch, + ); + view.prepared = Some(view.content); + view.scene = Some(state.sample_workspace_frame(id, &output)); + view.scene_dirty = false; + state.image_capture.workspaces.insert((id, cursor), view); + } + } + let epoch = state.capture_epoch; + let top = first.toplevel().unwrap(); + with_states(top.wl_surface(), |states| { + let mut metadata = states + .data_map + .get::() + .unwrap() + .lock() + .unwrap(); + if title { + metadata.title = Some("Updated tab title".into()); + } else { + metadata.title = None; + metadata.app_id = Some("org.test.UpdatedApp".into()); + } + }); + if title { + state.title_changed(top.clone()); + } else { + state.app_id_changed(top.clone()); + } + assert_eq!(state.capture_epoch, epoch); + for cursor in [false, true] { + let view = &state.image_capture.workspaces[&(workspace, cursor)]; + assert_ne!( + view.prepared, + Some(view.content), + "tab metadata must invalidate its capture (hidden={hidden}, title={title})" + ); + assert!( + !view.scene_dirty, + "metadata must not force a layout resample" + ); + let other = &state.image_capture.workspaces[&(other_workspace, cursor)]; + assert_eq!(other.prepared, Some(other.content)); + } + } + } +} diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 78b5887..7b8870b 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -38,6 +38,13 @@ impl XdgShellHandler for Ferese { fn title_changed(&mut self, surface: ToplevelSurface) { self.refresh_capture_privacy(); if self.windows.refresh_metadata(surface.wl_surface()) { + if let Some(workspace) = self + .windows + .id_for_surface(surface.wl_surface()) + .and_then(|id| self.workspaces.workspace_for_window(id)) + { + self.image_capture.workspace_changed(workspace); + } self.refresh_idle_inhibition(); self.send_shell_snapshots(); for output in self.surface_outputs(surface.wl_surface()) { @@ -49,6 +56,13 @@ impl XdgShellHandler for Ferese { fn app_id_changed(&mut self, surface: ToplevelSurface) { self.refresh_capture_privacy(); if self.windows.refresh_metadata(surface.wl_surface()) { + if let Some(workspace) = self + .windows + .id_for_surface(surface.wl_surface()) + .and_then(|id| self.workspaces.workspace_for_window(id)) + { + self.image_capture.workspace_changed(workspace); + } self.refresh_idle_inhibition(); self.send_shell_snapshots(); for output in self.surface_outputs(surface.wl_surface()) { diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index a493ea0..1616abf 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -38,7 +38,8 @@ use overview::{overview_chrome_element, overview_strip_elements}; pub(crate) use resources::RenderResources; use scene::cursor_elements; pub(crate) use scene::{ - capture_output_elements, frame_effect_metrics, layer_surfaces, sampled_output_elements, + capture_output_elements, capture_workspace_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 f73da83..7804821 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -21,7 +21,12 @@ pub(super) fn overview_chrome_element( width: f64, radius: f64, ) -> Option { - let output_id = state.output_id(output)?; + let output_id = state.output_id(output).or_else(|| { + output + .user_data() + .get::() + .map(|id| id.0) + })?; let geometry = rounded_visual_rect(rect, output_geometry.loc); let corners = RoundedRect::new(rect, output_geometry.loc, scale, radius); let mode = output.current_mode()?; diff --git a/compositor/src/render/resources.rs b/compositor/src/render/resources.rs index 9af5224..d9418ec 100644 --- a/compositor/src/render/resources.rs +++ b/compositor/src/render/resources.rs @@ -56,6 +56,18 @@ pub(super) struct ContextPrograms { } impl RenderResources { + pub(crate) fn retain_workspace_windows( + &mut self, + windows: &std::collections::HashSet, + ) { + self.windows.retain(|id, _| windows.contains(id)); + for output in self.outputs.values_mut() { + output + .chrome + .retain(|(id, _), _| windows.contains(&WindowId(*id))); + } + } + pub(super) fn prepare_window_corners(&mut self, id: WindowId, shape: CornerShape) -> bool { let buffers = self.windows.entry(id).or_default(); diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index c7a78d2..d922343 100644 --- a/compositor/src/render/scene.rs +++ b/compositor/src/render/scene.rs @@ -32,7 +32,7 @@ pub(crate) fn sampled_output_elements( include_cursor: bool, frame: &crate::state::FrameScene, ) -> Vec { - scene_elements(state, renderer, output, include_cursor, frame, false) + scene_elements(state, renderer, output, include_cursor, frame, false, None) } pub(crate) fn capture_output_elements( @@ -46,11 +46,35 @@ pub(crate) fn capture_output_elements( // display rendering. Only the capture scene can populate these caches. crate::backends::direct::capture::touch_cache(state); std::mem::swap(&mut state.render, &mut state.capture_render); - let elements = scene_elements(state, renderer, output, include_cursor, frame, true); + let elements = scene_elements(state, renderer, output, include_cursor, frame, true, None); std::mem::swap(&mut state.render, &mut state.capture_render); elements } +/// The caller supplies resources owned by this capture view. No display or +/// other workspace's blur/snapshot cache may populate this scene. +pub(crate) fn capture_workspace_elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + view: &crate::handlers::workspace_capture::WorkspaceSource, + include_cursor: bool, + frame: &crate::state::FrameScene, + resources: &mut super::RenderResources, +) -> Vec { + std::mem::swap(&mut state.render, resources); + let elements = scene_elements( + state, + renderer, + &view.render_output, + include_cursor, + frame, + true, + Some(view), + ); + std::mem::swap(&mut state.render, resources); + elements +} + fn scene_elements( state: &mut Ferese, renderer: &mut GlesRenderer, @@ -58,6 +82,7 @@ fn scene_elements( include_cursor: bool, frame: &crate::state::FrameScene, capture: bool, + workspace: Option<&crate::handlers::workspace_capture::WorkspaceSource>, ) -> Vec { if state.session_lock.active() { let Some(geometry) = state.space.output_geometry(output) else { @@ -138,7 +163,10 @@ fn scene_elements( ); return elements; } - let Some(output_geometry) = state.space.output_geometry(output) else { + let Some(output_geometry) = workspace + .map(|view| view.geometry) + .or_else(|| state.space.output_geometry(output)) + else { return Vec::new(); }; let scale = output.current_scale().fractional_scale(); @@ -155,7 +183,9 @@ fn scene_elements( } else { &[Layer::Overlay, Layer::Top] }; - elements.extend(layer_elements(state, renderer, output, upper_layers)); + if workspace.is_none() { + elements.extend(layer_elements(state, renderer, output, upper_layers)); + } if frame.overview.is_presenting() { elements.extend(overview_strip_elements( state, @@ -170,14 +200,22 @@ fn scene_elements( 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 (capture && state.capture_protected(window)) || !state.window_content_ready(window) { + let ready = if workspace.is_some() { + crate::state::window_has_buffer(window) + } else { + state.window_content_ready(window) + }; + if (capture && state.capture_protected(window)) || !ready { return None; } let id = *state.windows.ids().get(window)?; // Scrolling columns may sit outside their monitor's rectangle. // They must not reappear on a neighboring output just because // their global animated coordinates overlap it. - if !state.window_belongs_to_output(id, output) { + if workspace.map_or_else( + || !state.window_belongs_to_output(id, output), + |workspace| state.workspaces.workspace_for_window(id) != Some(workspace.id), + ) { return None; } let sample = frame.windows.get(&id)?; @@ -194,12 +232,23 @@ fn scene_elements( presentation_alpha, )) }; - let windows = if state.overview.is_active() { + let windows = if workspace.is_none() && state.overview.is_active() { state .windows .overview_windows() .filter_map(prepare_window) .collect::>() + } else if workspace.is_some() { + let mut windows = frame + .windows + .keys() + .filter_map(|id| state.windows.window(*id)) + .filter_map(prepare_window) + .collect::>(); + windows.sort_by_key(|(_, id, sample, ..)| { + std::cmp::Reverse(state.workspace_capture_stack(*id, sample.geometry)) + }); + windows } else { state .space @@ -209,27 +258,38 @@ fn scene_elements( .collect::>() }; - let mut closing = super::closing::grouped_elements( - state, - renderer, - output, - windows.iter().map(|(_, id, ..)| *id), - frame.delta, - ); + let mut closing = if workspace.is_some() { + Default::default() + } else { + super::closing::grouped_elements( + state, + renderer, + output, + windows.iter().map(|(_, id, ..)| *id), + frame.delta, + ) + }; for (window, id, sample, visual, decoration_progress, presentation_alpha) in windows { if let Some(group) = closing.remove(&Some(id)) { elements.extend(group); } elements.extend(super::tabs::tab_elements( - state, renderer, output, frame, id, capture, + state, + renderer, + output, + output_geometry, + frame, + id, + capture, )); let constrain = rounded_visual_rect(visual, output_geometry.loc); let material_surface = window .toplevel() .map(|toplevel| toplevel.wl_surface()) .filter(|surface| { - crate::effects::surface_role(surface) - .is_some_and(|(role, _)| role == crate::effects::SemanticRole::Modal) + workspace.is_none() + && crate::effects::surface_role(surface) + .is_some_and(|(role, _)| role == crate::effects::SemanticRole::Modal) }); let window_radius = if material_surface.is_some() { state.theme_settings.material_radius @@ -418,13 +478,15 @@ fn scene_elements( if let Some(group) = closing.remove(&None) { elements.extend(group); } - elements.extend(layer_elements( - state, - renderer, - output, - &[Layer::Bottom, Layer::Background], - )); - state.prepare_theme_wallpaper(renderer, output); + if workspace.is_none() { + elements.extend(layer_elements( + state, + renderer, + output, + &[Layer::Bottom, Layer::Background], + )); + } + state.prepare_theme_wallpaper(renderer, workspace.map_or(output, |view| &view.output)); let progress = state.theme_progress(); let wallpaper = state.wallpaper.element(renderer, output); let has_wallpaper = wallpaper.is_some(); diff --git a/compositor/src/render/tab_indicator.rs b/compositor/src/render/tab_indicator.rs index 24e1b1e..06482d3 100644 --- a/compositor/src/render/tab_indicator.rs +++ b/compositor/src/render/tab_indicator.rs @@ -113,17 +113,17 @@ pub(super) fn elements( state: &mut Ferese, renderer: &mut GlesRenderer, output: &Output, + output_geometry: Rectangle, + frame: &crate::state::FrameScene, bar: &TabBar, content: Rect, alpha: f32, capture: bool, ) -> Vec { - let Some(g) = state.tab_indicator_geometry(bar, content) else { - return Vec::new(); - }; - let Some(output_geometry) = state.space.output_geometry(output) else { + let Some(g) = state.tab_indicator_geometry_for_frame(bar, content, frame) else { return Vec::new(); }; + let scale = output.current_scale().fractional_scale(); let settings = state.tab_indicator_settings; let anchor = bar.group.windows[0]; diff --git a/compositor/src/render/tabs.rs b/compositor/src/render/tabs.rs index 6e4390a..dbd8907 100644 --- a/compositor/src/render/tabs.rs +++ b/compositor/src/render/tabs.rs @@ -9,6 +9,7 @@ pub(super) fn tab_elements( state: &mut Ferese, renderer: &mut GlesRenderer, output: &Output, + output_geometry: Rectangle, frame: &crate::state::FrameScene, active: ferese_layout::WindowId, capture: bool, @@ -19,13 +20,27 @@ pub(super) fn tab_elements( let Some(workspace) = state.workspaces.workspace_for_window(active) else { return Vec::new(); }; - let Some(bar) = state - .visible_tab_bars(workspace) - .find(|bar| bar.group.active == active) - .cloned() - else { - return Vec::new(); + let bar = if let Some(tabs) = &frame.capture_tabs { + tabs.iter() + .find(|bar| bar.group.active == active) + .filter(|_| { + state + .workspaces + .workspace(workspace) + .is_some_and(|ws| ws.fullscreen.is_none()) + && state + .windows + .record(active) + .is_some_and(|window| !window.maximized) + }) + .cloned() + } else { + state + .visible_tab_bars(workspace) + .find(|bar| bar.group.active == active) + .cloned() }; + let Some(bar) = bar else { return Vec::new() }; let Some(sample) = frame.windows.get(&active) else { return Vec::new(); }; @@ -37,6 +52,8 @@ pub(super) fn tab_elements( state, renderer, output, + output_geometry, + frame, &bar, sample.presentation.bounds.current, sample.presentation.alpha(), @@ -48,9 +65,7 @@ pub(super) fn tab_elements( } let content = sample.presentation.bounds.current; let alpha = sample.presentation.alpha(); - let Some(output_geometry) = state.space.output_geometry(output) else { - return Vec::new(); - }; + let scale = output.current_scale().fractional_scale(); let width = content.width / bar.group.windows.len() as f64; let shell = ferese_layout::Rect::new( diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index ea94cf1..f1ac3f0 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -202,6 +202,20 @@ impl Ferese { } } + for workspace in self.image_capture.captured_workspaces() { + if let Some(output) = self + .output_workspaces + .output_for_workspace(workspace) + .and_then(|id| self.outputs_by_id.get(&id)) + && self.output_id(output).is_some_and(|id| { + self.output_workspaces.active_workspace(id) == Some(workspace) + }) + && (self.output_has_animations(output) + || self.output_has_pending_visual_changes(output)) + { + self.image_capture.workspace_geometry_changed(workspace); + } + } let delta = delta.mul_f64(self.animation_speed); if let Some(workspace) = self .focus_swipe() diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index 4d109ca..d7b753c 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -17,6 +17,7 @@ struct Preview { layout: WorkspaceLayout, input: PreviewInput, rectangles: HashMap, + tabs: Vec, } #[derive(PartialEq)] @@ -126,6 +127,10 @@ impl Ferese { .reconcile_workspaces(&mut self.workspaces, visible) { self.viewports.remove(&workspace); + self.workspace_previews + .borrow_mut() + .entries + .remove(&workspace); } } @@ -197,6 +202,7 @@ impl Ferese { } fn relayout_outputs(&mut self, mut redraw_outputs: Option>, finalize: bool) { + self.image_capture.invalidate_workspaces(); if self .desktop_transition .as_ref() @@ -705,6 +711,9 @@ impl Ferese { pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) { if let Some(id) = self.windows.ids().get(window) { self.window_stack.raise(*id); + if let Some(workspace) = self.workspaces.workspace_for_window(*id) { + self.image_capture.workspace_geometry_changed(workspace); + } } self.space.raise_element(window, activate); @@ -781,6 +790,14 @@ impl Ferese { return; } + if let Some(workspace) = self + .windows + .ids() + .get(&window) + .and_then(|id| self.workspaces.workspace_for_window(*id)) + { + self.image_capture.workspace_geometry_changed(workspace); + } self.space.map_element(window, location, false); self.stacking_cache.remapped(); } @@ -805,21 +822,50 @@ impl Ferese { } pub(crate) fn workspace_preview_rects(&self) -> HashMap { - let constraints = self.window_constraints(); + self.workspace_preview_rects_for(None) + } + + /// Capture resolves only its subscribed workspace; it never activates or + /// configures hidden clients. The preview cache also serves overview. + pub(crate) fn workspace_preview_rects_for( + &self, + selected: Option, + ) -> HashMap { + if let Some(id) = selected { + self.workspace_preview_views( + selected, + self.output_workspaces + .output_for_workspace(id) + .map(|output| (id, output)) + .into_iter(), + ) + } else { + self.workspace_preview_views( + None, + self.output_workspaces + .workspace_views_iter(&self.workspaces) + .map(|view| (view.id, view.output)), + ) + } + } + + fn workspace_preview_views( + &self, + selected: Option, + views: impl Iterator, + ) -> HashMap { + let constraints = self.window_constraints_for(selected); let mut cache = self.workspace_previews.borrow_mut(); let mut live = HashSet::new(); let mut previews = HashMap::new(); - for view in self - .output_workspaces - .workspace_views_iter(&self.workspaces) - { - let Some(workspace) = self.workspaces.workspace(view.id) else { + for (workspace_id, owner) in views { + let Some(workspace) = self.workspaces.workspace(workspace_id) else { continue; }; let Some(output) = self .output_ids .iter() - .find(|(_, id)| **id == view.output) + .find(|(_, id)| **id == owner) .map(|(o, _)| o) else { continue; @@ -827,7 +873,7 @@ impl Ferese { let Some(bounds) = self.output_bounds_for(output) else { continue; }; - live.insert(view.id); + live.insert(workspace_id); let mut windows: Vec<_> = workspace .layout .window_ids() @@ -859,21 +905,21 @@ impl Ferese { }; if !cache .entries - .get(&view.id) + .get(&workspace_id) .is_some_and(|cached| cached.input == input && cached.layout == workspace.layout) { // Only clone and solve the changed workspace. Titles, urgency and // other shell metadata never invalidate geometry. let mut layout = workspace.layout.clone(); - let mut rectangles = layout + let resolved = layout .resolve_geometry_with_constraints( input.bounds, input.gaps, &constraints, input.focused, ) - .map(|resolved| resolved.geometry) .unwrap_or_default(); + let mut rectangles = resolved.geometry; for window in &input.windows { if let Some(rect) = window.floating { rectangles.insert( @@ -894,11 +940,12 @@ impl Ferese { } } cache.entries.insert( - view.id, + workspace_id, Preview { layout: workspace.layout.clone(), input, rectangles, + tabs: resolved.tab_bars, }, ); #[cfg(test)] @@ -907,44 +954,77 @@ impl Ferese { } } previews.extend( - cache.entries[&view.id] + cache.entries[&workspace_id] .rectangles .iter() .map(|(&id, &rect)| (id, rect)), ); } - cache.entries.retain(|id, _| live.contains(id)); + cache + .entries + .retain(|id, _| selected.is_some_and(|selected| selected != *id) || live.contains(id)); previews } - pub(super) fn window_constraints(&self) -> HashMap { - self.windows - .ids() - .iter() - .filter_map(|(window, id)| { - let toplevel = window.toplevel()?; - let (minimum, maximum) = with_states(toplevel.wl_surface(), |states| { - let mut cached = states.cached_state.get::(); - let state = cached.current(); - (state.min_size, state.max_size) - }); - let (minimum, maximum) = self.effective_size_constraints( - window, - (minimum.w, minimum.h), - (maximum.w, maximum.h), - ); + pub(super) fn workspace_preview_tabs(&self, id: WorkspaceId) -> Vec { + self.workspace_previews + .borrow() + .entries + .get(&id) + .map_or_else(Vec::new, |preview| preview.tabs.clone()) + } - Some(( - *id, - SizeConstraints { - min_width: minimum.0.max(1) as f64, - min_height: minimum.1.max(1) as f64, - max_width: (maximum.0 > 0).then_some(maximum.0 as f64), - max_height: (maximum.1 > 0).then_some(maximum.1 as f64), - }, - )) - }) - .collect() + pub(super) fn window_constraints(&self) -> HashMap { + self.window_constraints_for(None) + } + + fn window_constraints_for( + &self, + workspace: Option, + ) -> HashMap { + if let Some(workspace) = workspace { + self.constraints_for_windows(self.workspaces.workspace(workspace).into_iter().flat_map( + |workspace| { + workspace + .layout + .window_ids() + .chain(workspace.floating.iter().copied()) + }, + )) + } else { + self.constraints_for_windows(self.windows.ids().values().copied()) + } + } + + fn constraints_for_windows( + &self, + ids: impl Iterator, + ) -> HashMap { + ids.filter_map(|id| { + let window = self.windows.window(id)?; + let toplevel = window.toplevel()?; + let (minimum, maximum) = with_states(toplevel.wl_surface(), |states| { + let mut cached = states.cached_state.get::(); + let state = cached.current(); + (state.min_size, state.max_size) + }); + let (minimum, maximum) = self.effective_size_constraints( + window, + (minimum.w, minimum.h), + (maximum.w, maximum.h), + ); + + Some(( + id, + SizeConstraints { + min_width: minimum.0.max(1) as f64, + min_height: minimum.1.max(1) as f64, + max_width: (maximum.0 > 0).then_some(maximum.0 as f64), + max_height: (maximum.1 > 0).then_some(maximum.1 as f64), + }, + )) + }) + .collect() } pub(super) fn arrange_layers(&self) { diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index 427cefd..4d4ead0 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -7,6 +7,7 @@ use super::{ /// geometry, configure barriers, animation clocks, or another output's view. pub(crate) struct FrameScene { pub windows: HashMap, + pub capture_tabs: Option>, pub overview: crate::overview::OverviewMotion, // Plane selection and scheduling reuse the activity sampled with this scene. pub animating: bool, @@ -332,6 +333,113 @@ impl Ferese { }) } + pub(crate) fn workspace_capture_visible( + &self, + workspace: ferese_core::WorkspaceId, + output: ferese_core::OutputId, + ) -> bool { + self.output_workspaces.active_workspace(output) == Some(workspace) + && !self.workspace_slides.contains_key(&output) + } + + pub(crate) fn workspace_capture_stack( + &self, + id: WindowId, + geometry: WindowGeometry, + ) -> (u8, usize) { + let floating = matches!( + self.workspaces.placement(id), + Some(ferese_core::WindowPlacement::Floating { .. }) + ); + let above = self + .floating_above_fullscreen + .get(&id) + .is_some_and(|parent| { + self.workspaces + .workspace_for_window(id) + .and_then(|workspace| self.workspaces.workspace(workspace)) + .is_some_and(|workspace| workspace.fullscreen == Some(*parent)) + }); + ( + crate::stacking::layer_priority(floating, false, geometry.is_fullscreen(), above), + self.window_stack.rank(id), + ) + } + + /// An independent workspace view. Global overview, output switching and + /// focus on another workspace do not enter this presentation. + pub(crate) fn sample_workspace_frame( + &self, + workspace: ferese_core::WorkspaceId, + output: &Output, + ) -> FrameScene { + let rectangles = self.workspace_preview_rects_for(Some(workspace)); + let visible = self.output_id(output).is_some_and(|output| { + self.output_workspaces.active_workspace(output) == Some(workspace) + }); + let focused = self + .workspaces + .workspace(workspace) + .and_then(|workspace| workspace.last_focused); + let mut windows = HashMap::with_capacity(rectangles.len()); + for (id, rect) in rectangles { + let Some(record) = self.windows.record(id) else { + continue; + }; + let Some(committed) = self.windows.window(id).and_then(super::client_size) else { + continue; + }; + let mut geometry = record + .geometry + .unwrap_or_else(|| WindowGeometry::new(rect, Some(committed))); + if !visible { + // A hidden window can have no display presentation yet. Resolve + // only this value; do not issue a client configure or map it. + geometry = WindowGeometry::new(rect, Some(committed)); + let fullscreen = self + .workspaces + .workspace(workspace) + .is_some_and(|workspace| workspace.fullscreen == Some(id)); + let mode = if fullscreen { + super::PresentationMode::Fullscreen + } else if record.maximized { + super::PresentationMode::Maximized + } else { + super::PresentationMode::Normal + }; + let _ = geometry.set_presentation_mode(rect, mode, Duration::ZERO); + geometry.settle_presentation(); + geometry.presentation_changed = false; + } + let emphasis = AnimatedValue::new(if focused == Some(id) { 1.0 } else { 0.0 }); + let presentation = crate::presentation::WindowPresentation { + id, + bounds: geometry.visual, + opacity: AnimatedValue::new(1.0), + focus_alpha: 1.0, + dim: 0.0, + emphasis, + shadow: emphasis, + scale_content: false, + native_size: None, + }; + windows.insert( + id, + WindowFrame { + geometry, + presentation, + }, + ); + } + FrameScene { + windows, + capture_tabs: Some(self.workspace_preview_tabs(workspace)), + overview: Default::default(), + animating: false, + delta: Duration::ZERO, + } + } + pub(crate) fn sample_frame(&self, output: &Output, horizon: Duration) -> FrameScene { let animating = self.output_has_animations(output); let delta = if self.animations_enabled && animating { @@ -406,6 +514,7 @@ impl Ferese { FrameScene { windows, + capture_tabs: None, overview, animating, delta, diff --git a/compositor/src/state/tab_indicator.rs b/compositor/src/state/tab_indicator.rs index ea505ce..7f401b9 100644 --- a/compositor/src/state/tab_indicator.rs +++ b/compositor/src/state/tab_indicator.rs @@ -184,10 +184,33 @@ impl Ferese { &self, bar: &ferese_layout::TabBar, content: Rect, + ) -> Option { + self.tab_indicator_geometry_sampled(bar, content, None) + } + + pub(crate) fn tab_indicator_geometry_for_frame( + &self, + bar: &ferese_layout::TabBar, + content: Rect, + frame: &super::FrameScene, + ) -> Option { + self.tab_indicator_geometry_sampled( + bar, + content, + frame.capture_tabs.as_ref().map(|_| frame), + ) + } + + fn tab_indicator_geometry_sampled( + &self, + bar: &ferese_layout::TabBar, + content: Rect, + frame: Option<&super::FrameScene>, ) -> Option { let workspace = self.workspaces.workspace_for_window(bar.group.active)?; let output_id = self.output_workspaces.output_for_workspace(workspace)?; - if self.output_workspaces.active_workspace(output_id) != Some(workspace) { + if frame.is_none() && self.output_workspaces.active_workspace(output_id) != Some(workspace) + { return None; } let output = self.outputs_by_id.get(&output_id)?; @@ -200,17 +223,30 @@ impl Ferese { ); let mut boundary = bounds.x; // Measure the current visible gap, including animated column positions. - for window in self.space.elements() { - let Some(id) = self.windows.ids().get(window).copied() else { - continue; - }; + let captured = frame + .into_iter() + .flat_map(|frame| frame.windows.keys().copied()); + let displayed = self + .space + .elements() + .filter(|_| frame.is_none()) + .filter_map(|window| self.windows.ids().get(window).copied()); + for id in captured.chain(displayed) { if id == bar.group.active || self.workspaces.workspace_for_window(id) != Some(workspace) || !self.workspaces.workspace(workspace)?.layout.contains(id) { continue; } - let Some(rect) = self.presented_window_rect(id) else { + let Some(rect) = frame.map_or_else( + || self.presented_window_rect(id), + |frame| { + frame + .windows + .get(&id) + .map(|sample| sample.presentation.bounds.current) + }, + ) else { continue; }; if rect.x + rect.width <= content.x diff --git a/docs/development.md b/docs/development.md index f0c12b4..5417fd2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -78,6 +78,8 @@ With release binaries built, run isolated integration checks: ```sh FERESE_TEST_SHORTCUTS=1 python3 scripts/tests/test_shortcuts_isolated.py FERESE_TEST_WINDOW_CAPTURE=1 python3 scripts/tests/test_window_capture_isolated.py +FERESE_TEST_WORKSPACE_CAPTURE=1 python3 scripts/tests/test_workspace_capture_isolated.py +FERESE_TEST_WORKSPACE_CAPTURE=1 FERESE_TEST_CAPTURE_TRANSPORT=dma python3 scripts/tests/test_workspace_capture_isolated.py FERESE_TEST_WINDOW_CAPTURE_SCALE=1 python3 scripts/tests/test_window_capture_scale_isolated.py FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py FERESE_TEST_RESTORE=1 python3 scripts/tests/test_restore_isolated.py diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 0f33285..0305037 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -1,6 +1,6 @@ # Screen sharing and recording -Share a monitor or window from a Wayland-compatible recording or conferencing app, +Share a monitor, workspace, or window from a Wayland-compatible recording or conferencing app, or save a video using the panel's Recording control. The [installation guide](installation.md) covers the portal and PipeWire dependencies. @@ -24,7 +24,7 @@ If recording fails, a notification reports the error and any unfinished ## Start sharing Select a Wayland screen-sharing source in your application. In Ferese's picker, -choose a display or window and click **Share**. Nothing is selected initially. +choose a display, workspace, or window and click **Share**. Nothing is selected initially. Apps requesting multiple sources can select up to eight. A shared display includes everything visible on it, including notifications. @@ -39,6 +39,26 @@ At fractional scales, clients can round the two backing dimensions separately. Capture preserves those pixel dimensions rather than adding a row or column by applying the larger axis density to both axes, which would soften text. +A shared workspace stays attached to its identity when you switch elsewhere. It +includes that workspace's windows, their popups and decorations, and the wallpaper. +It excludes desktop layers (including panels and notifications), overview, and +windows protected by capture rules. Authentication windows are protected by default. +Scrolling shares the workspace viewport, not the entire offscreen canvas. Capture +does not activate the workspace, move focus, or resize hidden applications. + +Workspace video fits a 1920 × 1080 bounding box (1080 × 1920 for portrait displays), +preserving aspect ratio and avoiding upscaling. This bounds capture buffers even +on a desktop using 2× scaling. The canvas is fixed for the session. Changing the +source output's scale, transform, geometry, or workspace ownership ends sharing; +select the workspace again. Removing the workspace or locking also ends sharing. +Window blur and desktop transition effects are omitted from the workspace view. + +Workspaces appear when an application requests display sharing. The portal +exposes them as restricted views of an existing monitor using the standard +`MONITOR` source type; it does not advertise a new workspace type or create a +virtual display. Applications requesting only windows still see only windows. +The panel recorder continues to select displays. + The picker follows your shell colors, font, corners, opacity, and blur. ## Stop sharing @@ -55,7 +75,7 @@ asking**, off by default. Approval lasts for the duration requested by the app. The portal manages stored permissions and revocation. A different app, changed display, or changed cursor option needs approval again. -Nested previews and displays without a reliable identity always ask. Window +Nested previews and displays without a reliable identity always ask. Window and workspace permissions are not saved. Stopping a stream does not erase a saved permission. ## Requirements and limits @@ -73,7 +93,8 @@ Log into a new Ferese session after installation. Streams negotiate their frame rate with the recording app, with a default of 30 fps and a ceiling of 60 fps. To set a different ceiling, give the portal process `FERESE_CAPTURE_MAX_FPS` with an integer from 1 to 240. This controls -capture independently of the display refresh rate; it does not make an idle +capture independently of the display refresh rate; for workspace capture, set +the same ceiling in the compositor environment. It does not make an idle desktop generate frames. On supported GPUs, Ferese renders directly into DMA-BUF buffers shared with @@ -84,6 +105,31 @@ consumer receives an initial image even if the desktop has stayed unchanged. For troubleshooting, `FERESE_CAPTURE_FORCE_SHM=1` selects shared memory; large or rapidly changing captures on this path still incur GPU readback costs. +## Workspace capture resources + +Workspace sources reuse the existing image-copy-capture and PipeWire transport. +Each workspace/cursor view has one retained GPU image with independent damage +history and render resources. At most one changed workspace is composed per +event-loop dispatch, yielding to input and display deadlines between views. Matching consumers reuse its composition and keep +separate output buffers. Surface commits invalidate only their owning workspace; +layout samples are reused until geometry changes. Consumer demand alone does not +invalidate the image. + +Hidden applications receive paced frame callbacks only while their content +contributes to an active capture. An unchanged source waits for damage. A paused +consumer stops capture work without prematurely returning a buffer still owned by +the compositor; its retained capture target is released after two idle seconds. +Closing the last session releases its view immediately on event-loop cleanup. +No workspace capture targets or recurring capture timers exist without sessions. + +Persistent capture admission reserves up to 512 MiB of pixel storage across all +sessions, accounting conservatively for eight consumer buffers, a composition +target, a fallback transfer texture, and stride padding. Matching views can use +less than the reservation. This is not a cap on total process RSS, driver +bookkeeping, wallpaper storage, or the recording application's encoder. The +existing per-image and session-count limits also apply. GPU fences remain +asynchronous; slow consumers do not accumulate a queue of rendered frames. + ## Troubleshooting - Confirm PipeWire and WirePlumber are running. diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 3bca419..11ad20e 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -704,6 +704,16 @@ async fn start_streams( sources.extend(crate::desktop::sharing_window_sources(&windows)?); } let outputs = capture_ipc(&cancel, &work, "get-outputs", serde_json::json!({})).await?; + let workspaces = if source_types & 1 != 0 && !bar_controlled && discovery.supports_workspaces() + { + let workspaces = + capture_ipc(&cancel, &work, "get-workspaces", serde_json::json!({})).await?; + sources.extend(crate::workspace::sources(&workspaces, &outputs)?); + Some(workspaces) + } else { + None + }; + sources.sort_by_key(|source| source.kind().category()); let rememberable = sources .iter() .filter(|source| { @@ -769,7 +779,7 @@ async fn start_streams( .await?; let region = if selection.region { - if selected.len() != 1 || selected[0].window_id().is_some() { + if selected.len() != 1 || !selected[0].is_monitor() { return Err("Region recording requires one display".into()); } let mut command = child_command_for("slurp")?; @@ -808,7 +818,7 @@ async fn start_streams( let monitors = selected .iter() - .filter(|source| source.window_id().is_none()) + .filter(|source| source.is_monitor()) .cloned() .collect::>(); if !crate::restore::selection_unchanged(&monitors, &outputs, &fresh_outputs) { @@ -838,7 +848,13 @@ async fn start_streams( for source in &selected { let mut command = child_command()?; - if let Some(id) = source.window_id() { + if let Some(id) = source.workspace_id() { + command.args([ + "--stream-workspace", + &id.to_string(), + if cursor { "embedded" } else { "hidden" }, + ]); + } else if let Some(id) = source.window_id() { command.args([ "--stream-window", &id.to_string(), @@ -904,6 +920,18 @@ async fn start_streams( return Err("A selected stream ended during startup".into()); } + if let Some(before) = &workspaces { + let after = capture_ipc(&cancel, &work, "get-workspaces", serde_json::json!({})).await?; + if !crate::workspace::selection_unchanged( + &selected, + before, + &outputs, + &after, + &final_outputs, + ) { + return Err("A selected workspace changed during approval or startup".into()); + } + } let final_windows = capture_ipc(&cancel, &work, "get-windows", serde_json::json!({})).await?; let recording = if bar_controlled { let crop = region @@ -941,7 +969,17 @@ async fn start_streams( let streams = streams .into_iter() .map(|(name, ready)| { - let metadata = if name.starts_with("window:") { + let metadata = if let crate::capture::SourceKind::Workspace(_) = + crate::capture::SourceKind::parse(&name) + { + let source = selected + .iter() + .find(|source| source.name == name) + .ok_or("Unknown workspace stream")?; + crate::workspace::metadata(source, &ready) + } else if let crate::capture::SourceKind::Window(_) = + crate::capture::SourceKind::parse(&name) + { let source = selected .iter() .find(|source| source.name == name) diff --git a/portal/src/capture.rs b/portal/src/capture.rs index 268e874..10482c8 100644 --- a/portal/src/capture.rs +++ b/portal/src/capture.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use ferese_protocols::window_capture::v1::client::ferese_window_capture_manager_v1 as window_manager; use serde::{Deserialize, Serialize}; use wayland_client::protocol::{wl_output, wl_registry, wl_shm}; -use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, WEnum}; +use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum}; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1 as manager; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -20,9 +20,62 @@ pub struct Source { pub scale: i32, } +/// Decode opaque worker names in one place. Unknown/malformed private names +/// must never fall through to monitor capture or remembered display consent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SourceKind { + Monitor, + Window(u64), + Workspace(u64), + Invalid, +} +impl SourceKind { + pub fn parse(name: &str) -> Self { + if let Some(id) = name.strip_prefix("window:") { + id.parse().map(Self::Window).unwrap_or(Self::Invalid) + } else if let Some(id) = name.strip_prefix("workspace:") { + id.parse().map(Self::Workspace).unwrap_or(Self::Invalid) + } else { + Self::Monitor + } + } + pub fn label(self) -> &'static str { + match self { + Self::Monitor => "Displays", + Self::Window(_) => "Windows", + Self::Workspace(_) => "Workspaces", + Self::Invalid => "Unavailable", + } + } + pub fn category(self) -> u8 { + match self { + Self::Monitor => 0, + Self::Workspace(_) => 1, + Self::Window(_) => 2, + Self::Invalid => 3, + } + } +} impl Source { + pub fn kind(&self) -> SourceKind { + SourceKind::parse(&self.name) + } + pub fn is_monitor(&self) -> bool { + self.kind() == SourceKind::Monitor + } pub fn window_id(&self) -> Option { - self.name.strip_prefix("window:")?.parse().ok() + if let SourceKind::Window(id) = self.kind() { + Some(id) + } else { + None + } + } + pub fn workspace_id(&self) -> Option { + if let SourceKind::Workspace(id) = self.kind() { + Some(id) + } else { + None + } } } @@ -85,6 +138,13 @@ impl Capture { Ok(()) } + pub fn supports_workspaces(&self) -> bool { + self.state + .window_manager + .as_ref() + .is_some_and(|manager| manager.version() >= 3) + } + pub fn supports_windows(&self) -> bool { self.state.window_manager.is_some() } @@ -195,7 +255,7 @@ impl Dispatch for State { }, }), "ferese_window_capture_manager_v1" => { - state.window_manager = Some(registry.bind(name, 1, qh, ())) + state.window_manager = Some(registry.bind(name, version.min(3), qh, ())) } "wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())), "zwlr_screencopy_manager_v1" => { diff --git a/portal/src/main.rs b/portal/src/main.rs index 49dce9b..ffdc596 100644 --- a/portal/src/main.rs +++ b/portal/src/main.rs @@ -16,6 +16,7 @@ mod restore; mod settings; mod shortcuts; mod stream; +mod workspace; fn main() -> Result<(), Box> { let args: Vec<_> = std::env::args().skip(1).collect(); @@ -24,6 +25,10 @@ fn main() -> Result<(), Box> { [flag, name, cursor] if flag == "--stream" => { stream::run(name.clone(), cursor == "embedded", None) } + [flag, id, cursor] if flag == "--stream-workspace" => { + let id: u64 = id.parse()?; + stream::run(format!("workspace:{id}"), cursor == "embedded", None) + } [flag, id, cursor] if flag == "--stream-window" => { let id: u64 = id.parse()?; stream::run(format!("window:{id}"), cursor == "embedded", None) diff --git a/portal/src/picker.rs b/portal/src/picker.rs index aa9e034..4d485ae 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -34,10 +34,10 @@ impl Prompt { let mut previous_kind = None; if !self.window_capture { for source in visible { - let is_window = source.window_id().is_some(); - if previous_kind != Some(is_window) { + let kind = source.kind().category(); + if previous_kind != Some(kind) { sections += 1; - previous_kind = Some(is_window); + previous_kind = Some(kind); } } } @@ -94,7 +94,7 @@ pub fn run() -> Result<(), Box> { } let prompt: Prompt = serde_json::from_str(&input)?; - if prompt.sources.is_empty() || prompt.sources.len() > 144 { + if prompt.sources.is_empty() || prompt.sources.len() > 272 { return Err("No shareable displays".into()); } @@ -328,25 +328,20 @@ impl cosmic::Application for Picker { let mut sources = column([]).spacing(6); let mut previous_kind = None; for (index, source) in self.prompt.sources.iter().enumerate() { - let is_window = self.prompt.window_capture || source.window_id().is_some(); - if !self.prompt.window_capture && previous_kind != Some(is_window) { - sources = sources.push( - self.text(if is_window { "Windows" } else { "Displays" }) - .size(12), - ); - previous_kind = Some(is_window); + let kind = source.kind().category(); + if !self.prompt.window_capture && previous_kind != Some(kind) { + sources = sources.push(self.text(source.kind().label()).size(12)); + previous_kind = Some(kind); } let selected = self.selected.contains(&index); let mut entry = row![ glyph(ferese_theme::icons::DISPLAY, 24), column![ - self.text( - if self.prompt.window_capture || source.window_id().is_some() { - &source.label - } else { - &source.name - } - ) + self.text(if self.prompt.window_capture || !source.is_monitor() { + &source.label + } else { + &source.name + }) .size(14), self.text(format!("{} × {}", source.width, source.height)) .size(12) @@ -377,6 +372,8 @@ impl cosmic::Application for Picker { "Choose a display, then record it in full or select a region." } else if self.prompt.window_capture { "Only the selected window will be captured." + } else if self.prompt.sources.iter().any(|source| source.workspace_id().is_some()) { + "A workspace stays shared when you switch away. Desktop panels and notifications are excluded." } else { "Displays share everything visible. Windows share only their own content." }) diff --git a/portal/src/restore.rs b/portal/src/restore.rs index 7a88ec5..af9fa22 100644 --- a/portal/src/restore.rs +++ b/portal/src/restore.rs @@ -148,6 +148,7 @@ impl Restore { || app.len() > 512 || sources.is_empty() || sources.len() > 16 + || sources.iter().any(|source| !source.is_monitor()) || !(1..=2).contains(&mode) { return None; diff --git a/portal/src/stream.rs b/portal/src/stream.rs index 2d44f79..df35643 100644 --- a/portal/src/stream.rs +++ b/portal/src/stream.rs @@ -88,6 +88,11 @@ impl Data { eprintln!("DMA-BUF capture unavailable ({error}); using shared memory"); self.force_shm = true; self.renegotiate = true; + // Retry unchanged content after a failed GPU transfer. + if let Err(error) = self.capture.borrow_mut().refresh() { + eprintln!("Could not refresh fallback capture: {error}"); + self.stop.store(true, Ordering::Relaxed); + } } else { eprintln!("{error}"); self.stop.store(true, Ordering::Relaxed); @@ -325,6 +330,10 @@ pub fn run( .state_changed(move |stream, data, _, state| { let mut data = data.borrow_mut(); let active = matches!(state, pw::stream::StreamState::Streaming); + if let Err(error) = data.capture.borrow_mut().set_active(active) { + eprintln!("Could not update capture activity: {error}"); + data.stop.store(true, Ordering::Relaxed); + } if active && !data.active && let Err(error) = data.capture.borrow_mut().refresh() @@ -559,7 +568,13 @@ pub fn run( data.borrow_mut().complete(&stream, completion); } let changed = capture.borrow_mut().take_changed(); - if changed && !name.starts_with("window:") && capture.borrow().constraints().size != size { + if changed + && !matches!( + crate::capture::SourceKind::parse(&name), + crate::capture::SourceKind::Window(_) + ) + && capture.borrow().constraints().size != size + { eprintln!("Shared monitor changed size; start sharing again"); break; } diff --git a/portal/src/stream/capture.rs b/portal/src/stream/capture.rs index c043e78..18aaf84 100644 --- a/portal/src/stream/capture.rs +++ b/portal/src/stream/capture.rs @@ -7,7 +7,7 @@ use std::time::{Duration, Instant}; use ferese_protocols::window_capture::v1::client::ferese_window_capture_manager_v1 as window_manager; use wayland_client::protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool}; -use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, WEnum}; +use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum}; use wayland_protocols::ext::image_capture_source::v1::client::{ ext_image_capture_source_v1 as source, ext_output_image_capture_source_manager_v1 as output_manager, @@ -94,6 +94,7 @@ pub(super) struct Capture { frame: Option, pub device: Option>, device_id: Option, + workspace: bool, } impl Capture { @@ -121,30 +122,37 @@ impl Capture { dispatch(&connection, &mut queue, &mut state, 20)?; } } - let source = if let Some(id) = name - .strip_prefix("window:") - .and_then(|value| value.parse::().ok()) - { - state + let source = match crate::capture::SourceKind::parse(name) { + crate::capture::SourceKind::Window(id) => state .windows .as_ref() .ok_or("Persistent window capture is unavailable")? - .create_source((id >> 32) as u32, id as u32, &qh, ()) - } else { - let (&id, (output, _)) = state - .outputs - .iter() - .find(|(_, (_, output_name))| output_name == name) - .ok_or("Shared display disconnected")?; - if generation.is_some_and(|generation| generation != id) { - return Err("Shared display was replaced".into()); + .create_source((id >> 32) as u32, id as u32, &qh, ()), + crate::capture::SourceKind::Workspace(id) => { + let manager = state + .windows + .as_ref() + .filter(|manager| manager.version() >= 3) + .ok_or("Workspace capture is unavailable")?; + manager.create_workspace_source((id >> 32) as u32, id as u32, &qh, ()) + } + crate::capture::SourceKind::Invalid => return Err("Invalid capture source".into()), + crate::capture::SourceKind::Monitor => { + let (&id, (output, _)) = state + .outputs + .iter() + .find(|(_, (_, output_name))| output_name == name) + .ok_or("Shared display disconnected")?; + if generation.is_some_and(|generation| generation != id) { + return Err("Shared display was replaced".into()); + } + state.selected = Some(id); + state + .output_manager + .as_ref() + .ok_or("Persistent display capture is unavailable")? + .create_source(output, &qh, ()) } - state.selected = Some(id); - state - .output_manager - .as_ref() - .ok_or("Persistent display capture is unavailable")? - .create_source(output, &qh, ()) }; let options = if cursor { manager::Options::PaintCursors @@ -184,6 +192,10 @@ impl Capture { frame: None, device, device_id, + workspace: matches!( + crate::capture::SourceKind::parse(name), + crate::capture::SourceKind::Workspace(_) + ), }) } @@ -231,6 +243,18 @@ impl Capture { } Some(completion) } + pub(super) fn set_active(&mut self, active: bool) -> Result<(), String> { + if self.workspace { + self.state + .windows + .as_ref() + .ok_or("Workspace capture manager disappeared")? + .set_source_active(&self.source, u32::from(active)); + self.connection.flush().map_err(|error| error.to_string())?; + } + Ok(()) + } + pub(super) fn refresh(&mut self) -> Result<(), String> { self.state .windows @@ -271,6 +295,14 @@ impl Capture { } let fd = bo.fd().map_err(|error| error.to_string())?; let stride = bo.stride_for_plane(0); + let max_stride = width + .checked_mul(4) + .and_then(|row| row.checked_add(4095)) + .map(|row| row & !4095) + .ok_or("DMA-BUF row is too large")?; + if stride > max_stride { + return Err("DMA-BUF stride exceeds the capture memory reservation".into()); + } let offset = bo.offset(0); if offset != 0 { return Err("Unsupported DMA-BUF plane offset".into()); @@ -453,7 +485,7 @@ impl Dispatch for State { state.output_manager = Some(registry.bind(name, 1, qh, ())) } "ferese_window_capture_manager_v1" if version >= 2 => { - state.windows = Some(registry.bind(name, 2, qh, ())) + state.windows = Some(registry.bind(name, version.min(3), qh, ())) } "wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())), "zwp_linux_dmabuf_v1" if version >= 3 => { diff --git a/portal/src/workspace.rs b/portal/src/workspace.rs new file mode 100644 index 0000000..10e697c --- /dev/null +++ b/portal/src/workspace.rs @@ -0,0 +1,177 @@ +//! Portal compatibility: a workspace is an explicitly chosen, restricted view +//! of an existing monitor. Offer it only when MONITOR was requested; never +//! advertise an invented WORKSPACE bit or VIRTUAL (which creates a new monitor). +use crate::backend::Options; +use crate::capture::Source; +use crate::stream::Ready; +use serde_json::Value as Json; +use zbus::zvariant::Value; + +pub(crate) fn sources(workspaces: &Json, outputs: &Json) -> Result, String> { + let workspaces = workspaces.as_array().ok_or("Invalid workspace list")?; + let outputs = outputs.as_array().ok_or("Invalid output list")?; + Ok(workspaces + .iter() + .take(128) + .filter_map(|workspace| { + let id = workspace["id"].as_u64()?; + let owner = workspace["output"].as_u64()?; + let output = outputs + .iter() + .find(|output| output["id"].as_u64() == Some(owner) && output["enabled"] == true)?; + let integer = |key: &str| { + output[key] + .as_i64() + .and_then(|value| i32::try_from(value).ok()) + }; + let (width, height) = (integer("width")?, integer("height")?); + if width <= 0 || height <= 0 { + return None; + } + Some(Source { + name: format!("workspace:{id}"), + label: format!( + "Workspace {} · {}", + workspace["index"].as_u64()?, + output["name"].as_str()? + ), + width, + height, + x: integer("x")?, + y: integer("y")?, + scale: 1, + }) + }) + .collect()) +} + +pub(crate) fn selection_unchanged( + selected: &[Source], + before: &Json, + old_outputs: &Json, + after: &Json, + new_outputs: &Json, +) -> bool { + selected.iter().filter_map(Source::workspace_id).all(|id| { + let owner = |workspaces: &Json| { + workspaces + .as_array()? + .iter() + .find(|workspace| workspace["id"].as_u64() == Some(id))?["output"] + .as_u64() + }; + let Some(old_owner) = owner(before) else { + return false; + }; + if owner(after) != Some(old_owner) { + return false; + } + let output = |outputs: &Json| { + outputs + .as_array()? + .iter() + .find(|output| output["id"].as_u64() == Some(old_owner)) + .cloned() + }; + let (Some(old), Some(new)) = (output(old_outputs), output(new_outputs)) else { + return false; + }; + new["enabled"] == true + && [ + "identity", + "name", + "width", + "height", + "x", + "y", + "scale", + "transform", + "current_mode", + ] + .into_iter() + .all(|key| old[key] == new[key]) + }) +} + +pub(crate) fn metadata(source: &Source, ready: &Ready) -> Result { + if source.workspace_id().is_none() + || ready.width == 0 + || ready.height == 0 + || ready.logical_size != Some((source.width as u32, source.height as u32)) + { + return Err("Invalid workspace stream geometry".into()); + } + Ok(Options::from([ + ("source_type".into(), 1u32.into()), + ( + "size".into(), + Value::from((source.width, source.height)) + .try_to_owned() + .map_err(|error| error.to_string())?, + ), + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::capture::SourceKind; + use serde_json::json; + + fn outputs() -> Json { + json!([{"id": 5, "name": "DP-1", "enabled": true, "width": 1920, "height": 1080, + "x": 0, "y": 0, "scale": 2.0, "identity": "drm-edid:test", "transform": "normal", + "current_mode": {"width": 3840, "height": 2160}}]) + } + + #[test] + fn workspace_selection_uses_identity_not_index_and_never_remembers_consent() { + let outputs = outputs(); + let before = json!([{"id": 42, "output": 5, "index": 1}]); + let selected = sources(&before, &outputs).unwrap(); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].kind(), SourceKind::Workspace(42)); + assert_eq!(selected[0].label, "Workspace 1 · DP-1"); + let renamed = json!([{"id": 42, "output": 5, "index": 3}]); + assert!(selection_unchanged( + &selected, &before, &outputs, &renamed, &outputs + )); + let replaced = json!([{"id": 43, "output": 5, "index": 1}]); + assert!(!selection_unchanged( + &selected, &before, &outputs, &replaced, &outputs + )); + let moved = json!([{"id": 42, "output": 6, "index": 1}]); + assert!(!selection_unchanged( + &selected, &before, &outputs, &moved, &outputs + )); + let mut resized = outputs.clone(); + resized[0]["current_mode"]["width"] = 2560.into(); + assert!(!selection_unchanged( + &selected, &before, &outputs, &before, &resized + )); + assert!( + crate::restore::Restore::create("org.test.App", false, &selected, &outputs, 2) + .is_none() + ); + } + + #[test] + fn workspace_metadata_keeps_logical_geometry_for_a_smaller_pixel_canvas() { + let selected = sources(&json!([{"id": 42, "output": 5, "index": 1}]), &outputs()).unwrap(); + let mut ready = Ready { + node: 7, + width: 1280, + height: 720, + logical_size: Some((1920, 1080)), + }; + let metadata = metadata(&selected[0], &ready).unwrap(); + assert_eq!(u32::try_from(&metadata["source_type"]).unwrap(), 1); + assert!(!metadata.contains_key("position")); + ready.logical_size = None; + assert!(super::metadata(&selected[0], &ready).is_err()); + for name in ["window:", "workspace:no", "workspace:18446744073709551616"] { + assert_eq!(SourceKind::parse(name), SourceKind::Invalid); + } + assert_eq!(SourceKind::parse("DP-1"), SourceKind::Monitor); + } +} diff --git a/protocols/ferese-window-capture-v1.xml b/protocols/ferese-window-capture-v1.xml index c52245b..a9cf9b9 100644 --- a/protocols/ferese-window-capture-v1.xml +++ b/protocols/ferese-window-capture-v1.xml @@ -7,7 +7,7 @@ requires session opt-in and fails while locked. Window IDs are compositor lifetime IDs and never resolve to replacement windows. - + @@ -68,5 +68,28 @@ + + + The ID is a compositor-lifetime workspace ID, not its mutable index. + Captures the workspace viewport, including its windows and wallpaper, + excluding desktop layers, overview and protected content. The source is + pinned to its initial output configuration. Removing the workspace or + changing that configuration stops the session. No workspace is activated + and no client is resized by this request. Uses ext-image-copy-capture-v1. + + + + + + + + Active is zero for paused and one for active. Outstanding frames retain + their buffer ownership. Pausing stops workspace composition and hidden + application callbacks; resuming is followed by refresh_source so an + initial image is delivered. This does not change desktop visibility. + + + + diff --git a/scripts/tests/fixtures/window-capture.c b/scripts/tests/fixtures/window-capture.c index fbaa102..9b27b3f 100644 --- a/scripts/tests/fixtures/window-capture.c +++ b/scripts/tests/fixtures/window-capture.c @@ -20,6 +20,7 @@ static int private_capture_seen; static int covered; static unsigned updates; static unsigned animation_frames; +static unsigned marker_size = 1; static void make_buffer(void); static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) { @@ -78,7 +79,14 @@ static void make_buffer(void) { for (size_t i = 0; i < (size_t)width * height; i++) { pixels[i] = covered ? 0xff00ff00 : (i % width < 80 ? 0x80800000 : (i / width < (size_t)height / 2 ? 0xffff0000 : 0xff0000ff)); } - if (updates) pixels[(size_t)(height / 2) * width + width / 2] = 0xff000000 | updates; + if (updates) { + for (unsigned y = 0; y < marker_size; y++) + for (unsigned x = 0; x < marker_size; x++) + // Keep even a one-pixel update visible after interpolation. + // Blue encodes progress; red/green alternate with high contrast. + pixels[(size_t)(height / 2 + y) * width + width / 2 + x] = + 0xff000000 | ((updates & 1) ? 0x00ffff00 : 0) | (updates & 0xff); + } struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes); buffer = wl_shm_pool_create_buffer(pool, 0, width, height, width * 4, WL_SHM_FORMAT_ARGB8888); wl_shm_pool_destroy(pool); @@ -93,7 +101,7 @@ static void animate(void *data, struct wl_callback *callback, uint32_t time) { wl_buffer_destroy(buffer); make_buffer(); wl_surface_attach(surface, buffer, 0, 0); - wl_surface_damage(surface, width / 2, height / 2, 1, 1); + wl_surface_damage(surface, width / 2, height / 2, marker_size, marker_size); if (--animation_frames) { struct wl_callback *next = wl_surface_frame(surface); static const struct wl_callback_listener listener = {.done = animate}; @@ -104,6 +112,12 @@ static void animate(void *data, struct wl_callback *callback, uint32_t time) { static const struct wl_callback_listener animation_listener = {.done = animate}; int main(int argc, char **argv) { + const char *marker = getenv("FERESE_TEST_CAPTURE_MARKER_SIZE"); + if (marker) { + unsigned value = strtoul(marker, NULL, 10); + if (value < 1 || value > 32) return 10; + marker_size = value; + } struct wl_display *display = wl_display_connect(NULL); if (!display) return 1; struct wl_registry *registry = wl_display_get_registry(display); @@ -143,7 +157,7 @@ int main(int argc, char **argv) { wl_buffer_destroy(buffer); make_buffer(); wl_surface_attach(surface, buffer, 0, 0); - wl_surface_damage(surface, width / 2, height / 2, 1, 1); + wl_surface_damage(surface, width / 2, height / 2, marker_size, marker_size); wl_surface_commit(surface); } if (command == 'r' || command == 'o') { diff --git a/scripts/tests/test_window_capture_isolated.py b/scripts/tests/test_window_capture_isolated.py index 977e3e8..42ac66f 100644 --- a/scripts/tests/test_window_capture_isolated.py +++ b/scripts/tests/test_window_capture_isolated.py @@ -324,8 +324,10 @@ class WindowCapture(unittest.TestCase): self.assertNotEqual(monitor[start:start + 3], bytes([0, 255, 0]), "private occluder leaked into the filtered capture") for index in range(1, 15): - self.assertNotEqual(monitor[(index - 1) * frame_bytes:index * frame_bytes], monitor[index * frame_bytes:(index + 1) * frame_bytes], - "monitor stream reused stale pixels after source damage") + self.assertTrue( + monitor[(index - 1) * frame_bytes:index * frame_bytes] + != monitor[index * frame_bytes:(index + 1) * frame_bytes], + f"monitor stream reused stale pixels after source damage at frame {index}") worker.stdin.close() self.assertEqual(worker.wait(timeout=7), 0) finally: diff --git a/scripts/tests/test_workspace_capture_isolated.py b/scripts/tests/test_workspace_capture_isolated.py new file mode 100644 index 0000000..e49cd91 --- /dev/null +++ b/scripts/tests/test_workspace_capture_isolated.py @@ -0,0 +1,241 @@ +"""Exercise workspace sharing on an isolated nested desktop and real PipeWire. + +FERESE_TEST_WORKSPACE_CAPTURE=1 python3 scripts/tests/test_workspace_capture_isolated.py +Set FERESE_TEST_CAPTURE_TRANSPORT=dma to require DMA-BUF (default: shm). +Only synthetic test windows are captured; the host desktop is never a source. +""" +import json +import os +from pathlib import Path +import selectors +import signal +import subprocess +import tempfile +import time +import unittest + +from session_socket import ipc_environment, ipc_socket + +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_WORKSPACE_CAPTURE") == "1", "requires Wayland and PipeWire") +class WorkspaceCapture(unittest.TestCase): + def test_pinned_workspace_damage_callbacks_privacy_and_resume(self): + from PIL import Image + children = [] + with tempfile.TemporaryDirectory(prefix="ferese-workspace-capture-") as temporary: + root = Path(temporary) + protocols = Path(subprocess.check_output( + ["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip()) + xml = protocols / "stable/xdg-shell/xdg-shell.xml" + for kind, name in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]: + subprocess.run(["wayland-scanner", kind, str(xml), str(root / name)], check=True) + for fixture, name, package, extra in [ + ("window-capture.c", "client", "wayland-client", [str(root / "xdg-shell-protocol.c")]), + ("capture-consumer.c", "consumer", "libpipewire-0.3", []), + ]: + flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", package], text=True).split() + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-I", str(root), + str(REPO / "scripts/tests/fixtures" / fixture), *extra, + "-o", str(root / name), *flags], check=True) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese/config.kdl" + config.parent.mkdir(parents=True) + base_config = ('animations { reduced-motion #true; }\n' + 'window-rule app-id="ferese.test.window-capture" floating=#true\n') + config.write_text(base_config) + host_runtime = Path(os.environ["XDG_RUNTIME_DIR"]) + display = Path(os.environ["WAYLAND_DISPLAY"]) + if not display.is_absolute(): + display = host_runtime / display + env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display), + XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), + XDG_CACHE_HOME=str(root / "cache"), FERESE_ENABLE_SCREENCOPY="1", + FERESE_TRACE_PERFORMANCE="1", FERESE_TEST_CAPTURE_MARKER_SIZE="16", + PIPEWIRE_REMOTE=str(host_runtime / "pipewire-0")) + for key in ("WAYLAND_SOCKET", "FERESE_SHELL_CONTROL_SOCKET", "DISPLAY", "XAUTHORITY"): + env.pop(key, None) + transport = os.environ.get("FERESE_TEST_CAPTURE_TRANSPORT", "shm") + if transport == "shm": + env["FERESE_CAPTURE_FORCE_SHM"] = "1" + else: + env.pop("FERESE_CAPTURE_FORCE_SHM", None) + log_path = root / "session.log" + with log_path.open("w") as log: + def launch(command, **kwargs): + child = subprocess.Popen(command, env=ipc_environment(env), stderr=log, + stdout=kwargs.pop("stdout", log), start_new_session=True, **kwargs) + children.append(child) + return child + + def until(predicate, message, timeout=10): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = predicate() + if result: + return result + time.sleep(.02) + self.fail((message() if callable(message) else message) + "\n" + log_path.read_text()) + + def call(*args): + return json.loads(subprocess.check_output([str(CTL), "-j", *args], env=ipc_environment(env), timeout=10)) + + def change(client, command): + client.stdin.write(command) + client.stdin.flush() + + try: + compositor = launch([str(COMPOSITOR), "--backend=nested"]) + until(lambda: ipc_socket(runtime).exists(), "nested compositor did not start") + env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock"))) + target_client = launch([str(root / "client"), "interactive"], stdin=subprocess.PIPE) + until(lambda: len(call("windows")) == 1, "target did not map") + target = call("windows")[0] + workspace = target["workspace"] + output = call("outputs")[0] + call("workspace", "2") + private_client = launch([str(root / "client"), "cover", "interactive"], stdin=subprocess.PIPE) + until(lambda: len(call("windows")) == 2, "private workspace did not map") + private_workspace = next(window["workspace"] for window in call("windows") if window["id"] != target["id"]) + + def usage(process): + fields = Path(f"/proc/{process.pid}/stat").read_text().rsplit(")", 1)[1].split() + ticks = int(fields[11]) + int(fields[12]) + rss = int(fields[21]) * os.sysconf("SC_PAGE_SIZE") + return ticks, rss + + def idle_cpu(processes, seconds=.6): + before = [usage(process) for process in processes] + start = time.monotonic() + time.sleep(seconds) + elapsed = time.monotonic() - start + after = [usage(process) for process in processes] + return [(100 * (end[0] - begin[0]) / os.sysconf("SC_CLK_TCK") / elapsed, end[1]) + for begin, end in zip(before, after)] + + time.sleep(1.5) + baseline = idle_cpu([compositor], seconds=1.0)[0] + + def worker(): + child = launch([str(PORTAL), "--stream-workspace", str(workspace), "hidden"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE) + with selectors.DefaultSelector() as selector: + selector.register(child.stdout, selectors.EVENT_READ) + self.assertTrue(selector.select(10), log_path.read_text()) + ready = json.loads(child.stdout.readline()) + self.assertEqual(ready["logical_size"], [output["width"], output["height"]]) + self.assertLessEqual(ready["width"] * ready["height"], 1920 * 1080) + return child, ready + + stream, ready = worker() + frame_bytes = ready["width"] * ready["height"] * 4 + raw = root / "frames.bgrx" + + def consumer(path, node, frames=1000): + return launch([str(root / "consumer"), str(node), transport, str(frames), str(path)]) + + first_consumer = consumer(raw, ready["node"]) + + def count(path=raw): + return path.stat().st_size // frame_bytes if path.exists() else 0 + + def image(path=raw): + with path.open("rb") as frames: + frames.seek((count(path) - 1) * frame_bytes) + return Image.frombytes("RGB", (ready["width"], ready["height"]), frames.read(frame_bytes), "raw", "BGRX") + + def pixels(frame): + return getattr(frame, "get_flattened_data", frame.getdata)() + + until(lambda: count() == 1, "initial workspace frame missing") + scale = ready["width"] / output["width"] + red = (round((target["x"] - output["x"] + target["width"] / 2) * scale), + round((target["y"] - output["y"] + target["height"] / 4) * scale)) + self.assertEqual(image().getpixel(red), (255, 0, 0)) + self.assertNotIn((0, 255, 0), pixels(image()), "private workspace leaked") + time.sleep(.5) + idle = idle_cpu([compositor, stream], seconds=1.0) + print(json.dumps({"transport": transport, "size": [ready["width"], ready["height"]], + "baseline_compositor_cpu_percent": round(baseline[0], 2), + "sharing_compositor_cpu_percent": round(idle[0][0], 2), + "sharing_portal_cpu_percent": round(idle[1][0], 2), + "compositor_rss_delta_mib": round((idle[0][1] - baseline[1]) / 1024**2, 2), + "portal_rss_mib": round(idle[1][1] / 1024**2, 2)}), flush=True) + # Unrelated commits must not compose/copy a new source frame. + for _ in range(8): + change(private_client, b"d") + time.sleep(.04) + self.assertEqual(count(), 1, "another workspace woke this capture") + # A callback-only commit starts an animation on a hidden workspace. + change(target_client, b"a") + marker = (round((target["x"] - output["x"] + target["width"] / 2 + 8) * scale), + round((target["y"] - output["y"] + target["height"] / 2 + 8) * scale)) + # Capture coalesces intermediate commits under backpressure. + # Require the final animation state, not one frame per commit. + until(lambda: count() >= 2 and image().getpixel(marker) == (255, 255, 3), + lambda: f"hidden animation stalled at {count()} frames, pixel {image().getpixel(marker)}") + animation_count = count() + time.sleep(.2) + self.assertEqual(count(), animation_count, "static workspace emitted duplicates") + # Independent consumers share the composed view and both update. + other_stream, other_ready = worker() + second_raw = root / "second.bgrx" + other_consumer = consumer(second_raw, other_ready["node"]) + until(lambda: count(second_raw) == 1, "second consumer did not receive cached frame") + self.assertEqual(image().tobytes(), image(second_raw).tobytes()) + for index in range(6): + change(target_client, b"d") + until(lambda: count() >= animation_count + 1 + index and count(second_raw) >= 2 + index, + "one consumer missed source damage") + # A changed privacy rule must clear retained pixels in both views. + previous = count() + config.write_text(base_config + 'window-rule title="Capture target" block-out-from-screencasts=#true\n') + call("reload-config") + until(lambda: count() > previous, "privacy change did not invalidate retained image") + self.assertNotIn((255, 0, 0), pixels(image())) + self.assertNotIn((0, 0, 255), pixels(image())) + previous = count() + config.write_text(base_config) + call("reload-config") + until(lambda: count() > previous, "privacy opt-in did not repaint") + self.assertEqual(image().getpixel(red), (255, 0, 0)) + # Pause long enough to release targets, then resume unchanged content. + for child in [first_consumer, other_consumer]: + os.killpg(child.pid, signal.SIGTERM) + child.wait(timeout=5) + time.sleep(2.3) + self.assertIn("released idle workspace capture target", log_path.read_text()) + resumed = root / "resumed.bgrx" + resumed_consumer = consumer(resumed, ready["node"], frames=1) + until(lambda: count(resumed) == 1, "resumed consumer did not get an initial frame") + resumed_consumer.wait(timeout=5) + self.assertEqual(resumed_consumer.returncode, 0) + self.assertEqual(image(resumed).getpixel(red), (255, 0, 0)) + self.assertEqual(next(ws["id"] for ws in call("workspaces") if ws["focused"]), private_workspace) + # Closing the source's last window removes that inactive workspace. + os.killpg(target_client.pid, signal.SIGTERM) + target_client.wait(timeout=5) + self.assertEqual(stream.wait(timeout=7), 0) + self.assertEqual(other_stream.wait(timeout=7), 0) + self.assertIsNone(compositor.poll(), log_path.read_text()) + finally: + for child in reversed(children): + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.wait() + for pipe in [child.stdin, child.stdout]: + if pipe is not None and pipe is not log: + pipe.close() + + +if __name__ == "__main__": + unittest.main()