From 56a888adb3ebddf90502de964860557c204a2bdf Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 30 Sep 2026 03:12:13 +0100 Subject: [PATCH] Restore explicitly approved ScreenCast display permissions --- docs/portals.md | 14 +- docs/screen-sharing.md | 11 +- portal/src/backend.rs | 241 +++++++++++++++---- portal/src/capture.rs | 36 +++ portal/src/desktop.rs | 2 + portal/src/main.rs | 8 +- portal/src/picker.rs | 66 +++++- portal/src/restore.rs | 314 +++++++++++++++++++++++++ portal/src/stream.rs | 9 +- scripts/tests/test_portal_contracts.py | 17 ++ scripts/tests/test_restore_isolated.py | 216 +++++++++++++++++ 11 files changed, 882 insertions(+), 52 deletions(-) create mode 100644 portal/src/restore.rs create mode 100644 scripts/tests/test_restore_isolated.py diff --git a/docs/portals.md b/docs/portals.md index 8872a79..c9c3dd8 100644 --- a/docs/portals.md +++ b/docs/portals.md @@ -47,7 +47,9 @@ standard file, print and application dialogs. Install both backends. for acknowledgements for up to one second. Power confirmations list blockers and recheck them before acting; authentication failure cancels the query. UserSwitch is recorded, but Ferese currently has no user-switch operation. -- **ScreenCast:** monitor sharing with a native picker and PipeWire streams. +- **ScreenCast:** monitor sharing with a native picker, PipeWire streams and explicit opt-in + saved permissions. The frontend stores and revokes saved choices. Invalid + restore data or changed display identity requires fresh consent. See [screen sharing](screen-sharing.md) for lifetime and recording details. Native request dialogs use Ferese's theme and are floating windows. Screenshot, @@ -71,7 +73,7 @@ Matching D-Bus signatures does not establish complete feature parity. | Settings | Native, including appearance change signals | | Screenshot | Native screen/area/window/active-window capture and PickColor | | Wallpaper | Native desktop, lock-screen and combined targets | -| ScreenCast | Native monitors with logical geometry metadata; window sources and persistence remain missing | +| ScreenCast | Native v4 monitors with logical geometry and opt-in saved permissions; window sources remain missing | | Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK | | Background | Native application state, per-instance consent and XDG autostart | | Usb | Native consent; standard frontend handles enumeration and device descriptors | @@ -133,3 +135,11 @@ FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py The shell now requires version 4 of Ferese's shell protocol; rebuild the shell and compositor together when upgrading. + +Check restored sharing, mode downgrade, stale-data fallback, output-generation +validation and frontend-disconnect cleanup with synthetic monitor identities on +an isolated bus and nested compositor: + +```sh +FERESE_TEST_RESTORE=1 python3 scripts/tests/test_restore_isolated.py +``` diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 15382f5..1df6f8c 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -38,7 +38,14 @@ windows accessible after display selection. The command-line recorder keeps the sharing window when it is not launched by the shell. Press **Stop sharing** or close the sharing window to revoke an ordinary application’s session. Closing the requesting application, locking Ferese, disconnecting the selected display, or changing its capture size -also ends sharing. A new request needs fresh consent; selections are not saved. +also ends sharing. By default, each request asks for consent. If an application requests saved +permissions, the picker offers an **Allow without asking** switch, off by default. +Permissions can last while the application runs or until revoked, as requested +by the app. The standard portal frontend owns permission storage and revocation. +A changed or missing display, different app, or changed cursor options asks again. +Displays without a reliable EDID identity, including nested previews, always need +fresh consent. Stopping a stream ends that stream; it does not erase a previously +saved permission. Everything visible on a shared display, including notifications, is included. ## Installation @@ -75,7 +82,7 @@ configuration is preserved during installation. - Wayland parent-window relationships for the consent picker. - Bounded frame storage and reusable shared-memory buffers. - Cancellation during selection, an explicit stop control, and process cleanup. -- ScreenCast backend version 3, without persistent or automatic consent. +- ScreenCast backend version 4 with explicit opt-in saved display permissions. Window-only sharing, region selection, and audio capture in Ferese’s recorder are not implemented yet. Other recording applications can handle audio separately. diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 3479dbd..64a7099 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -1,19 +1,15 @@ //! ScreenCast portal backend. Only the portal frontend may create sessions; -//! consent never survives a session, a process restart, or a capture failure. +//! saved monitor consent is restored only through the frontend permission store. use crate::{ capture::{Capture, Source}, picker::Prompt, stream::Ready, }; -use std::{ - collections::HashMap, - process::Stdio, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; +use std::{collections::HashMap, process::Stdio, time::Duration}; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::{Child, Command}, @@ -25,43 +21,49 @@ use zbus::{ object_server::SignalEmitter, zvariant::{OwnedObjectPath, OwnedValue, Value}, }; -pub(crate) type Options = HashMap; +pub(crate) type Options = HashMap; pub(crate) static CONFIG_TRANSACTION: std::sync::Mutex<()> = std::sync::Mutex::new(()); +const FRONTEND: &str = "org.freedesktop.portal.Desktop"; +const PATH: &str = "/org/freedesktop/portal/desktop"; #[derive(Debug)] enum StartError { Cancelled, Failed(String), } + impl From for StartError { fn from(message: String) -> Self { Self::Failed(message) } } + impl From<&str> for StartError { fn from(message: &str) -> Self { Self::Failed(message.into()) } } -const FRONTEND: &str = "org.freedesktop.portal.Desktop"; -const PATH: &str = "/org/freedesktop/portal/desktop"; + #[derive(Default)] pub(crate) struct Cancel { pub(crate) stopped: AtomicBool, changed: Notify, } + impl Cancel { pub(crate) fn stop(&self) { self.stopped.store(true, Ordering::SeqCst); self.changed.notify_one(); } + pub(crate) async fn wait(&self) { if !self.stopped.load(Ordering::SeqCst) { self.changed.notified().await; } } } + #[derive(Clone, Copy, Debug, PartialEq)] enum Phase { Created, @@ -69,6 +71,7 @@ enum Phase { Starting, Running, } + struct Session { owner: String, app: String, @@ -76,15 +79,20 @@ struct Session { multiple: bool, cursor: bool, bar_controlled: bool, + persist_mode: u32, + restore: Option, cancel: Arc, } + #[derive(Clone, Default)] pub struct Backend { sessions: Arc>>, } + // This private interface only transfers stop controls to the shell's own recorder. // The ordinary portal picker and all session revocation rules still apply. struct RecorderControl(Backend); + #[zbus::interface(name = "org.ferese.ScreenRecorder")] impl RecorderControl { async fn use_bar_controls( @@ -121,12 +129,14 @@ impl RecorderControl { Ok(()) } } + fn owns_session(sender: &str, path: &str) -> bool { let Some(sender) = sender.strip_prefix(':') else { return false; }; path.starts_with(&format!("{PATH}/session/{}/", sender.replace('.', "_"))) } + fn shell_recorder(pid: u32) -> bool { use std::os::unix::fs::MetadataExt; let matches_binary = |pid: u32, name: &str| -> Option { @@ -166,11 +176,14 @@ pub(crate) async fn authorize( "Use the desktop portal".into(), )); } + Ok(sender.to_string()) } + fn error(message: &str) -> zbus::fdo::Error { zbus::fdo::Error::InvalidArgs(message.into()) } + fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> { let number = |key: &str, default| { options @@ -194,8 +207,10 @@ fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> { .transpose() .map_err(|_| error("Invalid multiple option"))? .unwrap_or(false); + Ok((multiple, cursor == 2)) } + impl Backend { async fn end(&self, connection: &Connection, path: &str) { let session = self.sessions.lock().await.remove(path); @@ -216,20 +231,24 @@ impl Backend { } } } + #[zbus::interface(name = "org.freedesktop.impl.portal.ScreenCast")] impl Backend { #[zbus(property, name = "version")] fn version(&self) -> u32 { - 3 + 4 } + #[zbus(property)] fn available_source_types(&self) -> u32 { 1 } + #[zbus(property)] fn available_cursor_modes(&self) -> u32 { 3 } + async fn create_session( &self, _handle: OwnedObjectPath, @@ -276,11 +295,14 @@ impl Backend { multiple: false, cursor: false, bar_controlled: false, + persist_mode: 0, + restore: None, cancel: Arc::default(), }, ); Ok((0, Options::new())) } + async fn select_sources( &self, _handle: OwnedObjectPath, @@ -291,19 +313,25 @@ impl Backend { #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<(u32, Options)> { let owner = authorize(connection, &header).await?; - let settings = source_options(&options); + let settings = source_options(&options).and_then(|(multiple, cursor)| { + Ok((multiple, cursor, crate::restore::persist_mode(&options)?)) + }); let mut sessions = self.sessions.lock().await; let session = sessions .get_mut(session_handle.as_str()) .ok_or_else(|| error("Unknown session"))?; + if session.owner != owner || session.app != app_id || !matches!(session.phase, Phase::Created | Phase::Selected) { return Err(error("Invalid session state")); } + match settings { - Ok((multiple, cursor)) => { + Ok((multiple, cursor, persist_mode)) => { + session.persist_mode = persist_mode; + session.restore = crate::restore::Restore::read(&options); session.multiple = multiple; session.cursor = cursor; session.phase = Phase::Selected; @@ -316,6 +344,7 @@ impl Backend { } } } + #[allow(clippy::too_many_arguments)] // ScreenCast D-Bus method signature. async fn start( &self, @@ -328,25 +357,31 @@ impl Backend { #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<(u32, Options)> { let owner = authorize(connection, &header).await?; + if !handle .as_str() .starts_with("/org/freedesktop/portal/desktop/request/") { return Err(error("Invalid request path")); } - let (multiple, cursor, bar_controlled, cancel) = { + + let (multiple, cursor, bar_controlled, persist_mode, restore, cancel) = { let mut sessions = self.sessions.lock().await; let session = sessions .get_mut(session_handle.as_str()) .ok_or_else(|| error("Unknown session"))?; + if session.owner != owner || session.app != app_id || session.phase != Phase::Selected { return Err(error("Select sources before starting a session")); } + session.phase = Phase::Starting; ( session.multiple, session.cursor, session.bar_controlled, + session.persist_mode, + session.restore.clone(), session.cancel.clone(), ) }; @@ -360,6 +395,7 @@ impl Backend { }, ) .await; + match inserted { Ok(true) => (), result => { @@ -370,32 +406,46 @@ impl Backend { }); } } + + let sharing = start_streams( + &app_id, + &parent_window, + multiple, + cursor, + persist_mode, + restore, + bar_controlled, + ); let result = tokio::select! { _ = cancel.wait() => Err(StartError::Cancelled), - result = tokio::time::timeout(Duration::from_secs(120), start_streams(if bar_controlled { "Ferese" } else { &app_id }, &parent_window, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))), + result = tokio::time::timeout(Duration::from_secs(120), sharing) => { + result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))) + } }; let _ = connection .object_server() .remove::(handle) .await; + match result { - Ok((children, streams)) if !cancel.stopped.load(Ordering::SeqCst) => { + Ok((children, streams, mut reply)) if !cancel.stopped.load(Ordering::SeqCst) => { if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) { session.phase = Phase::Running; } let backend = self.clone(); let connection = connection.clone(); let path = session_handle.to_string(); + tokio::spawn(async move { supervise(children, cancel).await; backend.end(&connection, &path).await; }); - let mut reply = Options::new(); reply.insert( "streams".into(), OwnedValue::try_from(Value::from(streams)) .map_err(|e| error(&e.to_string()))?, ); + Ok((0, reply)) } result => { @@ -412,27 +462,37 @@ impl Backend { } } } + pub(crate) struct Request { pub(crate) cancel: Arc, pub(crate) owner: String, } + #[zbus::interface(name = "org.freedesktop.impl.portal.Request")] impl Request { fn close(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) { return Err(zbus::fdo::Error::AccessDenied("Wrong request owner".into())); } + self.cancel.stop(); Ok(()) } } + struct SessionObject { backend: Backend, path: String, owner: String, } + #[zbus::interface(name = "org.freedesktop.impl.portal.Session")] impl SessionObject { + #[zbus(property, name = "version")] + fn version(&self) -> u32 { + 1 + } + async fn close( &self, #[zbus(connection)] connection: &Connection, @@ -441,12 +501,15 @@ impl SessionObject { if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) { return Err(zbus::fdo::Error::AccessDenied("Wrong session owner".into())); } + self.backend.end(connection, &self.path).await; Ok(()) } + #[zbus(signal)] async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; } + // All helpers die with the service, including a picker awaiting user input. pub(crate) fn child_command() -> Result { child_command_for(std::env::current_exe().map_err(|e| e.to_string())?) @@ -467,9 +530,11 @@ pub(crate) fn child_command_for(program: impl AsRef) -> Result< Ok(()) }); } + command.kill_on_drop(true); Ok(command) } + async fn picker(prompt: &Prompt) -> Result { let mut child = child_command()? .arg("--picker") @@ -485,44 +550,104 @@ async fn picker(prompt: &Prompt) -> Result { .await .map_err(|e| e.to_string())?; drop(input); + Ok(child) } + async fn start_streams( app: &str, parent: &str, multiple: bool, cursor: bool, -) -> Result<(Vec, Vec<(u32, Options)>), StartError> { - let sources = tokio::task::spawn_blocking(|| { - Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources()) - }) - .await - .map_err(|e| e.to_string())??; - let prompt = Prompt { - app: app.to_owned(), - sources, - multiple, - parent: parent.into(), - window_capture: false, + requested_persistence: u32, + restore: Option, + bar_controlled: bool, +) -> Result<(Vec, Vec<(u32, Options)>, Options), StartError> { + let mut discovery = tokio::task::spawn_blocking(|| Capture::connect(&AtomicBool::new(false))) + .await + .map_err(|e| e.to_string())??; + let sources = discovery.sources(); + let outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?; + let rememberable = sources + .iter() + .filter(|source| { + crate::restore::Restore::create(app, cursor, std::slice::from_ref(*source), &outputs, 1) + .is_some() + }) + .map(|source| source.name.clone()) + .collect::>(); + let restored = restore.and_then(|restore| { + let selected = restore.resolve(app, cursor, multiple, &sources, &outputs)?; + Some((selected, restore.effective_mode(requested_persistence))) + }); + let (selected, persist_mode) = if let Some(restored) = restored { + restored + } else { + let prompt = Prompt { + app: if bar_controlled { "Ferese" } else { app }.to_owned(), + sources, + multiple, + rememberable: rememberable.clone(), + parent: parent.into(), + window_capture: false, + persist_mode: if rememberable.is_empty() { + 0 + } else { + requested_persistence + }, + }; + let picker = picker(&prompt).await?; + let picker_pid = picker.id().ok_or("Missing picker process ID")?; + let selection = picker.wait_with_output().await.map_err(|e| e.to_string())?; + if !selection.status.success() { + return Err(StartError::Cancelled); + } + let selection: crate::picker::Selection = + serde_json::from_slice(&selection.stdout).map_err(|_| StartError::Cancelled)?; + if selection.persist_mode > prompt.persist_mode { + return Err("Invalid persistence selection".into()); + } + let selected = validate_selection(&prompt.sources, &selection.names, multiple)?; + crate::desktop::wait_for_surface_removal(picker_pid).await?; + + (selected, selection.persist_mode) }; - let picker = picker(&prompt).await?; - let picker_pid = picker.id().ok_or("Missing picker process ID")?; - let selection = picker.wait_with_output().await.map_err(|e| e.to_string())?; - if !selection.status.success() { - return Err(StartError::Cancelled); + + let mut reply = Options::new(); + let fresh_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?; + + if !crate::restore::selection_unchanged(&selected, &outputs, &fresh_outputs) { + return Err("A selected display changed while approval was pending".into()); } - let names: Vec = - serde_json::from_slice(&selection.stdout).map_err(|_| StartError::Cancelled)?; - let selected = validate_selection(&prompt.sources, &names, multiple)?; - crate::desktop::wait_for_surface_removal(picker_pid).await?; + + let approved = crate::restore::Restore::create(app, cursor, &selected, &outputs, persist_mode); + let effective_persistence = if let Some(approved) = approved.filter(|_| persist_mode > 0) { + reply.insert("restore_data".into(), approved.value()?); + persist_mode + } else { + 0 + }; + reply.insert("persist_mode".into(), effective_persistence.into()); + + let generations = selected + .iter() + .map(|source| { + discovery + .generation(&source.name) + .map(|generation| (source.name.clone(), generation)) + .ok_or("A selected display disconnected before startup") + }) + .collect::, _>>()?; let mut children = Vec::new(); let mut streams = Vec::new(); - for source in &selected { + + for (source, (_, generation)) in selected.iter().zip(&generations) { let mut child = child_command()? .args([ "--stream", &source.name, if cursor { "embedded" } else { "hidden" }, + &generation.to_string(), ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -541,26 +666,39 @@ async fn start_streams( .map_err(|e| e.to_string())?; let ready: Ready = serde_json::from_str(&line).map_err(|_| "PipeWire stream could not start")?; + if ready.node == u32::MAX { return Err("Invalid PipeWire node".into()); } + streams.push((source.name.clone(), ready)); children.push(child); } - let outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?; + + tokio::task::spawn_blocking(move || discovery.validate_sources(&generations)) + .await + .map_err(|error| error.to_string())??; + + let final_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?; + if !crate::restore::selection_unchanged(&selected, &outputs, &final_outputs) { + return Err("A selected display changed during stream startup".into()); + } + if children .iter_mut() .any(|child| !matches!(child.try_wait(), Ok(None))) { return Err("A selected stream ended during startup".into()); } + let streams = streams .into_iter() .map(|(name, ready)| { - monitor_metadata(&outputs, &name, &ready).map(|metadata| (ready.node, metadata)) + monitor_metadata(&final_outputs, &name, &ready).map(|metadata| (ready.node, metadata)) }) .collect::, _>>()?; - Ok((children, streams)) + + Ok((children, streams, reply)) } fn monitor_metadata( @@ -598,15 +736,18 @@ fn monitor_metadata( .and_then(|value| u32::try_from(value).ok()) .ok_or("Invalid physical output height")?, ); + if matches!( output["transform"].as_str(), Some("rotate_90" | "rotate_270" | "flipped_90" | "flipped_270") ) { physical = (physical.1, physical.0); } + if (ready.width, ready.height) != physical { return Err("Selected monitor changed mode during startup".into()); } + Ok(HashMap::from([ ("source_type".into(), 1u32.into()), ( @@ -632,6 +773,7 @@ fn validate_selection( if names.is_empty() || names.len() > sources.len() || (!multiple && names.len() != 1) { return Err("Invalid display selection".into()); } + let mut selected = Vec::new(); for name in names { if selected.iter().any(|s: &Source| &s.name == name) { @@ -645,8 +787,10 @@ fn validate_selection( .clone(), ); } + Ok(selected) } + async fn supervise(mut children: Vec, cancel: Arc) { loop { if cancel.stopped.load(Ordering::SeqCst) @@ -658,11 +802,14 @@ async fn supervise(mut children: Vec, cancel: Arc) { } tokio::time::sleep(Duration::from_millis(100)).await; } + cancel.stop(); + for child in &mut children { let _ = child.kill().await; } } + pub async fn run() -> Result<(), Box> { let backend = Backend::default(); let settings = crate::settings::Settings::new(); @@ -688,11 +835,13 @@ pub async fn run() -> Result<(), Box> { )? .build() .await?; + tokio::spawn(settings.watch(connection.clone())); tokio::spawn(background.watch(connection.clone())); tokio::spawn(lockdown.watch(connection.clone())); tokio::spawn(inhibit.clone().watch(connection.clone())); tokio::spawn(inhibit.clone().watch_logind()); + // Frontend death must revoke every stream, even if Session.Close never arrives. loop { tokio::time::sleep(Duration::from_secs(1)).await; @@ -707,6 +856,7 @@ pub async fn run() -> Result<(), Box> { requests.revoke_stale(owner.as_deref()).await; shortcuts.revoke_stale(&connection, owner.as_deref()).await; inhibit.revoke_stale(&connection, owner.as_deref()).await; + let stale: Vec<_> = backend .sessions .lock() @@ -720,6 +870,7 @@ pub async fn run() -> Result<(), Box> { } } } + #[cfg(test)] mod tests { use super::*; @@ -739,6 +890,7 @@ mod tests { )); assert!(!shell_recorder(std::process::id())); } + #[test] fn rejects_unimplemented_sources_and_cursor_modes() { assert_eq!(source_options(&Options::new()).unwrap(), (false, false)); @@ -753,6 +905,7 @@ mod tests { } assert!(source_options(&HashMap::from([("cursor_mode".into(), 4u32.into())])).is_err()); } + #[test] fn monitor_metadata_uses_logical_fractional_scale_geometry() { let outputs = serde_json::json!([{"name":"display","enabled":true,"x":-1440,"y":20,"width":2560,"height":1440,"scale":1.5,"current_mode":{"width":3840,"height":2160}}]); diff --git a/portal/src/capture.rs b/portal/src/capture.rs index ee1a16f..b6d0a9c 100644 --- a/portal/src/capture.rs +++ b/portal/src/capture.rs @@ -73,6 +73,7 @@ pub struct Capture { connection: Connection, queue: EventQueue, state: State, + selected_output: Option, } impl Capture { @@ -84,6 +85,7 @@ impl Capture { connection, queue, state: State::default(), + selected_output: None, }; // Two syncs deliver registry bindings and output metadata without an // uninterruptible roundtrip on a stalled compositor. @@ -107,6 +109,32 @@ impl Capture { Ok(()) } + pub fn generation(&self, name: &str) -> Option { + self.state + .outputs + .iter() + .find(|output| output.source.name == name) + .map(|output| output.global) + } + + pub fn pin_output(&mut self, name: &str, generation: u32) -> Result<(), String> { + if self.generation(name) != Some(generation) { + return Err("Shared monitor was replaced".into()); + } + self.selected_output = Some(generation); + Ok(()) + } + + pub fn validate_sources(&mut self, selected: &[(String, u32)]) -> Result<(), String> { + self.sync(&AtomicBool::new(false))?; + for (name, generation) in selected { + if self.generation(name) != Some(*generation) { + return Err("A selected display disconnected during approval".into()); + } + } + Ok(()) + } + pub fn sources(&self) -> Vec { self.state .outputs @@ -135,6 +163,14 @@ impl Capture { .find(|o| o.source.name == name) .ok_or("Shared monitor disconnected")?; + if self + .selected_output + .is_some_and(|selected| selected != output.global) + { + return Err("Shared monitor was replaced".into()); + } + self.selected_output = Some(output.global); + let transform = output.transform; self.state.result = None; self.state.flipped = false; diff --git a/portal/src/desktop.rs b/portal/src/desktop.rs index ad2f1e2..aaba185 100644 --- a/portal/src/desktop.rs +++ b/portal/src/desktop.rs @@ -474,6 +474,8 @@ async fn select_window(app: &str, parent: &str) -> Result, String> { multiple: false, parent: parent.into(), window_capture: true, + persist_mode: 0, + rememberable: Vec::new(), }; let mut child = crate::backend::child_command()? .arg("--picker") diff --git a/portal/src/main.rs b/portal/src/main.rs index 72113b3..7c16ca6 100644 --- a/portal/src/main.rs +++ b/portal/src/main.rs @@ -8,6 +8,7 @@ mod lockdown; mod parent; mod permissions; mod picker; +mod restore; mod settings; mod shortcuts; mod stream; @@ -17,8 +18,13 @@ fn main() -> Result<(), Box> { match args.as_slice() { [flag, name, cursor] if flag == "--stream" => { - stream::run(name.clone(), cursor == "embedded") + stream::run(name.clone(), cursor == "embedded", None) } + [flag, name, cursor, generation] if flag == "--stream" => stream::run( + name.clone(), + cursor == "embedded", + Some(generation.parse()?), + ), [flag] if flag == "--sources" => { let capture = capture::Capture::connect(&std::sync::atomic::AtomicBool::new(false))?; println!("{}", serde_json::to_string(&capture.sources())?); diff --git a/portal/src/picker.rs b/portal/src/picker.rs index adc6fed..fcf3987 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -18,6 +18,17 @@ pub struct Prompt { pub parent: String, #[serde(default)] pub window_capture: bool, + #[serde(default)] + pub persist_mode: u32, + #[serde(default)] + pub rememberable: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Selection { + pub names: Vec, + pub persist_mode: u32, } #[derive(Clone, Debug)] @@ -33,6 +44,7 @@ enum Message { >, ), Select(usize), + Remember, Share, Cancel, } @@ -43,6 +55,7 @@ struct Picker { parent: Option>, prompt: Prompt, selected: Vec, + remember: bool, background: cosmic::iced::Color, palette: ferese_theme::Palette, font: cosmic::font::Font, @@ -65,7 +78,9 @@ pub fn run() -> Result<(), Box> { } let (theme, font, background, palette) = appearance(); - let height = 180. + (prompt.sources.len().min(4) as f32 * 56.); + let height = 180. + + (prompt.sources.len().min(4) as f32 * 56.) + + if prompt.persist_mode > 0 { 64. } else { 0. }; cosmic::app::run::( Settings::default() .size(cosmic::iced::Size::new(400., height)) @@ -114,6 +129,7 @@ impl cosmic::Application for Picker { parent: None, prompt, selected: Vec::new(), + remember: false, background, palette, font, @@ -176,14 +192,28 @@ impl cosmic::Application for Picker { } self.selected.push(index); } + self.remember &= self.can_remember(); } + Message::Remember if self.can_remember() => self.remember = !self.remember, Message::Share if !self.selected.is_empty() => { let names: Vec<_> = self .selected .iter() .map(|i| &self.prompt.sources[*i].name) .collect(); - println!("{}", serde_json::to_string(&names).unwrap()); + if self.prompt.window_capture { + println!("{}", serde_json::to_string(&names).unwrap()); + } else { + let selection = Selection { + names: names.into_iter().cloned().collect(), + persist_mode: if self.remember && self.can_remember() { + self.prompt.persist_mode + } else { + 0 + }, + }; + println!("{}", serde_json::to_string(&selection).unwrap()); + } let _ = std::io::stdout().flush(); return cosmic::iced::exit(); } @@ -280,6 +310,28 @@ impl cosmic::Application for Picker { .size(12) .width(Length::Fill), ); + if self.prompt.persist_mode > 0 && !self.prompt.window_capture { + let label = if !self.can_remember() { + "This selection needs approval each time" + } else if self.prompt.persist_mode == 1 { + "Allow without asking while this app is running" + } else { + "Allow this selection without asking again" + }; + content = content.push( + button::custom(ferese_theme::menus::row( + label, + ferese_theme::menus::switch(self.remember, self.palette, 1.), + self.font, + )) + .class(ferese_theme::controls::navigation_style( + self.palette, + false, + )) + .width(Length::Fill) + .on_press_maybe(self.can_remember().then_some(Message::Remember)), + ); + } let mut share = button::custom( row![ self.text(if self.prompt.window_capture { @@ -311,6 +363,16 @@ impl cosmic::Application for Picker { } impl Picker { + fn can_remember(&self) -> bool { + !self.selected.is_empty() + && self.selected.len() <= 16 + && self.selected.iter().all(|index| { + self.prompt + .rememberable + .contains(&self.prompt.sources[*index].name) + }) + } + fn text<'a>( &self, content: impl Into> + 'a, diff --git a/portal/src/restore.rs b/portal/src/restore.rs new file mode 100644 index 0000000..59b5be7 --- /dev/null +++ b/portal/src/restore.rs @@ -0,0 +1,314 @@ +use crate::{backend::Options, capture::Source}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; +use zbus::zvariant::{OwnedValue, Value}; + +const VENDOR: &str = "Ferese"; +const VERSION: u32 = 1; +const MAX_DATA: usize = 16 * 1024; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Restore { + app: String, + cursor: bool, + mode: u32, + monitors: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Monitor { + name: String, + identity: String, +} + +pub(crate) fn persist_mode(options: &Options) -> zbus::fdo::Result { + let mode = options + .get("persist_mode") + .map(u32::try_from) + .transpose() + .map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid persistence option".into()))? + .unwrap_or(0); + if mode > 2 { + return Err(zbus::fdo::Error::InvalidArgs( + "Unsupported persistence mode".into(), + )); + } + Ok(mode) +} + +fn identity<'a>(outputs: &'a Json, name: &str) -> Option<&'a str> { + outputs.as_array()?.iter().find_map(|output| { + (output["name"].as_str() == Some(name) && output["enabled"] == true) + .then(|| output["identity"].as_str()) + .flatten() + .filter(|identity| { + !identity.is_empty() && identity.len() <= 512 && identity.starts_with("drm-edid:") + }) + }) +} + +pub(crate) fn selection_unchanged(sources: &[Source], before: &Json, after: &Json) -> bool { + let find = |outputs: &Json, name: &str| { + outputs + .as_array() + .and_then(|outputs| { + outputs.iter().find(|output| { + output["name"].as_str() == Some(name) && output["enabled"] == true + }) + }) + .map(|output| (output["identity"].clone(), output["id"].clone())) + }; + sources.iter().all( + |source| match (find(before, &source.name), find(after, &source.name)) { + (Some((identity, id)), Some((current_identity, current_id))) => { + if identity.is_null() { + id.as_u64().is_some() && id == current_id && current_identity.is_null() + } else { + identity == current_identity + } + } + _ => false, + }, + ) +} + +impl Restore { + pub(crate) fn read(options: &Options) -> Option { + let value = options.get("restore_data")?.try_clone().ok()?; + let (vendor, version, data): (String, u32, OwnedValue) = value.try_into().ok()?; + if vendor != VENDOR || version != VERSION { + return None; + } + let data = data.downcast_ref::<&str>().ok()?; + if data.len() > MAX_DATA { + return None; + } + let restore: Self = serde_json::from_str(data).ok()?; + if restore.app.is_empty() + || restore.app.len() > 512 + || !(1..=2).contains(&restore.mode) + || restore.monitors.is_empty() + || restore.monitors.len() > 16 + || restore.monitors.iter().any(|monitor| { + monitor.name.is_empty() + || monitor.name.len() > 512 + || monitor.identity.is_empty() + || monitor.identity.len() > 512 + }) + { + return None; + } + Some(restore) + } + + pub(crate) fn effective_mode(&self, requested: u32) -> u32 { + self.mode.min(requested) + } + + pub(crate) fn resolve( + &self, + app: &str, + cursor: bool, + multiple: bool, + sources: &[Source], + outputs: &Json, + ) -> Option> { + if self.app != app || self.cursor != cursor || (!multiple && self.monitors.len() != 1) { + return None; + } + let mut selected: Vec = Vec::new(); + for monitor in &self.monitors { + if identity(outputs, &monitor.name) != Some(monitor.identity.as_str()) + || selected.iter().any(|source| source.name == monitor.name) + { + return None; + } + selected.push( + sources + .iter() + .find(|source| source.name == monitor.name)? + .clone(), + ); + } + Some(selected) + } + + pub(crate) fn create( + app: &str, + cursor: bool, + sources: &[Source], + outputs: &Json, + mode: u32, + ) -> Option { + if app.is_empty() + || app.len() > 512 + || sources.is_empty() + || sources.len() > 16 + || !(1..=2).contains(&mode) + { + return None; + } + let monitors = sources + .iter() + .map(|source| { + Some(Monitor { + name: source.name.clone(), + identity: identity(outputs, &source.name)?.to_owned(), + }) + }) + .collect::>>()?; + Some(Self { + app: app.into(), + cursor, + mode, + monitors, + }) + } + + pub(crate) fn value(&self) -> Result { + let data = serde_json::to_string(self).map_err(|error| error.to_string())?; + if data.len() > MAX_DATA { + return Err("Saved selection is too large".into()); + } + Value::from((VENDOR.to_owned(), VERSION, Value::new(data))) + .try_to_owned() + .map_err(|error| error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source() -> Source { + Source { + name: "DP-1".into(), + label: "Display".into(), + width: 1920, + height: 1080, + x: 0, + y: 0, + scale: 1, + } + } + + #[test] + fn restore_is_bound_to_app_capture_options_and_display_identity() { + let outputs = + serde_json::json!([{"name":"DP-1", "identity":"drm-edid:123", "enabled":true}]); + let sources = [source()]; + let saved = Restore::create("org.test.App", true, &sources, &outputs, 2).unwrap(); + let options = Options::from([("restore_data".into(), saved.value().unwrap())]); + let restored = Restore::read(&options).unwrap(); + assert!( + restored + .resolve("org.test.App", true, false, &sources, &outputs) + .is_some() + ); + assert!( + restored + .resolve("org.other.App", true, false, &sources, &outputs) + .is_none() + ); + assert!( + restored + .resolve("org.test.App", false, false, &sources, &outputs) + .is_none() + ); + assert!( + restored + .resolve("org.test.App", true, false, &[], &outputs) + .is_none() + ); + let changed = + serde_json::json!([{"name":"DP-1", "identity":"drm-edid:456", "enabled":true}]); + assert!( + restored + .resolve("org.test.App", true, false, &sources, &changed) + .is_none() + ); + let disabled = + serde_json::json!([{"name":"DP-1", "identity":"drm-edid:123", "enabled":false}]); + assert!( + restored + .resolve("org.test.App", true, false, &sources, &disabled) + .is_none() + ); + } + + #[test] + fn transient_permission_cannot_be_upgraded_without_new_consent() { + let outputs = + serde_json::json!([{"name":"DP-1", "identity":"drm-edid:123", "enabled":true}]); + let saved = Restore::create("org.test.App", false, &[source()], &outputs, 1).unwrap(); + let restored = Restore::read(&Options::from([( + "restore_data".into(), + saved.value().unwrap(), + )])) + .unwrap(); + assert_eq!(restored.effective_mode(2), 1); + assert_eq!(restored.effective_mode(0), 0); + let weak = serde_json::json!([{"name":"DP-1", "identity":"drm:DP-1", "enabled":true}]); + assert!(Restore::create("org.test.App", false, &[source()], &weak, 2).is_none()); + assert!(!selection_unchanged(&[source()], &outputs, &weak)); + } + + #[test] + fn anonymous_nested_selection_has_lifetime_checks_without_saved_grants() { + let outputs = serde_json::json!([{"name":"DP-1", "id":1, "identity":null, "enabled":true}]); + assert!(selection_unchanged(&[source()], &outputs, &outputs)); + let replacement = + serde_json::json!([{"name":"DP-1", "id":2, "identity":null, "enabled":true}]); + assert!(!selection_unchanged(&[source()], &outputs, &replacement)); + assert!(Restore::create("org.test.App", false, &[source()], &outputs, 2).is_none()); + } + + #[test] + fn restored_multiple_selection_rejects_duplicates_and_single_source_requests() { + let mut second = source(); + second.name = "DP-2".into(); + let sources = [source(), second]; + let outputs = serde_json::json!([ + {"name":"DP-1", "identity":"drm-edid:123", "enabled":true}, + {"name":"DP-2", "identity":"drm-edid:456", "enabled":true} + ]); + let mut saved = Restore::create("org.test.App", false, &sources, &outputs, 2).unwrap(); + assert_eq!( + saved + .resolve("org.test.App", false, true, &sources, &outputs) + .unwrap() + .len(), + 2 + ); + assert!( + saved + .resolve("org.test.App", false, false, &sources, &outputs) + .is_none() + ); + saved.monitors[1] = saved.monitors[0].clone(); + assert!( + saved + .resolve("org.test.App", false, true, &sources, &outputs) + .is_none() + ); + } + + #[test] + fn foreign_malformed_and_unbounded_restore_data_are_ignored() { + for data in ["garbage".to_owned(), "x".repeat(MAX_DATA + 1)] { + let value = Value::from((VENDOR.to_owned(), VERSION, Value::new(data))) + .try_to_owned() + .unwrap(); + assert!(Restore::read(&Options::from([("restore_data".into(), value)])).is_none()); + } + let foreign = Value::from(("GNOME".to_owned(), VERSION, Value::new("{}"))) + .try_to_owned() + .unwrap(); + assert!(Restore::read(&Options::from([("restore_data".into(), foreign)])).is_none()); + assert!(Restore::read(&Options::new()).is_none()); + assert!(persist_mode(&Options::from([("persist_mode".into(), 3u32.into())])).is_err()); + assert!(Restore::create("", false, &[source()], &serde_json::json!([]), 2).is_none()); + } +} diff --git a/portal/src/stream.rs b/portal/src/stream.rs index 109dd88..970a40b 100644 --- a/portal/src/stream.rs +++ b/portal/src/stream.rs @@ -110,7 +110,11 @@ fn pod(object: spa::pod::Object) -> Vec { .into_inner() } -pub fn run(name: String, cursor: bool) -> Result<(), Box> { +pub fn run( + name: String, + cursor: bool, + generation: Option, +) -> Result<(), Box> { let stop = Arc::new(AtomicBool::new(false)); let pipe_stop = stop.clone(); std::thread::spawn(move || { @@ -119,6 +123,9 @@ pub fn run(name: String, cursor: bool) -> Result<(), Box> pipe_stop.store(true, Ordering::Relaxed); }); let mut capture = Capture::connect(&stop)?; + if let Some(generation) = generation { + capture.pin_output(&name, generation)?; + } let first = capture.frame(&name, cursor, &stop, Vec::new())?; let (width, height) = (first.width, first.height); let latest = Arc::new(Mutex::new(first)); diff --git a/scripts/tests/test_portal_contracts.py b/scripts/tests/test_portal_contracts.py index 48c4ff2..359cc7b 100644 --- a/scripts/tests/test_portal_contracts.py +++ b/scripts/tests/test_portal_contracts.py @@ -110,6 +110,23 @@ def private_bus_checks(): GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType.new("(u)"), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0] assert request_name == 1 + version, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "version")), "(v)") + assert version == 4 + for mode in (0, 1, 2): + session = PATH + f"/session/test/persist{mode}" + handle = PATH + f"/request/test/persist{mode}" + assert call("org.freedesktop.impl.portal.ScreenCast", "CreateSession", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", {})), "(ua{sv})") == (0, {}) + options = {"persist_mode": GLib.Variant("u", mode), "restore_data": GLib.Variant("(suv)", ("Other", 9, GLib.Variant("s", "foreign data")))} + assert call("org.freedesktop.impl.portal.ScreenCast", "SelectSources", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", options)), "(ua{sv})") == (0, {}) + bus.call_sync(NAME, session, "org.freedesktop.impl.portal.Session", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None) + session = PATH + "/session/test/badpersist" + handle = PATH + "/request/test/badpersist" + call("org.freedesktop.impl.portal.ScreenCast", "CreateSession", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", {}))) + try: + call("org.freedesktop.impl.portal.ScreenCast", "SelectSources", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", {"persist_mode": GLib.Variant("u", 3)}))) + raise AssertionError("Invalid persistence mode succeeded") + except GLib.Error as error: + assert "InvalidArgs" in str(error), error response, values = call("org.freedesktop.impl.portal.Screenshot", "Screenshot", params, "(ua{sv})") assert response == 2 and values == {} diff --git a/scripts/tests/test_restore_isolated.py b/scripts/tests/test_restore_isolated.py new file mode 100644 index 0000000..90f03a3 --- /dev/null +++ b/scripts/tests/test_restore_isolated.py @@ -0,0 +1,216 @@ +"""Check restored monitor sharing on a private bus and nested compositor. + +FERESE_TEST_RESTORE=1 python3 scripts/tests/test_restore_isolated.py +The IPC fixture supplies synthetic EDID identities for the nested display. +No host permission store, compositor configuration or portal service is changed. +""" +import json +import os +from pathlib import Path +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +from test_inhibit_isolated import IPC + +REPO = Path(__file__).resolve().parents[2] +NAME = "org.freedesktop.impl.portal.desktop.ferese" +PATH = "/org/freedesktop/portal/desktop" +INTERFACE = "org.freedesktop.impl.portal.ScreenCast" + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_RESTORE") == "1", "requires a Wayland host and PipeWire") +class RestoreIntegration(unittest.TestCase): + def test_private_bus_restore_lifetimes(self): + result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], capture_output=True, text=True, timeout=70) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +def private_test(): + import gi + gi.require_version("Gio", "2.0") + gi.require_version("Gst", "1.0") + from gi.repository import Gio, GLib, Gst + Gst.init(None) + processes = [] + stop = threading.Event() + proxy = None + + def wait_for(check, message, seconds=10): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + while GLib.MainContext.default().pending(): + GLib.MainContext.default().iteration(False) + if check(): + return + time.sleep(0.02) + raise AssertionError(message) + + def proxy_connection(client, destination): + with client: + upstream = IPC(destination) + try: + def read(size): + result = b"" + while len(result) < size: + chunk = client.recv(size - len(result)) + if not chunk: + raise EOFError() + result += chunk + return result + while not stop.is_set(): + request = json.loads(read(struct.unpack(">I", read(4))[0])) + result = upstream.call(request["command"], request.get("args")) + if request["command"] == "get-outputs": + for output in result: + output["identity"] = "drm-edid:test-" + output["name"] + reply = json.dumps({"version": 1, "id": request["id"], "result": result}).encode() + client.sendall(struct.pack(">I", len(reply)) + reply) + except (EOFError, OSError): + pass + finally: + upstream.close() + + with tempfile.TemporaryDirectory(prefix="ferese-restore-") as directory: + root = Path(directory) + runtime = root / "compositor" + runtime.mkdir(mode=0o700) + backend_runtime = root / "portal" + (backend_runtime / "ferese").mkdir(parents=True, mode=0o700) + config = root / "config/ferese/config.kdl" + config.parent.mkdir(parents=True) + config.write_text("status { 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", DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"]) + env.pop("WAYLAND_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) + destination = runtime / "ferese/control.sock" + wait_for(lambda: destination.exists(), "Nested compositor did not start") + admin = IPC(destination) + outputs = admin.call("get-outputs") + name = next(output["name"] for output in outputs if output["enabled"]) + admin.close() + displays = lambda: [path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")] + wait_for(displays, "Missing nested Wayland socket") + env["WAYLAND_DISPLAY"] = str(displays()[0]) + env["XDG_RUNTIME_DIR"] = str(backend_runtime) + proxy = socket.socket(socket.AF_UNIX) + proxy.bind(str(backend_runtime / "ferese/control.sock")) + proxy.listen() + proxy.settimeout(0.1) + def accept(): + while not stop.is_set(): + try: + client, _ = proxy.accept() + except socket.timeout: + continue + except OSError: + break + threading.Thread(target=proxy_connection, args=(client, destination), daemon=True).start() + threading.Thread(target=accept, daemon=True).start() + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), None, Gio.DBusCallFlags.NONE, 2000, None) + backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=env, stdout=log, stderr=log, start_new_session=True) + processes.append(backend) + def call(method, params=None, path=PATH, interface=INTERFACE): + return bus.call_sync(NAME, path, interface, method, params, None, Gio.DBusCallFlags.NO_AUTO_START, 12000, None).unpack() + def ready(): + try: + call("Introspect", interface="org.freedesktop.DBus.Introspectable") + return True + except GLib.Error: + return False + wait_for(ready, "Portal backend did not start") + data = {"app": "org.test.Share", "cursor": False, "mode": 1, "monitors": [{"name": name, "identity": "drm-edid:test-" + name}]} + def create(token, app="org.test.Share", saved=data): + session = PATH + "/session/test/" + token + handle = PATH + "/request/test/" + token + assert call("CreateSession", GLib.Variant("(oosa{sv})", (handle, session, app, {}))) == (0, {}) + options = {"persist_mode": GLib.Variant("u", 2), "restore_data": GLib.Variant("(suv)", ("Ferese", 1, GLib.Variant("s", json.dumps(saved))))} + assert call("SelectSources", GLib.Variant("(oosa{sv})", (handle, session, app, options))) == (0, {}) + return session, handle + def helpers(): + return [pid for children in Path(f"/proc/{backend.pid}/task").glob("*/children") for pid in children.read_text().split()] + session, handle = create("restore") + response, result = call("Start", GLib.Variant("(oossa{sv})", (handle, session, "org.test.Share", "", {}))) + assert response == 0 and result["persist_mode"] == 1, (response, result) + assert result["restore_data"][:2] == ("Ferese", 1), result + assert json.loads(result["restore_data"][2])["mode"] == 1 + assert len(result["streams"]) == 1 + assert all("--picker" not in Path(f"/proc/{pid}/cmdline").read_text() for pid in helpers()), "Restored selection opened a picker" + node = result["streams"][0][0] + pipeline = Gst.parse_launch(f"pipewiresrc path={node} num-buffers=5 ! video/x-raw,format=BGRx ! fakesink sync=false") + try: + pipeline.set_state(Gst.State.PLAYING) + message = pipeline.get_bus().timed_pop_filtered(8 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR) + assert message and message.type == Gst.MessageType.EOS, "Restored stream did not deliver video" + finally: + pipeline.set_state(Gst.State.NULL) + call("Close", path=session, interface="org.freedesktop.impl.portal.Session") + wait_for(lambda: not helpers(), "Closing restored session left capture children") + print("PASS: wire restore, transient duration retained, video delivery and Close cleanup", flush=True) + + def fallback(token, app="org.test.Share", saved=data): + session, handle = create(token, app, saved) + replies = [] + def done(connection, result): + try: + replies.append(connection.call_finish(result).unpack()) + except GLib.Error as error: + replies.append(error) + bus.call(NAME, PATH, INTERFACE, "Start", GLib.Variant("(oossa{sv})", (handle, session, app, "", {})), None, Gio.DBusCallFlags.NO_AUTO_START, 12000, None, done) + wait_for(lambda: any("--picker" in Path(f"/proc/{pid}/cmdline").read_text() for pid in helpers()), "Invalid restore did not require picker") + call("Close", path=handle, interface="org.freedesktop.impl.portal.Request") + wait_for(lambda: replies and not helpers(), "Cancelled fallback left helpers") + assert replies == [(1, {})], replies + fallback("wrongapp", app="org.other.App") + stale = dict(data, monitors=[{"name": name, "identity": "drm-edid:other-device"}]) + fallback("replacement", saved=stale) + print("PASS: other-app and changed-device restore require fresh consent", flush=True) + + mismatch = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream", name, "hidden", str(2**32 - 1)], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True) + processes.append(mismatch) + mismatch.wait(timeout=8) + error = mismatch.stderr.read() + mismatch.stdin.close() + assert mismatch.returncode != 0 and "replaced" in error, error + session, handle = create("frontendloss") + assert call("Start", GLib.Variant("(oossa{sv})", (handle, session, "org.test.Share", "", {})))[0] == 0 + bus.close_sync(None) + wait_for(lambda: not helpers(), "Frontend loss did not revoke restored capture") + print("PASS: output-generation mismatch and frontend disconnect revoke capture", flush=True) + except Exception: + print((root / "test.log").read_text(), file=sys.stderr) + raise + finally: + stop.set() + if proxy: + proxy.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() + log.close() + + +if __name__ == "__main__": + if "--private" in sys.argv: + private_test() + else: + unittest.main()