From 3509bcfd874f2b01e91cb7228ef50fafd8f9be99 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sat, 10 Oct 2026 14:17:14 +0200 Subject: [PATCH] macOS: open documents from Finder, the Dock and `open -a` (#69) Double-clicking a workbook, Open With, dropping on the Dock icon and `open -a GridCraft book.xlsx` send the app a kAEOpenDocuments Apple event instead of command-line paths. winit 0.30 doesn't handle it, so AppKit answered "GridCraft cannot open files in the Excel Workbook format". Register the handler with the audited fmv-macos-events crate that PdfCraft and PhotoCraft already use: it does the Objective-C interop (unsafe, which this workspace forbids) behind a safe main-thread API and leaves winit's delegate alone. Opened paths go through SheetApp::open_path; a quit event closes the window. The registration lives in main() until the event loop returns, so the event that launched the app is caught too. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 ++++++++ apps/gridcraft/Cargo.toml | 5 +++ apps/gridcraft/src/apple_events.rs | 49 ++++++++++++++++++++++++++++++ apps/gridcraft/src/main.rs | 13 +++++++- 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 apps/gridcraft/src/apple_events.rs diff --git a/Cargo.lock b/Cargo.lock index e3a4144..4b6c03c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1132,6 +1132,17 @@ dependencies = [ "zlib-rs", ] +[[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 = "foldhash" version = "0.2.0" @@ -1396,6 +1407,7 @@ version = "0.4.0" dependencies = [ "eframe", "egui", + "fmv-macos-events", "gridcraft-engine", "gridcraft-ui-egui", "image", diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 0d3e34c..8c6aa57 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -28,3 +28,8 @@ rfd = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } +# Finder double-click, Open With, Dock drops and `open -a GridCraft file.xlsx` arrive as a +# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle (#69). This audited crate (also used +# by 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" diff --git a/apps/gridcraft/src/apple_events.rs b/apps/gridcraft/src/apple_events.rs new file mode 100644 index 0000000..405ee89 --- /dev/null +++ b/apps/gridcraft/src/apple_events.rs @@ -0,0 +1,49 @@ +//! macOS open-documents and quit Apple events (#69). +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a GridCraft book.xlsx` 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`, so AppKit answered "GridCraft cannot open files in the Excel Workbook +//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this +//! workspace forbids. The audited `fmv-macos-events` crate (also used by 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 fmv_macos_events::{Event, Inbox, Registration}; +use gridcraft_ui_egui::SheetApp; + +/// 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 SheetApp, ctx: &egui::Context) { + for e in inbox.drain() { + match e { + Event::Open(paths) => { + for p in paths.iter().filter_map(|p| p.to_str()) { + app.open_path(p); + } + } + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } +} diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 6acaf51..e11f3cd 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -8,6 +8,8 @@ #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_os = "macos")] +mod apple_events; mod control_server; #[cfg(target_os = "macos")] mod native_menu; @@ -16,7 +18,7 @@ use gridcraft_engine::Session; use gridcraft_ui_egui::{Services, SheetApp}; use serde_json::json; -struct App(SheetApp, #[cfg(target_os = "macos")] Option); +struct App(SheetApp, #[cfg(target_os = "macos")] Option, #[cfg(target_os = "macos")] fmv_macos_events::Inbox); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { @@ -28,6 +30,7 @@ impl eframe::App for App { if let Some(m) = &self.1 { m.poll(&mut self.0, ctx); } + apple_events::poll(&self.2, &mut self.0, ctx); } self.0.logic(ctx); } @@ -187,6 +190,12 @@ fn main() -> eframe::Result<()> { viewport = viewport.with_icon(i); } let options = eframe::NativeOptions { viewport, ..Default::default() }; + // 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( "GridCraft", options, @@ -202,6 +211,8 @@ fn main() -> eframe::Result<()> { app, #[cfg(target_os = "macos")] None, + #[cfg(target_os = "macos")] + apple_events.connect(&cc.egui_ctx), ))) }), )