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:
Samuel Onoja committed 2026-09-30 04:02:33 +01:00
1 parent 56a888adb3
commit baca852ee9
26 files changed
+1307 -139

No files matched your search

Generated
+2
View File
@@ -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",
+25
View File
@@ -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)]
+1
View File
@@ -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::{
+17 -6
View File
@@ -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
+388
View File
@@ -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);
}
}
+2
View File
@@ -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),
}))
+1
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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())])
}
}
+3 -2
View File
@@ -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
+2
View File
@@ -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");
}
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+46
View File
@@ -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]
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+43
View File
@@ -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
View File
@@ -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, &registry_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
View File
@@ -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, &registry_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;
}
+3 -1
View File
@@ -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()