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.rs
new 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);
