Skip to main content

rws_proxy/traffic/
device.rs

1//! Browser, operating system and device class of a person's browser, from
2//! its User-Agent — and from Chromium's default client hints
3//! (`Sec-CH-UA-Platform`, `Sec-CH-UA-Mobile`), which win when present
4//! because Chromium freezes those parts of its User-Agent. Families only;
5//! versions aren't kept.
6//!
7//! Known limit: iPad Safari sends a macOS desktop User-Agent, so it counts
8//! as a desktop Mac.
9
10/// What a browser runs on.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
12pub struct Device {
13    pub browser: &'static str,
14    pub os: &'static str,
15    pub class: &'static str,
16}
17
18fn has(ua: &str, token: &str) -> bool {
19    ua.contains(token)
20}
21
22/// The browser family. Order matters: most browsers also say "Chrome" and
23/// "Safari", so the more specific tokens come first.
24fn browser(ua: &str) -> &'static str {
25    const BROWSERS: &[(&str, &str)] = &[
26        ("Edg/", "Edge"),
27        ("EdgA/", "Edge"),
28        ("EdgiOS/", "Edge"),
29        ("OPR/", "Opera"),
30        ("OPiOS/", "Opera"),
31        ("Opera", "Opera"),
32        ("YaBrowser/", "Yandex"),
33        ("SamsungBrowser/", "Samsung Internet"),
34        ("UCBrowser/", "UC Browser"),
35        ("Vivaldi/", "Vivaldi"),
36        ("Brave", "Brave"),
37        ("DuckDuckGo/", "DuckDuckGo"),
38        ("FxiOS/", "Firefox"),
39        ("Firefox/", "Firefox"),
40        ("CriOS/", "Chrome"),
41        ("Chrome/", "Chrome"),
42        ("Chromium/", "Chrome"),
43        ("Safari/", "Safari"),
44    ];
45    BROWSERS.iter().find(|(token, _)| has(ua, token)).map_or("Other", |(_, name)| name)
46}
47
48fn os(ua: &str) -> &'static str {
49    if has(ua, "Windows") {
50        "Windows"
51    } else if has(ua, "iPhone") || has(ua, "iPad") || has(ua, "iPod") {
52        "iOS"
53    } else if has(ua, "Android") {
54        "Android"
55    } else if has(ua, "CrOS") {
56        "ChromeOS"
57    } else if has(ua, "Mac OS X") || has(ua, "Macintosh") {
58        "macOS"
59    } else if has(ua, "Linux") || has(ua, "X11") {
60        "Linux"
61    } else {
62        "Other"
63    }
64}
65
66fn class(ua: &str, os: &str) -> &'static str {
67    if has(ua, "iPad") || has(ua, "Tablet") || (os == "Android" && !has(ua, "Mobile")) {
68        "tablet"
69    } else if has(ua, "Mobi") || has(ua, "iPhone") || has(ua, "iPod") || os == "Android" {
70        "mobile"
71    } else {
72        "desktop"
73    }
74}
75
76/// `Sec-CH-UA-Platform` (a quoted string) as an OS family.
77fn os_from_hint(platform: &str) -> Option<&'static str> {
78    Some(match platform.trim().trim_matches('"') {
79        "Windows" => "Windows",
80        "macOS" => "macOS",
81        "Android" => "Android",
82        "iOS" => "iOS",
83        "Chrome OS" | "ChromeOS" => "ChromeOS",
84        "Linux" => "Linux",
85        "" => return None,
86        _ => "Other",
87    })
88}
89
90/// The device of a browser: `user_agent` and, when sent, the
91/// `Sec-CH-UA-Platform` and `Sec-CH-UA-Mobile` hints.
92pub fn parse(user_agent: &str, platform_hint: Option<&str>, mobile_hint: Option<&str>) -> Device {
93    let os = platform_hint.and_then(os_from_hint).unwrap_or_else(|| os(user_agent));
94    let mut class = class(user_agent, os);
95    // `?1`: a phone; `?0`: not one (a desktop, or a tablet in desktop mode).
96    match mobile_hint.map(str::trim) {
97        Some("?1") => class = "mobile",
98        Some("?0") if class == "mobile" => class = "desktop",
99        _ => {}
100    }
101    Device { browser: browser(user_agent), os, class }
102}