collective

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

fix(install): menu entries list the file types each app opens

fix(install): menu entries list the file types each app opens An AppImage install writes `<data>/applications/ai.storyteller.<app>.desktop` without `MimeType=`, so the desktop has no way to offer the app for a file: a fresh GridCraft install never opens an `.xlsx` from the file manager or "Open with", and no default can be pointed at it. Write the file types each app's own `packaging/linux/ai.storyteller.<app>.desktop` declares (`desktop_entry::file_types`; DesignCraft and EffectCraft declare none, so they get no `MimeType=` line), and refresh the desktop's MIME database after writing or removing an entry: GLib-based desktops read `mimeinfo.cache` besid

Status
open
Score
0.00
Adopters
0 other operators
Author
mflkee (imported from pull request #447, open)
Base
0f295d4b9c90d15c2e38e36c322bdbc0fa943a54
Size
+120 −4 in 2 files
Review
pending
Shared
2026-10-28
Id
7c734761ea7beaae3277c7a9

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@@ -3,7 +3,11 @@ //! An AppImage installs nothing by itself, so without an entry the app is missing from the //! desktop's menu and, on Wayland, the dock can't match the window's app id to an icon. The //! launcher writes `<data>/applications/ai.storyteller.<app>.desktop` (the id each app's window-//! reports) and marks it as its own, so it only ever replaces or removes entries it wrote.+//! reports) and marks it as its own, so it only ever replaces or removes entries it wrote. The+//! entry also carries the file types the app opens (`MimeType=`), which is what makes the desktop+//! offer the app for a file at all; `install::linux` refreshes the desktop's MIME database after+//! writing or removing one, since GLib-based desktops read `mimeinfo.cache` beside the entries+//! rather than the entries themselves. //! //! The text is built by pure functions, tested on every platform; only Linux writes the files. @@ -18,6 +22,7 @@ pub const MARKER: &str = "X-ArtCraft-Launcher=true";  /// The menu categories and the window class each app's own `.desktop` file declares. pub fn meta(app: &CraftApp) -> (&'static str, Option<&'static str>) {+    // Keep in step with [`file_types`], the other half of what the apps declare there.     match app.id {         "photocraft" => ("Graphics;2DGraphics;RasterGraphics;Photography;", Some("photocraft")),         "pdfcraft" => ("Office;Viewer;", Some("pdfcraft")),@@ -36,6 +41,65 @@ pub fn meta(app: &CraftApp) -> (&'static str, Option<&'static str>) {     } } +/// The file types (`MimeType=`) each app's own `.desktop` file declares, copied verbatim from+/// `<app>/packaging/linux/ai.storyteller.<app>.desktop`. They are how the desktop knows the app+/// opens a file at all: an entry without them shows up as a launch button and nothing else, so+/// double-clicking an `.xlsx` can't offer GridCraft and "Open with" never lists it.+///+/// The list is the app's, not a choice the launcher makes: where two apps claim the same type+/// (PhotoCraft and LightCraft both take `image/jpeg`), which one a click reaches is the user's+/// default, and the desktop decides it. DesignCraft and EffectCraft declare none yet.+pub fn file_types(app: &CraftApp) -> Option<&'static str> {+    let mimes = match app.id {+        "photocraft" => {+            "application/x-photocraft;image/x-paintnet;image/openraster;application/x-krita;\+             image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;\+             image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;\+             image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;\+             image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi;"+        }+        "pdfcraft" => "application/pdf;",+        "lightcraft" => {+            "image/jpeg;image/png;image/tiff;image/webp;image/x-adobe-dng;image/x-sony-arw;\+             image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-fuji-raf;\+             image/x-olympus-orf;image/x-panasonic-rw2;image/x-panasonic-rw;image/x-pentax-pef;"+        }+        "filmcraft" => {+            "application/x-filmcraft;video/mp4;video/x-m4v;video/quicktime;video/x-matroska;\+             video/webm;video/vnd.avi;video/x-msvideo;application/mxf;video/mp2t;video/mpeg;video/3gpp;"+        }+        "vectorcraft" => "image/svg+xml;application/pdf;",+        "wordcraft" => {+            "application/vnd.openxmlformats-officedocument.wordprocessingml.document;\+             application/vnd.ms-word.document.macroEnabled.12;\+             application/vnd.openxmlformats-officedocument.wordprocessingml.template;\+             application/vnd.ms-word.template.macroEnabled.12;application/msword;\+             application/msword-template;application/vnd.oasis.opendocument.text;application/rtf;\+             text/rtf;text/markdown;text/x-tex;text/html;text/plain;"+        }+        "deckcraft" => {+            "application/x-deckcraft;\+             application/vnd.openxmlformats-officedocument.presentationml.presentation;\+             application/vnd.openxmlformats-officedocument.presentationml.template;\+             application/vnd.openxmlformats-officedocument.presentationml.slideshow;"+        }+        "gridcraft" => {+            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;\+             application/vnd.ms-excel.sheet.macroEnabled.12;\+             application/vnd.ms-excel.sheet.binary.macroEnabled.12;\+             application/vnd.oasis.opendocument.spreadsheet;text/csv;text/tab-separated-values;"+        }+        "cadcraft" => "image/vnd.dxf;image/vnd.dwg;application/x-dxf;application/acad;",+        "soundcraft" => {+            "application/x-soundcraft-session;audio/x-wav;audio/wav;audio/vnd.wave;audio/x-aiff;\+             audio/aiff;audio/flac;audio/x-flac;audio/mpeg;audio/midi;audio/x-midi;"+        }+        "figcraft" => "application/x-figcraft;image/svg+xml;image/png;image/jpeg;x-scheme-handler/figcraft;",+        _ => return None,+    };+    Some(mimes)+}+ /// `path` as one quoted argument of an `Exec=` key, per the Desktop Entry spec: inside double /// quotes `"`, `` ` ``, `$` and `\` take a backslash, then the string-value rule doubles every /// backslash, and `%` (field codes) is written `%%`. `None` for paths that can't be written@@ -81,6 +145,11 @@ pub fn render(app: &CraftApp, appimage: &Path, icon: bool) -> Option<String> {     }     text.push_str("Terminal=false\n");     text.push_str(&format!("Categories={categories}\n"));+    // What the app opens, as its own entry declares it. An empty `MimeType=` is worse than none:+    // the desktop reads it as a claim on nothing, so leave the key out.+    if let Some(file_types) = file_types(app) {+        text.push_str(&format!("MimeType={file_types}\n"));+    }     text.push_str(&format!("StartupWMClass={}\n", wm_class.map_or_else(|| app.bundle_id(), str::to_string)));     text.push_str(MARKER);     text.push('\n');@@ -147,8 +216,9 @@ mod tests {             "[Desktop Entry]\nType=Application\nName=PhotoCraft\nComment={}\n\              Exec=\"/home/ana/Applications/photocraft-0.6.0-linux-x86_64.AppImage\" %F\n\              Icon=ai.storyteller.photocraft\nTerminal=false\nCategories=Graphics;2DGraphics;RasterGraphics;Photography;\n\-             StartupWMClass=photocraft\nX-ArtCraft-Launcher=true\n",-            app("photocraft").tagline+             MimeType={}\nStartupWMClass=photocraft\nX-ArtCraft-Launcher=true\n",+            app("photocraft").tagline,+            file_types(app("photocraft")).unwrap()         );         assert_eq!(text, expected);         assert!(is_ours(&text));@@ -158,6 +228,29 @@ mod tests {         assert!(no_icon.contains("Categories=Office;WordProcessor;\n"));     } +    #[test]+    fn lists_the_file_types_each_app_opens() {+        let wordcraft = render(app("wordcraft"), Path::new("/x/w.AppImage"), false).unwrap();+        assert!(wordcraft.contains("MimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document;"));+        assert!(wordcraft.contains(";text/plain;\n"), "{wordcraft}");+        let figcraft = render(app("figcraft"), Path::new("/x/f.AppImage"), false).unwrap();+        assert!(figcraft.contains(";x-scheme-handler/figcraft;\n"), "{figcraft}");+        // DesignCraft declares no file types, so its entry must claim none either: an app that+        // has never opened a `.psd` shouldn't be offered for one.+        let designcraft = render(app("designcraft"), Path::new("/x/d.AppImage"), false).unwrap();+        assert!(!designcraft.contains("MimeType="), "{designcraft}");+        // Every list is a well-formed `MimeType=`: `type/subtype;`, no spaces, closed with `;`.+        for app in &artcraft_launcher_core::SUITE {+            let Some(file_types) = file_types(app) else {+                continue;+            };+            assert!(file_types.ends_with(';'), "{}: {file_types}", app.id);+            for one in file_types.split(';').filter(|t| !t.is_empty()) {+                assert!(one.contains('/') && !one.contains(char::is_whitespace), "{}: {one}", app.id);+            }+        }+    }+     #[test]     fn quotes_exec_paths_per_the_spec() {         assert_eq!(exec_quote(Path::new("/home/ana/My Apps/p.AppImage")).unwrap(), "\"/home/ana/My Apps/p.AppImage\"");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@@ -461,6 +461,7 @@ fn cancelled(stop: &Stop) -> impl Fn() -> bool + '_ { pub(crate) mod linux {     use std::ffi::{OsStr, OsString};     use std::os::unix::fs::PermissionsExt;+    use std::time::Duration;      use artcraft_launcher_core::distro;     use artcraft_launcher_core::protocol::CANCELLED;@@ -772,6 +773,23 @@ pub(crate) mod linux {         data.join("icons/hicolor/128x128/apps").join(format!("{}.png", app.bundle_id()))     } +    /// Tells the desktop's MIME database that a menu entry under this path was written or removed.+    ///+    /// What the entries say isn't enough on its own: GLib-based desktops (GTK file managers, most+    /// desktops' "Open with") read the `mimeinfo.cache` sitting beside them, so without this the+    /// app's file types stay unknown — GridCraft is installed, its entry lists `.xlsx`, and no+    /// click can reach it — and an entry that's gone keeps being offered until the next refresh.+    /// Best effort: a failure here never fails the install, and `desktop-file-utils` (the package+    /// holding `update-desktop-database`) may not be installed at all.+    fn refresh_mime_database(desktop: &Path) {+        let Some(dir) = desktop.parent().and_then(Path::to_str) else {+            return;+        };+        if let Err(e) = crate::process::run("update-desktop-database", &[dir], Duration::from_secs(10)) {+            log::debug!("couldn't refresh the desktop's MIME database: {e}");+        }+    }+     /// Adds `appimage` to the desktop's menu. Best effort: a failure here never fails the     /// install. Returns the files written, for the record.     pub fn integrate(app: &CraftApp, appimage: &Path, replaced: &[PathBuf], previous: Option<&Record>) -> Vec<PathBuf> {@@ -820,7 +838,10 @@ pub(crate) mod linux {             return written;         }         match crate::paths::write_atomic(&desktop, text.as_bytes()) {-            Ok(()) => written.push(desktop),+            Ok(()) => {+                refresh_mime_database(&desktop);+                written.push(desktop);+            }             Err(e) => log::warn!("{}: couldn't add the menu entry: {e}", app.name),         }         written@@ -844,6 +865,7 @@ pub(crate) mod linux {             let ours_and_dangling = crate::desktop_entry::is_ours(&text) && program.is_none_or(|p| !p.exists());             if runs_removed || ours_and_dangling {                 let _ = std::fs::remove_file(&desktop);+                refresh_mime_database(&desktop);             }         }         let icon = icon_path(data, app);@@ -1591,6 +1613,7 @@ mod tests {         let text = std::fs::read_to_string(&desktop).unwrap();         assert!(text.contains("Exec=\"") && text.contains("My Apps/wordcraft-0.3.0-linux-x86_64.AppImage\" %F\n"), "{text}");         assert!(text.contains("Icon=ai.storyteller.wordcraft\n"));+        assert!(text.contains(";text/plain;\n"), "{text}");         assert!(std::fs::read(&icon).unwrap().starts_with(b"\x89PNG"));         let rec = record("0.3.0", PackageKind::AppImage, new.clone(), files, false);