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:
1 parent
e64ab52190
commit
ab11269246
32 files changed
+2070
-110
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -386,3 +386,5 @@ impl XdgForeignHandler for Ferese {
|
||||
}
|
||||
|
||||
impl XdgToplevelIconHandler for Ferese {}
|
||||
|
||||
pub(crate) mod workspace_capture;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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]
|
||||
};
|
||||
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,26 +258,37 @@ fn scene_elements(
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let mut closing = super::closing::grouped_elements(
|
||||
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)
|
||||
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() {
|
||||
@@ -418,13 +478,15 @@ fn scene_elements(
|
||||
if let Some(group) = closing.remove(&None) {
|
||||
elements.extend(group);
|
||||
}
|
||||
if workspace.is_none() {
|
||||
elements.extend(layer_elements(
|
||||
state,
|
||||
renderer,
|
||||
output,
|
||||
&[Layer::Bottom, Layer::Background],
|
||||
));
|
||||
state.prepare_theme_wallpaper(renderer, output);
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
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()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,21 +954,54 @@ 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 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())
|
||||
}
|
||||
|
||||
pub(super) fn window_constraints(&self) -> HashMap<WindowId, SizeConstraints> {
|
||||
self.windows
|
||||
.ids()
|
||||
.iter()
|
||||
.filter_map(|(window, id)| {
|
||||
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>();
|
||||
@@ -935,7 +1015,7 @@ impl Ferese {
|
||||
);
|
||||
|
||||
Some((
|
||||
*id,
|
||||
id,
|
||||
SizeConstraints {
|
||||
min_width: minimum.0.max(1) as f64,
|
||||
min_height: minimum.1.max(1) as f64,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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" => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+12
-15
@@ -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() {
|
||||
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."
|
||||
})
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,16 +122,22 @@ 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 {
|
||||
.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()
|
||||
@@ -145,6 +152,7 @@ impl Capture {
|
||||
.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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user