Restore explicitly approved ScreenCast display permissions
This commit is contained in:
1 parent
da1428dd00
commit
56a888adb3
11 files changed
+882
-52
No files matched your search
+12
-2
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
|
|||||||
+197
-44
@@ -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,
|
Arc,
|
||||||
process::Stdio,
|
atomic::{AtomicBool, Ordering},
|
||||||
sync::{
|
|
||||||
Arc,
|
|
||||||
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,44 +550,104 @@ 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> {
|
||||||
.await
|
let mut discovery = tokio::task::spawn_blocking(|| Capture::connect(&AtomicBool::new(false)))
|
||||||
.map_err(|e| e.to_string())??;
|
.await
|
||||||
let prompt = Prompt {
|
.map_err(|e| e.to_string())??;
|
||||||
app: app.to_owned(),
|
let sources = discovery.sources();
|
||||||
sources,
|
let outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
|
||||||
multiple,
|
let rememberable = sources
|
||||||
parent: parent.into(),
|
.iter()
|
||||||
window_capture: false,
|
.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 {
|
||||||
|
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 mut reply = Options::new();
|
||||||
let selection = picker.wait_with_output().await.map_err(|e| e.to_string())?;
|
let fresh_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
|
||||||
if !selection.status.success() {
|
|
||||||
return Err(StartError::Cancelled);
|
if !crate::restore::selection_unchanged(&selected, &outputs, &fresh_outputs) {
|
||||||
|
return Err("A selected display changed while approval was pending".into());
|
||||||
}
|
}
|
||||||
let names: Vec<String> =
|
|
||||||
serde_json::from_slice(&selection.stdout).map_err(|_| StartError::Cancelled)?;
|
let approved = crate::restore::Restore::create(app, cursor, &selected, &outputs, persist_mode);
|
||||||
let selected = validate_selection(&prompt.sources, &names, multiple)?;
|
let effective_persistence = if let Some(approved) = approved.filter(|_| persist_mode > 0) {
|
||||||
crate::desktop::wait_for_surface_removal(picker_pid).await?;
|
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}}]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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())?);
|
||||||
|
|||||||
+64
-2
@@ -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();
|
||||||
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();
|
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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 == {}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user