use std::sync::{OnceLock, RwLock};
pub const MAX_APPS: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct App {
pub name: &'static str,
pub platform: &'static str,
pub version: Option<String>,
}
#[derive(Clone, Debug)]
struct Pattern {
name: &'static str,
pattern: String,
origin: bool,
}
fn patterns() -> &'static RwLock<Vec<Pattern>> {
static PATTERNS: OnceLock<RwLock<Vec<Pattern>>> = OnceLock::new();
PATTERNS.get_or_init(|| RwLock::new(parse(&super::config::apps())))
}
fn intern(name: &str) -> &'static str {
static NAMES: OnceLock<std::sync::Mutex<Vec<&'static str>>> = OnceLock::new();
let mut names = NAMES.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
if let Some(n) = names.iter().find(|n| **n == name) {
return n;
}
let leaked: &'static str = Box::leak(name.to_string().into_boxed_str());
names.push(leaked);
leaked
}
fn parse(entries: &[String]) -> Vec<Pattern> {
entries
.iter()
.filter_map(|entry| {
let (name, pattern) = entry.split_once('=')?;
let (name, pattern) = (name.trim(), pattern.trim());
if name.is_empty() || pattern.is_empty() || name.len() > 64 {
return None;
}
let name: String = name.chars().filter(|c| !c.is_control() && *c != '\t').collect();
Some(Pattern { name: intern(&name), pattern: pattern.to_ascii_lowercase(), origin: pattern.contains("://") })
})
.take(MAX_APPS)
.collect()
}
pub fn set_apps(entries: &[String]) {
*patterns().write().unwrap_or_else(|e| e.into_inner()) = parse(entries);
}
pub fn configured(name: &str) -> Option<&'static str> {
patterns().read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name.eq_ignore_ascii_case(name)).map(|p| p.name)
}
pub fn any_configured() -> bool {
!patterns().read().unwrap_or_else(|e| e.into_inner()).is_empty()
}
fn extension_platform(origin: &str, ua: &str) -> Option<&'static str> {
if origin.starts_with("chrome-extension://") {
Some(if ua.contains(" edg/") { "edge" } else { "chrome" })
} else if origin.starts_with("moz-extension://") {
Some("firefox")
} else if origin.starts_with("safari-web-extension://") {
Some("safari")
} else {
None
}
}
fn os_of(ua: &str) -> &'static str {
if ua.contains("windows") {
"windows"
} else if ua.contains("iphone") || ua.contains("ipad") || ua.contains(" ios/") {
"ios"
} else if ua.contains("mac os x") || ua.contains("macintosh") || ua.contains("macos/") {
"macos"
} else if ua.contains("android") || ua.contains("dalvik/") {
"android"
} else if ua.contains("linux") {
"linux"
} else if ua.contains("cfnetwork/") || ua.contains("darwin/") {
"apple"
} else {
"other"
}
}
fn version_after(ua_original: &str, ua: &str, pattern: &str) -> Option<String> {
if !pattern.ends_with('/') {
return None;
}
let at = ua.find(pattern)? + pattern.len();
let version: String = ua_original.get(at..)?.chars().take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+')).take(32).collect();
(!version.is_empty()).then_some(version)
}
pub fn detect(user_agent: Option<&str>, origin: Option<&str>) -> Option<App> {
let original = user_agent.unwrap_or("").trim();
let ua = original.to_ascii_lowercase();
let origin = origin.unwrap_or("").trim().to_ascii_lowercase();
{
let patterns = patterns().read().unwrap_or_else(|e| e.into_inner());
for p in patterns.iter() {
if p.origin {
if !origin.is_empty() && origin.starts_with(&p.pattern) {
let platform = extension_platform(&origin, &ua).unwrap_or_else(|| os_of(&ua));
return Some(App { name: p.name, platform, version: None });
}
} else if ua.contains(&p.pattern) {
return Some(App { name: p.name, platform: os_of(&ua), version: version_after(original, &ua, &p.pattern) });
}
}
}
if let Some(platform) = extension_platform(&origin, &ua) {
let name = match platform {
"edge" => "Edge extension",
"firefox" => "Firefox extension",
"safari" => "Safari extension",
_ => "Chrome extension",
};
return Some(App { name, platform, version: None });
}
if (ua.contains("cfnetwork/") && ua.contains("darwin/")) || ua.starts_with("networkingextension/") {
return Some(App { name: "iOS/macOS app", platform: os_of(&ua), version: None });
}
if ua.starts_with("dalvik/") {
return Some(App { name: "Android app", platform: "android", version: None });
}
if ua.contains(" electron/") {
return Some(App { name: "Desktop app (Electron)", platform: os_of(&ua), version: None });
}
None
}