diff --git a/Cargo.lock b/Cargo.lock index d7fc9434..9b8a353e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,6 +1142,7 @@ dependencies = [ "effectcraft-ui-egui", "eframe", "egui", + "fmv-macos-events", "log", "muda", "objc2 0.6.4", @@ -2280,6 +2281,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.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 5ca52d69..df691691 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -35,3 +35,8 @@ muda = { version = "0.21", default-features = false } objc2 = "0.6" # NSApplication hide / hide others / show all, and the pasteboard for Edit ▸ Paste in text fields. objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSPasteboard"] } +# Finder double-click, Open With, Dock drops and `open -a EffectCraft x.ecproj` arrive as a +# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by +# PhotoCraft and PdfCraft) 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/effectcraft/src/apple_events.rs b/apps/effectcraft/src/apple_events.rs new file mode 100644 index 00000000..b590c7ac --- /dev/null +++ b/apps/effectcraft/src/apple_events.rs @@ -0,0 +1,80 @@ +//! macOS open-documents and quit Apple events. +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a EffectCraft x.ecproj` 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 the project never opened. Handling it ourselves needs Objective-C +//! class declarations, i.e. `unsafe`, which this workspace forbids. The audited `fmv-macos-events` +//! crate (also used by PhotoCraft and PdfCraft) 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 std::path::{Path, PathBuf}; +use std::sync::Arc; + +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 ([`Opened::feed`]); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> Opened { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + Opened { inbox: self.inbox.clone(), pending: Vec::new() } + } +} + +/// Documents macOS asked us to open, waiting for the UI to take them. +pub struct Opened { + inbox: Inbox, + /// Paths that arrived before the UI was ready (the Finder event that launched the app comes + /// before the first frame is drawn). + pending: Vec, +} + +impl Opened { + /// Hand the documents to the dropped-file handling once the UI is `ready` (a project opens, + /// asking about unsaved changes first; media is imported), and act on quit requests. + pub fn feed(&mut self, ready: bool, ctx: &egui::Context, raw: &mut egui::RawInput) { + for e in self.inbox.drain() { + match e { + Event::Open(paths) => self.pending.extend(paths), + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } + if self.pending.is_empty() { + return; + } + if !ready { + ctx.request_repaint(); + return; + } + raw.dropped_files.extend(self.pending.drain(..).map(|p| Arc::new(OpenedFile(p)) as egui::DroppedFileHandle)); + } +} + +/// A document macOS asked us to open, presented to egui as a dropped file. +#[derive(Debug)] +struct OpenedFile(PathBuf); + +impl egui::DroppedFile for OpenedFile { + fn path(&self) -> &Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 72afab5a..2750f7ba 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -13,6 +13,8 @@ // Built everywhere so its tests run on every platform; only Linux AppImages use it. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod appimage; +#[cfg(target_os = "macos")] +mod apple_events; mod audio_out; mod control_server; mod launch_guard; @@ -101,6 +103,12 @@ fn main() -> eframe::Result { // Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never // appears shows how far start-up got (#234). log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION")); + // 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; let result = eframe::run_native( "EffectCraft", options, @@ -207,6 +215,8 @@ fn main() -> eframe::Result { Ok(Box::new(Desktop { #[cfg(target_os = "macos")] menu: native_menu::NativeBar::new(&cc.egui_ctx), + #[cfg(target_os = "macos")] + apple_events: apple_events.connect(&cc.egui_ctx), app, launch, drawn: false, @@ -274,6 +284,9 @@ struct Desktop { app: EffectcraftApp, #[cfg(target_os = "macos")] menu: native_menu::NativeBar, + /// Documents opened from Finder, the Dock or `open` (macOS). + #[cfg(target_os = "macos")] + apple_events: apple_events::Opened, launch: launch_guard::Launch, /// The first frame was drawn (logged once). drawn: bool, @@ -300,6 +313,8 @@ impl eframe::App for Desktop { } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + #[cfg(target_os = "macos")] + self.apple_events.feed(self.app.is_ready(), ctx, raw_input); self.app.raw_input_hook(ctx, raw_input); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82be8a32..1607234e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -510,6 +510,12 @@ impl EffectcraftApp { && self.ui.viewer.extended.is_none() } + /// The UI draws its panels and handles input such as dropped files. Until the fonts are + /// installed (the first frames) it draws nothing, and that input goes unhandled. + pub fn is_ready(&self) -> bool { + self.fonts_ready + } + /// Provide the executable/embedding host's device notifications. The host installs the /// handlers once; this frontend never replaces handlers on a borrowed device. pub fn set_gpu_failure_bridge(&mut self, bridge: gpu_failure::GpuFailureBridge) { diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 606da5e9..3e3b8894 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -45,25 +45,44 @@ NSHumanReadableCopyright Copyright © the EffectCraft authors. MIT OR Apache-2.0. - + + CFBundleDocumentTypes + + + CFBundleTypeName + EffectCraft Project + CFBundleTypeRole + Editor + LSHandlerRank + Owner + CFBundleTypeIconFile + EffectCraft + LSItemContentTypes + ai.storyteller.effectcraft.document + + + UTExportedTypeDeclarations UTTypeIdentifier ai.storyteller.effectcraft.document UTTypeDescription - EffectCraft Document + EffectCraft Project UTTypeConformsTo public.data - public.image + public.content UTTypeIconFile EffectCraft UTTypeTagSpecification public.filename-extension - pcraft + + ecproj + ecprojx + public.mime-type application/x-effectcraft