Restore explicitly approved ScreenCast display permissions

This commit is contained in:
Samuel Onoja committed 2026-09-30 03:12:13 +01:00
1 parent da1428dd00
commit 56a888adb3
11 files changed
+865 -35

No files matched your search

+12 -2
View File
@@ -47,7 +47,9 @@ 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 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. See [screen sharing](screen-sharing.md) for lifetime and recording details.
Native request dialogs use Ferese's theme and are floating windows. Screenshot, 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 | | 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 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 | | 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 |
@@ -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 The shell now requires version 4 of Ferese's shell protocol; rebuild the shell
and compositor together when upgrading. 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
```
+9 -2
View File
@@ -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 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 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. Everything visible on a shared display, including notifications, is included.
## Installation ## Installation
@@ -75,7 +82,7 @@ configuration is preserved during installation.
- Wayland parent-window relationships for the consent picker. - Wayland parent-window relationships for the consent picker.
- Bounded frame storage and reusable shared-memory buffers. - Bounded frame storage and reusable shared-memory buffers.
- Cancellation during selection, an explicit stop control, and process cleanup. - 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 Window-only sharing, region selection, and audio capture in Ferese’s recorder
are not implemented yet. Other recording applications can handle audio separately. are not implemented yet. Other recording applications can handle audio separately.
+181 -28
View File
@@ -1,19 +1,15 @@
//! ScreenCast portal backend. Only the portal frontend may create sessions; //! 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::{ use crate::{
capture::{Capture, Source}, capture::{Capture, Source},
picker::Prompt, picker::Prompt,
stream::Ready, stream::Ready,
}; };
use std::{ use std::sync::{
collections::HashMap,
process::Stdio,
sync::{
Arc, Arc,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
},
time::Duration,
}; };
use std::{collections::HashMap, process::Stdio, time::Duration};
use tokio::{ use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::{Child, Command}, process::{Child, Command},
@@ -25,43 +21,49 @@ use zbus::{
object_server::SignalEmitter, object_server::SignalEmitter,
zvariant::{OwnedObjectPath, OwnedValue, Value}, zvariant::{OwnedObjectPath, OwnedValue, Value},
}; };
pub(crate) type Options = HashMap<String, OwnedValue>;
pub(crate) type Options = HashMap<String, OwnedValue>;
pub(crate) static CONFIG_TRANSACTION: std::sync::Mutex<()> = std::sync::Mutex::new(()); 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)] #[derive(Debug)]
enum StartError { enum StartError {
Cancelled, Cancelled,
Failed(String), Failed(String),
} }
impl From<String> for StartError { impl From<String> for StartError {
fn from(message: String) -> Self { fn from(message: String) -> Self {
Self::Failed(message) Self::Failed(message)
} }
} }
impl From<&str> for StartError { impl From<&str> for StartError {
fn from(message: &str) -> Self { fn from(message: &str) -> Self {
Self::Failed(message.into()) Self::Failed(message.into())
} }
} }
const FRONTEND: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
#[derive(Default)] #[derive(Default)]
pub(crate) struct Cancel { pub(crate) struct Cancel {
pub(crate) stopped: AtomicBool, pub(crate) stopped: AtomicBool,
changed: Notify, changed: Notify,
} }
impl Cancel { impl Cancel {
pub(crate) fn stop(&self) { pub(crate) fn stop(&self) {
self.stopped.store(true, Ordering::SeqCst); self.stopped.store(true, Ordering::SeqCst);
self.changed.notify_one(); self.changed.notify_one();
} }
pub(crate) async fn wait(&self) { pub(crate) async fn wait(&self) {
if !self.stopped.load(Ordering::SeqCst) { if !self.stopped.load(Ordering::SeqCst) {
self.changed.notified().await; self.changed.notified().await;
} }
} }
} }
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
enum Phase { enum Phase {
Created, Created,
@@ -69,6 +71,7 @@ enum Phase {
Starting, Starting,
Running, Running,
} }
struct Session { struct Session {
owner: String, owner: String,
app: String, app: String,
@@ -76,15 +79,20 @@ struct Session {
multiple: bool, multiple: bool,
cursor: bool, cursor: bool,
bar_controlled: bool, bar_controlled: bool,
persist_mode: u32,
restore: Option<crate::restore::Restore>,
cancel: Arc<Cancel>, cancel: Arc<Cancel>,
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct Backend { pub struct Backend {
sessions: Arc<Mutex<HashMap<String, Session>>>, sessions: Arc<Mutex<HashMap<String, Session>>>,
} }
// This private interface only transfers stop controls to the shell's own recorder. // This private interface only transfers stop controls to the shell's own recorder.
// The ordinary portal picker and all session revocation rules still apply. // The ordinary portal picker and all session revocation rules still apply.
struct RecorderControl(Backend); struct RecorderControl(Backend);
#[zbus::interface(name = "org.ferese.ScreenRecorder")] #[zbus::interface(name = "org.ferese.ScreenRecorder")]
impl RecorderControl { impl RecorderControl {
async fn use_bar_controls( async fn use_bar_controls(
@@ -121,12 +129,14 @@ impl RecorderControl {
Ok(()) Ok(())
} }
} }
fn owns_session(sender: &str, path: &str) -> bool { fn owns_session(sender: &str, path: &str) -> bool {
let Some(sender) = sender.strip_prefix(':') else { let Some(sender) = sender.strip_prefix(':') else {
return false; return false;
}; };
path.starts_with(&format!("{PATH}/session/{}/", sender.replace('.', "_"))) path.starts_with(&format!("{PATH}/session/{}/", sender.replace('.', "_")))
} }
fn shell_recorder(pid: u32) -> bool { fn shell_recorder(pid: u32) -> bool {
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
let matches_binary = |pid: u32, name: &str| -> Option<bool> { let matches_binary = |pid: u32, name: &str| -> Option<bool> {
@@ -166,11 +176,14 @@ pub(crate) async fn authorize(
"Use the desktop portal".into(), "Use the desktop portal".into(),
)); ));
} }
Ok(sender.to_string()) Ok(sender.to_string())
} }
fn error(message: &str) -> zbus::fdo::Error { 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)> {
let number = |key: &str, default| { let number = |key: &str, default| {
options options
@@ -194,8 +207,10 @@ fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
.transpose() .transpose()
.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))
} }
impl Backend { impl Backend {
async fn end(&self, connection: &Connection, path: &str) { async fn end(&self, connection: &Connection, path: &str) {
let session = self.sessions.lock().await.remove(path); let session = self.sessions.lock().await.remove(path);
@@ -216,20 +231,24 @@ impl Backend {
} }
} }
} }
#[zbus::interface(name = "org.freedesktop.impl.portal.ScreenCast")] #[zbus::interface(name = "org.freedesktop.impl.portal.ScreenCast")]
impl Backend { impl Backend {
#[zbus(property, name = "version")] #[zbus(property, name = "version")]
fn version(&self) -> u32 { fn version(&self) -> u32 {
3 4
} }
#[zbus(property)] #[zbus(property)]
fn available_source_types(&self) -> u32 { fn available_source_types(&self) -> u32 {
1 1
} }
#[zbus(property)] #[zbus(property)]
fn available_cursor_modes(&self) -> u32 { fn available_cursor_modes(&self) -> u32 {
3 3
} }
async fn create_session( async fn create_session(
&self, &self,
_handle: OwnedObjectPath, _handle: OwnedObjectPath,
@@ -276,11 +295,14 @@ impl Backend {
multiple: false, multiple: false,
cursor: false, cursor: false,
bar_controlled: false, bar_controlled: false,
persist_mode: 0,
restore: None,
cancel: Arc::default(), cancel: Arc::default(),
}, },
); );
Ok((0, Options::new())) Ok((0, Options::new()))
} }
async fn select_sources( async fn select_sources(
&self, &self,
_handle: OwnedObjectPath, _handle: OwnedObjectPath,
@@ -291,19 +313,25 @@ 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); 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 mut sessions = self.sessions.lock().await;
let session = sessions let session = sessions
.get_mut(session_handle.as_str()) .get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?; .ok_or_else(|| error("Unknown session"))?;
if session.owner != owner if session.owner != owner
|| session.app != app_id || session.app != app_id
|| !matches!(session.phase, Phase::Created | Phase::Selected) || !matches!(session.phase, Phase::Created | Phase::Selected)
{ {
return Err(error("Invalid session state")); return Err(error("Invalid session state"));
} }
match settings { 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.multiple = multiple;
session.cursor = cursor; session.cursor = cursor;
session.phase = Phase::Selected; session.phase = Phase::Selected;
@@ -316,6 +344,7 @@ impl Backend {
} }
} }
} }
#[allow(clippy::too_many_arguments)] // ScreenCast D-Bus method signature. #[allow(clippy::too_many_arguments)] // ScreenCast D-Bus method signature.
async fn start( async fn start(
&self, &self,
@@ -328,25 +357,31 @@ 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?;
if !handle if !handle
.as_str() .as_str()
.starts_with("/org/freedesktop/portal/desktop/request/") .starts_with("/org/freedesktop/portal/desktop/request/")
{ {
return Err(error("Invalid request path")); 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 mut sessions = self.sessions.lock().await;
let session = sessions let session = sessions
.get_mut(session_handle.as_str()) .get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?; .ok_or_else(|| error("Unknown session"))?;
if session.owner != owner || session.app != app_id || session.phase != Phase::Selected { if session.owner != owner || session.app != app_id || session.phase != Phase::Selected {
return Err(error("Select sources before starting a session")); return Err(error("Select sources before starting a session"));
} }
session.phase = Phase::Starting; session.phase = Phase::Starting;
( (
session.multiple, session.multiple,
session.cursor, session.cursor,
session.bar_controlled, session.bar_controlled,
session.persist_mode,
session.restore.clone(),
session.cancel.clone(), session.cancel.clone(),
) )
}; };
@@ -360,6 +395,7 @@ impl Backend {
}, },
) )
.await; .await;
match inserted { match inserted {
Ok(true) => (), Ok(true) => (),
result => { 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! { let result = tokio::select! {
_ = cancel.wait() => Err(StartError::Cancelled), _ = 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 let _ = connection
.object_server() .object_server()
.remove::<Request, _>(handle) .remove::<Request, _>(handle)
.await; .await;
match result { 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()) { if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) {
session.phase = Phase::Running; session.phase = Phase::Running;
} }
let backend = self.clone(); let backend = self.clone();
let connection = connection.clone(); let connection = connection.clone();
let path = session_handle.to_string(); let path = session_handle.to_string();
tokio::spawn(async move { tokio::spawn(async move {
supervise(children, cancel).await; supervise(children, cancel).await;
backend.end(&connection, &path).await; backend.end(&connection, &path).await;
}); });
let mut reply = Options::new();
reply.insert( reply.insert(
"streams".into(), "streams".into(),
OwnedValue::try_from(Value::from(streams)) OwnedValue::try_from(Value::from(streams))
.map_err(|e| error(&e.to_string()))?, .map_err(|e| error(&e.to_string()))?,
); );
Ok((0, reply)) Ok((0, reply))
} }
result => { result => {
@@ -412,27 +462,37 @@ impl Backend {
} }
} }
} }
pub(crate) struct Request { pub(crate) struct Request {
pub(crate) cancel: Arc<Cancel>, pub(crate) cancel: Arc<Cancel>,
pub(crate) owner: String, pub(crate) owner: String,
} }
#[zbus::interface(name = "org.freedesktop.impl.portal.Request")] #[zbus::interface(name = "org.freedesktop.impl.portal.Request")]
impl Request { impl Request {
fn close(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { fn close(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> {
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) { if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
return Err(zbus::fdo::Error::AccessDenied("Wrong request owner".into())); return Err(zbus::fdo::Error::AccessDenied("Wrong request owner".into()));
} }
self.cancel.stop(); self.cancel.stop();
Ok(()) Ok(())
} }
} }
struct SessionObject { struct SessionObject {
backend: Backend, backend: Backend,
path: String, path: String,
owner: String, owner: String,
} }
#[zbus::interface(name = "org.freedesktop.impl.portal.Session")] #[zbus::interface(name = "org.freedesktop.impl.portal.Session")]
impl SessionObject { impl SessionObject {
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
1
}
async fn close( async fn close(
&self, &self,
#[zbus(connection)] connection: &Connection, #[zbus(connection)] connection: &Connection,
@@ -441,12 +501,15 @@ impl SessionObject {
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) { if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
return Err(zbus::fdo::Error::AccessDenied("Wrong session owner".into())); return Err(zbus::fdo::Error::AccessDenied("Wrong session owner".into()));
} }
self.backend.end(connection, &self.path).await; self.backend.end(connection, &self.path).await;
Ok(()) Ok(())
} }
#[zbus(signal)] #[zbus(signal)]
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
} }
// All helpers die with the service, including a picker awaiting user input. // All helpers die with the service, including a picker awaiting user input.
pub(crate) fn child_command() -> Result<Command, String> { pub(crate) fn child_command() -> Result<Command, String> {
child_command_for(std::env::current_exe().map_err(|e| e.to_string())?) 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<std::ffi::OsStr>) -> Result<
Ok(()) Ok(())
}); });
} }
command.kill_on_drop(true); command.kill_on_drop(true);
Ok(command) Ok(command)
} }
async fn picker(prompt: &Prompt) -> Result<Child, String> { async fn picker(prompt: &Prompt) -> Result<Child, String> {
let mut child = child_command()? let mut child = child_command()?
.arg("--picker") .arg("--picker")
@@ -485,25 +550,51 @@ async fn picker(prompt: &Prompt) -> Result<Child, String> {
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
drop(input); drop(input);
Ok(child) Ok(child)
} }
async fn start_streams( async fn start_streams(
app: &str, app: &str,
parent: &str, parent: &str,
multiple: bool, multiple: bool,
cursor: bool, cursor: bool,
) -> Result<(Vec<Child>, Vec<(u32, Options)>), StartError> { requested_persistence: u32,
let sources = tokio::task::spawn_blocking(|| { restore: Option<crate::restore::Restore>,
Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources()) bar_controlled: bool,
}) ) -> Result<(Vec<Child>, Vec<(u32, Options)>, Options), StartError> {
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 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::<Vec<_>>();
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 { let prompt = Prompt {
app: app.to_owned(), app: if bar_controlled { "Ferese" } else { app }.to_owned(),
sources, sources,
multiple, multiple,
rememberable: rememberable.clone(),
parent: parent.into(), parent: parent.into(),
window_capture: false, window_capture: false,
persist_mode: if rememberable.is_empty() {
0
} else {
requested_persistence
},
}; };
let picker = picker(&prompt).await?; let picker = picker(&prompt).await?;
let picker_pid = picker.id().ok_or("Missing picker process ID")?; let picker_pid = picker.id().ok_or("Missing picker process ID")?;
@@ -511,18 +602,52 @@ async fn start_streams(
if !selection.status.success() { if !selection.status.success() {
return Err(StartError::Cancelled); return Err(StartError::Cancelled);
} }
let names: Vec<String> = let selection: crate::picker::Selection =
serde_json::from_slice(&selection.stdout).map_err(|_| StartError::Cancelled)?; serde_json::from_slice(&selection.stdout).map_err(|_| StartError::Cancelled)?;
let selected = validate_selection(&prompt.sources, &names, multiple)?; 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?; crate::desktop::wait_for_surface_removal(picker_pid).await?;
(selected, selection.persist_mode)
};
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 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::<Result<Vec<_>, _>>()?;
let mut children = Vec::new(); let mut children = Vec::new();
let mut streams = Vec::new(); let mut streams = Vec::new();
for source in &selected {
for (source, (_, generation)) in selected.iter().zip(&generations) {
let mut child = child_command()? let mut child = child_command()?
.args([ .args([
"--stream", "--stream",
&source.name, &source.name,
if cursor { "embedded" } else { "hidden" }, if cursor { "embedded" } else { "hidden" },
&generation.to_string(),
]) ])
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@@ -541,26 +666,39 @@ async fn start_streams(
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let ready: Ready = let ready: Ready =
serde_json::from_str(&line).map_err(|_| "PipeWire stream could not start")?; serde_json::from_str(&line).map_err(|_| "PipeWire stream could not start")?;
if ready.node == u32::MAX { if ready.node == u32::MAX {
return Err("Invalid PipeWire node".into()); return Err("Invalid PipeWire node".into());
} }
streams.push((source.name.clone(), ready)); streams.push((source.name.clone(), ready));
children.push(child); 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 if children
.iter_mut() .iter_mut()
.any(|child| !matches!(child.try_wait(), Ok(None))) .any(|child| !matches!(child.try_wait(), Ok(None)))
{ {
return Err("A selected stream ended during startup".into()); return Err("A selected stream ended during startup".into());
} }
let streams = streams let streams = streams
.into_iter() .into_iter()
.map(|(name, ready)| { .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::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok((children, streams))
Ok((children, streams, reply))
} }
fn monitor_metadata( fn monitor_metadata(
@@ -598,15 +736,18 @@ fn monitor_metadata(
.and_then(|value| u32::try_from(value).ok()) .and_then(|value| u32::try_from(value).ok())
.ok_or("Invalid physical output height")?, .ok_or("Invalid physical output height")?,
); );
if matches!( if matches!(
output["transform"].as_str(), output["transform"].as_str(),
Some("rotate_90" | "rotate_270" | "flipped_90" | "flipped_270") Some("rotate_90" | "rotate_270" | "flipped_90" | "flipped_270")
) { ) {
physical = (physical.1, physical.0); physical = (physical.1, physical.0);
} }
if (ready.width, ready.height) != physical { if (ready.width, ready.height) != physical {
return Err("Selected monitor changed mode during startup".into()); return Err("Selected monitor changed mode during startup".into());
} }
Ok(HashMap::from([ Ok(HashMap::from([
("source_type".into(), 1u32.into()), ("source_type".into(), 1u32.into()),
( (
@@ -632,6 +773,7 @@ fn validate_selection(
if names.is_empty() || names.len() > sources.len() || (!multiple && names.len() != 1) { if names.is_empty() || names.len() > sources.len() || (!multiple && names.len() != 1) {
return Err("Invalid display selection".into()); return Err("Invalid display selection".into());
} }
let mut selected = Vec::new(); let mut selected = Vec::new();
for name in names { for name in names {
if selected.iter().any(|s: &Source| &s.name == name) { if selected.iter().any(|s: &Source| &s.name == name) {
@@ -645,8 +787,10 @@ fn validate_selection(
.clone(), .clone(),
); );
} }
Ok(selected) Ok(selected)
} }
async fn supervise(mut children: Vec<Child>, cancel: Arc<Cancel>) { async fn supervise(mut children: Vec<Child>, cancel: Arc<Cancel>) {
loop { loop {
if cancel.stopped.load(Ordering::SeqCst) if cancel.stopped.load(Ordering::SeqCst)
@@ -658,11 +802,14 @@ async fn supervise(mut children: Vec<Child>, cancel: Arc<Cancel>) {
} }
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
} }
cancel.stop(); cancel.stop();
for child in &mut children { for child in &mut children {
let _ = child.kill().await; let _ = child.kill().await;
} }
} }
pub async fn run() -> Result<(), Box<dyn std::error::Error>> { pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let backend = Backend::default(); let backend = Backend::default();
let settings = crate::settings::Settings::new(); let settings = crate::settings::Settings::new();
@@ -688,11 +835,13 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
)? )?
.build() .build()
.await?; .await?;
tokio::spawn(settings.watch(connection.clone())); tokio::spawn(settings.watch(connection.clone()));
tokio::spawn(background.watch(connection.clone())); tokio::spawn(background.watch(connection.clone()));
tokio::spawn(lockdown.watch(connection.clone())); tokio::spawn(lockdown.watch(connection.clone()));
tokio::spawn(inhibit.clone().watch(connection.clone())); tokio::spawn(inhibit.clone().watch(connection.clone()));
tokio::spawn(inhibit.clone().watch_logind()); tokio::spawn(inhibit.clone().watch_logind());
// Frontend death must revoke every stream, even if Session.Close never arrives. // Frontend death must revoke every stream, even if Session.Close never arrives.
loop { loop {
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
@@ -707,6 +856,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
requests.revoke_stale(owner.as_deref()).await; requests.revoke_stale(owner.as_deref()).await;
shortcuts.revoke_stale(&connection, owner.as_deref()).await; shortcuts.revoke_stale(&connection, owner.as_deref()).await;
inhibit.revoke_stale(&connection, owner.as_deref()).await; inhibit.revoke_stale(&connection, owner.as_deref()).await;
let stale: Vec<_> = backend let stale: Vec<_> = backend
.sessions .sessions
.lock() .lock()
@@ -720,6 +870,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -739,6 +890,7 @@ mod tests {
)); ));
assert!(!shell_recorder(std::process::id())); assert!(!shell_recorder(std::process::id()));
} }
#[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));
@@ -753,6 +905,7 @@ mod tests {
} }
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] #[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}}]);
+36
View File
@@ -73,6 +73,7 @@ pub struct Capture {
connection: Connection, connection: Connection,
queue: EventQueue<State>, queue: EventQueue<State>,
state: State, state: State,
selected_output: Option<u32>,
} }
impl Capture { impl Capture {
@@ -84,6 +85,7 @@ impl Capture {
connection, connection,
queue, queue,
state: State::default(), state: State::default(),
selected_output: None,
}; };
// Two syncs deliver registry bindings and output metadata without an // Two syncs deliver registry bindings and output metadata without an
// uninterruptible roundtrip on a stalled compositor. // uninterruptible roundtrip on a stalled compositor.
@@ -107,6 +109,32 @@ impl Capture {
Ok(()) Ok(())
} }
pub fn generation(&self, name: &str) -> Option<u32> {
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<Source> { pub fn sources(&self) -> Vec<Source> {
self.state self.state
.outputs .outputs
@@ -135,6 +163,14 @@ impl Capture {
.find(|o| o.source.name == name) .find(|o| o.source.name == name)
.ok_or("Shared monitor disconnected")?; .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; let transform = output.transform;
self.state.result = None; self.state.result = None;
self.state.flipped = false; self.state.flipped = false;
+2
View File
@@ -474,6 +474,8 @@ async fn select_window(app: &str, parent: &str) -> Result<Option<u64>, String> {
multiple: false, multiple: false,
parent: parent.into(), parent: parent.into(),
window_capture: true, window_capture: true,
persist_mode: 0,
rememberable: Vec::new(),
}; };
let mut child = crate::backend::child_command()? let mut child = crate::backend::child_command()?
.arg("--picker") .arg("--picker")
+7 -1
View File
@@ -8,6 +8,7 @@ mod lockdown;
mod parent; mod parent;
mod permissions; mod permissions;
mod picker; mod picker;
mod restore;
mod settings; mod settings;
mod shortcuts; mod shortcuts;
mod stream; mod stream;
@@ -17,8 +18,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match args.as_slice() { match args.as_slice() {
[flag, name, cursor] if flag == "--stream" => { [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" => { [flag] if flag == "--sources" => {
let capture = capture::Capture::connect(&std::sync::atomic::AtomicBool::new(false))?; let capture = capture::Capture::connect(&std::sync::atomic::AtomicBool::new(false))?;
println!("{}", serde_json::to_string(&capture.sources())?); println!("{}", serde_json::to_string(&capture.sources())?);
+63 -1
View File
@@ -18,6 +18,17 @@ pub struct Prompt {
pub parent: String, pub parent: String,
#[serde(default)] #[serde(default)]
pub window_capture: bool, pub window_capture: bool,
#[serde(default)]
pub persist_mode: u32,
#[serde(default)]
pub rememberable: Vec<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Selection {
pub names: Vec<String>,
pub persist_mode: u32,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -33,6 +44,7 @@ enum Message {
>, >,
), ),
Select(usize), Select(usize),
Remember,
Share, Share,
Cancel, Cancel,
} }
@@ -43,6 +55,7 @@ struct Picker {
parent: Option<std::sync::Arc<crate::parent::Parent>>, parent: Option<std::sync::Arc<crate::parent::Parent>>,
prompt: Prompt, prompt: Prompt,
selected: Vec<usize>, selected: Vec<usize>,
remember: bool,
background: cosmic::iced::Color, background: cosmic::iced::Color,
palette: ferese_theme::Palette, palette: ferese_theme::Palette,
font: cosmic::font::Font, font: cosmic::font::Font,
@@ -65,7 +78,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
} }
let (theme, font, background, palette) = appearance(); 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::<Picker>( cosmic::app::run::<Picker>(
Settings::default() Settings::default()
.size(cosmic::iced::Size::new(400., height)) .size(cosmic::iced::Size::new(400., height))
@@ -114,6 +129,7 @@ impl cosmic::Application for Picker {
parent: None, parent: None,
prompt, prompt,
selected: Vec::new(), selected: Vec::new(),
remember: false,
background, background,
palette, palette,
font, font,
@@ -176,14 +192,28 @@ impl cosmic::Application for Picker {
} }
self.selected.push(index); 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() => { Message::Share if !self.selected.is_empty() => {
let names: Vec<_> = self let names: Vec<_> = self
.selected .selected
.iter() .iter()
.map(|i| &self.prompt.sources[*i].name) .map(|i| &self.prompt.sources[*i].name)
.collect(); .collect();
if self.prompt.window_capture {
println!("{}", serde_json::to_string(&names).unwrap()); 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(); let _ = std::io::stdout().flush();
return cosmic::iced::exit(); return cosmic::iced::exit();
} }
@@ -280,6 +310,28 @@ impl cosmic::Application for Picker {
.size(12) .size(12)
.width(Length::Fill), .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( let mut share = button::custom(
row![ row![
self.text(if self.prompt.window_capture { self.text(if self.prompt.window_capture {
@@ -311,6 +363,16 @@ impl cosmic::Application for Picker {
} }
impl 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>( fn text<'a>(
&self, &self,
content: impl Into<std::borrow::Cow<'a, str>> + 'a, content: impl Into<std::borrow::Cow<'a, str>> + 'a,
+314
View File
@@ -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<Monitor>,
}
#[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<u32> {
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<Self> {
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<Vec<Source>> {
if self.app != app || self.cursor != cursor || (!multiple && self.monitors.len() != 1) {
return None;
}
let mut selected: Vec<Source> = 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<Self> {
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::<Option<Vec<_>>>()?;
Some(Self {
app: app.into(),
cursor,
mode,
monitors,
})
}
pub(crate) fn value(&self) -> Result<OwnedValue, String> {
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());
}
}
+8 -1
View File
@@ -110,7 +110,11 @@ fn pod(object: spa::pod::Object) -> Vec<u8> {
.into_inner() .into_inner()
} }
pub fn run(name: String, cursor: bool) -> Result<(), Box<dyn std::error::Error>> { pub fn run(
name: String,
cursor: bool,
generation: Option<u32>,
) -> Result<(), Box<dyn std::error::Error>> {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let pipe_stop = stop.clone(); let pipe_stop = stop.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -119,6 +123,9 @@ pub fn run(name: String, cursor: bool) -> Result<(), Box<dyn std::error::Error>>
pipe_stop.store(true, Ordering::Relaxed); pipe_stop.store(true, Ordering::Relaxed);
}); });
let mut capture = Capture::connect(&stop)?; 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 first = capture.frame(&name, cursor, &stop, Vec::new())?;
let (width, height) = (first.width, first.height); let (width, height) = (first.width, first.height);
let latest = Arc::new(Mutex::new(first)); let latest = Arc::new(Mutex::new(first));
+17
View File
@@ -110,6 +110,23 @@ def private_bus_checks():
GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType.new("(u)"), GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType.new("(u)"),
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0] Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
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)")
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})") response, values = call("org.freedesktop.impl.portal.Screenshot", "Screenshot", params, "(ua{sv})")
assert response == 2 and values == {} assert response == 2 and values == {}
+216
View File
@@ -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()