desktop: add systemd service spawn option alongside scope

Per systemd's desktop environment recommendations [1], transient .service
units are preferred over .scope units when launching applications. This
ensures the systemd user session is the direct parent of launched processes.

The previous approach (desktop-systemd-scope) spawned processes via
double-fork (orphaning them to PID 1), then moved them into a scope.
Security tools like 1Password that verify parent process lineage rejected
these processes because their ancestor chain led to PID 1 rather than
systemd --user.

This commit adds a new 'desktop-systemd-service' feature that uses
StartTransientUnit with ExecStart to let systemd spawn the process
directly, giving launched applications a proper parent lineage.

Feature behavior:
- desktop-systemd-service only: Uses transient .service units
- desktop-systemd-scope only: Uses transient .scope units (existing behavior)
- Both enabled: Tries .service first, falls back to .scope, then double-fork
- Neither enabled: Uses double-fork directly

Also fixes typo: SystemdManger -> SystemdManager

[1] https://systemd.io/DESKTOP_ENVIRONMENTS/
This commit is contained in:
James Tucker 2026-02-08 15:34:05 -08:00
parent 3e78eb2381
commit 2af4cff958
No known key found for this signature in database
2 changed files with 192 additions and 41 deletions

View file

@ -49,6 +49,11 @@ desktop = [
] ]
# Enables launching desktop files inside systemd scopes # Enables launching desktop files inside systemd scopes
desktop-systemd-scope = ["desktop", "dep:zbus"] desktop-systemd-scope = ["desktop", "dep:zbus"]
# Enables launching desktop files via systemd transient services.
# This is preferred over scopes as systemd becomes the direct parent,
# which satisfies security tools that verify process lineage.
# If both this and desktop-systemd-scope are enabled, service is tried first.
desktop-systemd-service = ["desktop", "dep:zbus"]
# Enables keycode serialization # Enables keycode serialization
serde-keycode = ["iced_core/serde"] serde-keycode = ["iced_core/serde"]
# Prevents multiple separate process instances. # Prevents multiple separate process instances.

View file

@ -792,71 +792,217 @@ pub async fn spawn_desktop_exec<S, I, K, V>(
exec.as_ref() exec.as_ref()
}; };
let mut exec = shlex::Shlex::new(exec_str); let mut args = shlex::Shlex::new(exec_str);
let executable = match exec.next() { let executable = match args.next() {
Some(executable) if !executable.contains('=') => executable, Some(executable) if !executable.contains('=') => executable,
_ => return, _ => return,
}; };
let mut cmd = std::process::Command::new(&executable); let mut argv: Vec<String> = vec![executable.clone()];
for arg in args {
for arg in exec {
// TODO handle "%" args here if necessary? // TODO handle "%" args here if necessary?
if !arg.starts_with('%') { if !arg.starts_with('%') {
cmd.arg(arg); argv.push(arg);
} }
} }
cmd.envs(env_vars); // Collect environment variables for systemd-based spawning
#[cfg(any(feature = "desktop-systemd-service", feature = "desktop-systemd-scope"))]
let env_vars: Vec<(String, String)> = env_vars
.into_iter()
.map(|(k, v)| {
(
k.as_ref().to_string_lossy().into_owned(),
v.as_ref().to_string_lossy().into_owned(),
)
})
.collect();
// https://systemd.io/DESKTOP_ENVIRONMENTS // https://systemd.io/DESKTOP_ENVIRONMENTS
// //
// Similar to what Gnome sets, for now. // Per systemd recommendations, prefer .service units over .scope units
if let Some(pid) = crate::process::spawn(cmd).await { // so that systemd is the direct parent of the process. This ensures proper
#[cfg(feature = "desktop-systemd-scope")] // process lineage for security tools that verify parent process chains.
if let Ok(session) = zbus::Connection::session().await {
if let Ok(systemd_manager) = SystemdMangerProxy::new(&session).await { // Try systemd service first if enabled
let _ = systemd_manager #[cfg(feature = "desktop-systemd-service")]
.start_transient_unit( {
&format!("app-cosmic-{}-{}.scope", app_id.unwrap_or(&executable), pid), if spawn_via_systemd_service(&executable, &argv, &env_vars, app_id).await {
"fail", return;
&[ }
( }
"Description".to_string(),
zbus::zvariant::Value::from("Application launched by COSMIC") // Fall back to systemd scope if enabled (or if service failed)
.try_to_owned() #[cfg(feature = "desktop-systemd-scope")]
.unwrap(), {
), #[cfg(feature = "desktop-systemd-service")]
( tracing::debug!("Falling back to systemd scope after service spawn failed");
"PIDs".to_string(),
zbus::zvariant::Value::from(vec![pid]) if spawn_via_systemd_scope(&executable, &argv, &env_vars, app_id).await {
.try_to_owned() return;
.unwrap(), }
), }
(
"CollectMode".to_string(), #[cfg(any(feature = "desktop-systemd-service", feature = "desktop-systemd-scope"))]
zbus::zvariant::Value::from("inactive-or-failed") tracing::debug!("Falling back to direct spawn");
.try_to_owned()
.unwrap(), let mut cmd = std::process::Command::new(&executable);
), cmd.args(&argv[1..]);
], cmd.envs(env_vars);
&[], let _ = crate::process::spawn(cmd).await;
) }
.await;
} /// Spawn an application via a transient systemd .service unit.
/// Returns true if the service was started successfully.
#[cfg(not(windows))]
#[cfg(feature = "desktop-systemd-service")]
async fn spawn_via_systemd_service(
executable: &str,
argv: &[String],
env_vars: &[(String, String)],
app_id: Option<&str>,
) -> bool {
let unit_name = format!(
"app-cosmic-{}@{}.service",
app_id.unwrap_or(executable),
std::process::id()
);
let Ok(session) = zbus::Connection::session().await else {
return false;
};
let Ok(systemd_manager) = SystemdManagerProxy::new(&session).await else {
return false;
};
// Build ExecStart property: (path, argv, is_ignore_failure)
let exec_start_value = vec![(executable.to_string(), argv.to_vec(), false)];
// Build Environment property: array of "KEY=VALUE" strings
let environment: Vec<String> = env_vars
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
let mut properties: Vec<(String, zbus::zvariant::OwnedValue)> = vec![
(
"Description".to_string(),
zbus::zvariant::Value::from("Application launched by COSMIC")
.try_to_owned()
.unwrap(),
),
(
"ExecStart".to_string(),
zbus::zvariant::Value::from(exec_start_value)
.try_to_owned()
.unwrap(),
),
(
"Type".to_string(),
zbus::zvariant::Value::from("simple")
.try_to_owned()
.unwrap(),
),
(
"CollectMode".to_string(),
zbus::zvariant::Value::from("inactive-or-failed")
.try_to_owned()
.unwrap(),
),
];
if !environment.is_empty() {
properties.push((
"Environment".to_string(),
zbus::zvariant::Value::from(environment)
.try_to_owned()
.unwrap(),
));
}
match systemd_manager
.start_transient_unit(&unit_name, "fail", &properties, &[])
.await
{
Ok(_) => true,
Err(err) => {
tracing::warn!("Failed to start transient service unit: {}", err);
false
} }
} }
} }
/// Spawn an application and move it into a transient systemd .scope unit.
/// Returns true if the process was spawned and moved into a scope successfully.
#[cfg(not(windows))] #[cfg(not(windows))]
#[cfg(feature = "desktop-systemd-scope")] #[cfg(feature = "desktop-systemd-scope")]
async fn spawn_via_systemd_scope(
executable: &str,
argv: &[String],
env_vars: &[(String, String)],
app_id: Option<&str>,
) -> bool {
let mut cmd = std::process::Command::new(executable);
for arg in &argv[1..] {
cmd.arg(arg);
}
for (k, v) in env_vars {
cmd.env(k, v);
}
let Some(pid) = crate::process::spawn(cmd).await else {
return false;
};
let Ok(session) = zbus::Connection::session().await else {
return true; // Process spawned, just couldn't create scope
};
let Ok(systemd_manager) = SystemdManagerProxy::new(&session).await else {
return true; // Process spawned, just couldn't create scope
};
let _ = systemd_manager
.start_transient_unit(
&format!("app-cosmic-{}-{}.scope", app_id.unwrap_or(executable), pid),
"fail",
&[
(
"Description".to_string(),
zbus::zvariant::Value::from("Application launched by COSMIC")
.try_to_owned()
.unwrap(),
),
(
"PIDs".to_string(),
zbus::zvariant::Value::from(vec![pid])
.try_to_owned()
.unwrap(),
),
(
"CollectMode".to_string(),
zbus::zvariant::Value::from("inactive-or-failed")
.try_to_owned()
.unwrap(),
),
],
&[],
)
.await;
true
}
#[cfg(not(windows))]
#[cfg(any(feature = "desktop-systemd-service", feature = "desktop-systemd-scope"))]
#[zbus::proxy( #[zbus::proxy(
interface = "org.freedesktop.systemd1.Manager", interface = "org.freedesktop.systemd1.Manager",
default_service = "org.freedesktop.systemd1", default_service = "org.freedesktop.systemd1",
default_path = "/org/freedesktop/systemd1" default_path = "/org/freedesktop/systemd1"
)] )]
trait SystemdManger { trait SystemdManager {
async fn start_transient_unit( async fn start_transient_unit(
&self, &self,
name: &str, name: &str,