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), ()))
+ })
+}