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

commit91094b8b06b04c6514bd9f97253a0e97a9b965e4
parent6479e90960
authorLucas Galante <[email protected]>
date2026-08-10 18:26
feat: XDG-correct app scanning — data dirs, Hidden, ID shadowing

Scan $XDG_DATA_HOME + $XDG_DATA_DIRS (spec defaults as fallback) so
Flatpak/Snap exports are visible; dedup by desktop-file ID so a user
entry shadows the system entry even when renamed or Hidden=true (the
XDG uninstall mechanism), instead of deduping by display name alone.

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

 src/main.rs | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 101 insertions(+), 15 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 0007b7f..cd0db23 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -280,6 +280,13 @@ fn parse_desktop_file(path: &std::path::Path) -> Option<AppInfo> {
                             terminal = true;
                         }
                     }
+                    // Hidden means "treat as deleted" — same outcome as
+                    // NoDisplay for a launcher: the entry never shows.
+                    "Hidden" => {
+                        if value == "true" {
+                            no_display = true;
+                        }
+                    }
                     _ => {}
                 }
             }
@@ -296,31 +303,55 @@ fn parse_desktop_file(path: &std::path::Path) -> Option<AppInfo> {
 
 fn scan_apps() -> Vec<AppInfo> {
     let mut apps = Vec::new();
-    // XDG precedence: user entries override system ones. The stable sort +
-    // dedup below keeps the first-pushed entry per name, so scan
-    // highest-priority dirs first.
+    // XDG precedence: $XDG_DATA_HOME first, then each $XDG_DATA_DIRS entry in
+    // order (defaults per the base-directory spec). Honoring XDG_DATA_DIRS is
+    // what makes Flatpak/Snap exports visible.
     let mut dirs = Vec::new();
-    if let Ok(home) = std::env::var("HOME") {
-        dirs.push(std::path::PathBuf::from(home).join(".local/share/applications"));
+    let data_home = std::env::var("XDG_DATA_HOME")
+        .ok()
+        .filter(|v| !v.is_empty())
+        .map(std::path::PathBuf::from)
+        .or_else(|| {
+            std::env::var("HOME")
+                .ok()
+                .map(|h| std::path::PathBuf::from(h).join(".local/share"))
+        });
+    if let Some(data_home) = data_home {
+        dirs.push(data_home.join("applications"));
+    }
+    let data_dirs = std::env::var("XDG_DATA_DIRS")
+        .ok()
+        .filter(|v| !v.is_empty())
+        .unwrap_or_else(|| "/usr/local/share:/usr/share".to_string());
+    for dir in std::env::split_paths(&data_dirs) {
+        if !dir.as_os_str().is_empty() {
+            dirs.push(dir.join("applications"));
+        }
     }
-    dirs.push(std::path::PathBuf::from("/usr/local/share/applications"));
-    dirs.push(std::path::PathBuf::from("/usr/share/applications"));
 
+    // The first file claiming a desktop-file ID (the file stem) shadows that
+    // ID in every later dir — even when the winning entry is itself
+    // Hidden/NoDisplay, which is how a user entry deletes a system one.
+    let mut seen_ids = std::collections::HashSet::new();
     for dir in dirs {
         if let Ok(entries) = std::fs::read_dir(dir) {
-            for entry in entries {
-                if let Ok(entry) = entry {
-                    let path = entry.path();
-                    if path.is_file() && path.extension().map_or(false, |ext| ext == "desktop") {
-                        if let Some(app) = parse_desktop_file(&path) {
-                            apps.push(app);
-                        }
+            for entry in entries.flatten() {
+                let path = entry.path();
+                if path.is_file() && path.extension().map_or(false, |ext| ext == "desktop") {
+                    let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
+                        continue;
+                    };
+                    if !seen_ids.insert(id.to_string()) {
+                        continue;
+                    }
+                    if let Some(app) = parse_desktop_file(&path) {
+                        apps.push(app);
                     }
                 }
             }
         }
     }
-    
+
     // Terminal=true apps need an emulator to host them; with none installed,
     // spawning them bare would fail silently, so drop the entries instead.
     if terminal_emulator().is_none() {
@@ -3299,6 +3330,61 @@ mod tests {
         let _ = std::fs::remove_dir_all(&dir);
     }
 
+    #[test]
+    fn scan_apps_xdg_precedence() {
+        let base = std::path::PathBuf::from("/tmp/cce-cloud-test-xdg");
+        let _ = std::fs::remove_dir_all(&base);
+        let user = base.join("home/applications");
+        let sys = base.join("sys/applications");
+        std::fs::create_dir_all(&user).unwrap();
+        std::fs::create_dir_all(&sys).unwrap();
+
+        std::fs::write(
+            sys.join("editor.desktop"),
+            "[Desktop Entry]\nType=Application\nName=Editor\nExec=editor-sys\n",
+        )
+        .unwrap();
+        std::fs::write(
+            sys.join("player.desktop"),
+            "[Desktop Entry]\nType=Application\nName=Player\nExec=player\n",
+        )
+        .unwrap();
+        // User dir: renames editor (same ID must still shadow the system
+        // entry) and deletes player via Hidden.
+        std::fs::write(
+            user.join("editor.desktop"),
+            "[Desktop Entry]\nType=Application\nName=My Editor\nExec=editor-user\n",
+        )
+        .unwrap();
+        std::fs::write(
+            user.join("player.desktop"),
+            "[Desktop Entry]\nType=Application\nName=Player\nExec=player\nHidden=true\n",
+        )
+        .unwrap();
+
+        let orig_home = std::env::var("XDG_DATA_HOME").ok();
+        let orig_dirs = std::env::var("XDG_DATA_DIRS").ok();
+        std::env::set_var("XDG_DATA_HOME", base.join("home"));
+        std::env::set_var("XDG_DATA_DIRS", base.join("sys"));
+
+        let apps = scan_apps();
+
+        match orig_home {
+            Some(v) => std::env::set_var("XDG_DATA_HOME", v),
+            None => std::env::remove_var("XDG_DATA_HOME"),
+        }
+        match orig_dirs {
+            Some(v) => std::env::set_var("XDG_DATA_DIRS", v),
+            None => std::env::remove_var("XDG_DATA_DIRS"),
+        }
+
+        assert_eq!(apps.len(), 1);
+        assert_eq!(apps[0].name, "My Editor");
+        assert_eq!(apps[0].exec, "editor-user");
+
+        let _ = std::fs::remove_dir_all(&base);
+    }
+
     #[test]
     fn test_app_history_sorting() {
         let temp_dir = std::path::PathBuf::from("/tmp/cce-cloud-test-cache-dir");