From c986902a06ead7cd25f96ca3cc89e4d9490b0bfa Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 31 Mar 2026 01:42:30 +0200 Subject: [PATCH] fix: handle macOS deep-link URLs via Carbon Apple Event handler On macOS, void:// URL activations are delivered via Apple Events (kAEGetURL), not as CLI arguments. Uses the Carbon AEInstallEventHandler API to capture URLs and feed them into the existing pending_deeplink mechanism. Handles both cold start and warm activation. All new code is behind #[cfg(target_os = "macos")]. --- src/app.rs | 13 +++++ src/deeplink/macos.rs | 128 ++++++++++++++++++++++++++++++++++++++++++ src/deeplink/mod.rs | 2 + src/main.rs | 9 +++ 4 files changed, 152 insertions(+) create mode 100644 src/deeplink/macos.rs diff --git a/src/app.rs b/src/app.rs index 981869e..df4260b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -54,6 +54,11 @@ impl VoidApp { // Start IPC server for receiving deep-links from other instances let ipc_server = IpcServer::start(ctx.clone()); + // On macOS, store the egui context so Apple Event callbacks can + // trigger repaints when a void:// URL arrives. + #[cfg(target_os = "macos")] + crate::deeplink::macos::set_egui_context(cc.egui_ctx.clone()); + let brand_texture = { let png = include_bytes!("../assets/brand.png"); let img = image::load_from_memory(png) @@ -368,6 +373,14 @@ impl VoidApp { } } + // On macOS, also check for URLs delivered via Apple Events + #[cfg(target_os = "macos")] + if self.pending_deeplink.is_none() { + if let Some(url) = crate::deeplink::macos::take_pending_url() { + self.pending_deeplink = Some(url); + } + } + // Process pending deep-link if let Some(url) = self.pending_deeplink.take() { match crate::deeplink::parse(&url) { diff --git a/src/deeplink/macos.rs b/src/deeplink/macos.rs new file mode 100644 index 0000000..1635cb7 --- /dev/null +++ b/src/deeplink/macos.rs @@ -0,0 +1,128 @@ +//! macOS deep-link handler via Apple Events. +//! +//! On macOS, `void://` URL activations are delivered through Apple Events +//! (`kAEGetURL`), not as command-line arguments. This module installs a +//! Carbon Apple Event handler that captures the URL and stores it for the +//! next frame's deep-link processing. + +use std::ffi::c_void; +use std::sync::Mutex; + +static PENDING_URL: Mutex> = Mutex::new(None); +static EGUI_CTX: Mutex> = Mutex::new(None); + +// ── Carbon Apple Event FFI ────────────────────────────────────────────────── + +type FourCharCode = u32; + +// keyDirectObject = '----' +const KEY_DIRECT_OBJECT: FourCharCode = 0x2D2D2D2D; +// typeUTF8Text = 'utf8' +const TYPE_UTF8_TEXT: FourCharCode = 0x75746638; +// kInternetEventClass = kAEGetURL = 'GURL' +const K_AE_GET_URL: FourCharCode = 0x4755524C; + +#[link(name = "CoreServices", kind = "framework")] +extern "C" { + fn AEInstallEventHandler( + event_class: FourCharCode, + event_id: FourCharCode, + handler: extern "C" fn(*const c_void, *mut c_void, isize) -> i16, + handler_refcon: isize, + is_sys_handler: u8, + ) -> i16; + + fn AEGetParamPtr( + the_apple_event: *const c_void, + keyword: FourCharCode, + desired_type: FourCharCode, + actual_type: *mut FourCharCode, + data_ptr: *mut u8, + maximum_size: isize, + actual_size: *mut isize, + ) -> i16; +} + +// ── Handler ───────────────────────────────────────────────────────────────── + +/// Carbon Apple Event callback for `kAEGetURL`. +/// +/// Extracts the URL string from the event's `keyDirectObject` parameter +/// and stores it in `PENDING_URL` for the eframe update loop to pick up. +extern "C" fn handle_get_url_event( + event: *const c_void, + _reply: *mut c_void, + _refcon: isize, +) -> i16 { + if event.is_null() { + return -1; + } + + let mut buffer = [0u8; 4096]; + let mut actual_size: isize = 0; + let mut actual_type: FourCharCode = 0; + + let err = unsafe { + AEGetParamPtr( + event, + KEY_DIRECT_OBJECT, + TYPE_UTF8_TEXT, + &mut actual_type, + buffer.as_mut_ptr(), + buffer.len() as isize, + &mut actual_size, + ) + }; + + if err != 0 || actual_size <= 0 { + return err; + } + + let len = (actual_size as usize).min(buffer.len()); + let url = String::from_utf8_lossy(&buffer[..len]).to_string(); + + if url.starts_with("void://") { + if let Ok(mut guard) = PENDING_URL.lock() { + *guard = Some(url); + } + // Wake egui so the URL is processed promptly + if let Ok(guard) = EGUI_CTX.lock() { + if let Some(ctx) = guard.as_ref() { + ctx.request_repaint(); + } + } + } + + 0 // noErr +} + +/// Install the Apple Event handler for `kAEGetURL`. +/// +/// Must be called early in `main()`, before the run loop starts, so that +/// events arriving during launch are captured. +pub fn install_url_event_handler() { + let err = unsafe { + AEInstallEventHandler( + K_AE_GET_URL, // kInternetEventClass + K_AE_GET_URL, // kAEGetURL + handle_get_url_event, + 0, // refcon + 0u8, // not a system handler + ) + }; + if err != 0 { + log::warn!("Failed to install Apple Event URL handler: error {err}"); + } +} + +/// Take and clear the pending URL, if any. +pub fn take_pending_url() -> Option { + PENDING_URL.lock().ok().and_then(|mut g| g.take()) +} + +/// Store the egui context so the Apple Event callback can trigger repaints. +pub fn set_egui_context(ctx: egui::Context) { + if let Ok(mut guard) = EGUI_CTX.lock() { + *guard = Some(ctx); + } +} diff --git a/src/deeplink/mod.rs b/src/deeplink/mod.rs index f35be80..9acb4a6 100644 --- a/src/deeplink/mod.rs +++ b/src/deeplink/mod.rs @@ -6,6 +6,8 @@ // void://open//@,[,] → navigate to canvas coordinates pub mod ipc; +#[cfg(target_os = "macos")] +pub mod macos; pub mod register; pub mod toast; diff --git a/src/main.rs b/src/main.rs index 620e859..3351748 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,9 +23,18 @@ fn main() -> Result<()> { // Register void:// protocol handler on this system (idempotent, silent) deeplink::register::ensure_registered(); + // On macOS, URL scheme activations arrive via Apple Events, not CLI args. + // Install the handler early so events during launch are captured. + #[cfg(target_os = "macos")] + deeplink::macos::install_url_event_handler(); + // Check for void:// deep-link URL passed as CLI argument let url_arg = std::env::args().nth(1).filter(|a| a.starts_with("void://")); + // On macOS the URL may have arrived via Apple Event before we got here + #[cfg(target_os = "macos")] + let url_arg = url_arg.or_else(deeplink::macos::take_pending_url); + // If another instance is already running, send the URL to it and exit if let Some(ref url) = url_arg { if deeplink::ipc::try_send_to_running(url) {