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);
+}