collective

Browse / github.com/storytold/deckcraft / patch

fix(macos): say why Finder can't make DeckCraft the default app (#1305)

fix(macos): say why Finder can't make DeckCraft the default app (#1305) Refs #1305 ## What the report shows Setting DeckCraft as the default for `.pptx` fails in Finder (Get Info › Change All) with *"You can't change the item … to always open in the selected application … (error code 2)"*, while Open With on a single file works. ## What I found - `packaging/macos/Info.plist.in` already declares the `.pptx`, `.potx`, `.ppsx` and `.deckcraft` document types with `LSItemContentTypes`, so the plist is not the obvious culprit. I left it alone. - Change All records the app by its path. When the app runs from the mounted DMG (`/Volumes/…`) or from a quarantined

Status
open
Score
0.00
Adopters
0 other operators
Author
SofiaAFigueredo (imported from pull request #1387, open)
Base
aaf6c1c38aa6c59beda8110bd014cc6c96c6ec74
Size
+125 −2 in 6 files
Review
pending
Shared
2026-10-28
Id
1880fe5c3a8423265ce21f40

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/deckcraft/src/install_location.rs apps/deckcraft/src/main.rs crates/ui-egui/src/backstage.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/status.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | What landed | |---|---|+| 2026-10-25 | macOS: DeckCraft run from a mounted volume (the disk image) or from a quarantined Downloads copy (App Translocation) says in the status bar to move it to Applications, because Finder's Get Info › Change All can't record such a path as the default app for `.pptx` and fails with "error code 2" (#1305). The `Info.plist` document types were already right; not yet confirmed on a Mac whether this is the reporter's cause | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |@@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | trivial | Progress log: status-bar hint when the macOS app runs from a disk image or a quarantine location (#1305). Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |diff --git a/apps/deckcraft/src/install_location.rs b/apps/deckcraft/src/install_location.rsnew file mode 100644--- /dev/null+++ b/apps/deckcraft/src/install_location.rs@@ -0,0 +1,83 @@+//! macOS: notice when DeckCraft runs from somewhere Finder can't make it the default app (#1305).+//!+//! Finder's Get Info › Change All records the app by its path. An app started from the mounted+//! disk image, or from Downloads while it is still quarantined, runs from a read-only `/Volumes/…`+//! path or from a random, temporary App Translocation path that is gone once the app quits. Open+//! With still works for a single file, but Change All fails with "You can't change the item …+//! to always open in the selected application (error code 2)". Copying the app to Applications+//! avoids it, so the app says so instead of leaving the user guessing.++use std::path::Path;++/// Where the running app lives, when that blocks making it the default for a file type.+#[derive(Debug, PartialEq, Eq)]+pub enum Misplaced {+    /// Started from a mounted volume: the disk image, or any other volume that can be ejected+    /// (an external or network drive).+    Volume,+    /// Gatekeeper's App Translocation: a quarantined app run from Downloads or the like.+    Translocated,+}++impl Misplaced {+    /// The status-bar text: what is wrong and what to do about it.+    pub fn message(&self) -> &'static str {+        match self {+            Self::Volume => {+                "DeckCraft is running from a mounted volume or disk image: drag it to Applications, eject the volume and reopen it to make it the default app"+            }+            Self::Translocated => "DeckCraft is running from a temporary location: move it to Applications and reopen it to make it the default app",+        }+    }+}++/// Classify the path of the running executable (`…/DeckCraft.app/Contents/MacOS/DeckCraft`).+pub fn check(exe: &Path) -> Option<Misplaced> {+    let s = exe.to_str()?;+    if s.contains("/AppTranslocation/") {+        Some(Misplaced::Translocated)+    } else if s.starts_with("/Volumes/") {+        Some(Misplaced::Volume)+    } else {+        None+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    /// The verdict for an executable path.+    fn exe(p: &str) -> Option<Misplaced> {+        check(Path::new(p))+    }++    #[test]+    /// A disk image or any other mounted volume is no place to make an app the default from.+    fn a_mounted_volume_is_misplaced() {+        assert_eq!(exe("/Volumes/DeckCraft/DeckCraft.app/Contents/MacOS/DeckCraft"), Some(Misplaced::Volume));+    }++    #[test]+    /// Gatekeeper's temporary App Translocation path is misplaced.+    fn a_translocated_app_is_misplaced() {+        let p = "/private/var/folders/ab/xyz/T/AppTranslocation/6F1B/d/DeckCraft.app/Contents/MacOS/DeckCraft";+        assert_eq!(exe(p), Some(Misplaced::Translocated));+    }++    #[test]+    /// Applications folders and a development build raise no hint.+    fn installed_apps_are_fine() {+        assert_eq!(exe("/Applications/DeckCraft.app/Contents/MacOS/DeckCraft"), None);+        assert_eq!(exe("/Users/a/Applications/DeckCraft.app/Contents/MacOS/DeckCraft"), None);+        assert_eq!(exe("/Users/a/src/deckcraft/target/release/deckcraft"), None);+    }++    #[test]+    /// Each case tells the user where to move the app.+    fn every_case_has_advice() {+        for m in [Misplaced::Volume, Misplaced::Translocated] {+            assert!(m.message().contains("Applications"));+        }+    }+}diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs--- a/apps/deckcraft/src/main.rs+++ b/apps/deckcraft/src/main.rs@@ -22,6 +22,8 @@ mod control_server; mod display; #[cfg(any(target_os = "windows", test))] mod graphics;+#[cfg(any(target_os = "macos", test))]+mod install_location; mod logging; #[cfg(not(target_arch = "wasm32"))] mod pasteboard;@@ -269,6 +271,14 @@ fn main() -> eframe::Result {             let mut app = SlideApp::new(Session::new(), services());             load_prefs(&mut app);             app.enable_update_checks();+            // Finder can't make an app the default for a file type while it runs from a disk image+            // or a quarantine location (#1305). A standing notice, not a status message: opening+            // the file Finder launched us with clears the status. The recovery notice below+            // takes its place while it is fresh: unsaved work matters more.+            #[cfg(target_os = "macos")]+            if let Some(m) = std::env::current_exe().ok().and_then(|exe| install_location::check(&exe)) {+                app.set_standing_status(m.message());+            }             // AutoRecover: reopen what a previous run left unsaved, then keep it current.             if std::env::var_os("DECKCRAFT_NO_RECOVERY").is_none() {                 app.session.recovery_dir = deckcraft_engine::recovery::default_dir();diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs--- a/crates/ui-egui/src/backstage.rs+++ b/crates/ui-egui/src/backstage.rs@@ -408,7 +408,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {                 // Errors raised on a backstage page (a missing recent file, a failed open) would                 // otherwise vanish: the status bar only draws once the backstage is closed (#172).                 // A real label, not painted text, so screen readers see it too.-                if let Some(msg) = app.fresh_status() {+                if let Some(msg) = app.shown_status() {                     ui.label(RichText::new(&msg).font(theme::font(12.0)).color(t.accent));                     ui.add_space(8.0);                     ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -542,6 +542,9 @@ pub struct SlideApp {     /// Audio/video playback (editor control bar, slide show, `media.*` commands).     pub media: media::MediaHost,     pub status: Option<(String, f64)>,+    /// A notice that stays while nothing fresher is in `status` (opening a file clears `status`,+    /// not this): the app runs where Finder can't make it the default app (#1305).+    standing_status: Option<String>,     /// A presentation being read in the background, with its progress window ([`opening`]).     pub opening: Option<opening::Opening>,     /// A brief notice over the bottom of the window (missing fonts on opening a file).@@ -816,6 +819,7 @@ impl SlideApp {             reuse_target: None,             media: media::MediaHost::new(audio_out),             status: None,+            standing_status: None,             opening: None,             toast: None,             fonts_noted: Default::default(),@@ -888,6 +892,17 @@ impl SlideApp {         self.status = Some((msg.into(), now_ms()));     } +    /// Set the notice shown whenever there is no fresh status message (see [`Self::shown_status`]).+    pub fn set_standing_status(&mut self, msg: impl Into<String>) {+        self.standing_status = Some(msg.into());+    }++    /// What the status bar and the start screen show: the fresh status message, or else the+    /// standing notice.+    pub fn shown_status(&mut self) -> Option<String> {+        self.fresh_status().or_else(|| self.standing_status.clone())+    }+     /// Turn on the automatic check for a newer release at start-up (desktop shell only; tests and     /// the web build never touch the network on their own).     #[cfg(not(target_arch = "wasm32"))]@@ -5243,6 +5258,19 @@ mod tests {     /// The status bar's zoom slider has no room for a visible label; it still needs an accessible     /// name, and adding that name must not change the slider's shared control geometry at either     /// size.+    #[test]+    fn a_standing_notice_survives_opening_a_file_and_yields_to_a_fresh_status() {+        // #1305: the install-location hint was a status message, which opening the file Finder+        // launched the app with clears.+        let mut app = SlideApp::new(Session::with_new(), Services::default());+        app.set_standing_status("move me to Applications");+        assert_eq!(app.shown_status().as_deref(), Some("move me to Applications"));+        app.set_status("Recovered 1 unsaved presentation");+        assert_eq!(app.shown_status().as_deref(), Some("Recovered 1 unsaved presentation"));+        app.note_recent("/tmp/talk.pptx");+        assert_eq!(app.shown_status().as_deref(), Some("move me to Applications"));+    }+     #[test]     fn status_zoom_slider_is_named_without_growing_the_status_bar() {         for size in [egui::vec2(900.0, 560.0), egui::vec2(1440.0, 900.0)] {diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs--- a/crates/ui-egui/src/status.rs+++ b/crates/ui-egui/src/status.rs@@ -42,7 +42,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {             }         }         // Transient status message, clipped before the controls on the right.-        if let Some(msg) = app.fresh_status() {+        if let Some(msg) = app.shown_status() {             let mx = (rect.min.x + 290.0).max(lr.max.x + 16.0);             let clip = Rect::from_min_max(pos2(mx, rect.min.y), pos2(right_x.max(mx), rect.max.y));             let mr = p.with_clip_rect(clip).text(pos2(mx, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);