desktop: add systemd service spawn option alongside scope#1119
desktop: add systemd service spawn option alongside scope#1119raggi wants to merge 1 commit intopop-os:masterfrom
Conversation
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/
There was a problem hiding this comment.
Pull request overview
Adds a new systemd-based spawning strategy for .desktop launches that prefers transient .service units (per systemd DE guidance) to ensure systemd becomes the direct parent of launched applications, improving process lineage for tools that verify ancestry.
Changes:
- Add
desktop-systemd-servicefeature and implement transient.servicespawning viaStartTransientUnit+ExecStart. - Rework desktop spawning flow to try
.service, then.scope, then fall back to the existing double-fork spawn. - Fix typo in systemd proxy trait name (
SystemdManger→SystemdManager).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/desktop.rs |
Implements service-based systemd spawning, retains scope-based fallback, and refactors argument/env handling for systemd properties. |
Cargo.toml |
Adds the desktop-systemd-service feature flag alongside the existing scope option. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .start_transient_unit( | ||
| &format!("app-cosmic-{}-{}.scope", app_id.unwrap_or(executable), pid), | ||
| "fail", |
There was a problem hiding this comment.
Systemd unit naming/escaping concern also applies to the scope name: app_id.unwrap_or(executable) may include / or other characters invalid in unit names, which would make the scope creation fail even when the process spawn succeeded. Consider escaping/sanitizing the identifier portion before formatting the scope name so scope creation is reliable.
| /// 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() | ||
| ); |
There was a problem hiding this comment.
There are unit tests in this module, but the new systemd spawning paths aren’t covered. Consider adding tests around unit name generation (escaping + uniqueness) and property construction (ExecStart argv shape, Environment formatting), ideally by extracting helpers that can be tested without a running systemd user instance.
| let unit_name = format!( | ||
| "app-cosmic-{}@{}.service", | ||
| app_id.unwrap_or(executable), | ||
| std::process::id() | ||
| ); |
There was a problem hiding this comment.
Transient service unit name is not unique per launch: it only varies by app_id/executable and the launcher process PID (std::process::id()). Launching the same app multiple times from the same process will reuse the same unit name, causing StartTransientUnit(..., "fail", ...) to fail and trigger fallback behavior. Consider adding a per-launch unique suffix (e.g., monotonic AtomicU64 counter, timestamp+counter, or UUID) to guarantee uniqueness while the previous unit is still active.
| 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), |
There was a problem hiding this comment.
app_id.unwrap_or(executable) is used directly in the systemd unit name. If app_id is None and executable is an absolute path (contains /), or if either contains other characters systemd unit names don't allow, StartTransientUnit will fail. Consider escaping/sanitizing the identifier portion (systemd-escape semantics) before formatting the unit name (and keep this consistent with the scope naming below).
| 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), | |
| fn sanitize_systemd_unit_identifier(id: &str) -> String { | |
| let mut out = String::with_capacity(id.len()); | |
| for b in id.bytes() { | |
| // Allowed characters in unit names: letters, digits, ':', '_', '-', '.' | |
| if (b'A'..=b'Z').contains(&b) | |
| || (b'a'..=b'z').contains(&b) | |
| || (b'0'..=b'9').contains(&b) | |
| || b == b':' | |
| || b == b'_' | |
| || b == b'-' | |
| || b == b'.' | |
| { | |
| out.push(b as char); | |
| } else if b == b'/' { | |
| // systemd-escape replaces '/' with '-' | |
| out.push('-'); | |
| } else { | |
| use std::fmt::Write as _; | |
| let _ = write!(&mut out, "\\x{:02x}", b); | |
| } | |
| } | |
| out | |
| } | |
| #[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 identifier = app_id.unwrap_or(executable); | |
| let safe_identifier = sanitize_systemd_unit_identifier(identifier); | |
| let unit_name = format!( | |
| "app-cosmic-{}@{}.service", | |
| safe_identifier, |
| // Try systemd service first if enabled | ||
| #[cfg(feature = "desktop-systemd-service")] | ||
| { | ||
| if spawn_via_systemd_service(&executable, &argv, &env_vars, app_id).await { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // Fall back to systemd scope if enabled (or if service failed) | ||
| #[cfg(feature = "desktop-systemd-scope")] | ||
| { | ||
| #[cfg(feature = "desktop-systemd-service")] | ||
| tracing::debug!("Falling back to systemd scope after service spawn failed"); | ||
|
|
||
| if spawn_via_systemd_scope(&executable, &argv, &env_vars, app_id).await { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| #[cfg(any(feature = "desktop-systemd-service", feature = "desktop-systemd-scope"))] | ||
| tracing::debug!("Falling back to direct spawn"); | ||
|
|
||
| let mut cmd = std::process::Command::new(&executable); | ||
| cmd.args(&argv[1..]); | ||
| cmd.envs(env_vars); | ||
| let _ = crate::process::spawn(cmd).await; |
There was a problem hiding this comment.
The PR description says "desktop-systemd-service only: Uses transient .service units", but the implementation falls back to direct (double-fork) spawning whenever the service path returns false (e.g., no session bus, StartTransientUnit error). Either update the description to reflect the fallback behavior, or adjust the code so the feature enforces service-only behavior (and surfaces/logs failures appropriately).
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:
Also fixes typo: SystemdManger -> SystemdManager
[1] https://systemd.io/DESKTOP_ENVIRONMENTS/
In much simpler terms: A .desktop application launch as a service unit that calls into
op readwill work, a scope based launch will not.