Support isolated live window ScreenCast streams
Render selected toplevels through a private portal-only capture protocol, preserve logical snapshot geometry, and renegotiate PipeWire video on window resizing. Bound outstanding snapshots and retry temporary contention; stop on source closure and session locking. Validation: portal unit tests (31), capture budget unit, private-bus portal contracts, and real nested/PipeWire isolation, workspace, resize, close and paused-lock tests pass. Independent review cleared. Monitor restore integration timed out on both this build and the unchanged previous compositor under the current nested host.
This commit is contained in:
1 parent
56a888adb3
commit
baca852ee9
26 files changed
+1307
-139
No files matched your search
Generated
+2
@@ -1745,6 +1745,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
"wayland-server",
|
||||
]
|
||||
@@ -7417,6 +7418,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"ferese-config",
|
||||
"ferese-ipc",
|
||||
"ferese-protocols",
|
||||
"ferese-theme",
|
||||
"futures-util",
|
||||
"gstreamer",
|
||||
|
||||
@@ -109,6 +109,31 @@ impl DirectBackendState {
|
||||
output.current_scale().fractional_scale(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn capture_window_frame(
|
||||
&mut self,
|
||||
state: &Ferese,
|
||||
window: &smithay::desktop::Window,
|
||||
output: &Output,
|
||||
cursor: Option<smithay::utils::Rectangle<i32, smithay::utils::Logical>>,
|
||||
) -> Result<crate::handlers::screenshot::CaptureBuffer, String> {
|
||||
let device = self
|
||||
.devices
|
||||
.values_mut()
|
||||
.find(|device| {
|
||||
device
|
||||
.outputs
|
||||
.values()
|
||||
.any(|candidate| &candidate.output == output)
|
||||
})
|
||||
.ok_or("Window output is unavailable")?;
|
||||
crate::winit::capture_window_frame(
|
||||
&mut device.renderer,
|
||||
window,
|
||||
window.geometry(),
|
||||
output.current_scale().fractional_scale(),
|
||||
cursor.map(|rect| (state, rect)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -5,6 +5,7 @@ mod layer_shell;
|
||||
pub(crate) mod screencopy;
|
||||
pub(crate) mod screenshot;
|
||||
pub(crate) mod screenshot_worker;
|
||||
pub(crate) mod window_capture;
|
||||
mod xdg_shell;
|
||||
|
||||
use smithay::{
|
||||
|
||||
@@ -28,10 +28,11 @@ const BYTES_PER_PIXEL: usize = 4;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FrameData {
|
||||
output: Option<Output>,
|
||||
region: Rectangle<i32, Buffer>,
|
||||
overlay_cursor: bool,
|
||||
used: Mutex<bool>,
|
||||
pub(super) output: Option<Output>,
|
||||
pub(super) region: Rectangle<i32, Buffer>,
|
||||
pub(super) overlay_cursor: bool,
|
||||
pub(super) used: Mutex<bool>,
|
||||
pub(super) snapshot: Mutex<Option<super::window_capture::Snapshot>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -160,6 +161,7 @@ fn capture_opted_in(value: Option<&std::ffi::OsStr>) -> bool {
|
||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||
if capture_allowed() {
|
||||
display.create_global::<Ferese, ZwlrScreencopyManagerV1, ()>(3, ());
|
||||
super::window_capture::init_global(display);
|
||||
tracing::info!("authorized screencopy is enabled for this session");
|
||||
}
|
||||
}
|
||||
@@ -235,7 +237,9 @@ impl Dispatch<ZwlrScreencopyFrameV1, Arc<FrameData>> for Ferese {
|
||||
zwlr_screencopy_frame_v1::Request::CopyWithDamage { buffer } => {
|
||||
state.queue_screencopy(frame, data, buffer, true);
|
||||
}
|
||||
zwlr_screencopy_frame_v1::Request::Destroy => {}
|
||||
zwlr_screencopy_frame_v1::Request::Destroy => {
|
||||
data.snapshot.lock().unwrap().take();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -249,6 +253,10 @@ impl Ferese {
|
||||
buffer: WlBuffer,
|
||||
with_damage: bool,
|
||||
) {
|
||||
if data.snapshot.lock().unwrap().is_some() {
|
||||
super::window_capture::copy_snapshot(self, frame, data, &buffer);
|
||||
return;
|
||||
}
|
||||
let mut used = data.used.lock().unwrap();
|
||||
if self.session_lock.active {
|
||||
frame.failed();
|
||||
@@ -354,6 +362,7 @@ fn create_frame(
|
||||
region: Rectangle::from_size((1, 1).into()),
|
||||
overlay_cursor,
|
||||
used: Mutex::new(true),
|
||||
snapshot: Mutex::new(None),
|
||||
}),
|
||||
);
|
||||
frame.failed();
|
||||
@@ -367,6 +376,7 @@ fn create_frame(
|
||||
region: Rectangle::from_size((1, 1).into()),
|
||||
overlay_cursor,
|
||||
used: Mutex::new(true),
|
||||
snapshot: Mutex::new(None),
|
||||
}),
|
||||
);
|
||||
frame.failed();
|
||||
@@ -379,6 +389,7 @@ fn create_frame(
|
||||
region,
|
||||
overlay_cursor,
|
||||
used: Mutex::new(false),
|
||||
snapshot: Mutex::new(None),
|
||||
});
|
||||
let frame = data_init.init(frame, data);
|
||||
|
||||
@@ -439,7 +450,7 @@ pub(crate) fn capture_region_for_geometry(
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_shm_buffer(buffer: &WlBuffer, expected: Size<i32, Buffer>) -> bool {
|
||||
pub(super) fn valid_shm_buffer(buffer: &WlBuffer, expected: Size<i32, Buffer>) -> bool {
|
||||
with_buffer_contents_mut(buffer, |_, length, data| {
|
||||
let stride = expected.w.checked_mul(BYTES_PER_PIXEL as i32);
|
||||
let required = data
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
use std::{
|
||||
os::unix::fs::MetadataExt,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ferese_layout::WindowId;
|
||||
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1::{
|
||||
self as manager, FereseWindowCaptureManagerV1,
|
||||
};
|
||||
use smithay::{
|
||||
reexports::{
|
||||
wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::{
|
||||
self as frame, ZwlrScreencopyFrameV1,
|
||||
},
|
||||
wayland_server::{
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
|
||||
protocol::{wl_buffer::WlBuffer, wl_shm},
|
||||
},
|
||||
},
|
||||
utils::Rectangle,
|
||||
wayland::shm::with_buffer_contents_mut,
|
||||
};
|
||||
|
||||
use super::{screencopy::FrameData, screenshot::CaptureBuffer};
|
||||
use crate::Ferese;
|
||||
|
||||
const MAX_BYTES: usize = 128 * 1024 * 1024;
|
||||
const MAX_FRAMES: usize = 8;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Budget {
|
||||
bytes: usize,
|
||||
frames: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Permit {
|
||||
budget: Arc<Mutex<Budget>>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Permit {
|
||||
fn reserve(budget: &Arc<Mutex<Budget>>, bytes: usize) -> Option<Self> {
|
||||
let mut available = budget.lock().unwrap();
|
||||
if bytes == 0
|
||||
|| bytes > MAX_BYTES.saturating_sub(available.bytes)
|
||||
|| available.frames >= MAX_FRAMES
|
||||
{
|
||||
return None;
|
||||
}
|
||||
available.bytes += bytes;
|
||||
available.frames += 1;
|
||||
Some(Self {
|
||||
budget: budget.clone(),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Permit {
|
||||
fn drop(&mut self) {
|
||||
let mut budget = self.budget.lock().unwrap();
|
||||
budget.bytes -= self.bytes;
|
||||
budget.frames -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Snapshot {
|
||||
id: WindowId,
|
||||
logical_size: (u32, u32),
|
||||
pixels: CaptureBuffer,
|
||||
_permit: Permit,
|
||||
}
|
||||
|
||||
pub(crate) struct Global {
|
||||
budget: Arc<Mutex<Budget>>,
|
||||
}
|
||||
|
||||
pub(crate) fn is_portal(stream: &std::os::unix::net::UnixStream) -> bool {
|
||||
use std::os::fd::AsRawFd;
|
||||
let mut credentials = libc::ucred {
|
||||
pid: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
};
|
||||
let mut length = std::mem::size_of_val(&credentials) as libc::socklen_t;
|
||||
// SAFETY: the connected socket is live and both out pointers cover their
|
||||
// initialized stack allocations. SO_PEERCRED identifies the connecting process.
|
||||
let valid = unsafe {
|
||||
libc::getsockopt(
|
||||
stream.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_PEERCRED,
|
||||
(&raw mut credentials).cast(),
|
||||
&raw mut length,
|
||||
) == 0
|
||||
};
|
||||
if !valid || length as usize != std::mem::size_of_val(&credentials) || credentials.pid <= 0 {
|
||||
return false;
|
||||
}
|
||||
let expected = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| {
|
||||
exe.parent()
|
||||
.map(|dir| dir.join("xdg-desktop-portal-ferese"))
|
||||
})
|
||||
.and_then(|exe| std::fs::metadata(exe).ok());
|
||||
let actual = std::fs::metadata(format!("/proc/{}/exe", credentials.pid)).ok();
|
||||
matches!((expected, actual), (Some(expected), Some(actual)) if expected.dev() == actual.dev() && expected.ino() == actual.ino())
|
||||
}
|
||||
|
||||
pub(super) fn init_global(display: &DisplayHandle) {
|
||||
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(
|
||||
1,
|
||||
Global {
|
||||
budget: Arc::default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
impl GlobalDispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
|
||||
fn can_view(client: Client, _: &Global) -> bool {
|
||||
client
|
||||
.get_data::<crate::state::ClientState>()
|
||||
.is_some_and(|state| {
|
||||
state
|
||||
.capabilities
|
||||
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
|
||||
})
|
||||
}
|
||||
|
||||
fn bind(
|
||||
_: &mut Self,
|
||||
_: &DisplayHandle,
|
||||
_: &Client,
|
||||
resource: New<FereseWindowCaptureManagerV1>,
|
||||
global: &Global,
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
data_init.init(resource, global.budget.clone());
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<FereseWindowCaptureManagerV1, Arc<Mutex<Budget>>> for Ferese {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
_: &Client,
|
||||
manager: &FereseWindowCaptureManagerV1,
|
||||
request: manager::Request,
|
||||
budget: &Arc<Mutex<Budget>>,
|
||||
_: &DisplayHandle,
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
if let manager::Request::CaptureWindow {
|
||||
window_hi,
|
||||
window_lo,
|
||||
overlay_cursor,
|
||||
frame,
|
||||
} = request
|
||||
{
|
||||
let id = WindowId((u64::from(window_hi) << 32) | u64::from(window_lo));
|
||||
let mut busy = false;
|
||||
let snapshot = (overlay_cursor <= 1)
|
||||
.then(|| state.window_snapshot(id, overlay_cursor == 1, budget, &mut busy))
|
||||
.flatten();
|
||||
let size = snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| (snapshot.pixels.width, snapshot.pixels.height))
|
||||
.unwrap_or((1, 1));
|
||||
let logical_size = snapshot.as_ref().map(|snapshot| snapshot.logical_size);
|
||||
let failed = snapshot.is_none();
|
||||
let resource = data_init.init(
|
||||
frame,
|
||||
Arc::new(FrameData {
|
||||
output: None,
|
||||
region: Rectangle::from_size(size.into()),
|
||||
overlay_cursor: false,
|
||||
used: Mutex::new(failed),
|
||||
snapshot: Mutex::new(snapshot),
|
||||
}),
|
||||
);
|
||||
if failed {
|
||||
if busy {
|
||||
manager.busy(&resource);
|
||||
}
|
||||
resource.failed();
|
||||
} else {
|
||||
let logical_size = logical_size.unwrap();
|
||||
manager.geometry(&resource, logical_size.0, logical_size.1);
|
||||
resource.buffer(
|
||||
wl_shm::Format::Argb8888,
|
||||
size.0 as u32,
|
||||
size.1 as u32,
|
||||
size.0 as u32 * 4,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
fn window_snapshot(
|
||||
&mut self,
|
||||
id: WindowId,
|
||||
overlay_cursor: bool,
|
||||
budget: &Arc<Mutex<Budget>>,
|
||||
busy: &mut bool,
|
||||
) -> Option<Snapshot> {
|
||||
if self.session_lock.active || !super::screencopy::capture_allowed() {
|
||||
return None;
|
||||
}
|
||||
let window = self
|
||||
.window_ids
|
||||
.iter()
|
||||
.find(|(_, candidate)| **candidate == id)?
|
||||
.0
|
||||
.clone();
|
||||
let workspace = self.workspaces.workspace_for_window(id)?;
|
||||
let owner = self.output_workspaces.output_for_workspace(workspace)?;
|
||||
let output = self
|
||||
.output_ids
|
||||
.iter()
|
||||
.find(|(_, candidate)| **candidate == owner)?
|
||||
.0
|
||||
.clone();
|
||||
let geometry = window.geometry();
|
||||
let scale = output.current_scale().fractional_scale();
|
||||
let size: smithay::utils::Size<i32, smithay::utils::Physical> =
|
||||
geometry.size.to_physical_precise_round(scale);
|
||||
let bytes = usize::try_from(size.w)
|
||||
.ok()?
|
||||
.checked_mul(usize::try_from(size.h).ok()?)?
|
||||
.checked_mul(4)?;
|
||||
if bytes == 0 || bytes > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let Some(permit) = Permit::reserve(budget, bytes) else {
|
||||
*busy = true;
|
||||
return None;
|
||||
};
|
||||
let cursor = if overlay_cursor && !self.overview.is_presenting() {
|
||||
self.seat.get_pointer().and_then(|pointer| {
|
||||
let location = pointer.current_location();
|
||||
if self.window_under_visual(location).as_ref() != Some(&window) {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = self.inverse_presented_window_point(id, location.x, location.y)?;
|
||||
let surface_point = smithay::utils::Point::from((x, y)) + geometry.loc.to_f64();
|
||||
let (surface, _) = window
|
||||
.surface_under(surface_point, smithay::desktop::WindowSurfaceType::ALL)?;
|
||||
if pointer.current_focus().as_ref() != Some(&surface) {
|
||||
return None;
|
||||
}
|
||||
Some(Rectangle::new(
|
||||
(location - smithay::utils::Point::from((x, y))).to_i32_round(),
|
||||
geometry.size,
|
||||
))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result = if let Some(backend) = self.nested_backend.clone() {
|
||||
let mut backend = backend.try_borrow_mut().ok()?;
|
||||
crate::winit::capture_window_frame(
|
||||
backend.renderer(),
|
||||
&window,
|
||||
geometry,
|
||||
scale,
|
||||
cursor.map(|rect| (&*self, rect)),
|
||||
)
|
||||
} else if let Some(mut backend) = self.direct_backend.take() {
|
||||
let result = backend.capture_window_frame(self, &window, &output, cursor);
|
||||
self.direct_backend = Some(backend);
|
||||
result
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let pixels = result.ok()?;
|
||||
if let Some(toplevel) = window.toplevel() {
|
||||
smithay::desktop::utils::send_frames_surface_tree(
|
||||
toplevel.wl_surface(),
|
||||
&output,
|
||||
self.start_time.elapsed(),
|
||||
Some(std::time::Duration::ZERO),
|
||||
|_, _| Some(output.clone()),
|
||||
);
|
||||
}
|
||||
if pixels.pixels.len() != bytes {
|
||||
return None;
|
||||
}
|
||||
Some(Snapshot {
|
||||
id,
|
||||
logical_size: (geometry.size.w as u32, geometry.size.h as u32),
|
||||
pixels,
|
||||
_permit: permit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn copy_snapshot(
|
||||
state: &Ferese,
|
||||
resource: &ZwlrScreencopyFrameV1,
|
||||
data: &FrameData,
|
||||
buffer: &WlBuffer,
|
||||
) {
|
||||
let mut used = data.used.lock().unwrap();
|
||||
if *used {
|
||||
resource.post_error(
|
||||
frame::Error::AlreadyUsed,
|
||||
"window capture frame has already been used",
|
||||
);
|
||||
return;
|
||||
}
|
||||
*used = true;
|
||||
let Some(snapshot) = data.snapshot.lock().unwrap().take() else {
|
||||
resource.failed();
|
||||
return;
|
||||
};
|
||||
if state.session_lock.active
|
||||
|| !super::screencopy::capture_allowed()
|
||||
|| !state.window_ids.values().any(|id| *id == snapshot.id)
|
||||
{
|
||||
resource.failed();
|
||||
return;
|
||||
}
|
||||
if !super::screencopy::valid_shm_buffer(buffer, data.region.size) {
|
||||
resource.post_error(
|
||||
frame::Error::InvalidBuffer,
|
||||
"window capture requires the advertised ARGB8888 SHM buffer",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let copied = with_buffer_contents_mut(buffer, |destination, length, layout| {
|
||||
let bytes = snapshot.pixels.pixels.len();
|
||||
let Ok(offset) = usize::try_from(layout.offset) else {
|
||||
return false;
|
||||
};
|
||||
if offset.checked_add(bytes).is_none_or(|end| end > length) {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: Smithay owns the writable mapping, validated format/stride and
|
||||
// checked offset cover the complete snapshot; the source is separately owned.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
snapshot.pixels.pixels.as_ptr(),
|
||||
destination.add(offset),
|
||||
bytes,
|
||||
);
|
||||
}
|
||||
true
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !copied {
|
||||
resource.failed();
|
||||
return;
|
||||
}
|
||||
let timestamp = state.start_time.elapsed();
|
||||
resource.flags(frame::Flags::empty());
|
||||
resource.ready(
|
||||
(timestamp.as_secs() >> 32) as u32,
|
||||
timestamp.as_secs() as u32,
|
||||
timestamp.subsec_nanos(),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn outstanding_snapshots_release_both_limits_when_dropped() {
|
||||
let budget = Arc::default();
|
||||
let permits = (0..MAX_FRAMES)
|
||||
.map(|_| Permit::reserve(&budget, 4).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(Permit::reserve(&budget, 4).is_none());
|
||||
drop(permits);
|
||||
let full = Permit::reserve(&budget, MAX_BYTES).unwrap();
|
||||
assert!(Permit::reserve(&budget, 4).is_none());
|
||||
drop(full);
|
||||
assert!(Permit::reserve(&budget, MAX_BYTES).is_some());
|
||||
assert!(Permit::reserve(&budget, MAX_BYTES + 1).is_none());
|
||||
assert!(Permit::reserve(&budget, 0).is_none());
|
||||
assert_eq!(budget.lock().unwrap().bytes, 0);
|
||||
assert_eq!(budget.lock().unwrap().frames, 0);
|
||||
}
|
||||
}
|
||||
@@ -868,6 +868,8 @@ impl Ferese {
|
||||
"workspace": self.workspaces.workspace_for_window(*id).map(|workspace| workspace.0),
|
||||
"x": rect.as_ref().map(|rect| rect.loc.x),
|
||||
"y": rect.as_ref().map(|rect| rect.loc.y),
|
||||
"capture_width": window.geometry().size.w,
|
||||
"capture_height": window.geometry().size.h,
|
||||
"width": rect.as_ref().map(|rect| rect.size.w),
|
||||
"height": rect.as_ref().map(|rect| rect.size.h),
|
||||
}))
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(crate) struct ClientCapabilities(u8);
|
||||
impl ClientCapabilities {
|
||||
pub(crate) const EFFECTS: Self = Self(1 << 0);
|
||||
pub(crate) const SHELL_CONTROL: Self = Self(1 << 1);
|
||||
pub(crate) const WINDOW_CAPTURE: Self = Self(1 << 2);
|
||||
|
||||
pub(crate) fn contains(self, capability: Self) -> bool {
|
||||
self.0 & capability.0 == capability.0
|
||||
|
||||
@@ -865,9 +865,15 @@ impl Ferese {
|
||||
let loop_handle = event_loop.handle();
|
||||
|
||||
loop_handle.insert_source(listening_socket, |client_stream, _, state| {
|
||||
let mut client_state = ClientState::default();
|
||||
if crate::handlers::window_capture::is_portal(&client_stream) {
|
||||
client_state
|
||||
.capabilities
|
||||
.insert(crate::private_client::ClientCapabilities::WINDOW_CAPTURE);
|
||||
}
|
||||
if let Err(error) = state
|
||||
.display_handle
|
||||
.insert_client(client_stream, Arc::new(ClientState::default()))
|
||||
.insert_client(client_stream, Arc::new(client_state))
|
||||
{
|
||||
tracing::warn!(%error, "failed to register Wayland client");
|
||||
}
|
||||
|
||||
+32
-4
@@ -176,10 +176,38 @@ pub(crate) fn capture_window_buffer(
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
scale: f64,
|
||||
) -> Result<crate::handlers::screenshot::CaptureBuffer, String> {
|
||||
let snapshot = capture_resize_snapshot(renderer, window, geometry, scale, 128 * 1024 * 1024)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or("Window has no capturable content")?;
|
||||
capture_window_frame(renderer, window, geometry, scale, None)
|
||||
}
|
||||
|
||||
pub(crate) fn capture_window_frame(
|
||||
renderer: &mut GlesRenderer,
|
||||
window: &smithay::desktop::Window,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
scale: f64,
|
||||
cursor: Option<(&Ferese, Rectangle<i32, Logical>)>,
|
||||
) -> Result<crate::handlers::screenshot::CaptureBuffer, String> {
|
||||
let mut snapshot =
|
||||
capture_resize_snapshot(renderer, window, geometry, scale, 128 * 1024 * 1024)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or("Window has no capturable content")?;
|
||||
let size = snapshot.texture.size();
|
||||
if let Some((state, cursor_geometry)) = cursor {
|
||||
let elements = cursor_elements(state, renderer, cursor_geometry, scale);
|
||||
let mut target = renderer
|
||||
.bind(&mut snapshot.texture)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut frame = renderer
|
||||
.render(&mut target, (size.w, size.h).into(), Transform::Normal)
|
||||
.map_err(|e| e.to_string())?;
|
||||
draw_render_elements(
|
||||
&mut frame,
|
||||
scale,
|
||||
&elements,
|
||||
&[Rectangle::from_size((size.w, size.h).into())],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = frame.finish().map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mapping = renderer
|
||||
.copy_texture(
|
||||
&snapshot.texture,
|
||||
@@ -551,7 +579,7 @@ fn blur_damage(
|
||||
DamageSet::default()
|
||||
} else {
|
||||
// A new scene needs its entire sampling halo repainted before capture.
|
||||
DamageSet::from_slice(&[Rectangle::from_size(size)])
|
||||
DamageSet::from_slice(&[Rectangle::from_size((size.w, size.h).into())])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["client", "server"]
|
||||
client = ["dep:wayland-client"]
|
||||
server = ["dep:wayland-server"]
|
||||
client = ["dep:wayland-client", "wayland-protocols-wlr/client"]
|
||||
server = ["dep:wayland-server", "wayland-protocols-wlr/server"]
|
||||
|
||||
[dependencies]
|
||||
bitflags.workspace = true
|
||||
@@ -16,3 +16,4 @@ wayland-backend.workspace = true
|
||||
wayland-client = { workspace = true, optional = true }
|
||||
wayland-scanner.workspace = true
|
||||
wayland-server = { workspace = true, optional = true }
|
||||
wayland-protocols-wlr.workspace = true
|
||||
@@ -1,4 +1,6 @@
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=../../protocols/ferese-material-v1.xml");
|
||||
println!("cargo::rerun-if-changed=../../protocols/ferese-window-capture-v1.xml");
|
||||
println!("cargo::rerun-if-changed=../../protocols/ferese-effects-v1.xml");
|
||||
println!("cargo::rerun-if-changed=../../protocols/ferese-shell-v1.xml");
|
||||
}
|
||||
@@ -163,3 +163,69 @@ pub mod shell {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Private native portal window capture protocol.
|
||||
pub mod window_capture {
|
||||
/// Version 1 of the native portal window capture protocol.
|
||||
pub mod v1 {
|
||||
#[cfg(feature = "client")]
|
||||
pub use generated::client;
|
||||
#[cfg(feature = "server")]
|
||||
pub use generated::server;
|
||||
|
||||
mod generated {
|
||||
#![allow(
|
||||
dead_code,
|
||||
non_camel_case_types,
|
||||
non_upper_case_globals,
|
||||
non_snake_case,
|
||||
unused_imports,
|
||||
unused_unsafe,
|
||||
unused_variables,
|
||||
clippy::all
|
||||
)]
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
pub mod client {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
use wayland_protocols_wlr::screencopy::v1::client::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
use wayland_protocols_wlr::screencopy::v1::client::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_interfaces!(
|
||||
"../../protocols/ferese-window-capture-v1.xml"
|
||||
);
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_client_code!(
|
||||
"../../protocols/ferese-window-capture-v1.xml"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server {
|
||||
use wayland_protocols_wlr::screencopy::v1::server::*;
|
||||
use wayland_server;
|
||||
use wayland_server::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_protocols_wlr::screencopy::v1::server::__interfaces::*;
|
||||
use wayland_server::protocol::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_interfaces!(
|
||||
"../../protocols/ferese-window-capture-v1.xml"
|
||||
);
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_server_code!(
|
||||
"../../protocols/ferese-window-capture-v1.xml"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-6
@@ -47,7 +47,7 @@ standard file, print and application dialogs. Install both backends.
|
||||
for acknowledgements for up to one second. Power confirmations list blockers
|
||||
and recheck them before acting; authentication failure cancels the query.
|
||||
UserSwitch is recorded, but Ferese currently has no user-switch operation.
|
||||
- **ScreenCast:** monitor sharing with a native picker, PipeWire streams and explicit opt-in
|
||||
- **ScreenCast:** monitor and isolated window sharing with a native picker, PipeWire streams and explicit opt-in
|
||||
saved permissions. The frontend stores and revokes saved choices. Invalid
|
||||
restore data or changed display identity requires fresh consent.
|
||||
See [screen sharing](screen-sharing.md) for lifetime and recording details.
|
||||
@@ -73,7 +73,7 @@ Matching D-Bus signatures does not establish complete feature parity.
|
||||
| Settings | Native, including appearance change signals |
|
||||
| Screenshot | Native screen/area/window/active-window capture and PickColor |
|
||||
| Wallpaper | Native desktop, lock-screen and combined targets |
|
||||
| ScreenCast | Native v4 monitors with logical geometry and opt-in saved permissions; window sources remain missing |
|
||||
| ScreenCast | Native v4 monitors and isolated windows, logical geometry, live window resizing and opt-in saved display permissions |
|
||||
| Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK |
|
||||
| Background | Native application state, per-instance consent and XDG autostart |
|
||||
| Usb | Native consent; standard frontend handles enumeration and device descriptors |
|
||||
@@ -88,10 +88,15 @@ The frontend supplies additional APIs such as OpenURI, network monitoring,
|
||||
document export and permission storage. Adding a similarly named backend service
|
||||
is neither required nor sufficient for those APIs.
|
||||
|
||||
Do not advertise window sources for ScreenCast or input services until the
|
||||
compositor provides the corresponding operation. Screen crops include occluding
|
||||
windows and cannot serve as isolated window streams. GNOME's remote-input features require native
|
||||
compositor/session work, rather than substituting successful empty replies.
|
||||
Window streams render only the selected toplevel and its subsurfaces, including
|
||||
on an inactive workspace. Overlapping apps, shell layers, separate popup windows
|
||||
and compositor decorations are excluded. The private capture protocol is available
|
||||
only to the native portal executable and checks session opt-in and locking.
|
||||
Window resizing renegotiates PipeWire video dimensions. Closing the selected
|
||||
window ends sharing; its lifetime ID cannot select a replacement app. Window
|
||||
selections require fresh consent and are not saved as display permissions.
|
||||
InputCapture still needs native zones, barriers and EIS transport. RemoteDesktop
|
||||
and its Clipboard integration remain deferred.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -143,3 +148,10 @@ an isolated bus and nested compositor:
|
||||
```sh
|
||||
FERESE_TEST_RESTORE=1 python3 scripts/tests/test_restore_isolated.py
|
||||
```
|
||||
|
||||
Check live window pixels, overlap isolation, inactive workspaces, resizing and
|
||||
closure using a disposable nested compositor and a real PipeWire consumer:
|
||||
|
||||
```sh
|
||||
FERESE_TEST_WINDOW_STREAM=1 python3 scripts/tests/test_window_stream_isolated.py
|
||||
```
|
||||
+17
-9
@@ -1,7 +1,7 @@
|
||||
# Screen sharing and recording
|
||||
|
||||
Ferese includes a ScreenCast portal backend, `xdg-desktop-portal-ferese`.
|
||||
Applications use the standard desktop portal to request a display, then receive
|
||||
Applications use the standard desktop portal to request a display or window, then receive
|
||||
video through PipeWire. A recorder or conferencing application handles encoding,
|
||||
saving files, or sending video; those tasks do not run inside the compositor.
|
||||
|
||||
@@ -25,8 +25,14 @@ may remain at the path shown in the error.
|
||||
## Start sharing
|
||||
|
||||
Use a Wayland screen-sharing or recording source in your application. Ferese
|
||||
opens a display picker. Select a display and press **Share**. Nothing is selected
|
||||
by default. Applications that request multiple displays can select multiple available displays.
|
||||
opens a native source picker. Depending on what the application requests, choose
|
||||
from displays, windows, or both and press **Share**. Nothing is selected by default.
|
||||
Applications that request multiple sources can select up to eight. Display and
|
||||
window choices are grouped separately. Windows show their title and application.
|
||||
Window streams capture only the selected app's toplevel and subsurfaces, even
|
||||
when covered or on another workspace. Separate popups and compositor decorations
|
||||
are excluded. Resizing a shared window updates the video size without ending the
|
||||
session. Closing that window ends sharing.
|
||||
The picker uses your Ferese theme colors, font, and shell corner radius. Its
|
||||
background uses the same compositor material as popovers and authentication
|
||||
prompts: opaque in solid mode, or blurred and translucent using the shared
|
||||
@@ -37,14 +43,15 @@ Ferese’s built-in recorder uses the top-bar stop control instead, leaving your
|
||||
windows accessible after display selection. The command-line recorder keeps the
|
||||
sharing window when it is not launched by the shell. Press **Stop sharing** or
|
||||
close the sharing window to revoke an ordinary application’s session. Closing the requesting application,
|
||||
locking Ferese, disconnecting the selected display, or changing its capture size
|
||||
also ends sharing. By default, each request asks for consent. If an application requests saved
|
||||
locking Ferese, disconnecting the selected display, or changing a monitor's capture
|
||||
size also ends sharing. By default, each request asks for consent. If an application requests saved
|
||||
permissions, the picker offers an **Allow without asking** switch, off by default.
|
||||
Permissions can last while the application runs or until revoked, as requested
|
||||
by the app. The standard portal frontend owns permission storage and revocation.
|
||||
A changed or missing display, different app, or changed cursor options asks again.
|
||||
Displays without a reliable EDID identity, including nested previews, always need
|
||||
fresh consent. Stopping a stream ends that stream; it does not erase a previously
|
||||
fresh consent. Window selections also require fresh consent and cannot enable saved
|
||||
display permissions. Stopping a stream ends that stream; it does not erase a previously
|
||||
saved permission.
|
||||
Everything visible on a shared display, including notifications, is included.
|
||||
|
||||
@@ -76,7 +83,8 @@ configuration is preserved during installation.
|
||||
|
||||
## Current capabilities
|
||||
|
||||
- Monitor capture, one PipeWire stream per selected display, at up to 30 fps.
|
||||
- Monitor and isolated window capture, one PipeWire stream per source, up to 30 fps.
|
||||
- Window streams keep working across workspace switches and renegotiate on resize.
|
||||
- Hidden or embedded pointer, as requested by the application.
|
||||
- Logical stream position and size, including fractional output scales.
|
||||
- Wayland parent-window relationships for the consent picker.
|
||||
@@ -84,8 +92,8 @@ configuration is preserved during installation.
|
||||
- Cancellation during selection, an explicit stop control, and process cleanup.
|
||||
- ScreenCast backend version 4 with explicit opt-in saved display permissions.
|
||||
|
||||
Window-only sharing, region selection, and audio capture in Ferese’s recorder
|
||||
are not implemented yet. Other recording applications can handle audio separately.
|
||||
Region selection and audio capture in Ferese’s recorder are not implemented yet.
|
||||
The top-bar recorder currently requests monitors. Other recording applications can handle audio separately.
|
||||
Video currently uses CPU copies through the compositor's shared-memory screencopy path, not DMA-BUF zero-copy. High-resolution
|
||||
or multiple-display recording can therefore use significant CPU and memory
|
||||
bandwidth. A paused stream checks capture availability once per second so locking
|
||||
|
||||
@@ -14,6 +14,7 @@ name = "ferese-record"
|
||||
path = "src/record_main.rs"
|
||||
|
||||
[dependencies]
|
||||
ferese-protocols = { path = "../crates/ferese-protocols", default-features = false, features = ["client"] }
|
||||
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||
url = "2.5"
|
||||
png = "0.18"
|
||||
|
||||
+178
-23
@@ -78,6 +78,7 @@ struct Session {
|
||||
phase: Phase,
|
||||
multiple: bool,
|
||||
cursor: bool,
|
||||
source_types: u32,
|
||||
bar_controlled: bool,
|
||||
persist_mode: u32,
|
||||
restore: Option<crate::restore::Restore>,
|
||||
@@ -184,7 +185,7 @@ fn error(message: &str) -> zbus::fdo::Error {
|
||||
zbus::fdo::Error::InvalidArgs(message.into())
|
||||
}
|
||||
|
||||
fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
|
||||
fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool, u32)> {
|
||||
let number = |key: &str, default| {
|
||||
options
|
||||
.get(key)
|
||||
@@ -195,8 +196,8 @@ fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
|
||||
};
|
||||
let types = number("types", 1)?;
|
||||
let cursor = number("cursor_mode", 1)?;
|
||||
if types & 1 == 0 {
|
||||
return Err(error("Only monitor sharing is supported"));
|
||||
if types == 0 || types & !3 != 0 {
|
||||
return Err(error("Unsupported source types"));
|
||||
}
|
||||
if cursor != 1 && cursor != 2 {
|
||||
return Err(error("Unsupported cursor mode"));
|
||||
@@ -208,7 +209,7 @@ fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
|
||||
.map_err(|_| error("Invalid multiple option"))?
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok((multiple, cursor == 2))
|
||||
Ok((multiple, cursor == 2, types))
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
@@ -241,7 +242,7 @@ impl Backend {
|
||||
|
||||
#[zbus(property)]
|
||||
fn available_source_types(&self) -> u32 {
|
||||
1
|
||||
3
|
||||
}
|
||||
|
||||
#[zbus(property)]
|
||||
@@ -294,6 +295,7 @@ impl Backend {
|
||||
phase: Phase::Created,
|
||||
multiple: false,
|
||||
cursor: false,
|
||||
source_types: 1,
|
||||
bar_controlled: false,
|
||||
persist_mode: 0,
|
||||
restore: None,
|
||||
@@ -313,8 +315,13 @@ impl Backend {
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> zbus::fdo::Result<(u32, Options)> {
|
||||
let owner = authorize(connection, &header).await?;
|
||||
let settings = source_options(&options).and_then(|(multiple, cursor)| {
|
||||
Ok((multiple, cursor, crate::restore::persist_mode(&options)?))
|
||||
let settings = source_options(&options).and_then(|(multiple, cursor, types)| {
|
||||
Ok((
|
||||
multiple,
|
||||
cursor,
|
||||
types,
|
||||
crate::restore::persist_mode(&options)?,
|
||||
))
|
||||
});
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
@@ -329,11 +336,12 @@ impl Backend {
|
||||
}
|
||||
|
||||
match settings {
|
||||
Ok((multiple, cursor, persist_mode)) => {
|
||||
Ok((multiple, cursor, types, persist_mode)) => {
|
||||
session.persist_mode = persist_mode;
|
||||
session.restore = crate::restore::Restore::read(&options);
|
||||
session.multiple = multiple;
|
||||
session.cursor = cursor;
|
||||
session.source_types = types;
|
||||
session.phase = Phase::Selected;
|
||||
Ok((0, Options::new()))
|
||||
}
|
||||
@@ -365,7 +373,7 @@ impl Backend {
|
||||
return Err(error("Invalid request path"));
|
||||
}
|
||||
|
||||
let (multiple, cursor, bar_controlled, persist_mode, restore, cancel) = {
|
||||
let (multiple, cursor, source_types, bar_controlled, persist_mode, restore, cancel) = {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get_mut(session_handle.as_str())
|
||||
@@ -379,6 +387,7 @@ impl Backend {
|
||||
(
|
||||
session.multiple,
|
||||
session.cursor,
|
||||
session.source_types,
|
||||
session.bar_controlled,
|
||||
session.persist_mode,
|
||||
session.restore.clone(),
|
||||
@@ -412,6 +421,7 @@ impl Backend {
|
||||
&parent_window,
|
||||
multiple,
|
||||
cursor,
|
||||
source_types,
|
||||
persist_mode,
|
||||
restore,
|
||||
bar_controlled,
|
||||
@@ -559,6 +569,7 @@ async fn start_streams(
|
||||
parent: &str,
|
||||
multiple: bool,
|
||||
cursor: bool,
|
||||
source_types: u32,
|
||||
requested_persistence: u32,
|
||||
restore: Option<crate::restore::Restore>,
|
||||
bar_controlled: bool,
|
||||
@@ -566,7 +577,15 @@ async fn start_streams(
|
||||
let mut discovery = tokio::task::spawn_blocking(|| Capture::connect(&AtomicBool::new(false)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
let sources = discovery.sources();
|
||||
let mut sources = if source_types & 1 != 0 {
|
||||
discovery.sources()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if source_types & 2 != 0 && discovery.supports_windows() {
|
||||
let windows = crate::desktop::ipc("get-windows", serde_json::json!({})).await?;
|
||||
sources.extend(crate::desktop::sharing_window_sources(&windows)?);
|
||||
}
|
||||
let outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
|
||||
let rememberable = sources
|
||||
.iter()
|
||||
@@ -578,6 +597,15 @@ async fn start_streams(
|
||||
.collect::<Vec<_>>();
|
||||
let restored = restore.and_then(|restore| {
|
||||
let selected = restore.resolve(app, cursor, multiple, &sources, &outputs)?;
|
||||
let selected = validate_selection(
|
||||
&sources,
|
||||
&selected
|
||||
.iter()
|
||||
.map(|source| source.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
multiple,
|
||||
)
|
||||
.ok()?;
|
||||
Some((selected, restore.effective_mode(requested_persistence)))
|
||||
});
|
||||
let (selected, persist_mode) = if let Some(restored) = restored {
|
||||
@@ -616,7 +644,12 @@ async fn start_streams(
|
||||
let mut reply = Options::new();
|
||||
let fresh_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
|
||||
|
||||
if !crate::restore::selection_unchanged(&selected, &outputs, &fresh_outputs) {
|
||||
let monitors = selected
|
||||
.iter()
|
||||
.filter(|source| source.window_id().is_none())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !crate::restore::selection_unchanged(&monitors, &outputs, &fresh_outputs) {
|
||||
return Err("A selected display changed while approval was pending".into());
|
||||
}
|
||||
|
||||
@@ -629,7 +662,7 @@ async fn start_streams(
|
||||
};
|
||||
reply.insert("persist_mode".into(), effective_persistence.into());
|
||||
|
||||
let generations = selected
|
||||
let generations = monitors
|
||||
.iter()
|
||||
.map(|source| {
|
||||
discovery
|
||||
@@ -641,14 +674,28 @@ async fn start_streams(
|
||||
let mut children = Vec::new();
|
||||
let mut streams = Vec::new();
|
||||
|
||||
for (source, (_, generation)) in selected.iter().zip(&generations) {
|
||||
let mut child = child_command()?
|
||||
.args([
|
||||
for source in &selected {
|
||||
let mut command = child_command()?;
|
||||
if let Some(id) = source.window_id() {
|
||||
command.args([
|
||||
"--stream-window",
|
||||
&id.to_string(),
|
||||
if cursor { "embedded" } else { "hidden" },
|
||||
]);
|
||||
} else {
|
||||
let generation = generations
|
||||
.iter()
|
||||
.find(|(name, _)| name == &source.name)
|
||||
.ok_or("Missing display generation")?
|
||||
.1;
|
||||
command.args([
|
||||
"--stream",
|
||||
&source.name,
|
||||
if cursor { "embedded" } else { "hidden" },
|
||||
&generation.to_string(),
|
||||
])
|
||||
]);
|
||||
}
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
@@ -680,7 +727,7 @@ async fn start_streams(
|
||||
.map_err(|error| error.to_string())??;
|
||||
|
||||
let final_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
|
||||
if !crate::restore::selection_unchanged(&selected, &outputs, &final_outputs) {
|
||||
if !crate::restore::selection_unchanged(&monitors, &outputs, &final_outputs) {
|
||||
return Err("A selected display changed during stream startup".into());
|
||||
}
|
||||
|
||||
@@ -691,16 +738,59 @@ async fn start_streams(
|
||||
return Err("A selected stream ended during startup".into());
|
||||
}
|
||||
|
||||
let final_windows = crate::desktop::ipc("get-windows", serde_json::json!({})).await?;
|
||||
let streams = streams
|
||||
.into_iter()
|
||||
.map(|(name, ready)| {
|
||||
monitor_metadata(&final_outputs, &name, &ready).map(|metadata| (ready.node, metadata))
|
||||
let metadata = if name.starts_with("window:") {
|
||||
let source = selected
|
||||
.iter()
|
||||
.find(|source| source.name == name)
|
||||
.ok_or("Unknown window stream")?;
|
||||
let id = source.window_id().ok_or("Invalid selected window")?;
|
||||
if !final_windows.as_array().is_some_and(|windows| {
|
||||
windows
|
||||
.iter()
|
||||
.any(|window| window["id"].as_u64() == Some(id))
|
||||
}) {
|
||||
return Err("A selected window closed during startup".to_owned());
|
||||
}
|
||||
window_metadata(source, &ready)
|
||||
} else {
|
||||
monitor_metadata(&final_outputs, &name, &ready)
|
||||
};
|
||||
metadata.map(|metadata| (ready.node, metadata))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok((children, streams, reply))
|
||||
}
|
||||
|
||||
fn window_metadata(source: &Source, ready: &Ready) -> Result<Options, String> {
|
||||
if source.window_id().is_none() || ready.width == 0 || ready.height == 0 {
|
||||
return Err("Invalid window stream".into());
|
||||
}
|
||||
let (width, height) = ready
|
||||
.logical_size
|
||||
.ok_or("Missing captured window geometry")?;
|
||||
let size = (
|
||||
i32::try_from(width).map_err(|_| "Invalid window width")?,
|
||||
i32::try_from(height).map_err(|_| "Invalid window height")?,
|
||||
);
|
||||
if size.0 <= 0 || size.1 <= 0 {
|
||||
return Err("Invalid captured window geometry".into());
|
||||
}
|
||||
Ok(Options::from([
|
||||
("source_type".into(), 2u32.into()),
|
||||
(
|
||||
"size".into(),
|
||||
Value::from(size)
|
||||
.try_to_owned()
|
||||
.map_err(|e| e.to_string())?,
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
fn monitor_metadata(
|
||||
outputs: &serde_json::Value,
|
||||
name: &str,
|
||||
@@ -770,7 +860,11 @@ fn validate_selection(
|
||||
names: &[String],
|
||||
multiple: bool,
|
||||
) -> Result<Vec<Source>, String> {
|
||||
if names.is_empty() || names.len() > sources.len() || (!multiple && names.len() != 1) {
|
||||
if names.is_empty()
|
||||
|| names.len() > 8
|
||||
|| names.len() > sources.len()
|
||||
|| (!multiple && names.len() != 1)
|
||||
{
|
||||
return Err("Invalid display selection".into());
|
||||
}
|
||||
|
||||
@@ -893,19 +987,78 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_unimplemented_sources_and_cursor_modes() {
|
||||
assert_eq!(source_options(&Options::new()).unwrap(), (false, false));
|
||||
for bits in [0u32, 2, 4, 6] {
|
||||
assert_eq!(source_options(&Options::new()).unwrap(), (false, false, 1));
|
||||
for bits in [0u32, 4, 5, 6, 7] {
|
||||
assert!(source_options(&HashMap::from([("types".into(), bits.into())])).is_err());
|
||||
}
|
||||
for bits in [1u32, 3, 5, 7] {
|
||||
for bits in [1u32, 2, 3] {
|
||||
assert_eq!(
|
||||
source_options(&HashMap::from([("types".into(), bits.into())])).unwrap(),
|
||||
(false, false)
|
||||
(false, false, bits)
|
||||
);
|
||||
}
|
||||
assert!(source_options(&HashMap::from([("cursor_mode".into(), 4u32.into())])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_metadata_uses_captured_logical_geometry_and_omits_position() {
|
||||
let source = Source {
|
||||
name: "window:17".into(),
|
||||
label: "Editor".into(),
|
||||
width: 640,
|
||||
height: 480,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
};
|
||||
let ready = Ready {
|
||||
node: 1,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
logical_size: Some((600, 400)),
|
||||
};
|
||||
let metadata = window_metadata(&source, &ready).unwrap();
|
||||
assert_eq!(u32::try_from(&metadata["source_type"]).unwrap(), 2);
|
||||
assert_eq!(
|
||||
<(i32, i32)>::try_from(metadata["size"].try_clone().unwrap()).unwrap(),
|
||||
(600, 400)
|
||||
);
|
||||
assert!(!metadata.contains_key("position"));
|
||||
assert!(
|
||||
window_metadata(
|
||||
&source,
|
||||
&Ready {
|
||||
logical_size: None,
|
||||
..ready
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_selection_is_bounded_and_accepts_displays_with_windows() {
|
||||
let sources = (0..9)
|
||||
.map(|id| Source {
|
||||
name: format!("window:{id}"),
|
||||
label: "Editor".into(),
|
||||
width: 640,
|
||||
height: 480,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let names = sources
|
||||
.iter()
|
||||
.map(|source| source.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(validate_selection(&sources, &names[..8], true).is_ok());
|
||||
assert!(validate_selection(&sources, &names, true).is_err());
|
||||
assert!(validate_selection(&sources, &names[..2], false).is_err());
|
||||
assert!(validate_selection(&sources, &[names[0].clone(), names[0].clone()], true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_metadata_uses_logical_fractional_scale_geometry() {
|
||||
let outputs = serde_json::json!([{"name":"display","enabled":true,"x":-1440,"y":20,"width":2560,"height":1440,"scale":1.5,"current_mode":{"width":3840,"height":2160}}]);
|
||||
@@ -913,6 +1066,7 @@ mod tests {
|
||||
&outputs,
|
||||
"display",
|
||||
&Ready {
|
||||
logical_size: None,
|
||||
node: 1,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
@@ -932,6 +1086,7 @@ mod tests {
|
||||
&outputs,
|
||||
"missing",
|
||||
&Ready {
|
||||
logical_size: None,
|
||||
node: 1,
|
||||
width: 3840,
|
||||
height: 2160
|
||||
|
||||
+92
-28
@@ -1,4 +1,5 @@
|
||||
//! Bounded, cancellable Wayland capture. No screenshots or external capture tools.
|
||||
use ferese_protocols::window_capture::v1::client::ferese_window_capture_manager_v1 as window_manager;
|
||||
use memmap2::MmapMut;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
@@ -18,6 +19,8 @@ use wayland_protocols_wlr::screencopy::v1::client::{
|
||||
zwlr_screencopy_frame_v1 as frame, zwlr_screencopy_manager_v1 as manager,
|
||||
};
|
||||
|
||||
pub const BUSY: &str = "Window capture snapshot pool is busy";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Source {
|
||||
pub name: String,
|
||||
@@ -29,6 +32,12 @@ pub struct Source {
|
||||
pub scale: i32,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn window_id(&self) -> Option<u64> {
|
||||
self.name.strip_prefix("window:")?.parse().ok()
|
||||
}
|
||||
}
|
||||
|
||||
struct Output {
|
||||
global: u32,
|
||||
proxy: wl_output::WlOutput,
|
||||
@@ -37,6 +46,7 @@ struct Output {
|
||||
}
|
||||
|
||||
pub struct Frame {
|
||||
pub logical_size: Option<(u32, u32)>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub stride: u32,
|
||||
@@ -63,10 +73,13 @@ impl Drop for Buffer {
|
||||
struct State {
|
||||
outputs: Vec<Output>,
|
||||
manager: Option<manager::ZwlrScreencopyManagerV1>,
|
||||
window_manager: Option<window_manager::FereseWindowCaptureManagerV1>,
|
||||
shm: Option<wl_shm::WlShm>,
|
||||
buffer: Option<Buffer>,
|
||||
result: Option<Result<(), String>>,
|
||||
flipped: bool,
|
||||
logical_size: Option<(u32, u32)>,
|
||||
busy: bool,
|
||||
}
|
||||
|
||||
pub struct Capture {
|
||||
@@ -91,7 +104,9 @@ impl Capture {
|
||||
// uninterruptible roundtrip on a stalled compositor.
|
||||
this.sync(stop)?;
|
||||
this.sync(stop)?;
|
||||
if this.state.manager.is_none() || this.state.shm.is_none() {
|
||||
if (this.state.manager.is_none() && this.state.window_manager.is_none())
|
||||
|| this.state.shm.is_none()
|
||||
{
|
||||
return Err("Ferese screen capture is unavailable in this session".into());
|
||||
}
|
||||
Ok(this)
|
||||
@@ -109,6 +124,10 @@ impl Capture {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn supports_windows(&self) -> bool {
|
||||
self.state.window_manager.is_some()
|
||||
}
|
||||
|
||||
pub fn generation(&self, name: &str) -> Option<u32> {
|
||||
self.state
|
||||
.outputs
|
||||
@@ -156,31 +175,53 @@ impl Capture {
|
||||
stop: &AtomicBool,
|
||||
mut pixels: Vec<u8>,
|
||||
) -> Result<Frame, String> {
|
||||
let output = self
|
||||
.state
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|o| o.source.name == name)
|
||||
.ok_or("Shared monitor disconnected")?;
|
||||
|
||||
if self
|
||||
.selected_output
|
||||
.is_some_and(|selected| selected != output.global)
|
||||
{
|
||||
return Err("Shared monitor was replaced".into());
|
||||
}
|
||||
self.selected_output = Some(output.global);
|
||||
|
||||
let transform = output.transform;
|
||||
self.state.result = None;
|
||||
self.state.flipped = false;
|
||||
|
||||
let frame = self.state.manager.as_ref().unwrap().capture_output(
|
||||
cursor as i32,
|
||||
&output.proxy,
|
||||
&self.queue.handle(),
|
||||
(),
|
||||
);
|
||||
self.state.logical_size = None;
|
||||
self.state.busy = false;
|
||||
let (frame, transform) = if let Some(id) = name
|
||||
.strip_prefix("window:")
|
||||
.and_then(|id| id.parse::<u64>().ok())
|
||||
{
|
||||
let manager = self
|
||||
.state
|
||||
.window_manager
|
||||
.as_ref()
|
||||
.ok_or("Window capture is unavailable")?;
|
||||
(
|
||||
manager.capture_window(
|
||||
(id >> 32) as u32,
|
||||
id as u32,
|
||||
cursor as u32,
|
||||
&self.queue.handle(),
|
||||
(),
|
||||
),
|
||||
wl_output::Transform::Normal,
|
||||
)
|
||||
} else {
|
||||
let output = self
|
||||
.state
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|o| o.source.name == name)
|
||||
.ok_or("Shared monitor disconnected")?;
|
||||
if self
|
||||
.selected_output
|
||||
.is_some_and(|selected| selected != output.global)
|
||||
{
|
||||
return Err("Shared monitor was replaced".into());
|
||||
}
|
||||
self.selected_output = Some(output.global);
|
||||
let manager = self
|
||||
.state
|
||||
.manager
|
||||
.as_ref()
|
||||
.ok_or("Monitor capture is unavailable")?;
|
||||
(
|
||||
manager.capture_output(cursor as i32, &output.proxy, &self.queue.handle(), ()),
|
||||
output.transform,
|
||||
)
|
||||
};
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
|
||||
while self.state.result.is_none() {
|
||||
@@ -209,6 +250,7 @@ impl Capture {
|
||||
);
|
||||
|
||||
Ok(Frame {
|
||||
logical_size: self.state.logical_size,
|
||||
width,
|
||||
height,
|
||||
stride: width * 4,
|
||||
@@ -285,6 +327,9 @@ impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||
scale: 1,
|
||||
},
|
||||
}),
|
||||
"ferese_window_capture_manager_v1" => {
|
||||
state.window_manager = Some(registry.bind(name, 1, qh, ()))
|
||||
}
|
||||
"wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())),
|
||||
"zwlr_screencopy_manager_v1" => {
|
||||
state.manager = Some(registry.bind(name, version.min(3), qh, ()))
|
||||
@@ -415,10 +460,11 @@ impl Dispatch<frame::ZwlrScreencopyFrameV1, ()> for State {
|
||||
}
|
||||
frame::Event::Ready { .. } => state.result = Some(Ok(())),
|
||||
frame::Event::Failed => {
|
||||
state.result = Some(Err(
|
||||
"Capture ended: session locked, monitor removed, or compositor unavailable"
|
||||
.into(),
|
||||
))
|
||||
state.result = Some(Err(if state.busy {
|
||||
BUSY.into()
|
||||
} else {
|
||||
"Capture ended: session locked, source closed, or compositor unavailable".into()
|
||||
}))
|
||||
}
|
||||
frame::Event::Flags {
|
||||
flags: WEnum::Value(flags),
|
||||
@@ -522,6 +568,24 @@ wayland_client::delegate_noop!(State: ignore wl_shm::WlShm);
|
||||
wayland_client::delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
|
||||
wayland_client::delegate_noop!(State: ignore wl_buffer::WlBuffer);
|
||||
wayland_client::delegate_noop!(State: ignore manager::ZwlrScreencopyManagerV1);
|
||||
impl Dispatch<window_manager::FereseWindowCaptureManagerV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &window_manager::FereseWindowCaptureManagerV1,
|
||||
event: window_manager::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
window_manager::Event::Geometry { width, height, .. } => {
|
||||
state.logical_size = Some((width, height))
|
||||
}
|
||||
window_manager::Event::Busy { .. } => state.busy = true,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -462,6 +462,38 @@ fn window_sources(windows: &serde_json::Value) -> Result<Vec<crate::capture::Sou
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
pub(crate) fn sharing_window_sources(
|
||||
windows: &serde_json::Value,
|
||||
) -> Result<Vec<crate::capture::Source>, String> {
|
||||
let windows = windows.as_array().ok_or("Invalid window list")?;
|
||||
let eligible = windows
|
||||
.iter()
|
||||
.filter(|window| {
|
||||
window["workspace"].as_u64().is_some()
|
||||
&& window["capture_width"]
|
||||
.as_i64()
|
||||
.is_some_and(|width| width > 0)
|
||||
&& window["capture_height"]
|
||||
.as_i64()
|
||||
.is_some_and(|height| height > 0)
|
||||
&& window["app_id"].as_str() != Some("dev.ferese.ScreenShare")
|
||||
})
|
||||
.take(128)
|
||||
.map(|window| {
|
||||
let mut window = window.clone();
|
||||
window["mapped"] = true.into();
|
||||
window["width"] = window["capture_width"].clone();
|
||||
window["height"] = window["capture_height"].clone();
|
||||
window
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut sources = window_sources(&serde_json::Value::Array(eligible))?;
|
||||
for source in &mut sources {
|
||||
source.name = format!("window:{}", source.name);
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
async fn select_window(app: &str, parent: &str) -> Result<Option<u64>, String> {
|
||||
let windows = ipc("get-windows", serde_json::json!({})).await?;
|
||||
let sources = window_sources(&windows)?;
|
||||
@@ -754,6 +786,20 @@ fn edit_wallpaper(path: &Path, image: &Path, target: &str) -> Result<(), String>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn live_window_choices_include_inactive_workspaces_and_exclude_picker() {
|
||||
let windows = serde_json::json!([
|
||||
{"id": 1, "workspace": 2, "mapped": false, "app_id": "org.Editor", "title": "Notes", "capture_width": 640, "capture_height": 480},
|
||||
{"id": 2, "workspace": 1, "mapped": true, "app_id": "dev.ferese.ScreenShare", "capture_width": 400, "capture_height": 300},
|
||||
{"id": 3, "workspace": 1, "capture_width": 0, "capture_height": 0}
|
||||
]);
|
||||
let sources = super::sharing_window_sources(&windows).unwrap();
|
||||
assert_eq!(sources.len(), 1);
|
||||
assert_eq!(sources[0].window_id(), Some(1));
|
||||
assert_eq!((sources[0].width, sources[0].height), (640, 480));
|
||||
assert!(sources[0].label.contains("Notes"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,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-window" => {
|
||||
let id: u64 = id.parse()?;
|
||||
stream::run(format!("window:{id}"), cursor == "embedded", None)
|
||||
}
|
||||
[flag, name, cursor, generation] if flag == "--stream" => stream::run(
|
||||
name.clone(),
|
||||
cursor == "embedded",
|
||||
|
||||
+22
-10
@@ -71,9 +71,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() > if prompt.window_capture { 128 } else { 16 }
|
||||
{
|
||||
if prompt.sources.is_empty() || prompt.sources.len() > 144 {
|
||||
return Err("No shareable displays".into());
|
||||
}
|
||||
|
||||
@@ -187,6 +185,9 @@ impl cosmic::Application for Picker {
|
||||
if self.selected.contains(&index) {
|
||||
self.selected.retain(|i| *i != index);
|
||||
} else {
|
||||
if self.prompt.multiple && self.selected.len() >= 8 {
|
||||
return Task::none();
|
||||
}
|
||||
if !self.prompt.multiple {
|
||||
self.selected.clear();
|
||||
}
|
||||
@@ -244,7 +245,7 @@ impl cosmic::Application for Picker {
|
||||
self.text(if self.prompt.window_capture {
|
||||
"Choose a window"
|
||||
} else {
|
||||
"Choose a display"
|
||||
"Choose what to share"
|
||||
})
|
||||
.size(18),
|
||||
self.text(format!(
|
||||
@@ -264,16 +265,27 @@ impl cosmic::Application for Picker {
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
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 selected = self.selected.contains(&index);
|
||||
let mut entry = row![
|
||||
glyph(ferese_theme::icons::DISPLAY, 24),
|
||||
column![
|
||||
self.text(if self.prompt.window_capture {
|
||||
&source.label
|
||||
} else {
|
||||
&source.name
|
||||
})
|
||||
self.text(
|
||||
if self.prompt.window_capture || source.window_id().is_some() {
|
||||
&source.label
|
||||
} else {
|
||||
&source.name
|
||||
}
|
||||
)
|
||||
.size(14),
|
||||
self.text(format!("{} × {}", source.width, source.height))
|
||||
.size(12)
|
||||
@@ -305,7 +317,7 @@ impl cosmic::Application for Picker {
|
||||
self.text(if self.prompt.window_capture {
|
||||
"Only the selected window will be captured."
|
||||
} else {
|
||||
"Everything on this display, including notifications, is visible."
|
||||
"Displays share everything visible. Windows share only their own content."
|
||||
})
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
|
||||
+81
-33
@@ -15,6 +15,8 @@ use std::{
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Ready {
|
||||
#[serde(default)]
|
||||
pub logical_size: Option<(u32, u32)>,
|
||||
pub node: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
@@ -27,6 +29,7 @@ struct Data {
|
||||
fresh: Arc<AtomicBool>,
|
||||
announced: bool,
|
||||
sequence: u64,
|
||||
negotiated: (u32, u32),
|
||||
}
|
||||
|
||||
// pipewire-rs exposes read-only metadata through Buffer. Own a dequeued raw
|
||||
@@ -110,6 +113,38 @@ fn pod(object: spa::pod::Object) -> Vec<u8> {
|
||||
.into_inner()
|
||||
}
|
||||
|
||||
fn video_format(width: u32, height: u32) -> Vec<u8> {
|
||||
pod(spa::pod::object!(
|
||||
spa::utils::SpaTypes::ObjectParamFormat,
|
||||
spa::param::ParamType::EnumFormat,
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::MediaType,
|
||||
Id,
|
||||
spa::param::format::MediaType::Video
|
||||
),
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::MediaSubtype,
|
||||
Id,
|
||||
spa::param::format::MediaSubtype::Raw
|
||||
),
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::VideoFormat,
|
||||
Id,
|
||||
spa::param::video::VideoFormat::BGRx
|
||||
),
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::VideoSize,
|
||||
Rectangle,
|
||||
spa::utils::Rectangle { width, height }
|
||||
),
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::VideoFramerate,
|
||||
Fraction,
|
||||
spa::utils::Fraction { num: 30, denom: 1 }
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
name: String,
|
||||
cursor: bool,
|
||||
@@ -126,8 +161,18 @@ pub fn run(
|
||||
if let Some(generation) = generation {
|
||||
capture.pin_output(&name, generation)?;
|
||||
}
|
||||
let first = capture.frame(&name, cursor, &stop, Vec::new())?;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let first = loop {
|
||||
match capture.frame(&name, cursor, &stop, Vec::new()) {
|
||||
Err(error) if error == crate::capture::BUSY && Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
result => break result?,
|
||||
}
|
||||
};
|
||||
let (width, height) = (first.width, first.height);
|
||||
let logical_size = first.logical_size;
|
||||
let resizable = name.starts_with("window:");
|
||||
let latest = Arc::new(Mutex::new(first));
|
||||
let streaming = Arc::new(AtomicBool::new(false));
|
||||
|
||||
@@ -154,6 +199,7 @@ pub fn run(
|
||||
"node.latency" => "1/30",
|
||||
},
|
||||
)?;
|
||||
let format_frames = latest.clone();
|
||||
let data = Data {
|
||||
latest: latest.clone(),
|
||||
stop: stop.clone(),
|
||||
@@ -161,6 +207,7 @@ pub fn run(
|
||||
fresh: fresh.clone(),
|
||||
announced: false,
|
||||
sequence: 0,
|
||||
negotiated: (width, height),
|
||||
};
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(data)
|
||||
@@ -180,6 +227,7 @@ pub fn run(
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&Ready {
|
||||
logical_size,
|
||||
node,
|
||||
width,
|
||||
height
|
||||
@@ -206,13 +254,18 @@ pub fn run(
|
||||
let Some(param) = param else { return };
|
||||
let mut format = spa::param::video::VideoInfoRaw::default();
|
||||
if format.parse(param).is_err()
|
||||
|| format.size().width != width
|
||||
|| format.size().height != height
|
||||
|| format.size().width == 0
|
||||
|| format.size().height == 0
|
||||
|| (!resizable && (format.size().width, format.size().height) != (width, height))
|
||||
|| u64::from(format.size().width) * u64::from(format.size().height) * 4
|
||||
> 128 * 1024 * 1024
|
||||
|| format.format() != spa::param::video::VideoFormat::BGRx
|
||||
{
|
||||
data.stop.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let (width, height) = (format.size().width, format.size().height);
|
||||
data.negotiated = (width, height);
|
||||
let buffers = pod(spa::pod::object!(
|
||||
spa::utils::SpaTypes::ObjectParamBuffers,
|
||||
spa::param::ParamType::Buffers,
|
||||
@@ -260,6 +313,10 @@ pub fn run(
|
||||
return;
|
||||
}
|
||||
let frame = data.latest.lock().unwrap();
|
||||
if (frame.width, frame.height) != data.negotiated {
|
||||
*target.chunk_mut().size_mut() = 0;
|
||||
return;
|
||||
}
|
||||
let row = frame.width as usize * 4;
|
||||
let bytes = row * frame.height as usize;
|
||||
let Some(destination) = target.data() else {
|
||||
@@ -283,35 +340,7 @@ pub fn run(
|
||||
})
|
||||
.register()?;
|
||||
|
||||
let format = pod(spa::pod::object!(
|
||||
spa::utils::SpaTypes::ObjectParamFormat,
|
||||
spa::param::ParamType::EnumFormat,
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::MediaType,
|
||||
Id,
|
||||
spa::param::format::MediaType::Video
|
||||
),
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::MediaSubtype,
|
||||
Id,
|
||||
spa::param::format::MediaSubtype::Raw
|
||||
),
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::VideoFormat,
|
||||
Id,
|
||||
spa::param::video::VideoFormat::BGRx
|
||||
),
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::VideoSize,
|
||||
Rectangle,
|
||||
spa::utils::Rectangle { width, height }
|
||||
),
|
||||
spa::pod::property!(
|
||||
spa::param::format::FormatProperties::VideoFramerate,
|
||||
Fraction,
|
||||
spa::utils::Fraction { num: 30, denom: 1 }
|
||||
)
|
||||
));
|
||||
let format = video_format(width, height);
|
||||
|
||||
stream.connect(
|
||||
spa::utils::Direction::Output,
|
||||
@@ -334,7 +363,7 @@ pub fn run(
|
||||
{
|
||||
checked = Instant::now();
|
||||
match capture.frame(&name, cursor, &capture_stop, std::mem::take(&mut spare)) {
|
||||
Ok(frame) if frame.width == width && frame.height == height => {
|
||||
Ok(frame) if resizable || (frame.width == width && frame.height == height) => {
|
||||
spare = std::mem::replace(&mut *latest.lock().unwrap(), frame).pixels;
|
||||
fresh.store(capture_streaming.load(Ordering::Relaxed), Ordering::SeqCst);
|
||||
}
|
||||
@@ -342,6 +371,7 @@ pub fn run(
|
||||
eprintln!("Shared monitor changed size; start sharing again");
|
||||
capture_stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Err(error) if error == crate::capture::BUSY => (),
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
capture_stop.store(true, Ordering::Relaxed);
|
||||
@@ -352,8 +382,23 @@ pub fn run(
|
||||
}
|
||||
});
|
||||
let mut next_frame = Instant::now();
|
||||
let mut requested_size = (width, height);
|
||||
let mut renegotiation_error = None;
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let size = {
|
||||
let frame = format_frames.lock().unwrap();
|
||||
(frame.width, frame.height)
|
||||
};
|
||||
if resizable && size != requested_size {
|
||||
let format = video_format(size.0, size.1);
|
||||
if let Err(error) = stream.update_params(&mut [Pod::from_bytes(&format).unwrap()]) {
|
||||
renegotiation_error = Some(error);
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
requested_size = size;
|
||||
}
|
||||
let active = streaming.load(Ordering::Relaxed);
|
||||
let timeout = if active {
|
||||
next_frame
|
||||
@@ -375,6 +420,9 @@ pub fn run(
|
||||
|
||||
let _ = stream.disconnect();
|
||||
let _ = capture_thread.join();
|
||||
if let Some(error) = renegotiation_error {
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="ferese_window_capture_v1">
|
||||
<description summary="private native portal window capture">
|
||||
Captures only a managed toplevel's surface tree, independently of its
|
||||
workspace, occlusion, animations and output transform. The compositor
|
||||
advertises this interface only to its native portal executable. Capture
|
||||
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="1">
|
||||
<request name="destroy" type="destructor"/>
|
||||
<request name="capture_window">
|
||||
<description summary="snapshot a managed window">
|
||||
The two unsigned words encode a 64-bit window ID (high word first).
|
||||
A version 1 wlr screencopy frame announces a bounded SHM ARGB8888
|
||||
snapshot. It fails if the target has closed, capture is unavailable,
|
||||
or the outstanding snapshot budget is exhausted. Resize after the
|
||||
buffer announcement does not change this frame's dimensions.
|
||||
Overlay cursor is zero for hidden and one for embedded.
|
||||
</description>
|
||||
<arg name="window_hi" type="uint"/>
|
||||
<arg name="window_lo" type="uint"/>
|
||||
<arg name="overlay_cursor" type="uint"/>
|
||||
<arg name="frame" type="new_id" interface="zwlr_screencopy_frame_v1"/>
|
||||
</request>
|
||||
<event name="busy">
|
||||
<description summary="temporary capture resource contention">
|
||||
Sent before this frame's failed event if the bounded snapshot pool is
|
||||
occupied. Retry a later frame; this does not revoke the window source.
|
||||
</description>
|
||||
<arg name="frame" type="object" interface="zwlr_screencopy_frame_v1"/>
|
||||
</event>
|
||||
<event name="geometry">
|
||||
<description summary="logical dimensions of the announced snapshot">
|
||||
Sent before the frame's buffer event. These dimensions describe this
|
||||
captured frame, even if the window resizes before it is copied.
|
||||
</description>
|
||||
<arg name="frame" type="object" interface="zwlr_screencopy_frame_v1"/>
|
||||
<arg name="width" type="uint"/>
|
||||
<arg name="height" type="uint"/>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <wayland-client.h>
|
||||
#include "ext-session-lock-client-protocol.h"
|
||||
|
||||
static struct ext_session_lock_manager_v1 *manager;
|
||||
|
||||
static void global(void *data, struct wl_registry *registry, uint32_t name,
|
||||
const char *interface, uint32_t version) {
|
||||
(void)data; (void)version;
|
||||
if (!strcmp(interface, "ext_session_lock_manager_v1"))
|
||||
manager = wl_registry_bind(registry, name, &ext_session_lock_manager_v1_interface, 1);
|
||||
}
|
||||
|
||||
static void removed(void *data, struct wl_registry *registry, uint32_t name) {
|
||||
(void)data; (void)registry; (void)name;
|
||||
}
|
||||
|
||||
static void locked(void *data, struct ext_session_lock_v1 *lock) {
|
||||
(void)data; (void)lock;
|
||||
puts("locked");
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static void finished(void *data, struct ext_session_lock_v1 *lock) {
|
||||
(void)data; (void)lock;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
struct wl_display *display = wl_display_connect(NULL);
|
||||
if (!display) return 1;
|
||||
const struct wl_registry_listener registry_listener = {global, removed};
|
||||
struct wl_registry *registry = wl_display_get_registry(display);
|
||||
wl_registry_add_listener(registry, ®istry_listener, NULL);
|
||||
if (wl_display_roundtrip(display) < 0 || !manager) return 2;
|
||||
struct ext_session_lock_v1 *lock = ext_session_lock_manager_v1_lock(manager);
|
||||
const struct ext_session_lock_v1_listener listener = {locked, finished};
|
||||
ext_session_lock_v1_add_listener(lock, &listener, NULL);
|
||||
if (wl_display_roundtrip(display) < 0) return 3;
|
||||
puts("requested");
|
||||
fflush(stdout);
|
||||
while (wl_display_dispatch(display) != -1) {}
|
||||
return 0;
|
||||
}
|
||||
+49
-16
@@ -1,6 +1,7 @@
|
||||
/* Two overlapping clients with distinct colors for isolated capture tests. */
|
||||
#define _GNU_SOURCE
|
||||
#include <stdint.h>
|
||||
#include <poll.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -14,6 +15,9 @@ static struct wl_shm *shm;
|
||||
static struct xdg_wm_base *wm;
|
||||
static struct wl_surface *surface;
|
||||
static struct wl_buffer *buffer;
|
||||
static int width = 640, height = 480;
|
||||
static int private_capture_seen;
|
||||
static int covered;
|
||||
|
||||
static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) {
|
||||
(void)data;
|
||||
@@ -24,6 +28,7 @@ static const struct xdg_wm_base_listener wm_listener = {.ping = ping};
|
||||
static void global(void *data, struct wl_registry *registry, uint32_t name,
|
||||
const char *interface, uint32_t version) {
|
||||
(void)data; (void)version;
|
||||
if (!strcmp(interface, "ferese_window_capture_manager_v1")) private_capture_seen = 1;
|
||||
if (!strcmp(interface, "wl_compositor"))
|
||||
compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4);
|
||||
else if (!strcmp(interface, "wl_shm"))
|
||||
@@ -42,9 +47,9 @@ static const struct wl_registry_listener registry_listener = {global, removed};
|
||||
static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) {
|
||||
(void)data;
|
||||
xdg_surface_ack_configure(xdg, serial);
|
||||
xdg_surface_set_window_geometry(xdg, 0, 0, 640, 480);
|
||||
xdg_surface_set_window_geometry(xdg, 0, 0, width, height);
|
||||
wl_surface_attach(surface, buffer, 0, 0);
|
||||
wl_surface_damage(surface, 0, 0, 640, 480);
|
||||
wl_surface_damage(surface, 0, 0, width, height);
|
||||
wl_surface_commit(surface);
|
||||
}
|
||||
static const struct xdg_surface_listener surface_listener = {.configure = configure};
|
||||
@@ -59,25 +64,31 @@ static void close_window(void *data, struct xdg_toplevel *top) {
|
||||
}
|
||||
static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window};
|
||||
|
||||
static void make_buffer(void) {
|
||||
int fd = memfd_create("floating-size-test", MFD_CLOEXEC);
|
||||
size_t bytes = width * height * 4;
|
||||
if (fd < 0 || ftruncate(fd, bytes)) exit(3);
|
||||
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
if (pixels == MAP_FAILED) exit(4);
|
||||
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));
|
||||
}
|
||||
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);
|
||||
close(fd);
|
||||
munmap(pixels, bytes);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
struct wl_display *display = wl_display_connect(NULL);
|
||||
if (!display) return 1;
|
||||
struct wl_registry *registry = wl_display_get_registry(display);
|
||||
wl_registry_add_listener(registry, ®istry_listener, NULL);
|
||||
if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm) return 2;
|
||||
int fd = memfd_create("floating-size-test", MFD_CLOEXEC);
|
||||
size_t bytes = 640 * 480 * 4;
|
||||
if (fd < 0 || ftruncate(fd, bytes)) return 3;
|
||||
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
if (pixels == MAP_FAILED) return 4;
|
||||
int covered = argc > 1 && !strcmp(argv[1], "cover");
|
||||
for (size_t i = 0; i < 640 * 480; i++) {
|
||||
pixels[i] = covered ? 0xff00ff00 : (i % 640 < 80 ? 0x80800000 : (i / 640 < 240 ? 0xffff0000 : 0xff0000ff));
|
||||
}
|
||||
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
|
||||
buffer = wl_shm_pool_create_buffer(pool, 0, 640, 480, 640 * 4, WL_SHM_FORMAT_ARGB8888);
|
||||
wl_shm_pool_destroy(pool);
|
||||
close(fd);
|
||||
if (private_capture_seen) return 9;
|
||||
covered = argc > 1 && !strcmp(argv[1], "cover");
|
||||
make_buffer();
|
||||
surface = wl_compositor_create_surface(compositor);
|
||||
struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface);
|
||||
xdg_surface_add_listener(xdg, &surface_listener, NULL);
|
||||
@@ -86,6 +97,28 @@ int main(int argc, char **argv) {
|
||||
xdg_toplevel_set_app_id(top, "ferese.test.window-capture");
|
||||
xdg_toplevel_set_title(top, covered ? "Occluding window" : "Capture target");
|
||||
wl_surface_commit(surface);
|
||||
while (wl_display_dispatch(display) != -1) {}
|
||||
if (argc > 1 && !strcmp(argv[1], "resize")) {
|
||||
while (1) {
|
||||
wl_display_flush(display);
|
||||
struct pollfd fds[2] = {{wl_display_get_fd(display), POLLIN, 0}, {STDIN_FILENO, POLLIN, 0}};
|
||||
if (poll(fds, 2, -1) < 0) break;
|
||||
if (fds[0].revents & POLLIN && wl_display_dispatch(display) < 0) break;
|
||||
if (fds[1].revents & POLLIN) {
|
||||
char command;
|
||||
if (read(STDIN_FILENO, &command, 1) != 1) break;
|
||||
if (command == 'r') {
|
||||
width = 480; height = 320;
|
||||
wl_buffer_destroy(buffer);
|
||||
make_buffer();
|
||||
xdg_surface_set_window_geometry(xdg, 0, 0, width, height);
|
||||
wl_surface_attach(surface, buffer, 0, 0);
|
||||
wl_surface_damage(surface, 0, 0, width, height);
|
||||
wl_surface_commit(surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while (wl_display_dispatch(display) != -1) {}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -112,11 +112,13 @@ def private_bus_checks():
|
||||
assert request_name == 1
|
||||
version, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "version")), "(v)")
|
||||
assert version == 4
|
||||
source_types, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "AvailableSourceTypes")), "(v)")
|
||||
assert source_types == 3
|
||||
for mode in (0, 1, 2):
|
||||
session = PATH + f"/session/test/persist{mode}"
|
||||
handle = PATH + f"/request/test/persist{mode}"
|
||||
assert call("org.freedesktop.impl.portal.ScreenCast", "CreateSession", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", {})), "(ua{sv})") == (0, {})
|
||||
options = {"persist_mode": GLib.Variant("u", mode), "restore_data": GLib.Variant("(suv)", ("Other", 9, GLib.Variant("s", "foreign data")))}
|
||||
options = {"types": GLib.Variant("u", (1, 2, 3)[mode]), "persist_mode": GLib.Variant("u", mode), "restore_data": GLib.Variant("(suv)", ("Other", 9, GLib.Variant("s", "foreign data")))}
|
||||
assert call("org.freedesktop.impl.portal.ScreenCast", "SelectSources", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", options)), "(ua{sv})") == (0, {})
|
||||
bus.call_sync(NAME, session, "org.freedesktop.impl.portal.Session", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
|
||||
session = PATH + "/session/test/badpersist"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Exercise live isolated window sharing in a disposable nested compositor.
|
||||
|
||||
FERESE_TEST_WINDOW_STREAM=1 python3 scripts/tests/test_window_stream_isolated.py
|
||||
Requires a Wayland host, PipeWire, GStreamer Python bindings and a C compiler.
|
||||
Only this test's compositor, windows and streams are stopped.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from test_inhibit_isolated import IPC
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FERESE_TEST_WINDOW_STREAM") == "1", "requires Wayland and PipeWire")
|
||||
class WindowStream(unittest.TestCase):
|
||||
def test_isolation_resize_workspace_and_close(self):
|
||||
import gi
|
||||
gi.require_version("Gst", "1.0")
|
||||
from gi.repository import Gst
|
||||
Gst.init(None)
|
||||
processes = []
|
||||
pipeline = None
|
||||
admin = None
|
||||
|
||||
def wait_for(check, message):
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
value = check()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.02)
|
||||
self.fail(message)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="ferese-window-stream-") as directory:
|
||||
root = Path(directory)
|
||||
protocols = subprocess.check_output(["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip()
|
||||
xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml")
|
||||
for kind, output in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]:
|
||||
subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True)
|
||||
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split()
|
||||
subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(REPO / "scripts/tests/fixtures/window-capture.c"), str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True)
|
||||
lock_xml = str(Path(protocols) / "staging/ext-session-lock/ext-session-lock-v1.xml")
|
||||
for kind, output in [("client-header", "ext-session-lock-client-protocol.h"), ("private-code", "ext-session-lock-protocol.c")]:
|
||||
subprocess.run(["wayland-scanner", kind, lock_xml, str(root / output)], check=True)
|
||||
subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(REPO / "scripts/tests/fixtures/session-lock.c"), str(root / "ext-session-lock-protocol.c"), "-o", str(root / "locker"), *flags], check=True)
|
||||
runtime = root / "runtime"
|
||||
runtime.mkdir(mode=0o700)
|
||||
config = root / "config/ferese/config.kdl"
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\nstatus { keybinding-guide #false; }\n')
|
||||
host_runtime = os.environ["XDG_RUNTIME_DIR"]
|
||||
display = Path(os.environ["WAYLAND_DISPLAY"])
|
||||
if not display.is_absolute():
|
||||
display = Path(host_runtime) / display
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), XDG_DATA_HOME=str(root / "data"), WAYLAND_DISPLAY=str(display), PIPEWIRE_RUNTIME_DIR=host_runtime, FERESE_ENABLE_SCREENCOPY="1")
|
||||
env.pop("WAYLAND_SOCKET", None)
|
||||
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
|
||||
log = (root / "test.log").open("w")
|
||||
try:
|
||||
compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
|
||||
processes.append(compositor)
|
||||
socket = runtime / "ferese/control.sock"
|
||||
wait_for(socket.exists, "Nested compositor did not start")
|
||||
admin = IPC(socket)
|
||||
sockets = [path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")]
|
||||
env["WAYLAND_DISPLAY"] = str(sockets[0])
|
||||
target = subprocess.Popen([str(root / "client"), "resize"], env=env, stdin=subprocess.PIPE, stdout=log, stderr=log, start_new_session=True)
|
||||
processes.append(target)
|
||||
windows = wait_for(lambda: [window for window in admin.call("get-windows") if window["capture_width"] == 640], "Window fixture did not map; private manager must be hidden from ordinary clients")
|
||||
target_id = windows[0]["id"]
|
||||
cover = subprocess.Popen([str(root / "client"), "cover"], env=env, stdout=log, stderr=log, start_new_session=True)
|
||||
processes.append(cover)
|
||||
wait_for(lambda: len(admin.call("get-windows")) == 2, "Occluding window did not map")
|
||||
helper = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream-window", str(target_id), "hidden"], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True)
|
||||
processes.append(helper)
|
||||
self.assertTrue(select.select([helper.stdout], [], [], 10)[0], "Stream did not announce a PipeWire node")
|
||||
ready = json.loads(helper.stdout.readline())
|
||||
self.assertEqual(ready["logical_size"], [640, 480])
|
||||
pipeline = Gst.parse_launch(f'pipewiresrc path={ready["node"]} ! video/x-raw,format=BGRx ! appsink name=frames sync=false max-buffers=1 drop=true')
|
||||
sink = pipeline.get_by_name("frames")
|
||||
pipeline.set_state(Gst.State.PLAYING)
|
||||
|
||||
def frame(expected=None):
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline:
|
||||
sample = sink.emit("try-pull-sample", Gst.SECOND)
|
||||
if sample is None:
|
||||
continue
|
||||
caps = sample.get_caps().get_structure(0)
|
||||
size = (caps.get_value("width"), caps.get_value("height"))
|
||||
if expected and size != expected:
|
||||
continue
|
||||
buffer = sample.get_buffer()
|
||||
pixels = buffer.extract_dup(0, buffer.get_size())
|
||||
width, height = size
|
||||
self.assertEqual(tuple(pixels[((height // 4) * width + width // 2) * 4:][:3]), (0, 0, 255))
|
||||
self.assertEqual(tuple(pixels[((height * 3 // 4) * width + width // 2) * 4:][:3]), (255, 0, 0))
|
||||
return size
|
||||
message = pipeline.get_bus().pop_filtered(Gst.MessageType.ERROR)
|
||||
self.fail(f"Missing expected frame {expected}; pipeline error: {message.parse_error() if message else None}")
|
||||
|
||||
original = frame()
|
||||
self.assertEqual(original, (ready["width"], ready["height"]))
|
||||
admin.call("workspace", {"index": 2})
|
||||
time.sleep(0.4)
|
||||
frame(original)
|
||||
self.assertIsNone(helper.poll(), "Inactive workspace stopped the stream")
|
||||
target.stdin.write(b"r")
|
||||
target.stdin.flush()
|
||||
scale = ready["width"] / 640
|
||||
resized = (round(480 * scale), round(320 * scale))
|
||||
frame(resized)
|
||||
self.assertIsNone(helper.poll(), "Resize stopped the stream")
|
||||
target.terminate()
|
||||
target.wait(timeout=3)
|
||||
wait_for(lambda: helper.poll() is not None, "Closing the selected window left capture running")
|
||||
self.assertIsNone(cover.poll(), "Unrelated window was closed")
|
||||
pipeline.set_state(Gst.State.NULL)
|
||||
pipeline = None
|
||||
cover_id = next(window["id"] for window in admin.call("get-windows") if window["title"] == "Occluding window")
|
||||
paused = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream-window", str(cover_id), "hidden"], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True)
|
||||
processes.append(paused)
|
||||
self.assertTrue(select.select([paused.stdout], [], [], 10)[0])
|
||||
self.assertGreater(json.loads(paused.stdout.readline())["width"], 0)
|
||||
locker = subprocess.Popen([str(root / "locker")], env=env, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True)
|
||||
processes.append(locker)
|
||||
self.assertTrue(select.select([locker.stdout], [], [], 5)[0], "Nested lock request did not complete")
|
||||
self.assertIn(locker.stdout.readline().strip(), ("locked", "requested"))
|
||||
wait_for(lambda: paused.poll() is not None, "Lock did not end a paused window stream")
|
||||
print("PASS: isolated pixels, private manager, upright video, inactive workspace, resize renegotiation, closure and paused lock revocation")
|
||||
except Exception:
|
||||
print((root / "test.log").read_text())
|
||||
raise
|
||||
finally:
|
||||
if pipeline:
|
||||
pipeline.set_state(Gst.State.NULL)
|
||||
if admin:
|
||||
admin.close()
|
||||
for process in reversed(processes):
|
||||
if process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
for process in processes:
|
||||
for pipe in (process.stdin, process.stdout, process.stderr):
|
||||
if pipe:
|
||||
pipe.close()
|
||||
log.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user