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

+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()) {