Skip to main content

forge_runtime/signals/
device.rs

1//! Device classification from User-Agent strings and platform headers.
2//!
3//! Extracts device_type, browser, and os from the raw User-Agent header
4//! and the optional `x-forge-platform` header sent by client SDKs.
5
6/// Parsed device info for a request.
7pub struct DeviceInfo {
8    pub device_type: Option<String>,
9    pub browser: Option<String>,
10    pub os: Option<String>,
11}
12
13/// Parse device info from the `x-forge-platform` header and User-Agent.
14///
15/// The platform header is authoritative for device_type when present (the
16/// client SDK knows whether it's running as web, desktop, or mobile).
17/// Browser and OS are always parsed from the User-Agent.
18pub 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
23/// Parse device info from a pre-lowercased User-Agent string.
24pub 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
36/// Map the x-forge-platform header value to a device_type.
37fn 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
56/// Fallback: infer device type from User-Agent when no platform header is present.
57fn 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    // Order matters: check specific browsers before generic ones
80    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        // Safari/ present but no Version/ -- should NOT detect as Safari
284        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}