diff --git a/Cargo.lock b/Cargo.lock index c059a04..59d8cc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3855,6 +3855,7 @@ version = "0.7.2" dependencies = [ "arboard", "block2 0.6.2", + "crossbeam-channel", "emojis", "global-hotkey", "iced", diff --git a/Cargo.toml b/Cargo.toml index 812a7cb..7cb8af6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://github.com/RustCastLabs/rustcast" [dependencies] arboard = "3.6.1" block2 = "0.6.2" +crossbeam-channel = "0.5.15" emojis = "0.8.0" global-hotkey = "0.7.0" iced = { version = "0.14.0", features = ["image", "tokio"] } diff --git a/assets/macos/RustCast.app/Contents/Info.plist b/assets/macos/RustCast.app/Contents/Info.plist index 298774f..9f50001 100644 --- a/assets/macos/RustCast.app/Contents/Info.plist +++ b/assets/macos/RustCast.app/Contents/Info.plist @@ -1,32 +1,43 @@ - - CFBundleDevelopmentRegion - en - CFBundleExecutable - rustcast - CFBundleIdentifier - com.umangsurana.rustcast - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - RustCast - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSUIElement - - NSHighResolutionCapable - - NSHumanReadableCopyright - Copyright © 2025 Umang Surana. All rights reserved. - NSInputMonitoringUsageDescription - RustCast needs to monitor keyboard input to detect global shortcuts and control casting. - CFBundleIconFile - icon - + + CFBundleDevelopmentRegion + en + CFBundleExecutable + rustcast + CFBundleIdentifier + com.umangsurana.rustcast + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + RustCast + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSUIElement + + NSHighResolutionCapable + + CFBundleURLTypes + + + CFBundleURLSchemes + + rustcast + + CFBundleURLName + com.umangsurana.rustcast + + + NSHumanReadableCopyright + Copyright © 2025 Umang Surana. All rights reserved. + NSInputMonitoringUsageDescription + RustCast needs to monitor keyboard input to detect global shortcuts and control casting. + CFBundleIconFile + icon + diff --git a/src/app.rs b/src/app.rs index 68b19e4..6892bdb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -80,6 +80,7 @@ pub enum Editable { /// The message type that iced uses for actions that can do something #[derive(Debug, Clone)] pub enum Message { + UriReceived(String), WriteConfig(bool), SaveRanking, ToggleAutoStartup(bool), diff --git a/src/app/tile.rs b/src/app/tile.rs index 0ccbb4e..7b131be 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -231,6 +231,7 @@ impl Tile { Subscription::batch([ Subscription::run(handle_hot_reloading), keyboard, + Subscription::run(crate::platform::macos::urlscheme::url_stream), Subscription::run(handle_recipient), Subscription::run(handle_version_and_rankings), Subscription::run(handle_clipboard_history), diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 87fadf9..7fbe2ab 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -66,6 +66,8 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { ) .unwrap_or(HashMap::new()); + crate::platform::macos::urlscheme::install(); + ( Tile { update_available: false, diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index f3aa95b..1684476 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -15,6 +15,7 @@ use log::info; use rayon::iter::IntoParallelRefIterator; use rayon::iter::ParallelIterator; use rayon::slice::ParallelSliceMut; +use url::Url; use crate::app::Editable; use crate::app::SetConfigBufferFields; @@ -46,6 +47,12 @@ use crate::{app::DEFAULT_WINDOW_HEIGHT, platform::perform_haptic}; use crate::{app::Move, platform::HapticPattern}; use crate::{app::RUSTCAST_DESC_NAME, platform::get_installed_apps}; +fn extract_target(url: &Url) -> Option { + url.query_pairs() + .find(|(key, _)| key == "target") + .map(|(_, value)| value.into_owned()) +} + /// Handle the "elm" update pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match message { @@ -64,6 +71,29 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } } + Message::UriReceived(uri) => { + let Ok(url) = Url::parse(&uri) else { + return Task::none(); + }; + + match url.host_str().unwrap_or("") { + "open" => extract_target(&url) + .and_then(|x| tile.options.by_name.get(&x).map(|x| x.to_owned())) + .map(|app| match app.open_command { + AppCommand::Function(a) => Task::done(Message::RunFunction(a)), + AppCommand::Display => Task::none(), + AppCommand::Message(msg) => Task::done(msg), + }) + .unwrap_or(Task::none()), + + "show" => open_window(DEFAULT_WINDOW_HEIGHT), + + "quit" => Task::done(Message::RunFunction(Function::Quit)), + + _ => Task::none(), + } + } + Message::UpdateAvailable => { tile.update_available = true; Task::done(Message::ReloadConfig) diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index eeab128..0441f05 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -2,6 +2,7 @@ pub mod discovery; pub mod haptics; pub mod launching; +pub mod urlscheme; use iced::wgpu::rwh::WindowHandle; diff --git a/src/platform/macos/urlscheme.rs b/src/platform/macos/urlscheme.rs new file mode 100644 index 0000000..abf0873 --- /dev/null +++ b/src/platform/macos/urlscheme.rs @@ -0,0 +1,186 @@ +//! macOS URL scheme handler for `nexus://` deep links +//! +//! On macOS, clicking a `nexus://` link delivers the URL via Apple Events +//! (`kInternetEventClass` / `kAEGetURL`), not as a command-line argument. +//! +//! This module registers a handler with `NSAppleEventManager` to receive +//! those events and forwards URLs through a crossbeam channel consumed by +//! an async stream subscription. +//! +//! **Why NSAppleEventManager instead of NSApplicationDelegate?** +//! Iced/winit owns the `NSApplication` delegate for window and input event +//! handling. Replacing it with our own delegate breaks the entire event +//! chain and causes crashes. `NSAppleEventManager` hooks into URL delivery +//! at a lower level without touching the delegate. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use crossbeam_channel::{Receiver, Sender}; +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; +use objc2_foundation::{NSObject, NSObjectProtocol}; +use once_cell::sync::Lazy; + +use crate::app::Message; + +/// Channel for forwarding URLs from the Apple Event handler to the Iced event loop. +/// +/// `crossbeam_channel` is used because both `Sender` and `Receiver` are +/// `Send + Sync`, which is required for use in a `static`. The standard +/// library's `mpsc::Receiver` is not `Sync` and would fail to compile. +static URL_CHANNEL: Lazy<(Sender, Receiver)> = + Lazy::new(crossbeam_channel::unbounded); + +/// Flag set during app shutdown so the `spawn_blocking` recv loop can exit. +static SHUTTING_DOWN: AtomicBool = AtomicBool::new(false); + +/// Apple Event FourCharCode for `kInternetEventClass` and `kAEGetURL` (both `'GURL'`). +const K_AE_GET_URL: u32 = u32::from_be_bytes(*b"GURL"); + +/// Apple Event FourCharCode for `keyDirectObject` (`'----'`), the parameter +/// key that contains the URL string in a "get URL" event. +const KEY_DIRECT_OBJECT: u32 = u32::from_be_bytes(*b"----"); + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "NexusURLHandler"] + struct UrlHandler; + + unsafe impl NSObjectProtocol for UrlHandler {} + + /// Handler method registered with `NSAppleEventManager`. The selector + /// `handleGetURLEvent:withReplyEvent:` matches what AppKit expects for + /// Apple Event callbacks. + impl UrlHandler { + #[unsafe(method(handleGetURLEvent:withReplyEvent:))] + fn handle_get_url_event(&self, event: &AnyObject, _reply: &AnyObject) { + // event is an NSAppleEventDescriptor. Extract the direct object + // parameter which contains the URL as an NSAppleEventDescriptor, + // then get its stringValue (an NSString). + let descriptor: *mut AnyObject = + unsafe { msg_send![event, paramDescriptorForKeyword: KEY_DIRECT_OBJECT] }; + if descriptor.is_null() { + return; + } + let ns_string: *mut AnyObject = unsafe { msg_send![&*descriptor, stringValue] }; + if ns_string.is_null() { + return; + } + let utf8: *const std::ffi::c_char = unsafe { msg_send![&*ns_string, UTF8String] }; + if utf8.is_null() { + return; + } + let url_str = unsafe { std::ffi::CStr::from_ptr(utf8) } + .to_string_lossy() + .to_string(); + if url_str.to_lowercase().starts_with("rustcast://") { + let _ = URL_CHANNEL.0.send(url_str); + } + } + } +); + +impl UrlHandler { + fn new(mtm: MainThreadMarker) -> Retained { + // SAFETY: `Self::alloc(mtm)` returns a valid allocated instance of our + // NSObject subclass. Sending `init` to a freshly allocated NSObject + // subclass with no custom ivars is the standard Objective-C + // initialisation pattern and always succeeds. + unsafe { msg_send![Self::alloc(mtm), init] } + } +} + +/// Install the macOS URL scheme handler via `NSAppleEventManager`. +/// +/// Must be called **after** the Iced/winit event loop has been created +/// (i.e. from `NexusApp::new()`), so that AppKit is fully initialized. +/// +/// Registers for `kInternetEventClass` / `kAEGetURL` events, which macOS +/// sends when a `nexus://` URL is opened (clicked in browser, Finder, etc.). +pub fn install() { + let Some(mtm) = MainThreadMarker::new() else { + eprintln!("macos_url: not on main thread, skipping URL handler install"); + return; + }; + + let handler = UrlHandler::new(mtm); + + // Get [NSAppleEventManager sharedAppleEventManager] + let mgr: *mut AnyObject = unsafe { + msg_send![ + objc2::runtime::AnyClass::get(c"NSAppleEventManager") + .expect("NSAppleEventManager class not found"), + sharedAppleEventManager + ] + }; + assert!( + !mgr.is_null(), + "macos_url: sharedAppleEventManager returned nil" + ); + + // Register: [mgr setEventHandler:handler + // andSelector:@selector(handleGetURLEvent:withReplyEvent:) + // forEventClass:kInternetEventClass + // andEventID:kAEGetURL] + let handler_sel = sel!(handleGetURLEvent:withReplyEvent:); + unsafe { + let _: () = msg_send![ + &*mgr, + setEventHandler: &*handler, + andSelector: handler_sel, + forEventClass: K_AE_GET_URL, + andEventID: K_AE_GET_URL + ]; + } + + // Leak the handler so it lives for the entire process. + // + // `UrlHandler` is `MainThreadOnly` (`!Send + !Sync`), so + // `Retained` cannot be stored in a `static`. Leaking + // is the standard pattern for process-lifetime Objective-C objects. + // + // Unlike `NSApplication.delegate` (which is weak), the Apple Event + // Manager retains a strong reference — but leaking is still correct + // because we never want to unregister the handler. + std::mem::forget(handler); +} + +/// Signal the URL stream to stop so the `spawn_blocking` task can exit +/// and tokio's runtime drop won't hang. +/// +/// Must be called before `iced::window::close()` on macOS. +#[allow(unused)] +pub fn shutdown() { + SHUTTING_DOWN.store(true, Ordering::Relaxed); +} + +/// Async stream that yields URLs received via Apple Events. +/// +/// Uses `recv_timeout` inside `spawn_blocking` so the blocking thread +/// wakes periodically and can exit when the tokio runtime shuts down +/// (e.g., on app quit). Without this, `recv()` blocks indefinitely and +/// causes a hang on macOS during quit. +pub fn url_stream() -> impl iced::futures::Stream { + iced::futures::stream::unfold((), |()| async { + let url = tokio::task::spawn_blocking(|| { + loop { + if SHUTTING_DOWN.load(Ordering::Relaxed) { + return None; + } + match URL_CHANNEL.1.recv_timeout(Duration::from_millis(500)) { + Ok(url) => return Some(url), + Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue, + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return None, + } + } + }) + .await + .ok() + .flatten()?; + + Some((Message::UriReceived(url), ())) + }) +}