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();
