1pub struct DeviceInfo {
8 pub device_type: Option<String>,
9 pub browser: Option<String>,
10 pub os: Option<String>,
11}
12
13pub fn parse(platform_header: Option<&str>, user_agent: Option<&str>) -> DeviceInfo {
19 let ua = user_agent.unwrap_or_default();
20 parse_lowered(platform_header, &ua.to_ascii_lowercase())
21}
22
23pub fn parse_lowered(platform_header: Option<&str>, ua_lower: &str) -> DeviceInfo {
25 let device_type = platform_header
26 .map(classify_platform)
27 .unwrap_or_else(|| classify_device_from_ua(ua_lower));
28
29 DeviceInfo {
30 device_type,
31 browser: detect_browser(ua_lower),
32 os: detect_os(ua_lower),
33 }
34}
35
36fn classify_platform(platform: &str) -> Option<String> {
38 let p = platform.to_ascii_lowercase();
39 let device_type = if p.starts_with("desktop") {
40 "desktop"
41 } else if p == "web" || p == "wasm" {
42 "web"
43 } else if p == "mobile"
44 || p == "ios"
45 || p.starts_with("ios-")
46 || p == "android"
47 || p.starts_with("android-")
48 {
49 "mobile"
50 } else {
51 return Some(p);
52 };
53 Some(device_type.to_string())
54}
55
56fn classify_device_from_ua(ua: &str) -> Option<String> {
58 if ua.is_empty() {
59 return None;
60 }
61 if ua.contains("ipad") || ua.contains("tablet") {
62 Some("tablet".to_string())
63 } else if ua.contains("mobile")
64 || ua.contains("iphone")
65 || (ua.contains("android") && !ua.contains("tablet"))
66 {
67 Some("mobile".to_string())
68 } else if ua.contains("mozilla/") || ua.contains("chrome/") || ua.contains("safari/") {
69 Some("web".to_string())
70 } else {
71 None
72 }
73}
74
75fn detect_browser(ua: &str) -> Option<String> {
76 if ua.is_empty() {
77 return None;
78 }
79 if ua.contains("edg/") || ua.contains("edge/") {
81 Some("Edge".to_string())
82 } else if ua.contains("opr/") || ua.contains("opera") {
83 Some("Opera".to_string())
84 } else if ua.contains("firefox/") {
85 Some("Firefox".to_string())
86 } else if ua.contains("chrome/") && !ua.contains("chromium/") {
87 Some("Chrome".to_string())
88 } else if ua.contains("chromium/") {
89 Some("Chromium".to_string())
90 } else if ua.contains("safari/") && ua.contains("version/") {
91 Some("Safari".to_string())
92 } else if ua.contains("dioxus") || ua.contains("forge-dioxus") {
93 Some("Dioxus".to_string())
94 } else {
95 None
96 }
97}
98
99fn detect_os(ua: &str) -> Option<String> {
100 if ua.is_empty() {
101 return None;
102 }
103 if ua.contains("iphone") || ua.contains("ipad") || ua.contains("ios") {
104 Some("iOS".to_string())
105 } else if ua.contains("android") {
106 Some("Android".to_string())
107 } else if ua.contains("mac os x") || ua.contains("macos") || ua.contains("macintosh") {
108 Some("macOS".to_string())
109 } else if ua.contains("windows") {
110 Some("Windows".to_string())
111 } else if ua.contains("linux") && !ua.contains("android") {
112 Some("Linux".to_string())
113 } else if ua.contains("cros") {
114 Some("ChromeOS".to_string())
115 } else {
116 None
117 }
118}
119
120#[cfg(test)]
121#[allow(clippy::unwrap_used)]
122mod tests {
123 use super::*;
124
125 #[tokio::test]
126 async fn parses_chrome_on_macos() {
127 let info = parse(
128 None,
129 Some(
130 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
131 ),
132 );
133 assert_eq!(info.browser.as_deref(), Some("Chrome"));
134 assert_eq!(info.os.as_deref(), Some("macOS"));
135 assert_eq!(info.device_type.as_deref(), Some("web"));
136 }
137
138 #[tokio::test]
139 async fn parses_mobile_safari() {
140 let info = parse(
141 None,
142 Some(
143 "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
144 ),
145 );
146 assert_eq!(info.browser.as_deref(), Some("Safari"));
147 assert_eq!(info.os.as_deref(), Some("iOS"));
148 assert_eq!(info.device_type.as_deref(), Some("mobile"));
149 }
150
151 #[tokio::test]
152 async fn platform_header_overrides_ua() {
153 let info = parse(Some("desktop-macos"), Some("reqwest/0.12"));
154 assert_eq!(info.device_type.as_deref(), Some("desktop"));
155 }
156
157 #[tokio::test]
158 async fn detects_firefox_on_linux() {
159 let info = parse(
160 None,
161 Some("Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0"),
162 );
163 assert_eq!(info.browser.as_deref(), Some("Firefox"));
164 assert_eq!(info.os.as_deref(), Some("Linux"));
165 }
166
167 #[tokio::test]
168 async fn handles_empty_ua() {
169 let info = parse(None, None);
170 assert!(info.browser.is_none());
171 assert!(info.os.is_none());
172 assert!(info.device_type.is_none());
173 }
174
175 #[tokio::test]
176 async fn ios_platform_header() {
177 let info = parse(Some("ios"), None);
178 assert_eq!(info.device_type.as_deref(), Some("mobile"));
179 }
180
181 #[tokio::test]
182 async fn detects_edge_browser() {
183 let info = parse(
184 None,
185 Some(
186 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91",
187 ),
188 );
189 assert_eq!(info.browser.as_deref(), Some("Edge"));
190 }
191
192 #[tokio::test]
193 async fn detects_opera() {
194 let info = parse(
195 None,
196 Some(
197 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0",
198 ),
199 );
200 assert_eq!(info.browser.as_deref(), Some("Opera"));
201 }
202
203 #[tokio::test]
204 async fn detects_chromium_vs_chrome() {
205 let info = parse(
206 None,
207 Some(
208 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chromium/120.0.6099.129 Safari/537.36",
209 ),
210 );
211 assert_eq!(info.browser.as_deref(), Some("Chromium"));
212 }
213
214 #[tokio::test]
215 async fn detects_ipad_as_tablet() {
216 let info = parse(
217 None,
218 Some(
219 "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
220 ),
221 );
222 assert_eq!(info.device_type.as_deref(), Some("tablet"));
223 assert_eq!(info.os.as_deref(), Some("iOS"));
224 }
225
226 #[tokio::test]
227 async fn detects_android_phone() {
228 let info = parse(
229 None,
230 Some(
231 "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.144 Mobile Safari/537.36",
232 ),
233 );
234 assert_eq!(info.device_type.as_deref(), Some("mobile"));
235 assert_eq!(info.os.as_deref(), Some("Android"));
236 }
237
238 #[tokio::test]
239 async fn detects_windows_chrome() {
240 let info = parse(
241 None,
242 Some(
243 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
244 ),
245 );
246 assert_eq!(info.browser.as_deref(), Some("Chrome"));
247 assert_eq!(info.os.as_deref(), Some("Windows"));
248 assert_eq!(info.device_type.as_deref(), Some("web"));
249 }
250
251 #[tokio::test]
252 async fn desktop_windows_platform_header() {
253 let info = parse(Some("desktop-windows"), Some("reqwest/0.12"));
254 assert_eq!(info.device_type.as_deref(), Some("desktop"));
255 }
256
257 #[tokio::test]
258 async fn desktop_linux_platform_header() {
259 let info = parse(Some("desktop-linux"), Some("reqwest/0.12"));
260 assert_eq!(info.device_type.as_deref(), Some("desktop"));
261 }
262
263 #[tokio::test]
264 async fn android_platform_header() {
265 let info = parse(Some("android"), None);
266 assert_eq!(info.device_type.as_deref(), Some("mobile"));
267 }
268
269 #[tokio::test]
270 async fn wasm_platform_header() {
271 let info = parse(Some("wasm"), None);
272 assert_eq!(info.device_type.as_deref(), Some("web"));
273 }
274
275 #[tokio::test]
276 async fn unknown_platform_passes_through() {
277 let info = parse(Some("custom-device"), None);
278 assert_eq!(info.device_type.as_deref(), Some("custom-device"));
279 }
280
281 #[tokio::test]
282 async fn safari_needs_version() {
283 let info = parse(
285 None,
286 Some(
287 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
288 ),
289 );
290 assert_ne!(info.browser.as_deref(), Some("Safari"));
291 }
292
293 #[tokio::test]
294 async fn detects_chromeos() {
295 let info = parse(
296 None,
297 Some(
298 "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
299 ),
300 );
301 assert_eq!(info.os.as_deref(), Some("ChromeOS"));
302 }
303}