Add background and USB consent with XDG session autostart
This commit is contained in:
1 parent
915bc8afd6
commit
ddbed192c0
13 files changed
+690
-11
No files matched your search
Generated
+1
@@ -1800,6 +1800,7 @@ name = "feresectl"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ferese-ipc",
|
"ferese-ipc",
|
||||||
|
"libc",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ impl Ferese {
|
|||||||
attributes.title.clone().unwrap_or_default(),
|
attributes.title.clone().unwrap_or_default(),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let rect = self.visual_rect_for_window(window)?;
|
let rect = self.visual_rect_for_window(window);
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"id": id.0,
|
"id": id.0,
|
||||||
"app_id": app_id,
|
"app_id": app_id,
|
||||||
@@ -564,10 +564,10 @@ impl Ferese {
|
|||||||
"focused": self.focused_window == Some(*id),
|
"focused": self.focused_window == Some(*id),
|
||||||
"mapped": self.space.element_location(window).is_some(),
|
"mapped": self.space.element_location(window).is_some(),
|
||||||
"workspace": self.workspaces.workspace_for_window(*id).map(|workspace| workspace.0),
|
"workspace": self.workspaces.workspace_for_window(*id).map(|workspace| workspace.0),
|
||||||
"x": rect.loc.x,
|
"x": rect.as_ref().map(|rect| rect.loc.x),
|
||||||
"y": rect.loc.y,
|
"y": rect.as_ref().map(|rect| rect.loc.y),
|
||||||
"width": rect.size.w,
|
"width": rect.as_ref().map(|rect| rect.size.w),
|
||||||
"height": rect.size.h,
|
"height": rect.as_ref().map(|rect| rect.size.h),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
+10
-3
@@ -19,11 +19,18 @@ standard file, print and application dialogs. Install both backends.
|
|||||||
temporary portal files can disappear safely. Image bytes and decoded dimensions
|
temporary portal files can disappear safely. Image bytes and decoded dimensions
|
||||||
are bounded. `theme.background.lock-path` overrides the lock-screen image;
|
are bounded. `theme.background.lock-path` overrides the lock-screen image;
|
||||||
without it the lock screen follows `theme.background.path`.
|
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.
|
- **ScreenCast:** monitor sharing with a native picker and PipeWire streams.
|
||||||
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,
|
||||||
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.
|
a transient relationship with the requesting window.
|
||||||
Cancelling a request or losing the portal frontend terminates its pending helper.
|
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 |
|
| Wallpaper | Native desktop, lock-screen and combined targets |
|
||||||
| ScreenCast | Native monitors; window sources, persistence and richer metadata remain missing |
|
| ScreenCast | Native monitors; window sources, persistence and richer metadata remain missing |
|
||||||
| Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK |
|
| Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK |
|
||||||
| Background | Missing native application state and background consent |
|
| Background | Native application state, per-instance consent and XDG autostart |
|
||||||
| Usb | Missing native device consent |
|
| Usb | Native consent; standard frontend handles enumeration and device descriptors |
|
||||||
| GlobalShortcuts | Missing session-scoped compositor registration and activation signals |
|
| GlobalShortcuts | Missing session-scoped compositor registration and activation signals |
|
||||||
| RemoteDesktop | Missing authorized input injection and EIS transport |
|
| RemoteDesktop | Missing authorized input injection and EIS transport |
|
||||||
| Clipboard | Missing remote-session clipboard transfer |
|
| Clipboard | Missing remote-session clipboard transfer |
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ rust-version.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ferese-ipc = { path = "../crates/ferese-ipc" }
|
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod autostart;
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -9,6 +11,12 @@ use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
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 (command, args) = parse_args(env::args().skip(1))?;
|
||||||
let request = Request {
|
let request = Request {
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
@@ -118,7 +126,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ set -euo pipefail
|
|||||||
session_target_started=0
|
session_target_started=0
|
||||||
shell_pid=
|
shell_pid=
|
||||||
agent_pid=
|
agent_pid=
|
||||||
|
autostart_pid=
|
||||||
cleanup() {
|
cleanup() {
|
||||||
trap - EXIT INT TERM
|
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 $shell_pid ]]; then kill "$shell_pid" 2>/dev/null || true; fi
|
||||||
if [[ -n $agent_pid ]]; then
|
if [[ -n $agent_pid ]]; then
|
||||||
kill "$agent_pid" 2>/dev/null || true
|
kill "$agent_pid" 2>/dev/null || true
|
||||||
@@ -53,6 +58,11 @@ env -u WAYLAND_SOCKET WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAN
|
|||||||
agent_pid=$!
|
agent_pid=$!
|
||||||
"$release_dir/ferese-shell" &
|
"$release_dir/ferese-shell" &
|
||||||
shell_pid=$!
|
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
|
if wait "$shell_pid"; then
|
||||||
status=0
|
status=0
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ org.freedesktop.impl.portal.ScreenCast=ferese;
|
|||||||
org.freedesktop.impl.portal.Settings=ferese;
|
org.freedesktop.impl.portal.Settings=ferese;
|
||||||
org.freedesktop.impl.portal.Screenshot=ferese;
|
org.freedesktop.impl.portal.Screenshot=ferese;
|
||||||
org.freedesktop.impl.portal.Wallpaper=ferese;
|
org.freedesktop.impl.portal.Wallpaper=ferese;
|
||||||
|
org.freedesktop.impl.portal.Background=ferese;
|
||||||
|
org.freedesktop.impl.portal.Usb=ferese;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
[portal]
|
[portal]
|
||||||
DBusName=org.freedesktop.impl.portal.desktop.ferese
|
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;
|
UseIn=Ferese;
|
||||||
@@ -593,12 +593,15 @@ 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();
|
||||||
let requests = crate::desktop::Requests::default();
|
let requests = crate::desktop::Requests::default();
|
||||||
|
let background = crate::permissions::Background::new(requests.clone());
|
||||||
let connection = zbus::connection::Builder::session()?
|
let connection = zbus::connection::Builder::session()?
|
||||||
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
||||||
.serve_at(PATH, backend.clone())?
|
.serve_at(PATH, backend.clone())?
|
||||||
.serve_at(PATH, settings.clone())?
|
.serve_at(PATH, settings.clone())?
|
||||||
.serve_at(PATH, crate::desktop::Screenshot(requests.clone()))?
|
.serve_at(PATH, crate::desktop::Screenshot(requests.clone()))?
|
||||||
.serve_at(PATH, crate::desktop::Wallpaper(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(
|
.serve_at(
|
||||||
"/org/ferese/ScreenRecorder",
|
"/org/ferese/ScreenRecorder",
|
||||||
RecorderControl(backend.clone()),
|
RecorderControl(backend.clone()),
|
||||||
@@ -606,6 +609,7 @@ 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()));
|
||||||
// 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;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod capture;
|
|||||||
mod consent;
|
mod consent;
|
||||||
mod desktop;
|
mod desktop;
|
||||||
mod parent;
|
mod parent;
|
||||||
|
mod permissions;
|
||||||
mod picker;
|
mod picker;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Check native portal wire contracts on a private bus without touching the desktop."""
|
"""Check native portal wire contracts on a private bus without touching the desktop."""
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -53,7 +54,7 @@ def private_bus_checks():
|
|||||||
raise AssertionError((root / "backend.log").read_text())
|
raise AssertionError((root / "backend.log").read_text())
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
node = ET.fromstring(xml)
|
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}']")
|
interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']")
|
||||||
assert interface is not None, 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")
|
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})")
|
response, values = call("org.freedesktop.impl.portal.Screenshot", "Screenshot", params, "(ua{sv})")
|
||||||
assert response == 2 and values == {}
|
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 = []
|
signals = []
|
||||||
subscription = bus.signal_subscribe(NAME, "org.freedesktop.impl.portal.Settings", "SettingChanged", PATH,
|
subscription = bus.signal_subscribe(NAME, "org.freedesktop.impl.portal.Settings", "SettingChanged", PATH,
|
||||||
None, Gio.DBusSignalFlags.NONE, lambda *args: signals.append(args[-1].unpack()))
|
None, Gio.DBusSignalFlags.NONE, lambda *args: signals.append(args[-1].unpack()))
|
||||||
|
|||||||
Reference in new issue
Block a user