git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commitbc18c71c67dd0d8df59d4c545c1f571a145c8644
parent06343dfaed
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 17:38
fix: name a launch's scope after the app, not the shell

Every desktop entry is launched as `sh -c <Exec>`, so every launch scope
was app-cce\x2dcloud-sh-<n>.scope. launch_name() takes the first word of
the shell command instead -- past a leading exec, env and VAR=value --
and, for a terminal-hosted entry, the program inside the terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md   |  3 ++-
 src/main.rs | 44 ++++++++++++++++++++++++++++++++++++++++----
 2 files changed, 42 insertions(+), 5 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 1b2a253..1d6801b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,7 +93,8 @@ needs-stdin decision, in `run_client()`).
   `Exec` (spawn output logged to `$XDG_RUNTIME_DIR/cce/spawn.log`, via
   `cce_ui::config::cce_runtime_dir()` — it was `/tmp/cce-spawn.log` before
   2026-08-22). Every launch (`spawn_detached`) runs in **its own transient
-  scope**, `app-cce\x2dcloud-<name>-<n>.scope` in app.slice, `PartOf=`
+  scope**, `app-cce\x2dcloud-<app>-<n>.scope` in app.slice — `<app>` the
+  program the launch runs, not the `sh -c` wrapping it (`launch_name`) — `PartOf=`
   `cce-session.target` (`scope_argv`, through `systemd-run --scope`): a daemon
   restart never reaches it, and the session's end stops it. Until 2026-09-26
   launches stayed in cce-cloud.service's own cgroup, where `KillMode=process`
diff --git a/src/main.rs b/src/main.rs
index d84cd06..66d6de8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -653,16 +653,39 @@ fn scope_name_part(program: &str) -> String {
     if part.is_empty() { "app".to_string() } else { part }
 }
 
+/// The program a launch actually runs, for naming its scope. Every desktop
+/// entry goes through `sh -c <Exec>` (a terminal-hosted one through
+/// `<terminal> sh -c "exec <Exec>"`), so naming the scope after `program`
+/// called every one of them `sh`: the first word of the shell command is
+/// the app, past a leading `exec`, `env` and `VAR=value` assignments.
+fn launch_name(program: &str, args: &[&str]) -> String {
+    let shell_cmd = args
+        .windows(2)
+        .position(|w| w[0] == "-c")
+        .filter(|&i| i == 0 || matches!(scope_name_part(args[i - 1]).as_str(), "sh" | "bash"))
+        .filter(|&i| i > 0 || matches!(scope_name_part(program).as_str(), "sh" | "bash"))
+        .map(|i| args[i + 1]);
+    let Some(cmd) = shell_cmd else {
+        return program.to_string();
+    };
+    cmd.split_whitespace()
+        .map(|w| w.trim_matches(|c| c == '\'' || c == '"'))
+        .find(|w| !w.is_empty() && *w != "exec" && *w != "env" && !w.contains('='))
+        .unwrap_or(program)
+        .to_string()
+}
+
 /// `systemd-run` arguments that start `program args` in its own transient
-/// scope, `app-cce\x2dcloud-<name>-<n>.scope` in app.slice, `PartOf` the
-/// session target. `n` only has to make the name unique.
+/// scope, `app-cce\x2dcloud-<app>-<n>.scope` in app.slice, `PartOf` the
+/// session target, named after the app it runs (`launch_name`). `n` only
+/// has to make the name unique.
 fn scope_argv(program: &str, args: &[&str], n: u128) -> Vec<String> {
     let mut argv = vec![
         "--user".to_string(),
         "--scope".to_string(),
         "--collect".to_string(),
         "--slice=app.slice".to_string(),
-        format!("--unit=app-cce\\x2dcloud-{}-{n}", scope_name_part(program)),
+        format!("--unit=app-cce\\x2dcloud-{}-{n}", scope_name_part(&launch_name(program, args))),
         format!("--property=PartOf={SESSION_TARGET}"),
         "--".to_string(),
         program.to_string(),
@@ -4991,7 +5014,7 @@ mod scope_tests {
                 "--scope",
                 "--collect",
                 "--slice=app.slice",
-                "--unit=app-cce\\x2dcloud-sh-7",
+                "--unit=app-cce\\x2dcloud-cce-files-7",
                 "--property=PartOf=cce-session.target",
                 "--",
                 "/usr/bin/sh",
@@ -5001,6 +5024,19 @@ mod scope_tests {
         );
     }
 
+    #[test]
+    fn a_scope_is_named_after_the_app_not_the_shell() {
+        assert_eq!(launch_name("sh", &["-c", "cce-mail"]), "cce-mail");
+        assert_eq!(launch_name("sh", &["-c", "/opt/google/chrome/chrome %U"]), "/opt/google/chrome/chrome");
+        assert_eq!(launch_name("sh", &["-c", "env GDK_SCALE=1 inkscape"]), "inkscape");
+        assert_eq!(launch_name("sh", &["-c", "FOO=1 exec 'cce-files'"]), "cce-files");
+        // A terminal-hosted entry: the app inside the terminal.
+        assert_eq!(launch_name("foot", &["sh", "-c", "exec htop"]), "htop");
+        // Not a shell command: the program itself.
+        assert_eq!(launch_name("ccectl", &["close"]), "ccectl");
+        assert_eq!(scope_name_part(&launch_name("sh", &["-c", "/opt/1Password/1password"])), "1password");
+    }
+
     #[test]
     fn unit_names_keep_only_legal_characters() {
         assert_eq!(scope_name_part("google-chrome-stable"), "google-chrome-stable");