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
@@ -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 |new file mode 100644@@ -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"));+ }+ }+}@@ -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();@@ -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));@@ -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)] {@@ -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);