rws_proxy/traffic/
apps.rs1use std::sync::{OnceLock, RwLock};
20
21pub const MAX_APPS: usize = 64;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct App {
27 pub name: &'static str,
29 pub platform: &'static str,
32 pub version: Option<String>,
34}
35
36#[derive(Clone, Debug)]
38struct Pattern {
39 name: &'static str,
40 pattern: String,
42 origin: bool,
43}
44
45fn patterns() -> &'static RwLock<Vec<Pattern>> {
46 static PATTERNS: OnceLock<RwLock<Vec<Pattern>>> = OnceLock::new();
47 PATTERNS.get_or_init(|| RwLock::new(parse(&super::config::apps())))
48}
49
50fn intern(name: &str) -> &'static str {
53 static NAMES: OnceLock<std::sync::Mutex<Vec<&'static str>>> = OnceLock::new();
54 let mut names = NAMES.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
55 if let Some(n) = names.iter().find(|n| **n == name) {
56 return n;
57 }
58 let leaked: &'static str = Box::leak(name.to_string().into_boxed_str());
59 names.push(leaked);
60 leaked
61}
62
63fn parse(entries: &[String]) -> Vec<Pattern> {
64 entries
65 .iter()
66 .filter_map(|entry| {
67 let (name, pattern) = entry.split_once('=')?;
68 let (name, pattern) = (name.trim(), pattern.trim());
69 if name.is_empty() || pattern.is_empty() || name.len() > 64 {
70 return None;
71 }
72 let name: String = name.chars().filter(|c| !c.is_control() && *c != '\t').collect();
73 Some(Pattern { name: intern(&name), pattern: pattern.to_ascii_lowercase(), origin: pattern.contains("://") })
74 })
75 .take(MAX_APPS)
76 .collect()
77}
78
79pub fn set_apps(entries: &[String]) {
81 *patterns().write().unwrap_or_else(|e| e.into_inner()) = parse(entries);
82}
83
84pub fn configured(name: &str) -> Option<&'static str> {
86 patterns().read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name.eq_ignore_ascii_case(name)).map(|p| p.name)
87}
88
89pub fn any_configured() -> bool {
91 !patterns().read().unwrap_or_else(|e| e.into_inner()).is_empty()
92}
93
94fn extension_platform(origin: &str, ua: &str) -> Option<&'static str> {
95 if origin.starts_with("chrome-extension://") {
96 Some(if ua.contains(" edg/") { "edge" } else { "chrome" })
97 } else if origin.starts_with("moz-extension://") {
98 Some("firefox")
99 } else if origin.starts_with("safari-web-extension://") {
100 Some("safari")
101 } else {
102 None
103 }
104}
105
106fn os_of(ua: &str) -> &'static str {
107 if ua.contains("windows") {
108 "windows"
109 } else if ua.contains("iphone") || ua.contains("ipad") || ua.contains(" ios/") {
110 "ios"
111 } else if ua.contains("mac os x") || ua.contains("macintosh") || ua.contains("macos/") {
112 "macos"
113 } else if ua.contains("android") || ua.contains("dalvik/") {
114 "android"
115 } else if ua.contains("linux") {
116 "linux"
117 } else if ua.contains("cfnetwork/") || ua.contains("darwin/") {
118 "apple"
119 } else {
120 "other"
121 }
122}
123
124fn version_after(ua_original: &str, ua: &str, pattern: &str) -> Option<String> {
126 if !pattern.ends_with('/') {
127 return None;
128 }
129 let at = ua.find(pattern)? + pattern.len();
130 let version: String = ua_original.get(at..)?.chars().take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+')).take(32).collect();
131 (!version.is_empty()).then_some(version)
132}
133
134pub fn detect(user_agent: Option<&str>, origin: Option<&str>) -> Option<App> {
136 let original = user_agent.unwrap_or("").trim();
137 let ua = original.to_ascii_lowercase();
138 let origin = origin.unwrap_or("").trim().to_ascii_lowercase();
139 {
140 let patterns = patterns().read().unwrap_or_else(|e| e.into_inner());
141 for p in patterns.iter() {
142 if p.origin {
143 if !origin.is_empty() && origin.starts_with(&p.pattern) {
144 let platform = extension_platform(&origin, &ua).unwrap_or_else(|| os_of(&ua));
145 return Some(App { name: p.name, platform, version: None });
146 }
147 } else if ua.contains(&p.pattern) {
148 return Some(App { name: p.name, platform: os_of(&ua), version: version_after(original, &ua, &p.pattern) });
149 }
150 }
151 }
152 if let Some(platform) = extension_platform(&origin, &ua) {
153 let name = match platform {
154 "edge" => "Edge extension",
155 "firefox" => "Firefox extension",
156 "safari" => "Safari extension",
157 _ => "Chrome extension",
158 };
159 return Some(App { name, platform, version: None });
160 }
161 if (ua.contains("cfnetwork/") && ua.contains("darwin/")) || ua.starts_with("networkingextension/") {
162 return Some(App { name: "iOS/macOS app", platform: os_of(&ua), version: None });
163 }
164 if ua.starts_with("dalvik/") {
165 return Some(App { name: "Android app", platform: "android", version: None });
166 }
167 if ua.contains(" electron/") {
168 return Some(App { name: "Desktop app (Electron)", platform: os_of(&ua), version: None });
169 }
170 None
171}