Skip to content

Commit 3ef7eb8

Browse files
authored
Merge pull request #261 from RustCastLabs/rustcast-deeplink
Rustcast deeplink
2 parents a5f2c76 + e7ebdea commit 3ef7eb8

9 files changed

Lines changed: 262 additions & 28 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ repository = "https://github.com/RustCastLabs/rustcast"
1010
[dependencies]
1111
arboard = "3.6.1"
1212
block2 = "0.6.2"
13+
crossbeam-channel = "0.5.15"
1314
emojis = "0.8.0"
1415
global-hotkey = "0.7.0"
1516
iced = { version = "0.14.0", features = ["image", "tokio"] }
Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,43 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
4-
<dict>
5-
<key>CFBundleDevelopmentRegion</key>
6-
<string>en</string>
7-
<key>CFBundleExecutable</key>
8-
<string>rustcast</string>
9-
<key>CFBundleIdentifier</key>
10-
<string>com.umangsurana.rustcast</string>
11-
<key>CFBundleInfoDictionaryVersion</key>
12-
<string>6.0</string>
13-
<key>CFBundleName</key>
14-
<string>RustCast</string>
15-
<key>CFBundlePackageType</key>
16-
<string>APPL</string>
17-
<key>CFBundleShortVersionString</key>
18-
<string>1.0</string>
19-
<key>CFBundleVersion</key>
20-
<string>1</string>
21-
<key>LSUIElement</key>
22-
<true/>
23-
<key>NSHighResolutionCapable</key>
24-
<true/>
25-
<key>NSHumanReadableCopyright</key>
26-
<string>Copyright © 2025 Umang Surana. All rights reserved.</string>
27-
<key>NSInputMonitoringUsageDescription</key>
28-
<string>RustCast needs to monitor keyboard input to detect global shortcuts and control casting.</string>
29-
<key>CFBundleIconFile</key>
30-
<string>icon</string>
31-
</dict>
4+
<dict>
5+
<key>CFBundleDevelopmentRegion</key>
6+
<string>en</string>
7+
<key>CFBundleExecutable</key>
8+
<string>rustcast</string>
9+
<key>CFBundleIdentifier</key>
10+
<string>com.umangsurana.rustcast</string>
11+
<key>CFBundleInfoDictionaryVersion</key>
12+
<string>6.0</string>
13+
<key>CFBundleName</key>
14+
<string>RustCast</string>
15+
<key>CFBundlePackageType</key>
16+
<string>APPL</string>
17+
<key>CFBundleShortVersionString</key>
18+
<string>1.0</string>
19+
<key>CFBundleVersion</key>
20+
<string>1</string>
21+
<key>LSUIElement</key>
22+
<true/>
23+
<key>NSHighResolutionCapable</key>
24+
<true/>
25+
<key>CFBundleURLTypes</key>
26+
<array>
27+
<dict>
28+
<key>CFBundleURLSchemes</key>
29+
<array>
30+
<string>rustcast</string>
31+
</array>
32+
<key>CFBundleURLName</key>
33+
<string>com.umangsurana.rustcast</string>
34+
</dict>
35+
</array>
36+
<key>NSHumanReadableCopyright</key>
37+
<string>Copyright © 2025 Umang Surana. All rights reserved.</string>
38+
<key>NSInputMonitoringUsageDescription</key>
39+
<string>RustCast needs to monitor keyboard input to detect global shortcuts and control casting.</string>
40+
<key>CFBundleIconFile</key>
41+
<string>icon</string>
42+
</dict>
3243
</plist>

src/app.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ pub enum Editable<T> {
8080
/// The message type that iced uses for actions that can do something
8181
#[derive(Debug, Clone)]
8282
pub enum Message {
83+
UriReceived(String),
8384
WriteConfig(bool),
8485
SaveRanking,
8586
ToggleAutoStartup(bool),

src/app/tile.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ impl Tile {
231231
Subscription::batch([
232232
Subscription::run(handle_hot_reloading),
233233
keyboard,
234+
Subscription::run(crate::platform::macos::urlscheme::url_stream),
234235
Subscription::run(handle_recipient),
235236
Subscription::run(handle_version_and_rankings),
236237
Subscription::run(handle_clipboard_history),

src/app/tile/elm.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task<Message>) {
6666
)
6767
.unwrap_or(HashMap::new());
6868

69+
crate::platform::macos::urlscheme::install();
70+
6971
(
7072
Tile {
7173
update_available: false,

src/app/tile/update.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use log::info;
1515
use rayon::iter::IntoParallelRefIterator;
1616
use rayon::iter::ParallelIterator;
1717
use rayon::slice::ParallelSliceMut;
18+
use url::Url;
1819

1920
use crate::app::Editable;
2021
use crate::app::SetConfigBufferFields;
@@ -46,6 +47,12 @@ use crate::{app::DEFAULT_WINDOW_HEIGHT, platform::perform_haptic};
4647
use crate::{app::Move, platform::HapticPattern};
4748
use crate::{app::RUSTCAST_DESC_NAME, platform::get_installed_apps};
4849

50+
fn extract_target(url: &Url) -> Option<String> {
51+
url.query_pairs()
52+
.find(|(key, _)| key == "target")
53+
.map(|(_, value)| value.into_owned())
54+
}
55+
4956
/// Handle the "elm" update
5057
pub fn handle_update(tile: &mut Tile, message: Message) -> Task<Message> {
5158
match message {
@@ -64,6 +71,29 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task<Message> {
6471
}
6572
}
6673

74+
Message::UriReceived(uri) => {
75+
let Ok(url) = Url::parse(&uri) else {
76+
return Task::none();
77+
};
78+
79+
match url.host_str().unwrap_or("") {
80+
"open" => extract_target(&url)
81+
.and_then(|x| tile.options.by_name.get(&x).map(|x| x.to_owned()))
82+
.map(|app| match app.open_command {
83+
AppCommand::Function(a) => Task::done(Message::RunFunction(a)),
84+
AppCommand::Display => Task::none(),
85+
AppCommand::Message(msg) => Task::done(msg),
86+
})
87+
.unwrap_or(Task::none()),
88+
89+
"show" => open_window(DEFAULT_WINDOW_HEIGHT),
90+
91+
"quit" => Task::done(Message::RunFunction(Function::Quit)),
92+
93+
_ => Task::none(),
94+
}
95+
}
96+
6797
Message::UpdateAvailable => {
6898
tile.update_available = true;
6999
Task::done(Message::ReloadConfig)

src/platform/macos/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
pub mod discovery;
33
pub mod haptics;
44
pub mod launching;
5+
pub mod urlscheme;
56

67
use iced::wgpu::rwh::WindowHandle;
78

src/platform/macos/urlscheme.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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

Comments
 (0)