From ddbed192c0bc9af87d011bae74983f9d01d4d39c Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 29 Sep 2026 22:53:30 +0100 Subject: [PATCH] Add background and USB consent with XDG session autostart --- Cargo.lock | 1 + compositor/src/ipc.rs | 10 +- docs/portals.md | 13 +- feresectl/Cargo.toml | 1 + feresectl/src/autostart.rs | 185 +++++++++++ feresectl/src/main.rs | 10 +- packaging/ferese-session-shell | 10 + packaging/portal/ferese-portals.conf | 2 + packaging/portal/ferese.portal | 2 +- portal/src/backend.rs | 4 + portal/src/main.rs | 1 + portal/src/permissions.rs | 427 +++++++++++++++++++++++++ scripts/tests/test_portal_contracts.py | 35 +- 13 files changed, 690 insertions(+), 11 deletions(-) create mode 100644 feresectl/src/autostart.rs create mode 100644 portal/src/permissions.rs diff --git a/Cargo.lock b/Cargo.lock index 0e193be..3d2b33b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,7 @@ name = "feresectl" version = "0.1.0" dependencies = [ "ferese-ipc", + "libc", "serde_json", ] diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 344a960..57bb1e8 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -556,7 +556,7 @@ impl Ferese { attributes.title.clone().unwrap_or_default(), ) }); - let rect = self.visual_rect_for_window(window)?; + let rect = self.visual_rect_for_window(window); Some(json!({ "id": id.0, "app_id": app_id, @@ -564,10 +564,10 @@ impl Ferese { "focused": self.focused_window == Some(*id), "mapped": self.space.element_location(window).is_some(), "workspace": self.workspaces.workspace_for_window(*id).map(|workspace| workspace.0), - "x": rect.loc.x, - "y": rect.loc.y, - "width": rect.size.w, - "height": rect.size.h, + "x": rect.as_ref().map(|rect| rect.loc.x), + "y": rect.as_ref().map(|rect| rect.loc.y), + "width": rect.as_ref().map(|rect| rect.size.w), + "height": rect.as_ref().map(|rect| rect.size.h), })) }) .collect::>(); diff --git a/docs/portals.md b/docs/portals.md index 705c9fc..bd1f050 100644 --- a/docs/portals.md +++ b/docs/portals.md @@ -19,11 +19,18 @@ standard file, print and application dialogs. Install both backends. temporary portal files can disappear safely. Image bytes and decoded dimensions are bounded. `theme.background.lock-path` overrides the lock-screen image; without it the lock screen follows `theme.background.path`. +- **Background:** native open-window/app state, change signals and per-instance + background consent. Legacy autostart requests create managed XDG desktop entries; + current frontends manage those files themselves. Direct sessions launch standard + XDG autostart entries after importing the display environment. User overrides, + Hidden, OnlyShowIn, NotShowIn and TryExec are respected; previews skip autostart. +- **USB:** native confirmation showing device vendor/model and requested read-only + or read/write access. The portal frontend handles device enumeration and opening. - **ScreenCast:** monitor sharing with a native picker and PipeWire streams. See [screen sharing](screen-sharing.md) for lifetime and recording details. Native request dialogs use Ferese's theme and are floating windows. Screenshot, -PickColor and Wallpaper consent dialogs use Wayland parent identifiers to establish +PickColor, Wallpaper and USB consent dialogs use Wayland parent identifiers to establish a transient relationship with the requesting window. Cancelling a request or losing the portal frontend terminates its pending helper. @@ -45,8 +52,8 @@ Matching D-Bus signatures does not establish complete feature parity. | Wallpaper | Native desktop, lock-screen and combined targets | | ScreenCast | Native monitors; window sources, persistence and richer metadata remain missing | | Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK | -| Background | Missing native application state and background consent | -| Usb | Missing native device consent | +| Background | Native application state, per-instance consent and XDG autostart | +| Usb | Native consent; standard frontend handles enumeration and device descriptors | | GlobalShortcuts | Missing session-scoped compositor registration and activation signals | | RemoteDesktop | Missing authorized input injection and EIS transport | | Clipboard | Missing remote-session clipboard transfer | diff --git a/feresectl/Cargo.toml b/feresectl/Cargo.toml index 21a3574..738a259 100644 --- a/feresectl/Cargo.toml +++ b/feresectl/Cargo.toml @@ -8,3 +8,4 @@ rust-version.workspace = true [dependencies] ferese-ipc = { path = "../crates/ferese-ipc" } serde_json.workspace = true +libc.workspace = true diff --git a/feresectl/src/autostart.rs b/feresectl/src/autostart.rs new file mode 100644 index 0000000..c323f3b --- /dev/null +++ b/feresectl/src/autostart.rs @@ -0,0 +1,185 @@ +use std::{ + collections::BTreeMap, + env, fs, + io::Read, + os::unix::process::CommandExt, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +pub(crate) fn run() -> Result<(), Box> { + for key in ["WAYLAND_SOCKET", "FERESE_SHELL_CONTROL_SOCKET"] { + if let Some(fd) = env::var(key) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|fd| *fd >= 3) + { + // These session-helper descriptors must never reach ordinary startup apps. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags < 0 || libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) < 0 { + return Err(std::io::Error::last_os_error().into()); + } + } + } + } + let home = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .ok_or("Missing user config directory")?; + let system = env::var_os("XDG_CONFIG_DIRS").unwrap_or_default(); + let mut system_directories = env::split_paths(&system) + .filter(|path| path.is_absolute()) + .collect::>(); + if system_directories.is_empty() { + system_directories.push(PathBuf::from("/etc/xdg")); + } + let directories = std::iter::once(home) + .chain(system_directories) + .collect::>(); + let mut entries = BTreeMap::new(); + for directory in directories { + let Ok(files) = fs::read_dir(directory.join("autostart")) else { + continue; + }; + for file in files.flatten() { + if file + .path() + .extension() + .is_some_and(|extension| extension == "desktop") + { + entries.entry(file.file_name()).or_insert(file.path()); + } + } + } + let desktop = env::var("XDG_CURRENT_DESKTOP").unwrap_or_else(|_| "Ferese".into()); + for path in entries.into_values() { + let Some(entry) = read_entry(&path) else { + continue; + }; + if !enabled(&entry, &desktop) { + continue; + } + if let Some(program) = entry.get("TryExec").filter(|program| !program.is_empty()) { + if !available(program) { + continue; + } + } + let parent = std::process::id() as libc::pid_t; + let mut command = Command::new("timeout"); + command + .args(["--kill-after=1s", "15s", "gio", "launch"]) + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .env_remove("WAYLAND_SOCKET") + .env_remove("FERESE_SHELL_CONTROL_SOCKET") + .env_remove("FERESE_PUBLIC_WAYLAND_DISPLAY"); + // Only signal-safe calls run in the child before exec. A pending launcher + // dies with this helper, while apps already launched by GIO remain independent. + unsafe { + command.pre_exec(move || { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::getppid() != parent { + libc::_exit(1); + } + Ok(()) + }); + } + let result = command.status(); + if !matches!(result, Ok(status) if status.success()) { + eprintln!("ferese: could not autostart {}", path.display()); + } + } + Ok(()) +} + +fn read_entry(path: &Path) -> Option> { + use std::os::unix::fs::OpenOptionsExt; + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + .ok()?; + if !file.metadata().ok()?.is_file() { + return None; + } + let mut source = String::new(); + file.take(1024 * 1024 + 1) + .read_to_string(&mut source) + .ok()?; + if source.len() > 1024 * 1024 { + return None; + } + let mut in_entry = false; + let mut fields = BTreeMap::new(); + for line in source.lines().map(str::trim) { + if line.starts_with('[') { + in_entry = line == "[Desktop Entry]"; + continue; + } + if !in_entry || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + fields.insert(key.trim().to_owned(), value.to_owned()); + } + } + Some(fields) +} + +fn enabled(entry: &BTreeMap, desktop: &str) -> bool { + if entry.get("Type").is_none_or(|value| value != "Application") + || entry.get("Hidden").is_some_and(|value| value == "true") + || entry + .get("X-GNOME-Autostart-enabled") + .is_some_and(|value| value == "false") + { + return false; + } + let matching = |list: &str| { + list.split(';') + .filter(|name| !name.is_empty()) + .any(|name| desktop.split(':').any(|current| current == name)) + }; + entry.get("OnlyShowIn").is_none_or(|list| matching(list)) + && entry.get("NotShowIn").is_none_or(|list| !matching(list)) +} + +fn available(program: &str) -> bool { + use std::os::unix::fs::PermissionsExt; + let executable = |path: &Path| { + fs::metadata(path) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + }; + if program.contains('/') { + executable(Path::new(program)) + } else { + env::var_os("PATH").is_some_and(|paths| { + env::split_paths(&paths).any(|path| executable(&path.join(program))) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn respects_hidden_and_desktop_filters() { + let mut entry = BTreeMap::from([("Type".into(), "Application".into())]); + assert!(enabled(&entry, "Ferese")); + entry.insert("OnlyShowIn".into(), "GNOME;".into()); + assert!(!enabled(&entry, "Ferese")); + entry.insert("OnlyShowIn".into(), "Ferese;".into()); + assert!(enabled(&entry, "Ferese:Other")); + entry.insert("NotShowIn".into(), "Ferese;".into()); + assert!(!enabled(&entry, "Ferese")); + entry.remove("NotShowIn"); + entry.insert("Hidden".into(), "true".into()); + assert!(!enabled(&entry, "Ferese")); + } +} diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index a44d077..4d7c736 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -1,3 +1,5 @@ +mod autostart; + use std::env; use std::error::Error; use std::fs; @@ -9,6 +11,12 @@ use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; use serde_json::{Value, json}; fn main() -> Result<(), Box> { + if env::args().nth(1).as_deref() == Some("autostart") { + if env::args().len() != 2 { + return Err("usage: feresectl autostart".into()); + } + return autostart::run(); + } let (command, args) = parse_args(env::args().skip(1))?; let request = Request { version: VERSION, @@ -118,7 +126,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] diff --git a/packaging/ferese-session-shell b/packaging/ferese-session-shell index 1caeb76..363229b 100755 --- a/packaging/ferese-session-shell +++ b/packaging/ferese-session-shell @@ -3,8 +3,13 @@ set -euo pipefail session_target_started=0 shell_pid= agent_pid= +autostart_pid= cleanup() { trap - EXIT INT TERM + if [[ -n $autostart_pid ]]; then + kill "$autostart_pid" 2>/dev/null || true + wait "$autostart_pid" 2>/dev/null || true + fi if [[ -n $shell_pid ]]; then kill "$shell_pid" 2>/dev/null || true; fi if [[ -n $agent_pid ]]; then kill "$agent_pid" 2>/dev/null || true @@ -53,6 +58,11 @@ env -u WAYLAND_SOCKET WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAN agent_pid=$! "$release_dir/ferese-shell" & shell_pid=$! +if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 && -x $release_dir/feresectl ]]; then + env WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \ + "$release_dir/feresectl" autostart & + autostart_pid=$! +fi if wait "$shell_pid"; then status=0 else diff --git a/packaging/portal/ferese-portals.conf b/packaging/portal/ferese-portals.conf index 7597f87..8fda0f6 100644 --- a/packaging/portal/ferese-portals.conf +++ b/packaging/portal/ferese-portals.conf @@ -4,3 +4,5 @@ org.freedesktop.impl.portal.ScreenCast=ferese; org.freedesktop.impl.portal.Settings=ferese; org.freedesktop.impl.portal.Screenshot=ferese; org.freedesktop.impl.portal.Wallpaper=ferese; +org.freedesktop.impl.portal.Background=ferese; +org.freedesktop.impl.portal.Usb=ferese; diff --git a/packaging/portal/ferese.portal b/packaging/portal/ferese.portal index 9b2fc6a..5558d2c 100644 --- a/packaging/portal/ferese.portal +++ b/packaging/portal/ferese.portal @@ -1,4 +1,4 @@ [portal] DBusName=org.freedesktop.impl.portal.desktop.ferese -Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper; +Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb; UseIn=Ferese; diff --git a/portal/src/backend.rs b/portal/src/backend.rs index f29eba5..9b86bc3 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -593,12 +593,15 @@ pub async fn run() -> Result<(), Box> { let backend = Backend::default(); let settings = crate::settings::Settings::new(); let requests = crate::desktop::Requests::default(); + let background = crate::permissions::Background::new(requests.clone()); let connection = zbus::connection::Builder::session()? .name("org.freedesktop.impl.portal.desktop.ferese")? .serve_at(PATH, backend.clone())? .serve_at(PATH, settings.clone())? .serve_at(PATH, crate::desktop::Screenshot(requests.clone()))? .serve_at(PATH, crate::desktop::Wallpaper(requests.clone()))? + .serve_at(PATH, crate::permissions::Usb(requests.clone()))? + .serve_at(PATH, background.clone())? .serve_at( "/org/ferese/ScreenRecorder", RecorderControl(backend.clone()), @@ -606,6 +609,7 @@ pub async fn run() -> Result<(), Box> { .build() .await?; tokio::spawn(settings.watch(connection.clone())); + tokio::spawn(background.watch(connection.clone())); // Frontend death must revoke every stream, even if Session.Close never arrives. loop { tokio::time::sleep(Duration::from_secs(1)).await; diff --git a/portal/src/main.rs b/portal/src/main.rs index 73eccae..d7eaca0 100644 --- a/portal/src/main.rs +++ b/portal/src/main.rs @@ -3,6 +3,7 @@ mod capture; mod consent; mod desktop; mod parent; +mod permissions; mod picker; mod settings; mod stream; diff --git a/portal/src/permissions.rs b/portal/src/permissions.rs new file mode 100644 index 0000000..07e523b --- /dev/null +++ b/portal/src/permissions.rs @@ -0,0 +1,427 @@ +use crate::{ + backend::{Options, authorize}, + desktop::{Requests, consent, ipc, reply}, +}; +use std::{ + collections::{HashMap, HashSet}, + path::Path, + sync::Arc, + time::Duration, +}; +use tokio::sync::RwLock; +use zbus::{ + Connection, + message::Header, + object_server::SignalEmitter, + zvariant::{OwnedObjectPath, OwnedValue, Value}, +}; + +const PATH: &str = "/org/freedesktop/portal/desktop"; + +#[derive(Clone)] +pub(crate) struct Usb(pub(crate) Requests); + +#[zbus::interface(name = "org.freedesktop.impl.portal.Usb")] +impl Usb { + #[zbus(property, name = "version")] + fn version(&self) -> u32 { + 1 + } + + async fn acquire_devices( + &self, + handle: OwnedObjectPath, + parent_window: String, + app_id: String, + devices: Vec<(String, Options, Options)>, + _options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let cancel = self.0.begin(connection, &header, &handle).await?; + let result = tokio::select! { + biased; + _ = cancel.wait() => (1, Options::new()), + result = usb_consent(&app_id, &parent_window, devices) => reply(result), + }; + self.0.end(connection, &handle).await; + Ok(result) + } +} + +fn usb_label(info: &Options, id: &str) -> String { + let properties = info + .get("properties") + .and_then(|value| value.try_clone().ok()) + .and_then(|value| HashMap::::try_from(value).ok()) + .unwrap_or_default(); + let property = |keys: &[&str]| { + keys.iter().find_map(|key| { + properties + .get(*key) + .and_then(|value| <&str>::try_from(value).ok()) + }) + }; + let vendor = property(&["ID_VENDOR_FROM_DATABASE", "ID_VENDOR", "ID_VENDOR_ID"]); + let model = property(&["ID_MODEL_FROM_DATABASE", "ID_MODEL", "ID_MODEL_ID"]); + let label = [vendor, model] + .into_iter() + .flatten() + .collect::>() + .join(" "); + let label = if label.is_empty() { id } else { &label }; + label + .chars() + .filter(|character| !character.is_control()) + .take(256) + .collect() +} + +async fn usb_consent( + app: &str, + parent: &str, + devices: Vec<(String, Options, Options)>, +) -> Result, String> { + if devices.is_empty() || devices.len() > 32 { + return Err("Invalid USB device count".into()); + } + let mut ids = HashSet::new(); + let mut selected = Vec::new(); + let mut description = "Allow access to these USB devices?\n".to_owned(); + for (id, info, options) in devices { + if id.is_empty() || id.len() > 512 || !ids.insert(id.clone()) { + return Err("Invalid or duplicate USB device identifier".into()); + } + let writable = options + .get("writable") + .map(bool::try_from) + .transpose() + .map_err(|_| "Invalid USB access mode")? + .unwrap_or(false); + let label = usb_label(&info, &id); + description.push_str(&format!( + "\n{} — {}", + label, + if writable { + "read and write" + } else { + "read only" + } + )); + selected.push(( + id, + HashMap::from([("writable".to_owned(), OwnedValue::from(writable))]), + )); + } + if !consent( + app, + parent, + "USB device access", + &description, + "Allow access", + None, + ) + .await? + { + return Ok(None); + } + Ok(Some(HashMap::from([( + "devices".into(), + Value::from(selected) + .try_to_owned() + .map_err(|error| error.to_string())?, + )]))) +} + +#[derive(Clone)] +pub(crate) struct Background { + requests: Requests, + states: Arc>>, +} + +impl Background { + pub(crate) fn new(requests: Requests) -> Self { + Self { + requests, + states: Default::default(), + } + } + + pub(crate) async fn watch(self, connection: Connection) { + let emitter = SignalEmitter::new(&connection, PATH).unwrap(); + loop { + if let Ok(windows) = ipc("get-windows", serde_json::json!({})).await { + let next = app_states(&windows); + let mut previous = self.states.write().await; + if *previous != next { + *previous = next; + drop(previous); + let _ = Self::running_applications_changed(&emitter).await; + } + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + } +} + +#[zbus::interface(name = "org.freedesktop.impl.portal.Background")] +impl Background { + async fn get_app_state( + &self, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result { + authorize(connection, &header).await?; + Ok(self + .states + .read() + .await + .iter() + .map(|(app, state)| (app.clone(), (*state).into())) + .collect()) + } + + async fn notify_background( + &self, + handle: OwnedObjectPath, + app_id: String, + name: String, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let cancel = self.requests.begin(connection, &header, &handle).await?; + let result = tokio::select! { + biased; + _ = cancel.wait() => (1, Options::new()), + result = background_consent(&app_id, &name) => reply(result), + }; + self.requests.end(connection, &handle).await; + Ok(result) + } + + async fn enable_autostart( + &self, + app_id: String, + enable: bool, + commandline: Vec, + flags: u32, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result { + authorize(connection, &header).await?; + let directory = dirs::config_dir() + .ok_or_else(|| zbus::fdo::Error::Failed("Missing config directory".into()))? + .join("autostart"); + set_autostart(&directory, &app_id, enable, &commandline, flags) + .map_err(zbus::fdo::Error::InvalidArgs) + } + + #[zbus(signal)] + async fn running_applications_changed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +async fn background_consent(app: &str, name: &str) -> Result, String> { + if name.len() > 512 { + return Err("Background application name is too long".into()); + } + let allow = consent( + app, + "", + "Background activity", + &format!("{name} wants to keep running after its windows close."), + "Allow this time", + None, + ) + .await?; + Ok(Some(HashMap::from([( + "result".into(), + OwnedValue::from(if allow { 2u32 } else { 0u32 }), + )]))) +} + +fn app_states(windows: &serde_json::Value) -> HashMap { + let mut apps: HashMap = HashMap::new(); + for window in windows.as_array().into_iter().flatten() { + let Some(app) = window["app_id"].as_str().filter(|app| !app.is_empty()) else { + continue; + }; + let app = app.strip_suffix(".desktop").unwrap_or(app).to_owned(); + let state = if window["focused"] == true { 2 } else { 1 }; + apps.entry(app) + .and_modify(|old| *old = (*old).max(state)) + .or_insert(state); + } + apps +} + +fn set_autostart( + directory: &Path, + app: &str, + enable: bool, + argv: &[String], + flags: u32, +) -> Result { + if app.is_empty() + || app.len() > 255 + || !app + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + || app.starts_with('.') + || flags & !1 != 0 + { + return Err("Invalid autostart application or flags".into()); + } + let path = directory.join(format!("{app}.desktop")); + let previous = read_managed_entry(&path)?; + if previous + .as_ref() + .is_some_and(|source| !source.lines().any(|line| line == "X-Ferese-Portal=true")) + { + return Err("Existing autostart entry is not managed by Ferese".into()); + } + if !enable { + match std::fs::remove_file(path) { + Ok(()) => (), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + Err(error) => return Err(error.to_string()), + } + return Ok(true); + } + if argv.len() > 128 + || (argv.first().is_none_or(|argument| argument.is_empty()) && flags & 1 == 0) + { + return Err("Invalid autostart command".into()); + } + let exec = argv + .iter() + .map(|argument| desktop_argument(argument)) + .collect::, _>>()? + .join(" "); + let entry = format!( + "[Desktop Entry]\nType=Application\nName={app}\nExec={exec}\nDBusActivatable={}\nX-Ferese-Portal=true\n", + flags & 1 != 0 + ); + std::fs::create_dir_all(directory).map_err(|error| error.to_string())?; + let mut file = tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?; + std::io::Write::write_all(&mut file, entry.as_bytes()).map_err(|error| error.to_string())?; + if read_managed_entry(&path)? != previous { + return Err("Autostart entry changed; please try again".into()); + } + file.persist(path).map_err(|error| error.to_string())?; + Ok(true) +} + +fn read_managed_entry(path: &Path) -> Result, String> { + use std::io::Read; + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Ok(metadata) if metadata.is_file() && metadata.len() <= 1024 * 1024 => (), + Ok(_) => return Err("Existing autostart entry is not a regular file".into()), + Err(error) => return Err(error.to_string()), + } + let mut source = String::new(); + std::fs::File::open(path) + .map_err(|error| error.to_string())? + .take(1024 * 1024 + 1) + .read_to_string(&mut source) + .map_err(|error| error.to_string())?; + if source.len() > 1024 * 1024 { + return Err("Existing autostart entry is too large".into()); + } + Ok(Some(source)) +} + +fn desktop_argument(argument: &str) -> Result { + if argument.len() > 4096 || argument.chars().any(|character| character.is_control()) { + return Err("Invalid autostart argument".into()); + } + let mut result = "\"".to_owned(); + for character in argument.chars() { + match character { + '%' => result.push_str("%%"), + '\\' => result.push_str("\\\\\\\\"), + '$' | '`' | '"' => { + result.push_str("\\\\"); + result.push(character); + } + _ => result.push(character), + } + } + result.push('"'); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn application_state_groups_windows_and_keeps_inactive_workspaces_running() { + let windows = serde_json::json!([ + {"app_id":"org.test.App.desktop","focused":false,"mapped":false}, + {"app_id":"org.test.App","focused":true,"mapped":true}, + {"app_id":"org.test.Other","focused":false,"mapped":true}, + {"app_id":"","focused":false} + ]); + assert_eq!( + app_states(&windows), + HashMap::from([("org.test.App".into(), 2), ("org.test.Other".into(), 1)]) + ); + assert!(app_states(&serde_json::json!([])).is_empty()); + } + + #[test] + fn usb_labels_use_vendor_and_model_from_udev_properties() { + let properties = HashMap::from([ + ( + "ID_VENDOR_FROM_DATABASE".to_owned(), + Value::from("Example Vendor").try_to_owned().unwrap(), + ), + ( + "ID_MODEL".to_owned(), + Value::from("Gamepad").try_to_owned().unwrap(), + ), + ]); + let info = HashMap::from([( + "properties".into(), + Value::from(properties).try_to_owned().unwrap(), + )]); + assert_eq!(usb_label(&info, "opaque-id"), "Example Vendor Gamepad"); + assert_eq!(usb_label(&Options::new(), "fallback-id"), "fallback-id"); + } + + #[test] + fn autostart_preserves_existing_links_directories_and_invalid_text() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("org.test.App.desktop"); + std::os::unix::fs::symlink("missing", &path).unwrap(); + assert!(set_autostart(dir.path(), "org.test.App", false, &[], 0).is_err()); + assert!(path.symlink_metadata().unwrap().file_type().is_symlink()); + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + assert!(set_autostart(dir.path(), "org.test.App", false, &[], 0).is_err()); + std::fs::remove_dir(&path).unwrap(); + std::fs::write(&path, [255u8]).unwrap(); + assert!(set_autostart(dir.path(), "org.test.App", false, &[], 0).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), [255]); + } + + #[test] + fn autostart_is_scoped_to_managed_files_and_literal_argv() { + let dir = tempfile::tempdir().unwrap(); + let argv = vec!["program".into(), "a $x `y` 50% \\\"".into()]; + assert!(set_autostart(dir.path(), "org.test.App", true, &argv, 0).unwrap()); + let path = dir.path().join("org.test.App.desktop"); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("50%%")); + assert!(text.contains("X-Ferese-Portal=true")); + assert!(set_autostart(dir.path(), "org.test.App", false, &[], 0).unwrap()); + assert!(!path.exists()); + std::fs::write(&path, "[Desktop Entry]\nName=My custom file\n").unwrap(); + assert!(set_autostart(dir.path(), "org.test.App", true, &argv, 0).is_err()); + for app in ["../escape", ".hidden", "bad/name", "bad\nname"] { + assert!(set_autostart(dir.path(), app, true, &argv, 0).is_err()); + } + assert!(desktop_argument("bad\nargument").is_err()); + } +} diff --git a/scripts/tests/test_portal_contracts.py b/scripts/tests/test_portal_contracts.py index 259447f..9b94e5e 100644 --- a/scripts/tests/test_portal_contracts.py +++ b/scripts/tests/test_portal_contracts.py @@ -1,5 +1,6 @@ """Check native portal wire contracts on a private bus without touching the desktop.""" import os +import json from pathlib import Path import signal import subprocess @@ -53,7 +54,7 @@ def private_bus_checks(): raise AssertionError((root / "backend.log").read_text()) time.sleep(0.05) node = ET.fromstring(xml) - for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper"): + for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb"): interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']") assert interface is not None, name expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface") @@ -95,6 +96,38 @@ def private_bus_checks(): response, values = call("org.freedesktop.impl.portal.Screenshot", "Screenshot", params, "(ua{sv})") assert response == 2 and values == {} + usb_params = GLib.Variant("(oss a(sa{sv}a{sv}) a{sv})".replace(" ", ""), + (PATH + "/request/test/usb", "", "org.test.App", [], {})) + response, values = call("org.freedesktop.impl.portal.Usb", "AcquireDevices", usb_params, "(ua{sv})") + assert response == 2 and values == {} + call("org.freedesktop.impl.portal.Background", "GetAppState", None, "(a{sv})") + + script = root / "startup.py" + script.write_text("import json, os, sys\nfd = int(sys.argv[2])\ntry:\n os.fstat(fd); inherited = True\nexcept OSError:\n inherited = False\nopen(sys.argv[1], 'w').write(json.dumps({'args': sys.argv[3:], 'private_fd': inherited, 'socket_env': os.environ.get('WAYLAND_SOCKET')}))\n") + output = root / "startup.json" + read_fd, write_fd = os.pipe() + try: + arguments = ["value with spaces", "50%", '$dollar `backtick` "quotes" \\ slash'] + argv = [sys.executable, str(script), str(output), str(write_fd)] + arguments + result, = call("org.freedesktop.impl.portal.Background", "EnableAutostart", + GLib.Variant("(sbasu)", ("org.test.Startup", True, argv, 0)), "(b)") + assert result is True + os.mkfifo(root / "autostart/000-fifo.desktop") + env = dict(os.environ, XDG_CONFIG_HOME=directory, XDG_CONFIG_DIRS=str(root / "empty"), XDG_CURRENT_DESKTOP="Ferese", WAYLAND_SOCKET=str(write_fd)) + launcher = REPO / "target/debug/feresectl" + subprocess.run([str(launcher), "autostart"], env=env, pass_fds=(write_fd,), check=True, timeout=20) + deadline = time.monotonic() + 5 + while not output.exists() and time.monotonic() < deadline: + time.sleep(0.02) + actual = json.loads(output.read_text()) + assert actual == {"args": arguments, "private_fd": False, "socket_env": None}, actual + result, = call("org.freedesktop.impl.portal.Background", "EnableAutostart", + GLib.Variant("(sbasu)", ("org.test.Startup", False, [], 0)), "(b)") + assert result is True and not (root / "autostart/org.test.Startup.desktop").exists() + finally: + os.close(read_fd) + os.close(write_fd) + signals = [] subscription = bus.signal_subscribe(NAME, "org.freedesktop.impl.portal.Settings", "SettingChanged", PATH, None, Gio.DBusSignalFlags.NONE, lambda *args: signals.append(args[-1].unpack()))