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
+882 -52

No files matched your search

+197 -44
View File
@@ -1,19 +1,15 @@
//! ScreenCast portal backend. Only the portal frontend may create sessions;
//! consent never survives a session, a process restart, or a capture failure.
//! saved monitor consent is restored only through the frontend permission store.
use crate::{
capture::{Capture, Source},
picker::Prompt,
stream::Ready,
};
use std::{
collections::HashMap,
process::Stdio,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::{collections::HashMap, process::Stdio, time::Duration};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::{Child, Command},
@@ -25,43 +21,49 @@ use zbus::{
object_server::SignalEmitter,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
pub(crate) type Options = HashMap<String, OwnedValue>;
pub(crate) type Options = HashMap<String, OwnedValue>;
pub(crate) static CONFIG_TRANSACTION: std::sync::Mutex<()> = std::sync::Mutex::new(());
const FRONTEND: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
#[derive(Debug)]
enum StartError {
Cancelled,
Failed(String),
}
impl From<String> for StartError {
fn from(message: String) -> Self {
Self::Failed(message)
}
}
impl From<&str> for StartError {
fn from(message: &str) -> Self {
Self::Failed(message.into())
}
}
const FRONTEND: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
#[derive(Default)]
pub(crate) struct Cancel {
pub(crate) stopped: AtomicBool,
changed: Notify,
}
impl Cancel {
pub(crate) fn stop(&self) {
self.stopped.store(true, Ordering::SeqCst);
self.changed.notify_one();
}
pub(crate) async fn wait(&self) {
if !self.stopped.load(Ordering::SeqCst) {
self.changed.notified().await;
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Phase {
Created,
@@ -69,6 +71,7 @@ enum Phase {
Starting,
Running,
}
struct Session {
owner: String,
app: String,
@@ -76,15 +79,20 @@ struct Session {
multiple: bool,
cursor: bool,
bar_controlled: bool,
persist_mode: u32,
restore: Option<crate::restore::Restore>,
cancel: Arc<Cancel>,
}
#[derive(Clone, Default)]
pub struct Backend {
sessions: Arc<Mutex<HashMap<String, Session>>>,
}
// This private interface only transfers stop controls to the shell's own recorder.
// The ordinary portal picker and all session revocation rules still apply.
struct RecorderControl(Backend);
#[zbus::interface(name = "org.ferese.ScreenRecorder")]
impl RecorderControl {
async fn use_bar_controls(
@@ -121,12 +129,14 @@ impl RecorderControl {
Ok(())
}
}
fn owns_session(sender: &str, path: &str) -> bool {
let Some(sender) = sender.strip_prefix(':') else {
return false;
};
path.starts_with(&format!("{PATH}/session/{}/", sender.replace('.', "_")))
}
fn shell_recorder(pid: u32) -> bool {
use std::os::unix::fs::MetadataExt;
let matches_binary = |pid: u32, name: &str| -> Option<bool> {
@@ -166,11 +176,14 @@ pub(crate) async fn authorize(
"Use the desktop portal".into(),
));
}
Ok(sender.to_string())
}
fn error(message: &str) -> zbus::fdo::Error {
zbus::fdo::Error::InvalidArgs(message.into())
}
fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
let number = |key: &str, default| {
options
@@ -194,8 +207,10 @@ fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
.transpose()
.map_err(|_| error("Invalid multiple option"))?
.unwrap_or(false);
Ok((multiple, cursor == 2))
}
impl Backend {
async fn end(&self, connection: &Connection, path: &str) {
let session = self.sessions.lock().await.remove(path);
@@ -216,20 +231,24 @@ impl Backend {
}
}
}
#[zbus::interface(name = "org.freedesktop.impl.portal.ScreenCast")]
impl Backend {
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
3
4
}
#[zbus(property)]
fn available_source_types(&self) -> u32 {
1
}
#[zbus(property)]
fn available_cursor_modes(&self) -> u32 {
3
}
async fn create_session(
&self,
_handle: OwnedObjectPath,
@@ -276,11 +295,14 @@ impl Backend {
multiple: false,
cursor: false,
bar_controlled: false,
persist_mode: 0,
restore: None,
cancel: Arc::default(),
},
);
Ok((0, Options::new()))
}
async fn select_sources(
&self,
_handle: OwnedObjectPath,
@@ -291,19 +313,25 @@ impl Backend {
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<(u32, Options)> {
let owner = authorize(connection, &header).await?;
let settings = source_options(&options);
let settings = source_options(&options).and_then(|(multiple, cursor)| {
Ok((multiple, cursor, crate::restore::persist_mode(&options)?))
});
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?;
if session.owner != owner
|| session.app != app_id
|| !matches!(session.phase, Phase::Created | Phase::Selected)
{
return Err(error("Invalid session state"));
}
match settings {
Ok((multiple, cursor)) => {
Ok((multiple, cursor, persist_mode)) => {
session.persist_mode = persist_mode;
session.restore = crate::restore::Restore::read(&options);
session.multiple = multiple;
session.cursor = cursor;
session.phase = Phase::Selected;
@@ -316,6 +344,7 @@ impl Backend {
}
}
}
#[allow(clippy::too_many_arguments)] // ScreenCast D-Bus method signature.
async fn start(
&self,
@@ -328,25 +357,31 @@ impl Backend {
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<(u32, Options)> {
let owner = authorize(connection, &header).await?;
if !handle
.as_str()
.starts_with("/org/freedesktop/portal/desktop/request/")
{
return Err(error("Invalid request path"));
}
let (multiple, cursor, bar_controlled, cancel) = {
let (multiple, cursor, bar_controlled, persist_mode, restore, cancel) = {
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?;
if session.owner != owner || session.app != app_id || session.phase != Phase::Selected {
return Err(error("Select sources before starting a session"));
}
session.phase = Phase::Starting;
(
session.multiple,
session.cursor,
session.bar_controlled,
session.persist_mode,
session.restore.clone(),
session.cancel.clone(),
)
};
@@ -360,6 +395,7 @@ impl Backend {
},
)
.await;
match inserted {
Ok(true) => (),
result => {
@@ -370,32 +406,46 @@ impl Backend {
});
}
}
let sharing = start_streams(
&app_id,
&parent_window,
multiple,
cursor,
persist_mode,
restore,
bar_controlled,
);
let result = tokio::select! {
_ = cancel.wait() => Err(StartError::Cancelled),
result = tokio::time::timeout(Duration::from_secs(120), start_streams(if bar_controlled { "Ferese" } else { &app_id }, &parent_window, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))),
result = tokio::time::timeout(Duration::from_secs(120), sharing) => {
result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into())))
}
};
let _ = connection
.object_server()
.remove::<Request, _>(handle)
.await;
match result {
Ok((children, streams)) if !cancel.stopped.load(Ordering::SeqCst) => {
Ok((children, streams, mut reply)) if !cancel.stopped.load(Ordering::SeqCst) => {
if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) {
session.phase = Phase::Running;
}
let backend = self.clone();
let connection = connection.clone();
let path = session_handle.to_string();
tokio::spawn(async move {
supervise(children, cancel).await;
backend.end(&connection, &path).await;
});
let mut reply = Options::new();
reply.insert(
"streams".into(),
OwnedValue::try_from(Value::from(streams))
.map_err(|e| error(&e.to_string()))?,
);
Ok((0, reply))
}
result => {
@@ -412,27 +462,37 @@ impl Backend {
}
}
}
pub(crate) struct Request {
pub(crate) cancel: Arc<Cancel>,
pub(crate) owner: String,
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Request")]
impl Request {
fn close(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> {
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
return Err(zbus::fdo::Error::AccessDenied("Wrong request owner".into()));
}
self.cancel.stop();
Ok(())
}
}
struct SessionObject {
backend: Backend,
path: String,
owner: String,
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Session")]
impl SessionObject {
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
1
}
async fn close(
&self,
#[zbus(connection)] connection: &Connection,
@@ -441,12 +501,15 @@ impl SessionObject {
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
return Err(zbus::fdo::Error::AccessDenied("Wrong session owner".into()));
}
self.backend.end(connection, &self.path).await;
Ok(())
}
#[zbus(signal)]
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
}
// All helpers die with the service, including a picker awaiting user input.
pub(crate) fn child_command() -> Result<Command, 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(())
});
}
command.kill_on_drop(true);
Ok(command)
}
async fn picker(prompt: &Prompt) -> Result<Child, String> {
let mut child = child_command()?
.arg("--picker")
@@ -485,44 +550,104 @@ async fn picker(prompt: &Prompt) -> Result<Child, String> {
.await
.map_err(|e| e.to_string())?;
drop(input);
Ok(child)
}
async fn start_streams(
app: &str,
parent: &str,
multiple: bool,
cursor: bool,
) -> Result<(Vec<Child>, Vec<(u32, Options)>), StartError> {
let sources = tokio::task::spawn_blocking(|| {
Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources())
})
.await
.map_err(|e| e.to_string())??;
let prompt = Prompt {
app: app.to_owned(),
sources,
multiple,
parent: parent.into(),
window_capture: false,
requested_persistence: u32,
restore: Option<crate::restore::Restore>,
bar_controlled: bool,
) -> Result<(Vec<Child>, Vec<(u32, Options)>, Options), StartError> {
let mut discovery = tokio::task::spawn_blocking(|| Capture::connect(&AtomicBool::new(false)))
.await
.map_err(|e| e.to_string())??;
let sources = discovery.sources();
let outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
let rememberable = sources
.iter()
.filter(|source| {
crate::restore::Restore::create(app, cursor, std::slice::from_ref(*source), &outputs, 1)
.is_some()
})
.map(|source| source.name.clone())
.collect::<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 selection = picker.wait_with_output().await.map_err(|e| e.to_string())?;
if !selection.status.success() {
return Err(StartError::Cancelled);
let mut reply = Options::new();
let fresh_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
if !crate::restore::selection_unchanged(&selected, &outputs, &fresh_outputs) {
return Err("A selected display changed while approval was pending".into());
}
let names: Vec<String> =
serde_json::from_slice(&selection.stdout).map_err(|_| StartError::Cancelled)?;
let selected = validate_selection(&prompt.sources, &names, multiple)?;
crate::desktop::wait_for_surface_removal(picker_pid).await?;
let approved = crate::restore::Restore::create(app, cursor, &selected, &outputs, persist_mode);
let effective_persistence = if let Some(approved) = approved.filter(|_| persist_mode > 0) {
reply.insert("restore_data".into(), approved.value()?);
persist_mode
} else {
0
};
reply.insert("persist_mode".into(), effective_persistence.into());
let generations = selected
.iter()
.map(|source| {
discovery
.generation(&source.name)
.map(|generation| (source.name.clone(), generation))
.ok_or("A selected display disconnected before startup")
})
.collect::<Result<Vec<_>, _>>()?;
let mut children = Vec::new();
let mut streams = Vec::new();
for source in &selected {
for (source, (_, generation)) in selected.iter().zip(&generations) {
let mut child = child_command()?
.args([
"--stream",
&source.name,
if cursor { "embedded" } else { "hidden" },
&generation.to_string(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -541,26 +666,39 @@ async fn start_streams(
.map_err(|e| e.to_string())?;
let ready: Ready =
serde_json::from_str(&line).map_err(|_| "PipeWire stream could not start")?;
if ready.node == u32::MAX {
return Err("Invalid PipeWire node".into());
}
streams.push((source.name.clone(), ready));
children.push(child);
}
let outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
tokio::task::spawn_blocking(move || discovery.validate_sources(&generations))
.await
.map_err(|error| error.to_string())??;
let final_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
if !crate::restore::selection_unchanged(&selected, &outputs, &final_outputs) {
return Err("A selected display changed during stream startup".into());
}
if children
.iter_mut()
.any(|child| !matches!(child.try_wait(), Ok(None)))
{
return Err("A selected stream ended during startup".into());
}
let streams = streams
.into_iter()
.map(|(name, ready)| {
monitor_metadata(&outputs, &name, &ready).map(|metadata| (ready.node, metadata))
monitor_metadata(&final_outputs, &name, &ready).map(|metadata| (ready.node, metadata))
})
.collect::<Result<Vec<_>, _>>()?;
Ok((children, streams))
Ok((children, streams, reply))
}
fn monitor_metadata(
@@ -598,15 +736,18 @@ fn monitor_metadata(
.and_then(|value| u32::try_from(value).ok())
.ok_or("Invalid physical output height")?,
);
if matches!(
output["transform"].as_str(),
Some("rotate_90" | "rotate_270" | "flipped_90" | "flipped_270")
) {
physical = (physical.1, physical.0);
}
if (ready.width, ready.height) != physical {
return Err("Selected monitor changed mode during startup".into());
}
Ok(HashMap::from([
("source_type".into(), 1u32.into()),
(
@@ -632,6 +773,7 @@ fn validate_selection(
if names.is_empty() || names.len() > sources.len() || (!multiple && names.len() != 1) {
return Err("Invalid display selection".into());
}
let mut selected = Vec::new();
for name in names {
if selected.iter().any(|s: &Source| &s.name == name) {
@@ -645,8 +787,10 @@ fn validate_selection(
.clone(),
);
}
Ok(selected)
}
async fn supervise(mut children: Vec<Child>, cancel: Arc<Cancel>) {
loop {
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;
}
cancel.stop();
for child in &mut children {
let _ = child.kill().await;
}
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let backend = Backend::default();
let settings = crate::settings::Settings::new();
@@ -688,11 +835,13 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
)?
.build()
.await?;
tokio::spawn(settings.watch(connection.clone()));
tokio::spawn(background.watch(connection.clone()));
tokio::spawn(lockdown.watch(connection.clone()));
tokio::spawn(inhibit.clone().watch(connection.clone()));
tokio::spawn(inhibit.clone().watch_logind());
// Frontend death must revoke every stream, even if Session.Close never arrives.
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
@@ -707,6 +856,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
requests.revoke_stale(owner.as_deref()).await;
shortcuts.revoke_stale(&connection, owner.as_deref()).await;
inhibit.revoke_stale(&connection, owner.as_deref()).await;
let stale: Vec<_> = backend
.sessions
.lock()
@@ -720,6 +870,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -739,6 +890,7 @@ mod tests {
));
assert!(!shell_recorder(std::process::id()));
}
#[test]
fn rejects_unimplemented_sources_and_cursor_modes() {
assert_eq!(source_options(&Options::new()).unwrap(), (false, false));
@@ -753,6 +905,7 @@ mod tests {
}
assert!(source_options(&HashMap::from([("cursor_mode".into(), 4u32.into())])).is_err());
}
#[test]
fn monitor_metadata_uses_logical_fractional_scale_geometry() {
let outputs = serde_json::json!([{"name":"display","enabled":true,"x":-1440,"y":20,"width":2560,"height":1440,"scale":1.5,"current_mode":{"width":3840,"height":2160}}]);
+36
View File
@@ -73,6 +73,7 @@ pub struct Capture {
connection: Connection,
queue: EventQueue<State>,
state: State,
selected_output: Option<u32>,
}
impl Capture {
@@ -84,6 +85,7 @@ impl Capture {
connection,
queue,
state: State::default(),
selected_output: None,
};
// Two syncs deliver registry bindings and output metadata without an
// uninterruptible roundtrip on a stalled compositor.
@@ -107,6 +109,32 @@ impl Capture {
Ok(())
}
pub fn generation(&self, name: &str) -> Option<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> {
self.state
.outputs
@@ -135,6 +163,14 @@ impl Capture {
.find(|o| o.source.name == name)
.ok_or("Shared monitor disconnected")?;
if self
.selected_output
.is_some_and(|selected| selected != output.global)
{
return Err("Shared monitor was replaced".into());
}
self.selected_output = Some(output.global);
let transform = output.transform;
self.state.result = None;
self.state.flipped = false;
+2
View File
@@ -474,6 +474,8 @@ async fn select_window(app: &str, parent: &str) -> Result<Option<u64>, String> {
multiple: false,
parent: parent.into(),
window_capture: true,
persist_mode: 0,
rememberable: Vec::new(),
};
let mut child = crate::backend::child_command()?
.arg("--picker")
+7 -1
View File
@@ -8,6 +8,7 @@ mod lockdown;
mod parent;
mod permissions;
mod picker;
mod restore;
mod settings;
mod shortcuts;
mod stream;
@@ -17,8 +18,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match args.as_slice() {
[flag, name, cursor] if flag == "--stream" => {
stream::run(name.clone(), cursor == "embedded")
stream::run(name.clone(), cursor == "embedded", None)
}
[flag, name, cursor, generation] if flag == "--stream" => stream::run(
name.clone(),
cursor == "embedded",
Some(generation.parse()?),
),
[flag] if flag == "--sources" => {
let capture = capture::Capture::connect(&std::sync::atomic::AtomicBool::new(false))?;
println!("{}", serde_json::to_string(&capture.sources())?);
+64 -2
View File
@@ -18,6 +18,17 @@ pub struct Prompt {
pub parent: String,
#[serde(default)]
pub window_capture: bool,
#[serde(default)]
pub persist_mode: u32,
#[serde(default)]
pub rememberable: Vec<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Selection {
pub names: Vec<String>,
pub persist_mode: u32,
}
#[derive(Clone, Debug)]
@@ -33,6 +44,7 @@ enum Message {
>,
),
Select(usize),
Remember,
Share,
Cancel,
}
@@ -43,6 +55,7 @@ struct Picker {
parent: Option<std::sync::Arc<crate::parent::Parent>>,
prompt: Prompt,
selected: Vec<usize>,
remember: bool,
background: cosmic::iced::Color,
palette: ferese_theme::Palette,
font: cosmic::font::Font,
@@ -65,7 +78,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
}
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>(
Settings::default()
.size(cosmic::iced::Size::new(400., height))
@@ -114,6 +129,7 @@ impl cosmic::Application for Picker {
parent: None,
prompt,
selected: Vec::new(),
remember: false,
background,
palette,
font,
@@ -176,14 +192,28 @@ impl cosmic::Application for Picker {
}
self.selected.push(index);
}
self.remember &= self.can_remember();
}
Message::Remember if self.can_remember() => self.remember = !self.remember,
Message::Share if !self.selected.is_empty() => {
let names: Vec<_> = self
.selected
.iter()
.map(|i| &self.prompt.sources[*i].name)
.collect();
println!("{}", serde_json::to_string(&names).unwrap());
if self.prompt.window_capture {
println!("{}", serde_json::to_string(&names).unwrap());
} else {
let selection = Selection {
names: names.into_iter().cloned().collect(),
persist_mode: if self.remember && self.can_remember() {
self.prompt.persist_mode
} else {
0
},
};
println!("{}", serde_json::to_string(&selection).unwrap());
}
let _ = std::io::stdout().flush();
return cosmic::iced::exit();
}
@@ -280,6 +310,28 @@ impl cosmic::Application for Picker {
.size(12)
.width(Length::Fill),
);
if self.prompt.persist_mode > 0 && !self.prompt.window_capture {
let label = if !self.can_remember() {
"This selection needs approval each time"
} else if self.prompt.persist_mode == 1 {
"Allow without asking while this app is running"
} else {
"Allow this selection without asking again"
};
content = content.push(
button::custom(ferese_theme::menus::row(
label,
ferese_theme::menus::switch(self.remember, self.palette, 1.),
self.font,
))
.class(ferese_theme::controls::navigation_style(
self.palette,
false,
))
.width(Length::Fill)
.on_press_maybe(self.can_remember().then_some(Message::Remember)),
);
}
let mut share = button::custom(
row![
self.text(if self.prompt.window_capture {
@@ -311,6 +363,16 @@ impl cosmic::Application for Picker {
}
impl Picker {
fn can_remember(&self) -> bool {
!self.selected.is_empty()
&& self.selected.len() <= 16
&& self.selected.iter().all(|index| {
self.prompt
.rememberable
.contains(&self.prompt.sources[*index].name)
})
}
fn text<'a>(
&self,
content: impl Into<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()
}
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 pipe_stop = stop.clone();
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);
});
let mut capture = Capture::connect(&stop)?;
if let Some(generation) = generation {
capture.pin_output(&name, generation)?;
}
let first = capture.frame(&name, cursor, &stop, Vec::new())?;
let (width, height) = (first.width, first.height);
let latest = Arc::new(Mutex::new(first));