Skip to main content

rightkit_tauri_shell/
open_url.rs

1//! Standalone "open URL" hook: usable without the full Shell plugin.
2//!
3//! One process-wide hub receives every URL the OS hands the app and fans it out to handlers:
4//!
5//! - macOS / iOS: Apple Event URLs (`tauri::RunEvent::Opened { urls }`) through
6//!   [`handle_run_event`].
7//! - Windows / Linux: URLs on the command line at cold start ([`capture_launch_args`]) and URLs on
8//!   the argv of a second launch, forwarded by [`crate::instance`] ([`deliver_second_instance`],
9//!   or [`init_single_instance`] which wires both).
10//!
11//! URLs that arrive before any handler is registered (cold start) are buffered and readable with
12//! [`current_urls`]; [`on_open_url`] handlers receive URLs that arrive after registration. The
13//! same URL delivered twice within [`DEDUP_WINDOW`] (for example argv and an Apple Event for one
14//! click) reaches handlers once.
15//!
16//! Scheme registration (`mailto:`, custom schemes) stays with the app's installer or bundle
17//! (`CFBundleURLTypes`, NSIS/MSI protocol entries, `.desktop` MIME types). This module only
18//! delivers what the OS already routed to the process. The full Shell plugin feeds the same hub,
19//! so handlers registered here also see its deliveries.
20
21use std::sync::{Arc, Mutex, OnceLock};
22use std::time::{Duration, Instant};
23
24use crate::instance::{self, Arbitration};
25
26/// The same URL seen again inside this window is a duplicate delivery.
27pub const DEDUP_WINDOW: Duration = Duration::from_secs(2);
28const MAX_URL_LEN: usize = 4096;
29
30/// Receives a batch of URLs that arrived together.
31pub type UrlHandler = Arc<dyn Fn(&[String]) + Send + Sync>;
32
33/// True for `scheme:rest` where the scheme is at least two characters, so Windows drive paths
34/// such as `C:\mail` are not URLs. Control characters and over-long input are rejected.
35pub fn is_url(raw: &str) -> bool {
36    let raw = raw.trim();
37    if raw.is_empty() || raw.len() > MAX_URL_LEN || raw.chars().any(char::is_control) {
38        return false;
39    }
40    let Some((scheme, rest)) = raw.split_once(':') else {
41        return false;
42    };
43    scheme.len() >= 2
44        && !rest.is_empty()
45        && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
46        && scheme
47            .chars()
48            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
49}
50
51/// The URL-shaped entries of a command line (flags, paths and drive letters are skipped).
52pub fn urls_from_args<I, S>(args: I) -> Vec<String>
53where
54    I: IntoIterator<Item = S>,
55    S: AsRef<str>,
56{
57    args.into_iter()
58        .map(|a| a.as_ref().trim().to_owned())
59        .filter(|a| is_url(a))
60        .collect()
61}
62
63/// Queue, de-dup and dispatch logic with an injectable clock. [`hub`] owns the process-wide one.
64#[derive(Default)]
65pub struct UrlHub {
66    inner: Mutex<Inner>,
67}
68
69#[derive(Default)]
70struct Inner {
71    /// Arrived before the first handler was registered.
72    cold: Vec<String>,
73    handlers: Vec<UrlHandler>,
74    recent: Vec<(String, Instant)>,
75}
76
77impl UrlHub {
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Deliver URLs. Invalid and duplicate entries are dropped; the rest are buffered (no handler
83    /// yet) or passed to every handler in registration order, outside the hub lock. Returns how
84    /// many URLs were new.
85    pub fn deliver_at<I, S>(&self, urls: I, now: Instant) -> usize
86    where
87        I: IntoIterator<Item = S>,
88        S: AsRef<str>,
89    {
90        let (fresh, handlers) = {
91            let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
92            g.recent
93                .retain(|(_, at)| now.saturating_duration_since(*at) < DEDUP_WINDOW);
94            let mut fresh: Vec<String> = Vec::new();
95            for raw in urls {
96                let raw = raw.as_ref().trim();
97                if !is_url(raw) || g.recent.iter().any(|(u, _)| u == raw) {
98                    continue;
99                }
100                g.recent.push((raw.to_owned(), now));
101                fresh.push(raw.to_owned());
102            }
103            if fresh.is_empty() {
104                return 0;
105            }
106            if g.handlers.is_empty() {
107                g.cold.extend(fresh.iter().cloned());
108                return fresh.len();
109            }
110            (fresh, g.handlers.clone())
111        };
112        for h in handlers {
113            h(&fresh);
114        }
115        fresh.len()
116    }
117
118    pub fn deliver<I, S>(&self, urls: I) -> usize
119    where
120        I: IntoIterator<Item = S>,
121        S: AsRef<str>,
122    {
123        self.deliver_at(urls, Instant::now())
124    }
125
126    /// Register a handler for URLs that arrive from now on. Cold-start URLs stay readable through
127    /// [`UrlHub::current_urls`]; they are not replayed.
128    pub fn on_open_url(&self, handler: UrlHandler) {
129        self.inner
130            .lock()
131            .unwrap_or_else(|e| e.into_inner())
132            .handlers
133            .push(handler);
134    }
135
136    /// URLs that arrived before the first handler was registered, in arrival order.
137    pub fn current_urls(&self) -> Vec<String> {
138        self.inner
139            .lock()
140            .unwrap_or_else(|e| e.into_inner())
141            .cold
142            .clone()
143    }
144}
145
146/// The process-wide hub.
147pub fn hub() -> &'static UrlHub {
148    static HUB: OnceLock<UrlHub> = OnceLock::new();
149    HUB.get_or_init(UrlHub::new)
150}
151
152/// Register a handler for URLs the OS opens in this app from now on.
153pub fn on_open_url(handler: impl Fn(&[String]) + Send + Sync + 'static) {
154    hub().on_open_url(Arc::new(handler));
155}
156
157/// URLs that arrived before the first [`on_open_url`] handler was registered (cold start).
158pub fn current_urls() -> Vec<String> {
159    hub().current_urls()
160}
161
162/// Deliver URLs from any source the app owns (for example a custom IPC). Returns how many were new.
163pub fn deliver<I, S>(urls: I) -> usize
164where
165    I: IntoIterator<Item = S>,
166    S: AsRef<str>,
167{
168    hub().deliver(urls)
169}
170
171/// Deliver the URLs on this process's command line (Windows/Linux cold start). Call once, early.
172pub fn capture_launch_args() -> usize {
173    deliver(urls_from_args(std::env::args().skip(1)))
174}
175
176/// Deliver the URLs on a second launch's argv (`argv[0]` is the executable and is skipped).
177pub fn deliver_second_instance(argv: &[String]) -> usize {
178    deliver(urls_from_args(argv.iter().skip(1)))
179}
180
181/// Single-instance arbitration wired to this hub, with no Shell plugin. The primary process
182/// captures its own launch URLs; a second launch forwards its argv to the primary and the caller
183/// must exit on [`Arbitration::Secondary`]. Keep the `Primary` guard alive for the process
184/// lifetime.
185pub fn init_single_instance(app_id: &str, version: &str) -> Arbitration {
186    let arb = instance::arbitrate(
187        app_id,
188        version,
189        Arc::new(|s| {
190            deliver_second_instance(&s.argv);
191        }),
192    );
193    if !matches!(arb, Arbitration::Secondary) {
194        capture_launch_args();
195    }
196    arb
197}
198
199/// Feed Apple Event URLs from the Tauri run loop: call it for every event in
200/// `app.run(|_, event| rightkit_tauri_shell::open_url::handle_run_event(&event))`. A no-op elsewhere.
201pub fn handle_run_event(event: &tauri::RunEvent) {
202    #[cfg(any(target_os = "macos", target_os = "ios"))]
203    if let tauri::RunEvent::Opened { urls } = event {
204        deliver(urls.iter().map(|u| u.to_string()));
205    }
206    #[cfg(not(any(target_os = "macos", target_os = "ios")))]
207    let _ = event;
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::sync::Mutex as StdMutex;
214
215    type Seen = Arc<StdMutex<Vec<Vec<String>>>>;
216
217    fn recorder() -> (UrlHandler, Seen) {
218        let seen: Seen = Arc::new(StdMutex::new(Vec::new()));
219        let s = seen.clone();
220        (
221            Arc::new(move |u: &[String]| s.lock().unwrap().push(u.to_vec())),
222            seen,
223        )
224    }
225
226    #[test]
227    fn url_shape() {
228        assert!(is_url("mailto:a@b.c"));
229        assert!(is_url("mailright://open/thread/1"));
230        assert!(is_url(" https://x.test "));
231        assert!(!is_url(r"C:\mail\x.eml"));
232        assert!(!is_url("C:/mail"));
233        assert!(!is_url("--background"));
234        assert!(!is_url("plain"));
235        assert!(!is_url("mailto:"));
236        assert!(!is_url("a:b\nc"));
237        assert!(!is_url(&format!("x-y:{}", "a".repeat(5000))));
238    }
239
240    #[test]
241    fn args_filtered() {
242        let got = urls_from_args(["--background", r"C:\x", "mailto:a@b.c", " app://z "]);
243        assert_eq!(got, ["mailto:a@b.c", "app://z"]);
244    }
245
246    #[test]
247    fn cold_start_buffers_in_order_without_handler() {
248        let hub = UrlHub::new();
249        let t = Instant::now();
250        assert_eq!(hub.deliver_at(["mailto:a@b.c", "app://one"], t), 2);
251        assert_eq!(hub.deliver_at(["app://two"], t), 1);
252        assert_eq!(
253            hub.current_urls(),
254            ["mailto:a@b.c", "app://one", "app://two"]
255        );
256    }
257
258    #[test]
259    fn cold_buffer_dedups() {
260        let hub = UrlHub::new();
261        let t = Instant::now();
262        hub.deliver_at(["app://one"], t);
263        assert_eq!(hub.deliver_at(["app://one", "app://one"], t), 0);
264        assert_eq!(hub.current_urls(), ["app://one"]);
265    }
266
267    #[test]
268    fn handler_gets_only_later_urls_and_current_urls_is_stable() {
269        let hub = UrlHub::new();
270        let t = Instant::now();
271        hub.deliver_at(["app://cold"], t);
272        let (h, seen) = recorder();
273        hub.on_open_url(h);
274        assert!(seen.lock().unwrap().is_empty());
275        hub.deliver_at(["app://warm"], t);
276        assert_eq!(*seen.lock().unwrap(), vec![vec!["app://warm".to_owned()]]);
277        assert_eq!(hub.current_urls(), ["app://cold"]);
278    }
279
280    #[test]
281    fn handlers_run_in_registration_order() {
282        let hub = UrlHub::new();
283        let order = Arc::new(StdMutex::new(Vec::new()));
284        for n in 0..3 {
285            let o = order.clone();
286            hub.on_open_url(Arc::new(move |_| o.lock().unwrap().push(n)));
287        }
288        hub.deliver(["app://x"]);
289        assert_eq!(*order.lock().unwrap(), [0, 1, 2]);
290    }
291
292    #[test]
293    fn duplicate_inside_window_dropped_then_allowed_after() {
294        let hub = UrlHub::new();
295        let (h, seen) = recorder();
296        hub.on_open_url(h);
297        let t = Instant::now();
298        assert_eq!(hub.deliver_at(["app://x"], t), 1);
299        assert_eq!(
300            hub.deliver_at(["app://x"], t + Duration::from_millis(500)),
301            0
302        );
303        assert_eq!(hub.deliver_at(["app://x"], t + DEDUP_WINDOW), 1);
304        assert_eq!(seen.lock().unwrap().len(), 2);
305    }
306
307    #[test]
308    fn batch_arrives_together_and_invalid_is_dropped() {
309        let hub = UrlHub::new();
310        let (h, seen) = recorder();
311        hub.on_open_url(h);
312        hub.deliver(["app://a", "not a url", "app://b"]);
313        assert_eq!(
314            *seen.lock().unwrap(),
315            vec![vec!["app://a".to_owned(), "app://b".to_owned()]]
316        );
317    }
318
319    #[test]
320    fn handler_may_reenter_the_hub() {
321        let hub = Arc::new(UrlHub::new());
322        let inner = hub.clone();
323        hub.on_open_url(Arc::new(move |_| {
324            let _ = inner.current_urls();
325        }));
326        assert_eq!(hub.deliver(["app://x"]), 1);
327    }
328
329    #[test]
330    fn second_instance_argv_skips_executable() {
331        let argv = ["C:/app.exe".to_owned(), "mailto:z@y.x".to_owned()];
332        assert_eq!(urls_from_args(argv.iter().skip(1)), ["mailto:z@y.x"]);
333    }
334}