From d5ae136cf19f4d5ccf66b172650b4875cde376b7 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:14:45 -0400 Subject: [PATCH] Goblin: route macOS goblin: link clicks to the send-review screen The goblin: scheme was registered everywhere last night, but macOS is the one platform that delivers a scheme click as a Carbon/Apple Event (kAEGetURL), not on argv and not through any path winit or eframe surface, so on a Mac the click went nowhere. Install a kAEGetURL handler straight on the shared NSAppleEventManager at startup (macOS only), pull the URL string out of the event, and feed it to on_data, the exact same entry the desktop argv path uses. The per-frame Goblin router then lands the pay URI on the prefilled review screen, identical to a scanned checkout QR or a Linux argv link. This covers both a cold launch (event queued at start-up) and a warm click (app already running). The bridge is entirely cfg(target_os = "macos"): zero bytes on Linux, Windows and Android (Linux binary size byte-for-byte unchanged). It talks to the Objective-C runtime through the classic objc crate, which is already in the macOS build graph via nokhwa/cocoa/metal/wgpu, so it adds no new dependency tree, only its own small handler class. It registers its own Apple Event handler rather than touching the NSApplicationDelegate winit owns, so nothing winit does is clobbered. --- Cargo.lock | 1 + Cargo.toml | 5 +++ src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index db4d0042..aec30b73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4190,6 +4190,7 @@ dependencies = [ "nostr-relay-pool", "nostr-sdk", "num-bigint 0.4.6", + "objc", "openssl", "parking_lot 0.12.5", "pin-project", diff --git a/Cargo.toml b/Cargo.toml index b9a55c7e..2cc031a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -182,6 +182,11 @@ nokhwa = { version = "0.10.10", default-features = false, features = ["input-msm [target.'cfg(target_os = "macos")'.dependencies] nokhwa = { version = "0.10.10", default-features = false, features = ["input-avfoundation", "output-threaded"] } +## Objective-C runtime shim for the macOS `goblin:` deep-link bridge (src/lib.rs, +## mac_deeplink). Already in the macOS build graph via nokhwa/cocoa/metal/wgpu, so +## this pulls no new crate — it just lets us name it directly. Not compiled on any +## other platform. +objc = "0.2" [target.'cfg(not(target_os = "android"))'.dependencies] env_logger = "0.11.3" diff --git a/src/lib.rs b/src/lib.rs index fe9c8493..1bdb6a7d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -138,6 +138,13 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe: if AppConfig::autostart_node() { Node::start(); } + // macOS delivers `goblin:` link clicks as a kAEGetURL Apple Event, not on argv + // and not through any path winit/eframe surface. Install a handler for it here, + // BEFORE the event loop starts, so both a cold launch (event queued at start-up) + // and a warm click (app already running) route the URL into the same argv entry + // (`on_data`). No-op / not compiled on every other platform. + #[cfg(target_os = "macos")] + mac_deeplink::install(); // Launch graphical interface. eframe::run_native("Goblin", options, app_creator) } @@ -389,6 +396,99 @@ pub fn on_data(data: String) { *w_data = Some(data); } +/// macOS-only bridge that turns a `goblin:` URL click into an [`on_data`] call. +/// +/// On Linux/Windows the OS hands the URL to the app on argv (cold) or through the +/// single-instance socket (warm); on Android it arrives as an Intent. macOS uses +/// neither: it dispatches scheme clicks as a Carbon/Apple Event (`kAEGetURL`) that +/// winit + eframe never surface, so the click would otherwise vanish. We install a +/// handler for that event straight on the shared `NSAppleEventManager`, extract the +/// URL string, and feed it to [`on_data`] — the exact same entry point argv uses, +/// so the Goblin surface's per-frame router lands the pay URI on the prefilled +/// review screen just as a scanned QR or a Linux argv link does. +/// +/// This talks to the Objective-C runtime through the classic `objc` crate, which is +/// already in the macOS build graph (nokhwa/cocoa/metal/wgpu all pull it), so it +/// adds no new dependency and only a few KB of handler code. The Apple Event route +/// deliberately avoids the `NSApplicationDelegate` that winit owns — registering our +/// own `kAEGetURL` handler neither subclasses nor swizzles winit's delegate. +#[cfg(target_os = "macos")] +mod mac_deeplink { + use objc::declare::ClassDecl; + use objc::runtime::{Object, Sel}; + use objc::{class, msg_send, sel}; + use std::ffi::CStr; + use std::os::raw::c_char; + use std::sync::Once; + + // Four-char codes (OSType) for the URL-open Apple Event. + const K_INTERNET_EVENT_CLASS: u32 = 0x4755_524c; // 'GURL' + const K_AE_GET_URL: u32 = 0x4755_524c; // 'GURL' + const KEY_DIRECT_OBJECT: u32 = 0x2d2d_2d2d; // '----' + + /// `- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event + /// withReplyEvent:(NSAppleEventDescriptor *)reply`. + extern "C" fn handle_get_url( + _this: &Object, + _cmd: Sel, + event: *mut Object, + _reply: *mut Object, + ) { + if event.is_null() { + return; + } + unsafe { + // [[event paramDescriptorForKeyword:keyDirectObject] stringValue] -> NSString*. + let desc: *mut Object = msg_send![event, paramDescriptorForKeyword: KEY_DIRECT_OBJECT]; + if desc.is_null() { + return; + } + let url: *mut Object = msg_send![desc, stringValue]; + if url.is_null() { + return; + } + let utf8: *const c_char = msg_send![url, UTF8String]; + if utf8.is_null() { + return; + } + let s = CStr::from_ptr(utf8).to_string_lossy().into_owned(); + if !s.is_empty() { + crate::on_data(s); + } + } + } + + /// Register the `kAEGetURL` handler on the shared `NSAppleEventManager`. Idempotent + /// (the handler class is built once); call before the event loop starts. + pub fn install() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| unsafe { + // Build a tiny NSObject subclass carrying the handler method. + let superclass = class!(NSObject); + let mut decl = match ClassDecl::new("GoblinAppleEventHandler", superclass) { + Some(d) => d, + None => return, + }; + decl.add_method( + sel!(handleGetURLEvent:withReplyEvent:), + handle_get_url as extern "C" fn(&Object, Sel, *mut Object, *mut Object), + ); + let cls = decl.register(); + + // One instance, intentionally leaked: it must outlive every event for the + // whole process lifetime, and the app never unregisters. + let handler: *mut Object = msg_send![cls, new]; + let manager: *mut Object = + msg_send![class!(NSAppleEventManager), sharedAppleEventManager]; + let _: () = msg_send![manager, + setEventHandler: handler + andSelector: sel!(handleGetURLEvent:withReplyEvent:) + forEventClass: K_INTERNET_EVENT_CLASS + andEventID: K_AE_GET_URL]; + }); + } +} + /// Unix-seconds timestamp of the most recent GUI frame. Background workers read /// it to tell whether the app is actually on-screen: while the app is /// backgrounded, eframe stops calling the per-frame draw and this stops