From 12085b1153a1004879de2a23a4d38e95aa57b46b Mon Sep 17 00:00:00 2001 From: cohenmendy Date: Mon, 12 Oct 2026 17:26:50 -0400 Subject: [PATCH] fix(macos): open .pptx and .deckcraft files from Finder The macOS bundle declared the .deckcraft and .pptx types but listed no CFBundleDocumentTypes, so DeckCraft never appeared under Open With and couldn't be made the default app for presentations. Finder opens also arrive as a kAEOpenDocuments Apple event, not argv, which winit doesn't handle, so a double-clicked file wouldn't have loaded anyway. - Info.plist.in: document types for DeckCraft Presentation (Owner) and PowerPoint Presentation (Alternate, so PowerPoint/Keynote stay preferred unless the user picks DeckCraft). - apple_events.rs: register an open-documents handler with the pinned fmv-macos-events crate GridCraft already uses, and open the paths each frame; failures show in the status bar. - Ignore the -psn_ argument older macOS passes on a Finder launch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WxAm8VMKHa5w6kwkjBaT3D --- Cargo.lock | 12 +++++++ apps/deckcraft/Cargo.toml | 7 ++++ apps/deckcraft/src/apple_events.rs | 52 ++++++++++++++++++++++++++++++ apps/deckcraft/src/main.rs | 40 +++++++++++++++++++++-- packaging/macos/Info.plist.in | 37 +++++++++++++++++++++ 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 apps/deckcraft/src/apple_events.rs diff --git a/Cargo.lock b/Cargo.lock index e2bc7965..a8bd9e93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -901,6 +901,7 @@ dependencies = [ "deckcraft-ui-egui", "eframe", "egui", + "fmv-macos-events", "image", "log", "rfd", @@ -1630,6 +1631,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmv-macos-events" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" +dependencies = [ + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml index 26ea3081..fb3b489c 100644 --- a/apps/deckcraft/Cargo.toml +++ b/apps/deckcraft/Cargo.toml @@ -29,5 +29,12 @@ arboard = { workspace = true, features = ["image-data"] } image = { workspace = true } cpal = "0.16" +[target.'cfg(target_os = "macos")'.dependencies] +# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a +# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by +# GridCraft, PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which +# our workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. +fmv-macos-events = "=0.1.0" + [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] winit = { version = "0.30", default-features = false, features = ["x11"] } diff --git a/apps/deckcraft/src/apple_events.rs b/apps/deckcraft/src/apple_events.rs new file mode 100644 index 00000000..4b583ad0 --- /dev/null +++ b/apps/deckcraft/src/apple_events.rs @@ -0,0 +1,52 @@ +//! macOS open-documents and quit Apple events. +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a DeckCraft deck.pptx` don't +//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a +//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the +//! `NSApplicationDelegate`. Handling it ourselves needs Objective-C class declarations, i.e. +//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate (also used by +//! GridCraft, PdfCraft and PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler +//! registered before Finder's launch event that leaves winit's delegate alone, behind a safe +//! main-thread API. + +use deckcraft_ui_egui::SlideApp; +use fmv_macos_events::{Event, Inbox, Registration}; + +/// Keeps the Apple-event handlers registered; hold it until the event loop returns. +pub struct AppleEvents { + _registration: Registration, + inbox: Inbox, +} + +impl AppleEvents { + /// Register the handlers. Call on the main thread before the event loop starts, so the event + /// that launched the app (a Finder double-click) is caught too. + pub fn install() -> Self { + let (registration, inbox) = Registration::install(); + Self { _registration: registration, inbox } + } + + /// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> Inbox { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + self.inbox.clone() + } +} + +/// Open the documents and act on the quit requests that arrived since the last frame. +pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) { + for e in inbox.drain() { + match e { + Event::Open(paths) => { + for p in paths.iter().filter_map(|p| p.to_str()) { + if let Err(e) = app.open_path(p) { + log::error!("{p}: {e}"); + app.set_status(format!("Couldn't open {p}: {e}")); + } + } + } + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } +} diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index ef80daaa..b842c0f3 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -13,6 +13,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(target_os = "macos")] +mod apple_events; mod audio; mod control_server; #[cfg(all(unix, not(target_os = "macos")))] @@ -26,10 +28,17 @@ mod pasteboard; use deckcraft_engine::Session; use deckcraft_ui_egui::{Services, SlideApp}; -struct App(SlideApp); +struct App( + SlideApp, + /// Files opened from Finder and quit requests (see `apple_events`). + #[cfg(target_os = "macos")] + fmv_macos_events::Inbox, +); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + apple_events::poll(&self.1, &mut self.0, ctx); self.0.logic(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { @@ -155,6 +164,8 @@ fn startup(mut control_port: Option, args: impl IntoIterator "--show" => show = true, "--version" | "-V" => return Startup::Version, "--help" | "-h" => return Startup::Help, + // The process serial number older macOS adds when Finder launches the app. + a if a.starts_with("-psn_") => {} _ => files.push(a), } } @@ -231,6 +242,12 @@ fn main() -> eframe::Result { // Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs). #[cfg(target_os = "windows")] graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env()); + // Registered before the event loop starts, so it catches the Finder event that launched us as + // well as later ones. Lives until the event loop returns; the app creator only borrows it. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + #[cfg(target_os = "macos")] + let apple_events = &apple_events; eframe::run_native( "DeckCraft", options, @@ -262,7 +279,11 @@ fn main() -> eframe::Result { if show { app.start_show(0, false); } - Ok(Box::new(App(app))) + Ok(Box::new(App( + app, + #[cfg(target_os = "macos")] + apple_events.connect(&cc.egui_ctx), + ))) }), ) } @@ -276,7 +297,11 @@ mod tests { #[test] fn on_exit_ends_the_process_on_macos() { if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() { - let mut app = App(SlideApp::new(Session::new(), Services::default())); + let mut app = App( + SlideApp::new(Session::new(), Services::default()), + #[cfg(target_os = "macos")] + fmv_macos_events::Inbox::default(), + ); println!("calling on_exit"); eframe::App::on_exit(&mut app); println!("on_exit returned"); @@ -309,4 +334,13 @@ mod tests { assert!(!show); } } + + #[test] + fn finder_process_serial_number_is_not_a_file() { + let launch = startup(None, ["-psn_0_1234567".to_string(), "talk.pptx".to_string()]); + assert!(matches!(launch, Startup::Run { .. })); + if let Startup::Run { files, .. } = launch { + assert_eq!(files, ["talk.pptx".to_string()]); + } + } } diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 22afe13e..bc1fd72e 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -45,6 +45,43 @@ NSHumanReadableCopyright Copyright © the DeckCraft authors. MIT OR Apache-2.0. + + CFBundleDocumentTypes + + + CFBundleTypeName + DeckCraft Presentation + CFBundleTypeRole + Editor + LSHandlerRank + Owner + CFBundleTypeIconFile + DeckCraft + LSItemContentTypes + + ai.storyteller.deckcraft.presentation + + + + CFBundleTypeName + PowerPoint Presentation + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + org.openxmlformats.presentationml.presentation + + CFBundleTypeExtensions + pptx + CFBundleTypeMIMETypes + application/vnd.openxmlformats-officedocument.presentationml.presentation + + + UTExportedTypeDeclarations