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