use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use crate::instance::{self, Arbitration};
pub const DEDUP_WINDOW: Duration = Duration::from_secs(2);
const MAX_URL_LEN: usize = 4096;
pub type UrlHandler = Arc<dyn Fn(&[String]) + Send + Sync>;
pub fn is_url(raw: &str) -> bool {
let raw = raw.trim();
if raw.is_empty() || raw.len() > MAX_URL_LEN || raw.chars().any(char::is_control) {
return false;
}
let Some((scheme, rest)) = raw.split_once(':') else {
return false;
};
scheme.len() >= 2
&& !rest.is_empty()
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
pub fn urls_from_args<I, S>(args: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
args.into_iter()
.map(|a| a.as_ref().trim().to_owned())
.filter(|a| is_url(a))
.collect()
}
#[derive(Default)]
pub struct UrlHub {
inner: Mutex<Inner>,
}
#[derive(Default)]
struct Inner {
cold: Vec<String>,
handlers: Vec<UrlHandler>,
recent: Vec<(String, Instant)>,
}
impl UrlHub {
pub fn new() -> Self {
Self::default()
}
pub fn deliver_at<I, S>(&self, urls: I, now: Instant) -> usize
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let (fresh, handlers) = {
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
g.recent
.retain(|(_, at)| now.saturating_duration_since(*at) < DEDUP_WINDOW);
let mut fresh: Vec<String> = Vec::new();
for raw in urls {
let raw = raw.as_ref().trim();
if !is_url(raw) || g.recent.iter().any(|(u, _)| u == raw) {
continue;
}
g.recent.push((raw.to_owned(), now));
fresh.push(raw.to_owned());
}
if fresh.is_empty() {
return 0;
}
if g.handlers.is_empty() {
g.cold.extend(fresh.iter().cloned());
return fresh.len();
}
(fresh, g.handlers.clone())
};
for h in handlers {
h(&fresh);
}
fresh.len()
}
pub fn deliver<I, S>(&self, urls: I) -> usize
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.deliver_at(urls, Instant::now())
}
pub fn on_open_url(&self, handler: UrlHandler) {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.handlers
.push(handler);
}
pub fn current_urls(&self) -> Vec<String> {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.cold
.clone()
}
}
pub fn hub() -> &'static UrlHub {
static HUB: OnceLock<UrlHub> = OnceLock::new();
HUB.get_or_init(UrlHub::new)
}
pub fn on_open_url(handler: impl Fn(&[String]) + Send + Sync + 'static) {
hub().on_open_url(Arc::new(handler));
}
pub fn current_urls() -> Vec<String> {
hub().current_urls()
}
pub fn deliver<I, S>(urls: I) -> usize
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
hub().deliver(urls)
}
pub fn capture_launch_args() -> usize {
deliver(urls_from_args(std::env::args().skip(1)))
}
pub fn deliver_second_instance(argv: &[String]) -> usize {
deliver(urls_from_args(argv.iter().skip(1)))
}
pub fn init_single_instance(app_id: &str, version: &str) -> Arbitration {
let arb = instance::arbitrate(
app_id,
version,
Arc::new(|s| {
deliver_second_instance(&s.argv);
}),
);
if !matches!(arb, Arbitration::Secondary) {
capture_launch_args();
}
arb
}
pub fn handle_run_event(event: &tauri::RunEvent) {
#[cfg(any(target_os = "macos", target_os = "ios"))]
if let tauri::RunEvent::Opened { urls } = event {
deliver(urls.iter().map(|u| u.to_string()));
}
#[cfg(not(any(target_os = "macos", target_os = "ios")))]
let _ = event;
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex as StdMutex;
type Seen = Arc<StdMutex<Vec<Vec<String>>>>;
fn recorder() -> (UrlHandler, Seen) {
let seen: Seen = Arc::new(StdMutex::new(Vec::new()));
let s = seen.clone();
(
Arc::new(move |u: &[String]| s.lock().unwrap().push(u.to_vec())),
seen,
)
}
#[test]
fn url_shape() {
assert!(is_url("mailto:a@b.c"));
assert!(is_url("mailright://open/thread/1"));
assert!(is_url(" https://x.test "));
assert!(!is_url(r"C:\mail\x.eml"));
assert!(!is_url("C:/mail"));
assert!(!is_url("--background"));
assert!(!is_url("plain"));
assert!(!is_url("mailto:"));
assert!(!is_url("a:b\nc"));
assert!(!is_url(&format!("x-y:{}", "a".repeat(5000))));
}
#[test]
fn args_filtered() {
let got = urls_from_args(["--background", r"C:\x", "mailto:a@b.c", " app://z "]);
assert_eq!(got, ["mailto:a@b.c", "app://z"]);
}
#[test]
fn cold_start_buffers_in_order_without_handler() {
let hub = UrlHub::new();
let t = Instant::now();
assert_eq!(hub.deliver_at(["mailto:a@b.c", "app://one"], t), 2);
assert_eq!(hub.deliver_at(["app://two"], t), 1);
assert_eq!(
hub.current_urls(),
["mailto:a@b.c", "app://one", "app://two"]
);
}
#[test]
fn cold_buffer_dedups() {
let hub = UrlHub::new();
let t = Instant::now();
hub.deliver_at(["app://one"], t);
assert_eq!(hub.deliver_at(["app://one", "app://one"], t), 0);
assert_eq!(hub.current_urls(), ["app://one"]);
}
#[test]
fn handler_gets_only_later_urls_and_current_urls_is_stable() {
let hub = UrlHub::new();
let t = Instant::now();
hub.deliver_at(["app://cold"], t);
let (h, seen) = recorder();
hub.on_open_url(h);
assert!(seen.lock().unwrap().is_empty());
hub.deliver_at(["app://warm"], t);
assert_eq!(*seen.lock().unwrap(), vec![vec!["app://warm".to_owned()]]);
assert_eq!(hub.current_urls(), ["app://cold"]);
}
#[test]
fn handlers_run_in_registration_order() {
let hub = UrlHub::new();
let order = Arc::new(StdMutex::new(Vec::new()));
for n in 0..3 {
let o = order.clone();
hub.on_open_url(Arc::new(move |_| o.lock().unwrap().push(n)));
}
hub.deliver(["app://x"]);
assert_eq!(*order.lock().unwrap(), [0, 1, 2]);
}
#[test]
fn duplicate_inside_window_dropped_then_allowed_after() {
let hub = UrlHub::new();
let (h, seen) = recorder();
hub.on_open_url(h);
let t = Instant::now();
assert_eq!(hub.deliver_at(["app://x"], t), 1);
assert_eq!(
hub.deliver_at(["app://x"], t + Duration::from_millis(500)),
0
);
assert_eq!(hub.deliver_at(["app://x"], t + DEDUP_WINDOW), 1);
assert_eq!(seen.lock().unwrap().len(), 2);
}
#[test]
fn batch_arrives_together_and_invalid_is_dropped() {
let hub = UrlHub::new();
let (h, seen) = recorder();
hub.on_open_url(h);
hub.deliver(["app://a", "not a url", "app://b"]);
assert_eq!(
*seen.lock().unwrap(),
vec![vec!["app://a".to_owned(), "app://b".to_owned()]]
);
}
#[test]
fn handler_may_reenter_the_hub() {
let hub = Arc::new(UrlHub::new());
let inner = hub.clone();
hub.on_open_url(Arc::new(move |_| {
let _ = inner.current_urls();
}));
assert_eq!(hub.deliver(["app://x"]), 1);
}
#[test]
fn second_instance_argv_skips_executable() {
let argv = ["C:/app.exe".to_owned(), "mailto:z@y.x".to_owned()];
assert_eq!(urls_from_args(argv.iter().skip(1)), ["mailto:z@y.x"]);
}
}