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