git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitd1e60d707aa0afb541f49dd230b9a68f7f6c3304
parent0a68510a70
authorLucas Galante <[email protected]>
date2026-09-09 08:05
fix(state): restore a window by its bare name when a PATH wrapper launched it

save_state records /proc/<pid>/cmdline, which is what the process exec'd
INTO, not what launched it. An exec wrapper — ~/.local/bin/inkscape, which
sets GDK_SCALE=1 and exec's /usr/bin/inkscape — leaves no trace, so the
window was saved as /usr/bin/inkscape and every login restored Inkscape
without its wrapper, at the oversized icons the wrapper exists to fix.

Desktop entries and the launcher run bare names, so the first PATH hit for
the name is how the user's environment launches the program. When that hit
is a different file from the one running, record the bare name and let the
restore's `sh -c` resolve it the same way (path_shadowed_name). The
absolute path is kept when PATH already agrees with it, through any
symlink, so a later PATH change cannot redirect a program that was never
wrapped; a relative argv[0], a binary gone from disk, or a non-executable
namesake leave it alone too.

Verified in a shadow: `inkscape --app-id-tag shadow` spawned through the
wrapper shows /usr/bin/inkscape in /proc and GDK_SCALE=1 in its environ,
and a clean exit saves `inkscape --app-id-tag shadow`. Four unit tests
cover the decision against scratch PATH dirs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                    |  10 +++-
 src/server/window_manager.rs | 121 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 130 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 539109a..a9e6ee6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -459,7 +459,15 @@ settings). Live reconfiguration comes in over IPC (`ccectl reload`, `bind`, `lay
 
 Persistent window state is saved to **`~/.local/state/cce/state.json`**
 (`XDG_STATE_HOME/cce/state.json`) on shutdown and restored on start
-(`save_state` / `load_state` / `spawn_restored_windows`).
+(`save_state` / `load_state` / `spawn_restored_windows`). A window's
+`cmdline` comes from `/proc/<pid>/cmdline`, which is what the process
+*exec'd into*, not what launched it: an `exec` wrapper in `~/.local/bin`
+(Inkscape's `GDK_SCALE=1` wrapper) reads as `/usr/bin/inkscape`, and a
+restore that replays that path skips the wrapper. So `save_state` records
+the **bare name** whenever the name's first `PATH` hit is a different file
+from the one running (`path_shadowed_name`), and the restore's `sh -c`
+resolves it the way the launcher did. The absolute path is kept when PATH
+agrees with it.
 
 ### IPC & status sockets
 
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index f725d8d..1f47736 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -25,6 +25,41 @@ fn foot_shell_cwd(foot_pid: i32) -> Option<String> {
     Some(cwd.to_string_lossy().into_owned())
 }
 
+/// The bare program name a window should be restored by, when the binary it
+/// is running is NOT what its name resolves to on `PATH`.
+///
+/// An `exec` wrapper leaves no trace in `/proc`: `~/.local/bin/inkscape`
+/// (`exec /usr/bin/inkscape "$@"`, the GDK_SCALE fix) shows up in the
+/// window's cmdline as `/usr/bin/inkscape`, and a restore that replays that
+/// path starts the program without its wrapper. Desktop entries and the
+/// launcher run bare names, so the first `PATH` hit for the name IS how the
+/// user's environment launches the program. When that hit is a different
+/// file from the one running, record the name and let the restore's
+/// `sh -c` resolve it the same way. Returns `None` for a relative argv[0],
+/// a binary no longer on disk, or a name whose first `PATH` hit is the very
+/// same file (through any symlink) — there the absolute path is already the
+/// truth, and keeping it means a later `PATH` change cannot redirect it.
+fn path_shadowed_name(argv0: &str, path_var: &str) -> Option<String> {
+    use std::os::unix::fs::PermissionsExt;
+    if !argv0.starts_with('/') {
+        return None;
+    }
+    let exe = std::path::Path::new(argv0);
+    let name = exe.file_name()?.to_str()?;
+    let real = std::fs::canonicalize(exe).ok()?;
+    for dir in path_var.split(':').filter(|d| !d.is_empty()) {
+        let candidate = std::path::Path::new(dir).join(name);
+        let Ok(meta) = std::fs::metadata(&candidate) else { continue };
+        if !meta.is_file() || meta.permissions().mode() & 0o111 == 0 {
+            continue;
+        }
+        // First executable hit decides, as `sh` would decide it.
+        let candidate_real = std::fs::canonicalize(&candidate).unwrap_or(candidate);
+        return if candidate_real == real { None } else { Some(name.to_string()) };
+    }
+    None
+}
+
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum WindowManagerState {
     Idle,
@@ -848,6 +883,17 @@ impl WindowManager {
                             }
                         }
                     }
+                    // A wrapper that exec'd the real binary is invisible here;
+                    // restore by the bare name when PATH says the name is a
+                    // different file (see path_shadowed_name).
+                    if !args.is_empty() {
+                        if let Some(name) = path_shadowed_name(
+                            &args[0],
+                            &std::env::var("PATH").unwrap_or_default(),
+                        ) {
+                            args[0] = name;
+                        }
+                    }
                     // foot only tracks its launch dir, not the shell's current
                     // dir, so restore the child shell's cwd via
                     // --working-directory. Strip any pre-existing one first so
@@ -6354,6 +6400,81 @@ mod tests {
         std::mem::forget(wm);
     }
 
+    /// A scratch dir holding one executable (or not) file per name.
+    struct BinDir(std::path::PathBuf);
+    impl BinDir {
+        fn new(tag: &str) -> Self {
+            let d = std::env::temp_dir().join(format!(
+                "cce-fx-shadowed-{}-{}-{}",
+                std::process::id(),
+                tag,
+                std::time::SystemTime::now()
+                    .duration_since(std::time::UNIX_EPOCH)
+                    .unwrap()
+                    .as_nanos()
+            ));
+            std::fs::create_dir_all(&d).unwrap();
+            BinDir(d)
+        }
+        fn file(&self, name: &str, executable: bool) -> String {
+            use std::os::unix::fs::PermissionsExt;
+            let p = self.0.join(name);
+            std::fs::write(&p, "#!/bin/sh\n").unwrap();
+            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(if executable { 0o755 } else { 0o644 })).unwrap();
+            p.to_string_lossy().into_owned()
+        }
+        fn path(&self) -> String {
+            self.0.to_string_lossy().into_owned()
+        }
+    }
+    impl Drop for BinDir {
+        fn drop(&mut self) {
+            let _ = std::fs::remove_dir_all(&self.0);
+        }
+    }
+
+    #[test]
+    fn a_wrapper_first_on_path_restores_by_name() {
+        let wrappers = BinDir::new("wrap");
+        let system = BinDir::new("sys");
+        wrappers.file("inkscape", true);
+        let real = system.file("inkscape", true);
+        let path = format!("{}:{}", wrappers.path(), system.path());
+        assert_eq!(path_shadowed_name(&real, &path), Some("inkscape".to_string()));
+    }
+
+    #[test]
+    fn the_same_binary_first_on_path_keeps_the_absolute_path() {
+        let system = BinDir::new("sys");
+        let real = system.file("inkscape", true);
+        // Directly...
+        assert_eq!(path_shadowed_name(&real, &system.path()), None);
+        // ...and through a symlink farm ahead of it on PATH.
+        let links = BinDir::new("links");
+        std::os::unix::fs::symlink(&real, links.0.join("inkscape")).unwrap();
+        let path = format!("{}:{}", links.path(), system.path());
+        assert_eq!(path_shadowed_name(&real, &path), None);
+    }
+
+    #[test]
+    fn a_non_executable_namesake_does_not_count() {
+        let junk = BinDir::new("junk");
+        let system = BinDir::new("sys");
+        junk.file("inkscape", false);
+        let real = system.file("inkscape", true);
+        let path = format!("{}:{}", junk.path(), system.path());
+        assert_eq!(path_shadowed_name(&real, &path), None);
+    }
+
+    #[test]
+    fn only_absolute_argv0_of_an_existing_binary_is_considered() {
+        let wrappers = BinDir::new("wrap");
+        wrappers.file("inkscape", true);
+        assert_eq!(path_shadowed_name("inkscape", &wrappers.path()), None);
+        let gone = wrappers.0.join("nope/inkscape").to_string_lossy().into_owned();
+        assert_eq!(path_shadowed_name(&gone, &wrappers.path()), None);
+    }
+
     #[test]
     fn secret_service_gating_picks_only_keyring_clients() {
         // The real restored cmdline that lost the race against the keyring.