Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
67 changes: 39 additions & 28 deletions assets/macos/RustCast.app/Contents/Info.plist
Original file line number Diff line number Diff line change
@@ -1,32 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>rustcast</string>
<key>CFBundleIdentifier</key>
<string>com.umangsurana.rustcast</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>RustCast</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 Umang Surana. All rights reserved.</string>
<key>NSInputMonitoringUsageDescription</key>
<string>RustCast needs to monitor keyboard input to detect global shortcuts and control casting.</string>
<key>CFBundleIconFile</key>
<string>icon</string>
</dict>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>rustcast</string>
<key>CFBundleIdentifier</key>
<string>com.umangsurana.rustcast</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>RustCast</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>rustcast</string>
</array>
<key>CFBundleURLName</key>
<string>com.umangsurana.rustcast</string>
</dict>
</array>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 Umang Surana. All rights reserved.</string>
<key>NSInputMonitoringUsageDescription</key>
<string>RustCast needs to monitor keyboard input to detect global shortcuts and control casting.</string>
<key>CFBundleIconFile</key>
<string>icon</string>
</dict>
</plist>
1 change: 1 addition & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub enum Editable<T> {
/// 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),
Expand Down
1 change: 1 addition & 0 deletions src/app/tile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/app/tile/elm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task<Message>) {
)
.unwrap_or(HashMap::new());

crate::platform::macos::urlscheme::install();

(
Tile {
update_available: false,
Expand Down
30 changes: 30 additions & 0 deletions src/app/tile/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> {
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<Message> {
match message {
Expand All @@ -64,6 +71,29 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task<Message> {
}
}

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)
Expand Down
1 change: 1 addition & 0 deletions src/platform/macos/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
pub mod discovery;
pub mod haptics;
pub mod launching;
pub mod urlscheme;

use iced::wgpu::rwh::WindowHandle;

Expand Down
186 changes: 186 additions & 0 deletions src/platform/macos/urlscheme.rs
Original file line number Diff line number Diff line change
@@ -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<String>, Receiver<String>)> =
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<Self> {
// 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<UrlHandler>` 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<Item = Message> {
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), ()))
})
}
Loading