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.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-25 13:59:34 +01:00
1 parent e64ab52190
commit ab11269246
32 files changed
+2125 -165

No files matched your search

+13
View File
@@ -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);
}
+24 -1
View File
@@ -18,6 +18,7 @@ pub(crate) mod changes;
pub(crate) struct CaptureTarget {
texture: GlesTexture,
visible: Option<smithay::backend::renderer::element::RenderElementStates>,
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<GlesRenderer>>(
render_capture_with_revision(renderer, target, output, elements).map(|(target, _)| target)
}
pub(super) fn render_capture_with_revision<'a, E: RenderElement<GlesRenderer>>(
pub(crate) fn render_capture_with_revision<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer,
target: &'a mut Option<CaptureTarget>,
output: &Output,
@@ -349,6 +365,13 @@ pub(super) fn render_capture_with_revision<'a, E: RenderElement<GlesRenderer>>(
target.compositions += 1;
target.revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
if output
.user_data()
.get::<crate::handlers::workspace_capture::CaptureOutputId>()
.is_some()
{
target.visible = Some(result.states);
}
Ok((framebuffer, target.revision))
}
+229 -19
View File
@@ -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<i32, Buffer>) -> 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::<SourcePaused>()
.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<f64, Logical>,
geometry: Rectangle<i32, Logical>,
image: CursorImageStamp,
@@ -141,10 +163,10 @@ fn pending_frame_callbacks(
pending
}
struct Active {
session: Session,
pending: Option<Frame>,
revision: Option<u64>,
pub(super) struct Active {
pub(super) session: Session,
pub(super) pending: Option<Frame>,
pub(super) revision: Option<u64>,
window_stamp: Option<WindowStamp>,
size: Size<i32, Buffer>,
dma_node: Option<smithay::backend::drm::DrmNode>,
@@ -165,6 +187,10 @@ pub(crate) struct CaptureSessions {
in_flight: HashMap<u64, InFlight>,
next_fence: u64,
callback_timer: Option<RegistrationToken>,
callback_deadline: Option<Instant>,
workspace_callbacks: HashMap<ferese_core::WorkspaceId, Instant>,
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<ferese_core::WorkspaceId> {
self.workspaces.keys().map(|(id, _)| *id).collect()
}
pub(crate) fn new(display: &DisplayHandle) -> Self {
Self {
copy: ImageCopyCaptureState::new_with_filter::<Ferese, _>(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<i32, Logical>,
) -> Option<CursorStamp> {
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::<smithay::input::pointer::CursorImageSurfaceData>()
.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>()? {
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<Duration> = 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::<Source>()
{
// 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>()
&& 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::<Source>() {
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::<Source>(), 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;
+2
View File
@@ -386,3 +386,5 @@ impl XdgForeignHandler for Ferese {
}
impl XdgToplevelIconHandler for Ferese {}
pub(crate) mod workspace_capture;
+47 -1
View File
@@ -191,7 +191,7 @@ pub(super) fn init_global(
}
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(
2,
3,
Global {
budget: Arc::default(),
worker,
@@ -273,6 +273,52 @@ impl Dispatch<FereseWindowCaptureManagerV1, Global> 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)
@@ -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<i32, smithay::utils::Physical>,
pub geometry: Rectangle<i32, Logical>,
pub size: Size<i32, Buffer>,
scale: f64,
transform: Transform,
}
impl WorkspaceSource {
pub fn new(state: &Ferese, id: WorkspaceId) -> Option<Self> {
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<u64>,
pub cursor: Option<super::image_capture::CursorStamp>,
pub epoch: u64,
pub next_frame: Instant,
pub callbacks_checked: Option<u64>,
pub callbacks_pending: bool,
pub target: Option<CaptureTarget>,
pub resources: crate::render::RenderResources,
pub scene: Option<crate::state::FrameScene>,
pub scene_dirty: bool,
pub last_used: Instant,
trace: bool,
preparations: u64,
compositions: u64,
last_revision: Option<u64>,
}
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::<smithay::wayland::compositor::SurfaceAttributes>()
.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<Duration> = std::sync::OnceLock::new();
*INTERVAL.get_or_init(|| {
let fps = std::env::var("FERESE_CAPTURE_MAX_FPS")
.ok()
.and_then(|value| value.parse::<u64>().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<i32, Logical>, native_scale: f64) -> (Size<i32, Buffer>, 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<T>(
state: &mut Ferese,
output: &Output,
render: impl FnOnce(&mut Ferese, &mut GlesRenderer) -> Result<T, String>,
) -> Result<T, String> {
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<Duration> {
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);
}
}
}
@@ -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::<XdgToplevelSurfaceData>()
.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));
}
}
}
}
+14
View File
@@ -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()) {
+2 -1
View File
@@ -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;
+6 -1
View File
@@ -21,7 +21,12 @@ pub(super) fn overview_chrome_element(
width: f64,
radius: f64,
) -> Option<PhysicalShaderElement> {
let output_id = state.output_id(output)?;
let output_id = state.output_id(output).or_else(|| {
output
.user_data()
.get::<crate::handlers::workspace_capture::CaptureOutputId>()
.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()?;
+12
View File
@@ -56,6 +56,18 @@ pub(super) struct ContextPrograms {
}
impl RenderResources {
pub(crate) fn retain_workspace_windows(
&mut self,
windows: &std::collections::HashSet<WindowId>,
) {
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();
+86 -24
View File
@@ -32,7 +32,7 @@ pub(crate) fn sampled_output_elements(
include_cursor: bool,
frame: &crate::state::FrameScene,
) -> Vec<AnimatedWindowRenderElement> {
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<AnimatedWindowRenderElement> {
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<AnimatedWindowRenderElement> {
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::<Vec<_>>()
} else if workspace.is_some() {
let mut windows = frame
.windows
.keys()
.filter_map(|id| state.windows.window(*id))
.filter_map(prepare_window)
.collect::<Vec<_>>();
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::<Vec<_>>()
};
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();
+4 -4
View File
@@ -113,17 +113,17 @@ pub(super) fn elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
output_geometry: Rectangle<i32, Logical>,
frame: &crate::state::FrameScene,
bar: &TabBar,
content: Rect,
alpha: f32,
capture: bool,
) -> Vec<AnimatedWindowRenderElement> {
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];
+24 -9
View File
@@ -9,6 +9,7 @@ pub(super) fn tab_elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
output_geometry: Rectangle<i32, super::Logical>,
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(
+14
View File
@@ -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()
+121 -41
View File
@@ -17,6 +17,7 @@ struct Preview {
layout: WorkspaceLayout,
input: PreviewInput,
rectangles: HashMap<WindowId, Rect>,
tabs: Vec<ferese_layout::TabBar>,
}
#[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<Vec<Output>>, 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<WindowId, Rect> {
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<WorkspaceId>,
) -> HashMap<WindowId, Rect> {
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<WorkspaceId>,
views: impl Iterator<Item = (WorkspaceId, ferese_core::OutputId)>,
) -> HashMap<WindowId, Rect> {
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<WindowId, SizeConstraints> {
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::<SurfaceCachedState>();
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<ferese_layout::TabBar> {
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<WindowId, SizeConstraints> {
self.window_constraints_for(None)
}
fn window_constraints_for(
&self,
workspace: Option<WorkspaceId>,
) -> HashMap<WindowId, SizeConstraints> {
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<Item = WindowId>,
) -> HashMap<WindowId, SizeConstraints> {
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::<SurfaceCachedState>();
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) {
+109
View File
@@ -7,6 +7,7 @@ use super::{
/// geometry, configure barriers, animation clocks, or another output's view.
pub(crate) struct FrameScene {
pub windows: HashMap<WindowId, WindowFrame>,
pub capture_tabs: Option<Vec<ferese_layout::TabBar>>,
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,
+42 -6
View File
@@ -184,10 +184,33 @@ impl Ferese {
&self,
bar: &ferese_layout::TabBar,
content: Rect,
) -> Option<IndicatorGeometry> {
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<IndicatorGeometry> {
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<IndicatorGeometry> {
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
+2
View File
@@ -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
+50 -4
View File
@@ -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.
+42 -4
View File
@@ -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::<Vec<_>>();
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)
+63 -3
View File
@@ -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<u64> {
self.name.strip_prefix("window:")?.parse().ok()
if let SourceKind::Window(id) = self.kind() {
Some(id)
} else {
None
}
}
pub fn workspace_id(&self) -> Option<u64> {
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<wl_registry::WlRegistry, ()> 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" => {
+5
View File
@@ -16,6 +16,7 @@ mod restore;
mod settings;
mod shortcuts;
mod stream;
mod workspace;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
@@ -24,6 +25,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
[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)
+15 -18
View File
@@ -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<dyn std::error::Error>> {
}
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."
})
+1
View File
@@ -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;
+16 -1
View File
@@ -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;
}
+54 -22
View File
@@ -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<frame::ExtImageCopyCaptureFrameV1>,
pub device: Option<gbm::Device<File>>,
device_id: Option<u64>,
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::<u64>().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<wl_registry::WlRegistry, ()> 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 => {
+177
View File
@@ -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<Vec<Source>, 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<Options, String> {
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);
}
}
+24 -1
View File
@@ -7,7 +7,7 @@
requires session opt-in and fails while locked. Window IDs are compositor
lifetime IDs and never resolve to replacement windows.
</description>
<interface name="ferese_window_capture_manager_v1" version="2">
<interface name="ferese_window_capture_manager_v1" version="3">
<request name="destroy" type="destructor"/>
<request name="capture_window">
<description summary="snapshot a managed window">
@@ -68,5 +68,28 @@
<arg name="width" type="uint"/>
<arg name="height" type="uint"/>
</event>
<request name="create_workspace_source" since="3">
<description summary="capture a workspace independently of the active desktop">
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.
</description>
<arg name="workspace_hi" type="uint"/>
<arg name="workspace_lo" type="uint"/>
<arg name="source" type="new_id" interface="ext_image_capture_source_v1"/>
</request>
<request name="set_source_active" since="3">
<description summary="suspend capture work while a consumer is paused">
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.
</description>
<arg name="source" type="object" interface="ext_image_capture_source_v1"/>
<arg name="active" type="uint"/>
</request>
</interface>
</protocol>
+17 -3
View File
@@ -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') {
@@ -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:
@@ -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()