Set Gopher64 as option for N64 ROM files on MacOS (#1364)

* add CFBundleDocumentTypes for MacOS

* macOS: launch ROMs opened via Finder or open -a (#1367)

Handle the 'open documents' Apple Event so ROMs can be opened by double-click, 'Open With' or 'open -a Gopher64 <rom>'. Files opened this way get a sandbox extension, so this works with the sandbox enabled.

* tie obj2 deps to gui build

---------

Co-authored-by: Michael Fuerst <29622305+lomdar67@users.noreply.github.com>
This commit is contained in:
Logan McNaughtonandMichael Fuerst authored and GitHub committed 2026-11-09 09:05:51 +00:00
1 parent 45f5c30011
commit 913cd4540d
6 files changed
+155 -1

No files matched your search

Generated
+26
View File
@@ -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"
+6 -1
View File
@@ -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"]
+17
View File
@@ -0,0 +1,17 @@
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Nintendo 64 ROM</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>n64</string>
<string>v64</string>
<string>z64</string>
</array>
</dict>
</array>
+2
View File
@@ -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")]
+7
View File
@@ -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();
}
+97
View File
@@ -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<dyn Fn(PathBuf)>,
}
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 <rom>`.
///
/// 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<OpenDocumentsHandler> = 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);
}