Skip to content

desktop: add systemd service spawn option alongside scope#1119

Open
raggi wants to merge 1 commit intopop-os:masterfrom
raggi:systemd-service-v2
Open

desktop: add systemd service spawn option alongside scope#1119
raggi wants to merge 1 commit intopop-os:masterfrom
raggi:systemd-service-v2

Conversation

@raggi
Copy link

@raggi raggi commented Feb 9, 2026

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/


In much simpler terms: A .desktop application launch as a service unit that calls into op read will work, a scope based launch will not.

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/
Copilot AI review requested due to automatic review settings February 9, 2026 04:12
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-service feature and implement transient .service spawning via StartTransientUnit + 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 (SystemdMangerSystemdManager).

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.

Comment on lines +968 to +970
.start_transient_unit(
&format!("app-cosmic-{}-{}.scope", app_id.unwrap_or(executable), pid),
"fail",
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +856 to +870
/// 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()
);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +866 to +870
let unit_name = format!(
"app-cosmic-{}@{}.service",
app_id.unwrap_or(executable),
std::process::id()
);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +860 to +868
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),
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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,

Copilot uses AI. Check for mistakes.
Comment on lines +828 to +853
// 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;
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant