1use std::sync::{Arc, Mutex, OnceLock};
22use std::time::{Duration, Instant};
23
24use crate::instance::{self, Arbitration};
25
26pub const DEDUP_WINDOW: Duration = Duration::from_secs(2);
28const MAX_URL_LEN: usize = 4096;
29
30pub type UrlHandler = Arc<dyn Fn(&[String]) + Send + Sync>;
32
33pub 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
51pub 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#[derive(Default)]
65pub struct UrlHub {
66 inner: Mutex<Inner>,
67}
68
69#[derive(Default)]
70struct Inner {
71 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 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 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 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
146pub fn hub() -> &'static UrlHub {
148 static HUB: OnceLock<UrlHub> = OnceLock::new();
149 HUB.get_or_init(UrlHub::new)
150}
151
152pub fn on_open_url(handler: impl Fn(&[String]) + Send + Sync + 'static) {
154 hub().on_open_url(Arc::new(handler));
155}
156
157pub fn current_urls() -> Vec<String> {
159 hub().current_urls()
160}
161
162pub fn deliver<I, S>(urls: I) -> usize
164where
165 I: IntoIterator<Item = S>,
166 S: AsRef<str>,
167{
168 hub().deliver(urls)
169}
170
171pub fn capture_launch_args() -> usize {
173 deliver(urls_from_args(std::env::args().skip(1)))
174}
175
176pub fn deliver_second_instance(argv: &[String]) -> usize {
178 deliver(urls_from_args(argv.iter().skip(1)))
179}
180
181pub 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
199pub 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}