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 <noreply@anthropic.com>
This commit is contained in:
lloyd 2026-10-10 14:17:14 +02:00
parent 13664fe66b
commit 3509bcfd87
4 changed files with 78 additions and 1 deletions

12
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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),
}
}
}

View File

@ -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<native_menu::NativeMenu>);
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[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),
)))
}),
)