diff --git a/Cargo.lock b/Cargo.lock index f459429..3c92adc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 1824b56..69e2433 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -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>, + ) -> Result { + 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)] diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 2726362..b0d8fe2 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -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::{ diff --git a/compositor/src/handlers/screencopy.rs b/compositor/src/handlers/screencopy.rs index 040a4da..4bd1f96 100644 --- a/compositor/src/handlers/screencopy.rs +++ b/compositor/src/handlers/screencopy.rs @@ -28,10 +28,11 @@ const BYTES_PER_PIXEL: usize = 4; #[derive(Debug)] pub(crate) struct FrameData { - output: Option, - region: Rectangle, - overlay_cursor: bool, - used: Mutex, + pub(super) output: Option, + pub(super) region: Rectangle, + pub(super) overlay_cursor: bool, + pub(super) used: Mutex, + pub(super) snapshot: Mutex>, } #[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::(3, ()); + super::window_capture::init_global(display); tracing::info!("authorized screencopy is enabled for this session"); } } @@ -235,7 +237,9 @@ impl Dispatch> 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) -> bool { +pub(super) fn valid_shm_buffer(buffer: &WlBuffer, expected: Size) -> bool { with_buffer_contents_mut(buffer, |_, length, data| { let stride = expected.w.checked_mul(BYTES_PER_PIXEL as i32); let required = data diff --git a/compositor/src/handlers/window_capture.rs b/compositor/src/handlers/window_capture.rs new file mode 100644 index 0000000..2fd9b1f --- /dev/null +++ b/compositor/src/handlers/window_capture.rs @@ -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>, + bytes: usize, +} + +impl Permit { + fn reserve(budget: &Arc>, bytes: usize) -> Option { + 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>, +} + +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::( + 1, + Global { + budget: Arc::default(), + }, + ); +} + +impl GlobalDispatch for Ferese { + fn can_view(client: Client, _: &Global) -> bool { + client + .get_data::() + .is_some_and(|state| { + state + .capabilities + .contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE) + }) + } + + fn bind( + _: &mut Self, + _: &DisplayHandle, + _: &Client, + resource: New, + global: &Global, + data_init: &mut DataInit<'_, Self>, + ) { + data_init.init(resource, global.budget.clone()); + } +} + +impl Dispatch>> for Ferese { + fn request( + state: &mut Self, + _: &Client, + manager: &FereseWindowCaptureManagerV1, + request: manager::Request, + budget: &Arc>, + _: &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>, + busy: &mut bool, + ) -> Option { + 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 = + 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::>(); + 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); + } +} diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 378cd58..5c71b98 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -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), })) diff --git a/compositor/src/private_client.rs b/compositor/src/private_client.rs index 2102534..d23ed20 100644 --- a/compositor/src/private_client.rs +++ b/compositor/src/private_client.rs @@ -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 diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 7643399..eda2937 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -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"); } diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 666a26e..6878c30 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -176,10 +176,38 @@ pub(crate) fn capture_window_buffer( geometry: Rectangle, scale: f64, ) -> Result { - 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, + scale: f64, + cursor: Option<(&Ferese, Rectangle)>, +) -> Result { + 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())]) } } diff --git a/crates/ferese-protocols/Cargo.toml b/crates/ferese-protocols/Cargo.toml index 5cddd5c..dd3f6ad 100644 --- a/crates/ferese-protocols/Cargo.toml +++ b/crates/ferese-protocols/Cargo.toml @@ -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 diff --git a/crates/ferese-protocols/build.rs b/crates/ferese-protocols/build.rs index da36b21..c12da73 100644 --- a/crates/ferese-protocols/build.rs +++ b/crates/ferese-protocols/build.rs @@ -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"); } diff --git a/crates/ferese-protocols/src/lib.rs b/crates/ferese-protocols/src/lib.rs index 17bdadf..8c6f044 100644 --- a/crates/ferese-protocols/src/lib.rs +++ b/crates/ferese-protocols/src/lib.rs @@ -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" + ); + } + } + } +} diff --git a/docs/portals.md b/docs/portals.md index c9c3dd8..09cc3e0 100644 --- a/docs/portals.md +++ b/docs/portals.md @@ -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 +``` diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 1df6f8c..c53152e 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -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 diff --git a/portal/Cargo.toml b/portal/Cargo.toml index ca60003..2e0c0c9 100644 --- a/portal/Cargo.toml +++ b/portal/Cargo.toml @@ -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" diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 64a7099..c211fc2 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -78,6 +78,7 @@ struct Session { phase: Phase, multiple: bool, cursor: bool, + source_types: u32, bar_controlled: bool, persist_mode: u32, restore: Option, @@ -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, 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::>(); 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::>(), + 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::>(); + 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::, _>>()?; Ok((children, streams, reply)) } +fn window_metadata(source: &Source, ready: &Ready) -> Result { + 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, 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::>(); + let names = sources + .iter() + .map(|source| source.name.clone()) + .collect::>(); + 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 diff --git a/portal/src/capture.rs b/portal/src/capture.rs index b6d0a9c..d52fa7b 100644 --- a/portal/src/capture.rs +++ b/portal/src/capture.rs @@ -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 { + 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, manager: Option, + window_manager: Option, shm: Option, buffer: Option, result: Option>, 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 { self.state .outputs @@ -156,31 +175,53 @@ impl Capture { stop: &AtomicBool, mut pixels: Vec, ) -> Result { - 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::().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 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 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 for State { + fn event( + state: &mut Self, + _: &window_manager::FereseWindowCaptureManagerV1, + event: window_manager::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 { diff --git a/portal/src/desktop.rs b/portal/src/desktop.rs index aaba185..6d63deb 100644 --- a/portal/src/desktop.rs +++ b/portal/src/desktop.rs @@ -462,6 +462,38 @@ fn window_sources(windows: &serde_json::Value) -> Result Result, 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::>(); + 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, 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] diff --git a/portal/src/main.rs b/portal/src/main.rs index 7c16ca6..fbef948 100644 --- a/portal/src/main.rs +++ b/portal/src/main.rs @@ -20,6 +20,10 @@ fn main() -> Result<(), Box> { [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", diff --git a/portal/src/picker.rs b/portal/src/picker.rs index fcf3987..eb495fe 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -71,9 +71,7 @@ pub fn run() -> Result<(), Box> { } 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), diff --git a/portal/src/stream.rs b/portal/src/stream.rs index 970a40b..43df624 100644 --- a/portal/src/stream.rs +++ b/portal/src/stream.rs @@ -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, 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 { .into_inner() } +fn video_format(width: u32, height: u32) -> Vec { + 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(()) } diff --git a/protocols/ferese-window-capture-v1.xml b/protocols/ferese-window-capture-v1.xml new file mode 100644 index 0000000..13e3ef5 --- /dev/null +++ b/protocols/ferese-window-capture-v1.xml @@ -0,0 +1,43 @@ + + + + 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. + + + + + + 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. + + + + + + + + + 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. + + + + + + Sent before the frame's buffer event. These dimensions describe this + captured frame, even if the window resizes before it is copied. + + + + + + + diff --git a/scripts/tests/fixtures/session-lock.c b/scripts/tests/fixtures/session-lock.c new file mode 100644 index 0000000..6b9694d --- /dev/null +++ b/scripts/tests/fixtures/session-lock.c @@ -0,0 +1,44 @@ +#include +#include +#include +#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; +} diff --git a/scripts/tests/fixtures/window-capture.c b/scripts/tests/fixtures/window-capture.c index b78cedd..cacd34f 100644 --- a/scripts/tests/fixtures/window-capture.c +++ b/scripts/tests/fixtures/window-capture.c @@ -1,6 +1,7 @@ /* Two overlapping clients with distinct colors for isolated capture tests. */ #define _GNU_SOURCE #include +#include #include #include #include @@ -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; } diff --git a/scripts/tests/test_portal_contracts.py b/scripts/tests/test_portal_contracts.py index 359cc7b..a76c6a5 100644 --- a/scripts/tests/test_portal_contracts.py +++ b/scripts/tests/test_portal_contracts.py @@ -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" diff --git a/scripts/tests/test_window_stream_isolated.py b/scripts/tests/test_window_stream_isolated.py new file mode 100644 index 0000000..8a8c805 --- /dev/null +++ b/scripts/tests/test_window_stream_isolated.py @@ -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()