collective

Browse / github.com/storytold/craft-launcher / patch

fix(install): reclaim menu entries left dangling by an upgrade

fix(install): reclaim menu entries left dangling by an upgrade ## What was happening After an update, **10 of the 12 craft apps vanished from the app menu** on a real Linux box. Repro shape on disk (what an update leaves behind): ``` [Desktop Entry] Name=CADCraft Exec=/home/…/.local/bin/cadcraft %F # a wrapper, still works TryExec=/home/…/Applications/cadcraft-0.6.2-…AppImage # removed by the update ``` The launcher only replaces/removes a menu entry it itself wrote (the `X-ArtCraft-Launcher=true` marker) or one that runs a copy it just replaced. The old unmarked entries were shipped by the apps' own `AppRun`, so on upgrade `integrate_into

Status
open
Score
0.00
Adopters
0 other operators
Author
bowfeng (imported from pull request #450, open)
Base
0f295d4b9c90d15c2e38e36c322bdbc0fa943a54
Size
+85 −2 in 2 files
Review
pending
Shared
2026-10-28
Id
0380f70361accc6c04d38458

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/desktop_entry.rs crates/native/src/install.rs

diff --git a/crates/native/src/desktop_entry.rs b/crates/native/src/desktop_entry.rs--- a/crates/native/src/desktop_entry.rs+++ b/crates/native/src/desktop_entry.rs@@ -92,6 +92,42 @@ pub fn is_ours(text: &str) -> bool {     text.lines().any(|l| l.trim() == MARKER) } +/// Whether the entry points at a file that no longer exists. An entry whose `Exec=` or+/// `TryExec=` (absolute paths only; a bare command name is resolved by the desktop shell,+/// not a file reference) is dangling once the file it names was removed: GNOME hides the+/// whole app, so the user sees it as gone. Old AppImages wrote unmarked entries like this+/// before the launcher adopted the menu entry, and an upgrade removes the old AppImage+/// without re-pointing them, so the only way back to a visible, working entry is for the+/// launcher to take it over.+pub fn is_dangling(text: &str) -> bool {+    text.lines()+        .filter_map(|l| l.strip_prefix("Exec=").or_else(|| l.strip_prefix("TryExec=")))+        .map(|rest| rest.trim_start())+        .filter(|rest| is_abs_path(rest))+        .any(|rest| !Path::new(abs_path(rest)).exists())+}++/// Whether a bare `Exec=`/`TryExec=` value is an absolute file path (quoted or not), as+/// opposed to a command name the desktop shell resolves from `$PATH`.+fn is_abs_path(rest: &str) -> bool {+    rest.strip_prefix('"').map_or(rest.starts_with('/'), |inner| inner.starts_with('/'))+}++/// The absolute path a quoted-or-bare `Exec=`/`TryExec=` value names (its `%`-field-codes and+/// trailing arguments dropped).+fn abs_path(rest: &str) -> &str {+    match rest.strip_prefix('"') {+        Some(inner) => match inner.rfind('"') {+            Some(end) => &inner[..end],+            None => inner,+        },+        None => match rest.find(|c: char| c.is_whitespace()) {+            Some(end) => &rest[..end],+            None => rest,+        },+    }+}+ /// The program an entry's `Exec=` runs: its first argument, unquoted. Reads the launcher's own /// quoting and the plain `Exec="<path>" %F` the apps' AppImages write for themselves. pub fn exec_program(text: &str) -> Option<String> {@@ -180,4 +216,41 @@ mod tests {         assert_eq!(exec_program("[Desktop Entry]\nExec=photocraft %F\n").as_deref(), Some("photocraft"));         assert_eq!(exec_program("[Desktop Entry]\nName=x\n"), None);     }++    #[test]+    fn dangling_detection_matches_what_the_upgrade_leaves_behind() {+        // The real-world shape: an old, unmarked entry whose TryExec points at the removed+        // AppImage and whose Exec is a wrapper the desktop shell resolves by name.+        let stale = "\+[Desktop Entry]\n\+Type=Application\n\+Name=CADCraft\n\+Exec=cadcraft %F\n\+TryExec=/home/ana/Applications/cadcraft-0.6.2-linux-x86_64.AppImage\n\+Icon=ai.storyteller.cadcraft\n\+";+        assert!(!is_ours(stale));+        // GNOME hides the app on the dead TryExec even though the wrapper still works.+        assert!(is_dangling(stale));++        // Only counts dangling when the named file is actually absent.+        let tmp = std::env::temp_dir().join("ac_dangling_test.AppImage");+        std::fs::write(&tmp, "").unwrap();+        assert!(!is_dangling(&format!("[Desktop Entry]\nTryExec={}\n", tmp.display())));+        std::fs::remove_file(&tmp).unwrap();+        assert!(is_dangling(&format!("[Desktop Entry]\nTryExec={}\n", tmp.display())));++        // A missing quoted Exec is dangling too (the shape the apps' own AppRun writes).+        assert!(is_dangling("[Desktop Entry]\nExec=\"/home/ana/Applications/figcraft-0.5.0-linux-x86_64.AppImage\" %F\n"));+        // A bare command name (resolved by the desktop shell) is not a file reference.+        assert!(!is_dangling("[Desktop Entry]\nExec=cadcraft %F\n"));+        assert!(!is_dangling("[Desktop Entry]\nExec=photocraft %F\nTryExec=\n"));+        // An entry pointing at a file that still exists is not dangling.+        let present = std::env::temp_dir().join("ac_present_test.AppImage");+        std::fs::write(&present, "").unwrap();+        assert!(!is_dangling(&format!("[Desktop Entry]\nExec={}\n", present.display())));+        // The launcher's own entry (marked, live path) is never dangling.+        assert!(!is_dangling(&render(app("cadcraft"), &present, true).unwrap()));+        std::fs::remove_file(&present).unwrap();+    } }diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs--- a/crates/native/src/install.rs+++ b/crates/native/src/install.rs@@ -810,6 +810,9 @@ pub(crate) mod linux {         let replace = match std::fs::read_to_string(&desktop) {             Ok(old) => {                 crate::desktop_entry::is_ours(&old)+                    // A dangling unmarked entry (Exec/TryExec points at a removed file) is+                    // already hidden by the desktop; taking it over is the only recovery.+                    || crate::desktop_entry::is_dangling(&old)                     || crate::desktop_entry::exec_program(&old)                         .is_some_and(|p| Path::new(&p) == appimage || replaced.iter().any(|r| r == Path::new(&p)))             }@@ -1597,8 +1600,9 @@ mod tests {         // An update rewrites both.         assert_eq!(linux::integrate_into(&data, app, &new, &[], Some(&rec)).len(), 2); -        // Someone else's entry and icon are left alone (and not recorded).-        std::fs::write(&desktop, "[Desktop Entry]\nExec=/usr/bin/wordcraft\n").unwrap();+        // Someone else's entry and icon are left alone (and not recorded) while it still+        // points at a file that exists.+        std::fs::write(&desktop, "[Desktop Entry]\nExec=/bin/sh\n").unwrap();         std::fs::write(&icon, b"theirs").unwrap();         assert!(linux::integrate_into(&data, app, &new, &[], None).is_empty());         assert_eq!(std::fs::read(&icon).unwrap(), b"theirs");@@ -1606,6 +1610,12 @@ mod tests {         std::fs::write(&desktop, format!("[Desktop Entry]\nExec=\"{}\" %F\n", old.display())).unwrap();         assert_eq!(linux::integrate_into(&data, app, &new, std::slice::from_ref(&old), None), std::slice::from_ref(&desktop));         assert!(desktop_entry_is_ours(&desktop));+        // ...or unless it is dangling: an old unmarked entry whose target the desktop already+        // hides (the upgrade bug that made 10 of the 12 craft apps vanish from the menu).+        std::fs::write(&desktop, "[Desktop Entry]\nExec=wordcraft %F\nTryExec=/does/not/exist.AppImage\n").unwrap();+        assert!(!desktop_entry_is_ours(&desktop));+        assert_eq!(linux::integrate_into(&data, app, &new, &[], None), std::slice::from_ref(&desktop));+        assert!(desktop_entry_is_ours(&desktop));          // Uninstall: an icon the record doesn't list stays; the entry for the removed copy goes.         std::fs::remove_file(&new).unwrap();