diff --git a/Cargo.lock b/Cargo.lock index 355cc82..570e54f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1937,7 +1937,7 @@ dependencies = [ [[package]] name = "inari" -version = "1.0.10" +version = "1.0.11" dependencies = [ "axum", "dirs", diff --git a/package.json b/package.json index 825b90c..c8360eb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "inari", "private": true, - "version": "1.0.10", + "version": "1.0.11", "type": "module", "license": "GPL-3.0-only", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1141c8c..af4a976 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "inari" -version = "1.0.10" +version = "1.0.11" description = "Linux-native audio routing and mixing for PipeWire, with SteelSeries device control" edition = "2021" rust-version = "1.77" diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 804e34e..7d052de 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -324,8 +324,23 @@ fn relaunch_script(exe: &str) -> String { /// and the app would just quit. Instead: resolve the real path, hand a detached /// helper the job of relaunching it after we've fully exited (so our virtual /// sinks are torn down before the fresh instance recreates them), then exit. +/// +/// Under the autostart unit that helper does not survive us either: +/// `setsid` gets a new session but stays in the service's cgroup, and systemd's +/// default `KillMode=control-group` sweeps it up the moment the main process +/// exits - mid-sleep, before it ever reaches the `exec`. `Restart=on-failure` +/// does not cover for that either, because quitting to restart is a clean exit. +/// +/// So when we *are* that unit, hand the job to systemd instead of trying to +/// outlive ourselves. #[tauri::command] pub fn restart_app(app: tauri::AppHandle) { + if crate::persistence::autostart::running_as_unit() + && crate::persistence::autostart::request_restart() + { + app.exit(0); + return; + } if let Ok(exe) = std::env::current_exe() { let script = relaunch_script(&exe.to_string_lossy()); // setsid detaches the relauncher into its own session so it survives our diff --git a/src-tauri/src/persistence/autostart.rs b/src-tauri/src/persistence/autostart.rs index 5a1d6d2..531e750 100644 --- a/src-tauri/src/persistence/autostart.rs +++ b/src-tauri/src/persistence/autostart.rs @@ -68,6 +68,47 @@ pub fn is_enabled() -> bool { .unwrap_or(false) } +/// Does this cgroup path put us inside `unit`? +/// +/// Split out from [`running_as_unit`] so the interesting part - telling our own +/// unit apart from the transient `app-*.service` a desktop launcher wraps +/// manual starts in - is testable without a systemd session. +fn cgroup_names_unit(cgroup: &str, unit: &str) -> bool { + cgroup.lines().any(|line| { + // Format is `hierarchy:controllers:path`; only the path matters, and on + // cgroup v2 the first two fields are empty. + line.rsplit(':') + .next() + .map(|path| path.trim_end_matches('/').ends_with(&format!("/{unit}"))) + .unwrap_or(false) + }) +} + +/// Are we running *as* the autostart unit? +/// +/// `INVOCATION_ID` is the obvious test and the wrong one: systemd sets it for +/// every unit, and KDE wraps a manual launch in a transient +/// `app-@.service` of its own. Restarting `inari.service` from +/// inside one of those would start a unit the user never asked to run - and +/// leave the launched instance behind. The cgroup path names the unit we are +/// actually in, so ask that instead. +pub fn running_as_unit() -> bool { + fs::read_to_string("/proc/self/cgroup") + .map(|c| cgroup_names_unit(&c, UNIT_NAME)) + .unwrap_or(false) +} + +/// Ask systemd to restart our own unit; `true` if the job was queued. +/// +/// `--no-block` is not optional here. The restart job's first half is stopping +/// this very process, so waiting for it to finish would wait on our own death. +/// Queue it and let systemd - which outlives us by definition - carry it out. +pub fn request_restart() -> bool { + systemctl(&["--no-block", "restart", UNIT_NAME]) + .map(|out| out.status.success()) + .unwrap_or(false) +} + pub fn enable() -> Result<(), SinkError> { let path = unit_path()?; if let Some(parent) = path.parent() { @@ -121,4 +162,42 @@ mod tests { assert!(unit.contains("WantedBy=graphical-session.target")); assert!(unit.contains("After=graphical-session.target pipewire.service")); } + + /// The cgroup of an instance actually started by the autostart unit. + const UNDER_UNIT: &str = + "0::/user.slice/user-1000.slice/user@1000.service/app.slice/inari.service"; + + /// What KDE's launcher produces for a manual start - captured from a real + /// session. It is a systemd unit too, which is exactly why `INVOCATION_ID` + /// cannot be used to tell the two apart. + const UNDER_KDE_LAUNCHER: &str = "0::/user.slice/user-1000.slice/user@1000.service/\ + app.slice/app-net.local.smart\\x2dlauncher@7fd00cd58eaa49578ad6a3cc871e6d24.service"; + + #[test] + fn cgroup_detection_finds_our_own_unit() { + assert!(cgroup_names_unit(UNDER_UNIT, UNIT_NAME)); + assert!(cgroup_names_unit(&format!("{UNDER_UNIT}/"), UNIT_NAME)); + } + + #[test] + fn cgroup_detection_rejects_a_launcher_scope() { + assert!(!cgroup_names_unit(UNDER_KDE_LAUNCHER, UNIT_NAME)); + } + + #[test] + fn cgroup_detection_rejects_a_merely_similar_name() { + // Suffix matching without the separator would accept these. + assert!(!cgroup_names_unit("0::/app.slice/not-inari.service", UNIT_NAME)); + assert!(!cgroup_names_unit("0::/app.slice/inari.service.d", UNIT_NAME)); + assert!(!cgroup_names_unit("", UNIT_NAME)); + } + + #[test] + fn cgroup_detection_reads_v1_style_lines() { + // A hybrid hierarchy lists one line per controller; the unit is named + // in the path field, not the first. + let v1 = "12:pids:/user.slice/user-1000.slice/user@1000.service/inari.service\n\ + 0::/user.slice/user-1000.slice/user@1000.service/inari.service"; + assert!(cgroup_names_unit(v1, UNIT_NAME)); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 71835e2..c246c21 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Inari", - "version": "1.0.10", + "version": "1.0.11", "identifier": "com.fbnlrz.inari", "build": { "beforeDevCommand": "npm run dev",