diff --git a/Cargo.lock b/Cargo.lock index 55d84e86..d605a525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2401,6 +2401,8 @@ dependencies = [ "matchbox_socket", "mimalloc", "obfstr", + "objc2 0.6.5", + "objc2-foundation 0.3.2", "open", "postcard", "quick-xml 0.42.0", @@ -4211,6 +4213,18 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -4250,6 +4264,7 @@ dependencies = [ "block2 0.6.2", "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-services", ] [[package]] @@ -4326,6 +4341,17 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + [[package]] name = "objc2-symbols" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index d404c1dc..8d86ff80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ rust-version = "1.99.1" [features] default = ["gui"] -gui = ["dep:rfd", "dep:slint", "dep:open", "dep:slint-build"] +gui = ["dep:rfd", "dep:slint", "dep:open", "dep:slint-build", "dep:objc2", "dep:objc2-foundation"] [lib] crate-type = ["cdylib", "rlib"] @@ -55,6 +55,10 @@ sdl3-sys = { version = "0.7", features = ["build-from-source"] } sdl3-ttf-sys = { version = "0.7", features = ["build-from-source", "no-sdlttf-harfbuzz", "no-sdlttf-plutosvg"] } webpki-roots = "1.0" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { version = "0.6", optional = true } +objc2-foundation = { version = "0.3", optional = true, default-features = false, features = ["std", "NSAppleEventManager", "NSAppleEventDescriptor", "NSURL", "NSNotification", "NSString", "objc2-core-services"] } + [build-dependencies] winresource = "0.1" bindgen = "0.73" @@ -79,3 +83,4 @@ icon = ["data/icon/gopher64_128x128.png","data/icon/gopher64_256x256.png","data/ [package.metadata.bundle.osx] frameworks = ["/opt/homebrew/opt/molten-vk/lib/libMoltenVK.dylib"] minimum_system_version = "15.0" +info_plist_exts = ["data/macos/document_types.plist"] diff --git a/data/macos/document_types.plist b/data/macos/document_types.plist new file mode 100644 index 00000000..4dc8841f --- /dev/null +++ b/data/macos/document_types.plist @@ -0,0 +1,17 @@ + CFBundleDocumentTypes + + + CFBundleTypeName + Nintendo 64 ROM + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + + n64 + v64 + z64 + + + diff --git a/src/ui.rs b/src/ui.rs index 1e9e4f8c..b99f552d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,6 +8,8 @@ pub mod config; pub mod gui; pub mod input; pub mod input_profile; +#[cfg(all(feature = "gui", target_os = "macos"))] +pub mod macos; #[cfg(feature = "gui")] pub mod netplay; #[cfg(feature = "gui")] diff --git a/src/ui/gui.rs b/src/ui/gui.rs index f40d0403..359b44c3 100644 --- a/src/ui/gui.rs +++ b/src/ui/gui.rs @@ -568,6 +568,13 @@ pub fn app_window( run_with_path(app.as_weak(), path); } + // Launch ROMs opened via Finder or `open -a` (macOS "open documents" Apple Event) + #[cfg(target_os = "macos")] + { + let weak = app.as_weak(); + ui::macos::register_open_documents_handler(move |path| run_with_path(weak.clone(), path)); + } + app.run().unwrap(); retroachievements::shutdown_client(); } diff --git a/src/ui/macos.rs b/src/ui/macos.rs new file mode 100644 index 00000000..48a9af49 --- /dev/null +++ b/src/ui/macos.rs @@ -0,0 +1,97 @@ +//! macOS-specific integration. + +use std::path::PathBuf; + +use objc2::rc::Retained; +use objc2::runtime::NSObject; +use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; +use objc2_foundation::{ + NSAppleEventDescriptor, NSAppleEventManager, NSNotification, NSNotificationCenter, NSString, +}; + +// Apple Event codes (FourCharCode), see AppleEvents.h / AEDataModel.h. +const K_CORE_EVENT_CLASS: u32 = u32::from_be_bytes(*b"aevt"); // kCoreEventClass +const K_AE_OPEN_DOCUMENTS: u32 = u32::from_be_bytes(*b"odoc"); // kAEOpenDocuments +const KEY_DIRECT_OBJECT: u32 = u32::from_be_bytes(*b"----"); // keyDirectObject + +struct Ivars { + on_open: Box, +} + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "Gopher64OpenDocumentsHandler"] + #[ivars = Ivars] + struct OpenDocumentsHandler; + + impl OpenDocumentsHandler { + // AppKit installs its own Apple Event handlers during `finishLaunching` + // (inside the event loop), which would replace ours if we registered it + // earlier. "Will finish launching" is the point Apple recommends for + // installing custom handlers. winit owns the app delegate, so we listen + // for the notification instead. + #[unsafe(method(applicationWillFinishLaunching:))] + fn application_will_finish_launching(&self, _notification: &NSNotification) { + let manager = NSAppleEventManager::sharedAppleEventManager(); + unsafe { + manager.setEventHandler_andSelector_forEventClass_andEventID( + self, + sel!(handleOpenDocuments:withReplyEvent:), + K_CORE_EVENT_CLASS, + K_AE_OPEN_DOCUMENTS, + ); + } + } + + #[unsafe(method(handleOpenDocuments:withReplyEvent:))] + fn handle_open_documents( + &self, + event: &NSAppleEventDescriptor, + _reply: &NSAppleEventDescriptor, + ) { + let Some(list) = event.paramDescriptorForKeyword(KEY_DIRECT_OBJECT) else { + return; + }; + // Only the first file is used, Gopher64 runs one ROM at a time. + let Some(url) = list + .descriptorAtIndex(1) + .and_then(|item| item.fileURLValue()) + else { + return; + }; + let Some(path) = url.to_file_path() else { + return; + }; + (self.ivars().on_open)(path); + } + } +); + +/// Handles ROMs opened via Finder (double-click, "Open With") or `open -a Gopher64 `. +/// +/// macOS delivers these as an "open documents" Apple Event. Files opened this way +/// get a sandbox extension, so the ROM can be read without extra permissions. +/// Must be called on the main thread before the event loop starts. +pub fn register_open_documents_handler(on_open: impl Fn(PathBuf) + 'static) { + let mtm = MainThreadMarker::new().expect("must be called on the main thread"); + let handler = OpenDocumentsHandler::alloc(mtm).set_ivars(Ivars { + on_open: Box::new(on_open), + }); + let handler: Retained = unsafe { msg_send![super(handler), init] }; + + let center = NSNotificationCenter::defaultCenter(); + let name = NSString::from_str("NSApplicationWillFinishLaunchingNotification"); + unsafe { + center.addObserver_selector_name_object( + &handler, + sel!(applicationWillFinishLaunching:), + Some(&name), + None, + ); + } + + // Neither NSNotificationCenter nor NSAppleEventManager retain the handler, + // so keep it alive for the lifetime of the app. + std::mem::forget(handler); +}