Add background and USB consent with XDG session autostart

This commit is contained in:
Samuel Onoja committed 2026-09-29 22:53:30 +01:00
1 parent 915bc8afd6
commit ddbed192c0
13 files changed
+690 -11

No files matched your search

Generated
+1
View File
@@ -1800,6 +1800,7 @@ name = "feresectl"
version = "0.1.0"
dependencies = [
"ferese-ipc",
"libc",
"serde_json",
]
+5 -5
View File
@@ -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::<Vec<_>>();
+10 -3
View File
@@ -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 |
+1
View File
@@ -8,3 +8,4 @@ rust-version.workspace = true
[dependencies]
ferese-ipc = { path = "../crates/ferese-ipc" }
serde_json.workspace = true
libc.workspace = true
+185
View File
@@ -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<dyn std::error::Error>> {
for key in ["WAYLAND_SOCKET", "FERESE_SHELL_CONTROL_SOCKET"] {
if let Some(fd) = env::var(key)
.ok()
.and_then(|value| value.parse::<i32>().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::<Vec<_>>();
if system_directories.is_empty() {
system_directories.push(PathBuf::from("/etc/xdg"));
}
let directories = std::iter::once(home)
.chain(system_directories)
.collect::<Vec<_>>();
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<BTreeMap<String, String>> {
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<String, String>, 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"));
}
}
+9 -1
View File
@@ -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<dyn Error>> {
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<PathBuf, io::Error> {
}
fn usage() -> String {
"usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
"usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
}
#[cfg(test)]
+10
View File
@@ -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
+2
View File
@@ -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;
+1 -1
View File
@@ -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;
+4
View File
@@ -593,12 +593,15 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
.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;
+1
View File
@@ -3,6 +3,7 @@ mod capture;
mod consent;
mod desktop;
mod parent;
mod permissions;
mod picker;
mod settings;
mod stream;
+427
View File
@@ -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::<String, OwnedValue>::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::<Vec<_>>()
.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<Option<Options>, 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<RwLock<HashMap<String, u32>>>,
}
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<Options> {
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<String>,
flags: u32,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<bool> {
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<Option<Options>, 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<String, u32> {
let mut apps: HashMap<String, u32> = 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<bool, String> {
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::<Result<Vec<_>, _>>()?
.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<Option<String>, 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<String, String> {
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());
}
}
+34 -1
View File
@@ -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()))