|
| 1 | +//! macOS URL scheme handler for `nexus://` deep links |
| 2 | +//! |
| 3 | +//! On macOS, clicking a `nexus://` link delivers the URL via Apple Events |
| 4 | +//! (`kInternetEventClass` / `kAEGetURL`), not as a command-line argument. |
| 5 | +//! |
| 6 | +//! This module registers a handler with `NSAppleEventManager` to receive |
| 7 | +//! those events and forwards URLs through a crossbeam channel consumed by |
| 8 | +//! an async stream subscription. |
| 9 | +//! |
| 10 | +//! **Why NSAppleEventManager instead of NSApplicationDelegate?** |
| 11 | +//! Iced/winit owns the `NSApplication` delegate for window and input event |
| 12 | +//! handling. Replacing it with our own delegate breaks the entire event |
| 13 | +//! chain and causes crashes. `NSAppleEventManager` hooks into URL delivery |
| 14 | +//! at a lower level without touching the delegate. |
| 15 | +
|
| 16 | +use std::sync::atomic::{AtomicBool, Ordering}; |
| 17 | +use std::time::Duration; |
| 18 | + |
| 19 | +use crossbeam_channel::{Receiver, Sender}; |
| 20 | +use objc2::rc::Retained; |
| 21 | +use objc2::runtime::AnyObject; |
| 22 | +use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; |
| 23 | +use objc2_foundation::{NSObject, NSObjectProtocol}; |
| 24 | +use once_cell::sync::Lazy; |
| 25 | + |
| 26 | +use crate::app::Message; |
| 27 | + |
| 28 | +/// Channel for forwarding URLs from the Apple Event handler to the Iced event loop. |
| 29 | +/// |
| 30 | +/// `crossbeam_channel` is used because both `Sender` and `Receiver` are |
| 31 | +/// `Send + Sync`, which is required for use in a `static`. The standard |
| 32 | +/// library's `mpsc::Receiver` is not `Sync` and would fail to compile. |
| 33 | +static URL_CHANNEL: Lazy<(Sender<String>, Receiver<String>)> = |
| 34 | + Lazy::new(crossbeam_channel::unbounded); |
| 35 | + |
| 36 | +/// Flag set during app shutdown so the `spawn_blocking` recv loop can exit. |
| 37 | +static SHUTTING_DOWN: AtomicBool = AtomicBool::new(false); |
| 38 | + |
| 39 | +/// Apple Event FourCharCode for `kInternetEventClass` and `kAEGetURL` (both `'GURL'`). |
| 40 | +const K_AE_GET_URL: u32 = u32::from_be_bytes(*b"GURL"); |
| 41 | + |
| 42 | +/// Apple Event FourCharCode for `keyDirectObject` (`'----'`), the parameter |
| 43 | +/// key that contains the URL string in a "get URL" event. |
| 44 | +const KEY_DIRECT_OBJECT: u32 = u32::from_be_bytes(*b"----"); |
| 45 | + |
| 46 | +define_class!( |
| 47 | + #[unsafe(super(NSObject))] |
| 48 | + #[thread_kind = MainThreadOnly] |
| 49 | + #[name = "NexusURLHandler"] |
| 50 | + struct UrlHandler; |
| 51 | + |
| 52 | + unsafe impl NSObjectProtocol for UrlHandler {} |
| 53 | + |
| 54 | + /// Handler method registered with `NSAppleEventManager`. The selector |
| 55 | + /// `handleGetURLEvent:withReplyEvent:` matches what AppKit expects for |
| 56 | + /// Apple Event callbacks. |
| 57 | + impl UrlHandler { |
| 58 | + #[unsafe(method(handleGetURLEvent:withReplyEvent:))] |
| 59 | + fn handle_get_url_event(&self, event: &AnyObject, _reply: &AnyObject) { |
| 60 | + // event is an NSAppleEventDescriptor. Extract the direct object |
| 61 | + // parameter which contains the URL as an NSAppleEventDescriptor, |
| 62 | + // then get its stringValue (an NSString). |
| 63 | + let descriptor: *mut AnyObject = |
| 64 | + unsafe { msg_send![event, paramDescriptorForKeyword: KEY_DIRECT_OBJECT] }; |
| 65 | + if descriptor.is_null() { |
| 66 | + return; |
| 67 | + } |
| 68 | + let ns_string: *mut AnyObject = unsafe { msg_send![&*descriptor, stringValue] }; |
| 69 | + if ns_string.is_null() { |
| 70 | + return; |
| 71 | + } |
| 72 | + let utf8: *const std::ffi::c_char = unsafe { msg_send![&*ns_string, UTF8String] }; |
| 73 | + if utf8.is_null() { |
| 74 | + return; |
| 75 | + } |
| 76 | + let url_str = unsafe { std::ffi::CStr::from_ptr(utf8) } |
| 77 | + .to_string_lossy() |
| 78 | + .to_string(); |
| 79 | + if url_str.to_lowercase().starts_with("rustcast://") { |
| 80 | + let _ = URL_CHANNEL.0.send(url_str); |
| 81 | + } |
| 82 | + } |
| 83 | + } |
| 84 | +); |
| 85 | + |
| 86 | +impl UrlHandler { |
| 87 | + fn new(mtm: MainThreadMarker) -> Retained<Self> { |
| 88 | + // SAFETY: `Self::alloc(mtm)` returns a valid allocated instance of our |
| 89 | + // NSObject subclass. Sending `init` to a freshly allocated NSObject |
| 90 | + // subclass with no custom ivars is the standard Objective-C |
| 91 | + // initialisation pattern and always succeeds. |
| 92 | + unsafe { msg_send![Self::alloc(mtm), init] } |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +/// Install the macOS URL scheme handler via `NSAppleEventManager`. |
| 97 | +/// |
| 98 | +/// Must be called **after** the Iced/winit event loop has been created |
| 99 | +/// (i.e. from `NexusApp::new()`), so that AppKit is fully initialized. |
| 100 | +/// |
| 101 | +/// Registers for `kInternetEventClass` / `kAEGetURL` events, which macOS |
| 102 | +/// sends when a `nexus://` URL is opened (clicked in browser, Finder, etc.). |
| 103 | +pub fn install() { |
| 104 | + let Some(mtm) = MainThreadMarker::new() else { |
| 105 | + eprintln!("macos_url: not on main thread, skipping URL handler install"); |
| 106 | + return; |
| 107 | + }; |
| 108 | + |
| 109 | + let handler = UrlHandler::new(mtm); |
| 110 | + |
| 111 | + // Get [NSAppleEventManager sharedAppleEventManager] |
| 112 | + let mgr: *mut AnyObject = unsafe { |
| 113 | + msg_send![ |
| 114 | + objc2::runtime::AnyClass::get(c"NSAppleEventManager") |
| 115 | + .expect("NSAppleEventManager class not found"), |
| 116 | + sharedAppleEventManager |
| 117 | + ] |
| 118 | + }; |
| 119 | + assert!( |
| 120 | + !mgr.is_null(), |
| 121 | + "macos_url: sharedAppleEventManager returned nil" |
| 122 | + ); |
| 123 | + |
| 124 | + // Register: [mgr setEventHandler:handler |
| 125 | + // andSelector:@selector(handleGetURLEvent:withReplyEvent:) |
| 126 | + // forEventClass:kInternetEventClass |
| 127 | + // andEventID:kAEGetURL] |
| 128 | + let handler_sel = sel!(handleGetURLEvent:withReplyEvent:); |
| 129 | + unsafe { |
| 130 | + let _: () = msg_send![ |
| 131 | + &*mgr, |
| 132 | + setEventHandler: &*handler, |
| 133 | + andSelector: handler_sel, |
| 134 | + forEventClass: K_AE_GET_URL, |
| 135 | + andEventID: K_AE_GET_URL |
| 136 | + ]; |
| 137 | + } |
| 138 | + |
| 139 | + // Leak the handler so it lives for the entire process. |
| 140 | + // |
| 141 | + // `UrlHandler` is `MainThreadOnly` (`!Send + !Sync`), so |
| 142 | + // `Retained<UrlHandler>` cannot be stored in a `static`. Leaking |
| 143 | + // is the standard pattern for process-lifetime Objective-C objects. |
| 144 | + // |
| 145 | + // Unlike `NSApplication.delegate` (which is weak), the Apple Event |
| 146 | + // Manager retains a strong reference — but leaking is still correct |
| 147 | + // because we never want to unregister the handler. |
| 148 | + std::mem::forget(handler); |
| 149 | +} |
| 150 | + |
| 151 | +/// Signal the URL stream to stop so the `spawn_blocking` task can exit |
| 152 | +/// and tokio's runtime drop won't hang. |
| 153 | +/// |
| 154 | +/// Must be called before `iced::window::close()` on macOS. |
| 155 | +#[allow(unused)] |
| 156 | +pub fn shutdown() { |
| 157 | + SHUTTING_DOWN.store(true, Ordering::Relaxed); |
| 158 | +} |
| 159 | + |
| 160 | +/// Async stream that yields URLs received via Apple Events. |
| 161 | +/// |
| 162 | +/// Uses `recv_timeout` inside `spawn_blocking` so the blocking thread |
| 163 | +/// wakes periodically and can exit when the tokio runtime shuts down |
| 164 | +/// (e.g., on app quit). Without this, `recv()` blocks indefinitely and |
| 165 | +/// causes a hang on macOS during quit. |
| 166 | +pub fn url_stream() -> impl iced::futures::Stream<Item = Message> { |
| 167 | + iced::futures::stream::unfold((), |()| async { |
| 168 | + let url = tokio::task::spawn_blocking(|| { |
| 169 | + loop { |
| 170 | + if SHUTTING_DOWN.load(Ordering::Relaxed) { |
| 171 | + return None; |
| 172 | + } |
| 173 | + match URL_CHANNEL.1.recv_timeout(Duration::from_millis(500)) { |
| 174 | + Ok(url) => return Some(url), |
| 175 | + Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue, |
| 176 | + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return None, |
| 177 | + } |
| 178 | + } |
| 179 | + }) |
| 180 | + .await |
| 181 | + .ok() |
| 182 | + .flatten()?; |
| 183 | + |
| 184 | + Some((Message::UriReceived(url), ())) |
| 185 | + }) |
| 186 | +} |
0 commit comments