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
+2125
-165
No files matched your search
@@ -24,10 +24,32 @@ use smithay::wayland::image_copy_capture::{BufferConstraints, DmabufConstraints,
|
||||
use crate::Ferese;
|
||||
|
||||
const MAX_SESSIONS: usize = 16;
|
||||
// Reserve for the maximum eight portal pool buffers, a retained target and a
|
||||
// fallback transfer texture. Imports of the same allocation are not extra pixels.
|
||||
const MAX_STREAM_BYTES: u64 = 512 * 1024 * 1024;
|
||||
fn stream_bytes(size: Size<i32, Buffer>) -> u64 {
|
||||
// The portal rejects DMA strides above this allowance. Driver bookkeeping
|
||||
// and encoder memory are separate from this pixel-storage reservation.
|
||||
let stride = (size.w.max(0) as u64 * 4 + 4095) & !4095;
|
||||
stride
|
||||
.saturating_mul(size.h.max(0) as u64)
|
||||
.saturating_mul(10)
|
||||
}
|
||||
|
||||
pub(super) enum Source {
|
||||
Output(WeakOutput),
|
||||
Window(WindowId),
|
||||
Workspace(super::workspace_capture::WorkspaceSource),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SourcePaused(pub std::sync::atomic::AtomicBool);
|
||||
|
||||
fn source_active(source: &ImageCaptureSource) -> bool {
|
||||
source
|
||||
.user_data()
|
||||
.get::<SourcePaused>()
|
||||
.is_none_or(|paused| !paused.0.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
@@ -50,7 +72,7 @@ struct WindowStamp {
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct CursorStamp {
|
||||
pub(super) struct CursorStamp {
|
||||
location: Point<f64, Logical>,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
image: CursorImageStamp,
|
||||
@@ -141,10 +163,10 @@ fn pending_frame_callbacks(
|
||||
pending
|
||||
}
|
||||
|
||||
struct Active {
|
||||
session: Session,
|
||||
pending: Option<Frame>,
|
||||
revision: Option<u64>,
|
||||
pub(super) struct Active {
|
||||
pub(super) session: Session,
|
||||
pub(super) pending: Option<Frame>,
|
||||
pub(super) revision: Option<u64>,
|
||||
window_stamp: Option<WindowStamp>,
|
||||
size: Size<i32, Buffer>,
|
||||
dma_node: Option<smithay::backend::drm::DrmNode>,
|
||||
@@ -165,6 +187,10 @@ pub(crate) struct CaptureSessions {
|
||||
in_flight: HashMap<u64, InFlight>,
|
||||
next_fence: u64,
|
||||
callback_timer: Option<RegistrationToken>,
|
||||
callback_deadline: Option<Instant>,
|
||||
workspace_callbacks: HashMap<ferese_core::WorkspaceId, Instant>,
|
||||
workspaces:
|
||||
HashMap<(ferese_core::WorkspaceId, bool), super::workspace_capture::WorkspaceCapture>,
|
||||
}
|
||||
|
||||
fn authorized(client: &Client) -> bool {
|
||||
@@ -179,6 +205,43 @@ fn authorized(client: &Client) -> bool {
|
||||
}
|
||||
|
||||
impl CaptureSessions {
|
||||
pub(super) fn workspace_callback_deadline(
|
||||
&self,
|
||||
id: ferese_core::WorkspaceId,
|
||||
now: Instant,
|
||||
) -> Instant {
|
||||
self.workspace_callbacks.get(&id).copied().unwrap_or(now)
|
||||
}
|
||||
pub(super) fn set_workspace_callback_deadline(
|
||||
&mut self,
|
||||
id: ferese_core::WorkspaceId,
|
||||
deadline: Instant,
|
||||
) {
|
||||
self.workspace_callbacks.insert(id, deadline);
|
||||
}
|
||||
pub(crate) fn invalidate_workspaces(&mut self) {
|
||||
for view in self.workspaces.values_mut() {
|
||||
view.invalidate();
|
||||
}
|
||||
}
|
||||
pub(crate) fn workspace_changed(&mut self, id: ferese_core::WorkspaceId) {
|
||||
for ((workspace, _), view) in &mut self.workspaces {
|
||||
if *workspace == id {
|
||||
view.content_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn workspace_geometry_changed(&mut self, id: ferese_core::WorkspaceId) {
|
||||
for ((workspace, _), view) in &mut self.workspaces {
|
||||
if *workspace == id {
|
||||
view.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn captured_workspaces(&self) -> Vec<ferese_core::WorkspaceId> {
|
||||
self.workspaces.keys().map(|(id, _)| *id).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn new(display: &DisplayHandle) -> Self {
|
||||
Self {
|
||||
copy: ImageCopyCaptureState::new_with_filter::<Ferese, _>(display, authorized),
|
||||
@@ -187,6 +250,9 @@ impl CaptureSessions {
|
||||
in_flight: HashMap::new(),
|
||||
next_fence: 0,
|
||||
callback_timer: None,
|
||||
callback_deadline: None,
|
||||
workspace_callbacks: HashMap::new(),
|
||||
workspaces: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +346,15 @@ impl ImageCopyCaptureHandler for Ferese {
|
||||
let Some(constraints) = session.current_constraints() else {
|
||||
return;
|
||||
};
|
||||
let reserved: u64 = self
|
||||
.image_capture
|
||||
.active
|
||||
.iter()
|
||||
.map(|active| stream_bytes(active.size))
|
||||
.sum();
|
||||
if stream_bytes(constraints.size) > MAX_STREAM_BYTES.saturating_sub(reserved) {
|
||||
return;
|
||||
}
|
||||
self.image_capture.active.push(Active {
|
||||
session,
|
||||
pending: None,
|
||||
@@ -347,6 +422,36 @@ impl ImageCopyCaptureHandler for Ferese {
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(super) fn capture_cursor_stamp(
|
||||
&self,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
) -> Option<CursorStamp> {
|
||||
use smithay::input::pointer::CursorImageStatus;
|
||||
let pointer = self.seat.get_pointer()?;
|
||||
let image = match &self.cursor_status {
|
||||
CursorImageStatus::Named(_) => {
|
||||
let (icon, epoch, frame) = self.cursor_animation.capture_revision();
|
||||
CursorImageStamp::Named(icon, epoch, frame)
|
||||
}
|
||||
CursorImageStatus::Surface(surface) => {
|
||||
let hotspot = smithay::wayland::compositor::with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<smithay::input::pointer::CursorImageSurfaceData>()
|
||||
.map(|data| data.lock().expect("cursor metadata mutex poisoned").hotspot)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
CursorImageStamp::Surface(surface_stamp(surface, None), hotspot)
|
||||
}
|
||||
CursorImageStatus::Hidden => CursorImageStamp::Hidden,
|
||||
};
|
||||
Some(CursorStamp {
|
||||
location: pointer.current_location(),
|
||||
geometry,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn capture_source_geometry(
|
||||
&self,
|
||||
source: &ImageCaptureSource,
|
||||
@@ -355,6 +460,12 @@ impl Ferese {
|
||||
return None;
|
||||
}
|
||||
let (size, output) = match source.user_data().get::<Source>()? {
|
||||
Source::Workspace(view) => {
|
||||
if !view.valid(self) {
|
||||
return None;
|
||||
}
|
||||
(view.size, view.output.clone())
|
||||
}
|
||||
Source::Window(id) => {
|
||||
let info = self.window_capture_info(*id)?;
|
||||
let size = info.geometry.size.to_physical_precise_round(info.scale);
|
||||
@@ -411,31 +522,48 @@ impl Ferese {
|
||||
outputs.push(output);
|
||||
}
|
||||
}
|
||||
crate::backends::direct::schedule_capture(self, &outputs);
|
||||
if !outputs.is_empty() {
|
||||
crate::backends::direct::schedule_capture(self, &outputs);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_capture_sessions(&mut self) {
|
||||
self.image_capture.copy.cleanup();
|
||||
let mut callback_delay: Option<Duration> = None;
|
||||
// Yield between distinct workspace compositions so input and display
|
||||
// deadlines can run even when several consumers request changed views.
|
||||
let mut workspace_compositions = 1usize;
|
||||
let mut sessions = std::mem::take(&mut self.image_capture.active);
|
||||
let mut workspaces = std::mem::take(&mut self.image_capture.workspaces);
|
||||
let mut reserved: u64 = sessions
|
||||
.iter()
|
||||
.map(|active| stream_bytes(active.size))
|
||||
.sum();
|
||||
sessions.retain_mut(|active| {
|
||||
let Some((size, output)) = self.capture_source_geometry(&active.session.source())
|
||||
else {
|
||||
let session = active.session.as_ref();
|
||||
let fences: Vec<_> = self
|
||||
.image_capture
|
||||
.in_flight
|
||||
.iter()
|
||||
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
|
||||
.collect();
|
||||
for id in fences {
|
||||
self.cancel_capture_fence(id);
|
||||
}
|
||||
self.cancel_session_fences(active.session.as_ref());
|
||||
reserved = reserved.saturating_sub(stream_bytes(active.size));
|
||||
return false;
|
||||
};
|
||||
let dma = self.capture_dma_constraints(&output);
|
||||
let dma_node = dma.as_ref().map(|dma| dma.node);
|
||||
if size != active.size || dma_node != active.dma_node {
|
||||
let next_reserved = reserved
|
||||
.saturating_sub(stream_bytes(active.size))
|
||||
.saturating_add(stream_bytes(size));
|
||||
if next_reserved > MAX_STREAM_BYTES {
|
||||
self.cancel_session_fences(active.session.as_ref());
|
||||
reserved = reserved.saturating_sub(stream_bytes(active.size));
|
||||
return false;
|
||||
}
|
||||
reserved = next_reserved;
|
||||
if let Some(Source::Workspace(source)) =
|
||||
active.session.source().user_data().get::<Source>()
|
||||
{
|
||||
// GPU resources belong to the renderer that created them.
|
||||
workspaces.remove(&(source.id, active.session.draw_cursor()));
|
||||
}
|
||||
active.size = size;
|
||||
active.dma_node = dma_node;
|
||||
active.revision = None;
|
||||
@@ -449,6 +577,37 @@ impl Ferese {
|
||||
frame.fail(FailureReason::BufferConstraints);
|
||||
}
|
||||
}
|
||||
if let Some(Source::Workspace(source)) =
|
||||
active.session.source().user_data().get::<Source>()
|
||||
&& source_active(&active.session.source())
|
||||
{
|
||||
let source = source.clone();
|
||||
let key = (source.id, active.session.draw_cursor());
|
||||
if let Some(frame) = active.pending.take() {
|
||||
let view = workspaces.entry(key).or_insert_with(|| {
|
||||
super::workspace_capture::WorkspaceCapture::new(
|
||||
source.clone(),
|
||||
self.capture_epoch,
|
||||
)
|
||||
});
|
||||
if !view.source.valid(self) {
|
||||
*view = super::workspace_capture::WorkspaceCapture::new(
|
||||
source.clone(),
|
||||
self.capture_epoch,
|
||||
);
|
||||
}
|
||||
if let Some(delay) = super::workspace_capture::process(
|
||||
view,
|
||||
self,
|
||||
active,
|
||||
frame,
|
||||
&mut workspace_compositions,
|
||||
) {
|
||||
callback_delay =
|
||||
Some(callback_delay.map_or(delay, |existing| existing.min(delay)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Source::Window(id)) = active.session.source().user_data().get::<Source>() {
|
||||
let id = *id;
|
||||
if let Some(frame) = active.pending.take() {
|
||||
@@ -574,24 +733,59 @@ impl Ferese {
|
||||
}
|
||||
true
|
||||
});
|
||||
workspaces.retain(|(id, cursor), view| {
|
||||
let matches = |active: &Active| {
|
||||
active.session.draw_cursor() == *cursor
|
||||
&& matches!(active.session.source().user_data().get::<Source>(), Some(Source::Workspace(source)) if source.id == *id)
|
||||
};
|
||||
if !sessions.iter().any(matches) { return false; }
|
||||
let pending = sessions.iter().any(|active| matches(active)
|
||||
&& active.pending.is_some() && source_active(&active.session.source()));
|
||||
if !pending && view.target.is_some() {
|
||||
let idle = view.last_used.elapsed();
|
||||
if idle >= Duration::from_secs(2) {
|
||||
view.release_idle_target();
|
||||
} else {
|
||||
let delay = Duration::from_secs(2) - idle;
|
||||
callback_delay = Some(callback_delay.map_or(delay, |existing| existing.min(delay)));
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
self.image_capture
|
||||
.workspace_callbacks
|
||||
.retain(|id, _| workspaces.keys().any(|(workspace, _)| id == workspace));
|
||||
self.image_capture.workspaces = workspaces;
|
||||
self.image_capture.active = sessions;
|
||||
if let Some(delay) = callback_delay {
|
||||
if self.image_capture.callback_timer.is_none() {
|
||||
let deadline = Instant::now() + delay;
|
||||
if self
|
||||
.image_capture
|
||||
.callback_deadline
|
||||
.is_none_or(|previous| deadline < previous)
|
||||
{
|
||||
if let Some(timer) = self.image_capture.callback_timer.take() {
|
||||
self.loop_handle.remove(timer);
|
||||
}
|
||||
self.image_capture.callback_deadline = Some(deadline);
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
match self
|
||||
.loop_handle
|
||||
.insert_source(Timer::from_duration(delay), |_, _, state| {
|
||||
state.image_capture.callback_timer = None;
|
||||
state.image_capture.callback_deadline = None;
|
||||
TimeoutAction::Drop
|
||||
}) {
|
||||
Ok(token) => self.image_capture.callback_timer = Some(token),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not schedule captured window callbacks")
|
||||
self.image_capture.callback_deadline = None;
|
||||
tracing::warn!(%error, "could not schedule capture callbacks")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(timer) = self.image_capture.callback_timer.take() {
|
||||
self.loop_handle.remove(timer);
|
||||
self.image_capture.callback_deadline = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,6 +829,18 @@ impl Ferese {
|
||||
self.image_capture.active = sessions;
|
||||
}
|
||||
|
||||
fn cancel_session_fences(&mut self, session: SessionRef) {
|
||||
let fences: Vec<_> = self
|
||||
.image_capture
|
||||
.in_flight
|
||||
.iter()
|
||||
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
|
||||
.collect();
|
||||
for id in fences {
|
||||
self.cancel_capture_fence(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_capture_fence(&mut self, id: u64) {
|
||||
if let Some(pending) = self.image_capture.in_flight.remove(&id) {
|
||||
self.loop_handle.remove(pending.fence);
|
||||
@@ -642,7 +848,7 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_stream_frame(
|
||||
pub(super) fn finish_stream_frame(
|
||||
&mut self,
|
||||
frame: Frame,
|
||||
session: SessionRef,
|
||||
@@ -740,3 +946,7 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "workspace_capture_tests.rs"]
|
||||
mod workspace_tests;
|
||||
@@ -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()) {
|
||||
|
||||
Reference in new issue
Block a user