mas_data_model/
user_agent.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use serde::Serialize;
8use woothee::{parser::Parser, woothee::VALUE_UNKNOWN};
9
10#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum DeviceType {
13    Pc,
14    Mobile,
15    Tablet,
16    Unknown,
17}
18
19#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
20pub struct UserAgent {
21    pub name: Option<String>,
22    pub version: Option<String>,
23    pub os: Option<String>,
24    pub os_version: Option<String>,
25    pub model: Option<String>,
26    pub device_type: DeviceType,
27    pub raw: String,
28}
29
30impl std::ops::Deref for UserAgent {
31    type Target = str;
32
33    fn deref(&self) -> &Self::Target {
34        &self.raw
35    }
36}
37
38impl UserAgent {
39    fn parse_custom(user_agent: &str) -> Option<(&str, &str, &str, &str, Option<&str>)> {
40        let regex = regex::Regex::new(r"^(?P<name>[^/]+)/(?P<version>[^ ]+) \((?P<segments>.+)\)$")
41            .unwrap();
42
43        let captures = regex.captures(user_agent)?;
44        let name = captures.name("name")?.as_str();
45        let version = captures.name("version")?.as_str();
46        let segments: Vec<&str> = captures
47            .name("segments")?
48            .as_str()
49            .split(';')
50            .map(str::trim)
51            .collect();
52
53        match segments[..] {
54            ["Linux", "U", os, model, ..] | [model, os, ..] => {
55                // Most android model have a `/[build version]` suffix we don't care about
56                let model = model.split_once('/').map_or(model, |(model, _)| model);
57                // Some android version also have `Build/[build version]` suffix we don't care
58                // about
59                let model = model.strip_suffix("Build").unwrap_or(model);
60                // And let's trim any leftovers
61                let model = model.trim();
62
63                let (os, os_version) = if let Some((os, version)) = os.split_once(' ') {
64                    (os, Some(version))
65                } else {
66                    (os, None)
67                };
68
69                Some((name, version, model, os, os_version))
70            }
71            _ => None,
72        }
73    }
74
75    fn parse_electron(user_agent: &str) -> Option<(&str, &str)> {
76        let regex = regex::Regex::new(r"(?m)\w+/[\w.]+").unwrap();
77        let omit_keys = ["Mozilla", "AppleWebKit", "Chrome", "Electron", "Safari"];
78        return regex
79            .find_iter(user_agent)
80            .map(|caps| caps.as_str().split_once('/').unwrap())
81            .find(|pair| !omit_keys.contains(&pair.0));
82    }
83
84    #[must_use]
85    pub fn parse(user_agent: String) -> Self {
86        if !user_agent.contains("Mozilla/") {
87            if let Some((name, version, model, os, os_version)) =
88                UserAgent::parse_custom(&user_agent)
89            {
90                let mut device_type = DeviceType::Unknown;
91
92                // Handle mobile simple mobile devices
93                if os == "Android" || os == "iOS" {
94                    device_type = DeviceType::Mobile;
95                }
96
97                // Handle iPads
98                if model.contains("iPad") {
99                    device_type = DeviceType::Tablet;
100                }
101
102                return Self {
103                    name: Some(name.to_owned()),
104                    version: Some(version.to_owned()),
105                    os: Some(os.to_owned()),
106                    os_version: os_version.map(std::borrow::ToOwned::to_owned),
107                    model: Some(model.to_owned()),
108                    device_type,
109                    raw: user_agent,
110                };
111            }
112        }
113
114        let mut model = None;
115        let Some(mut result) = Parser::new().parse(&user_agent) else {
116            return Self {
117                raw: user_agent,
118                name: None,
119                version: None,
120                os: None,
121                os_version: None,
122                model: None,
123                device_type: DeviceType::Unknown,
124            };
125        };
126
127        let mut device_type = match result.category {
128            "pc" => DeviceType::Pc,
129            "smartphone" | "mobilephone" => DeviceType::Mobile,
130            _ => DeviceType::Unknown,
131        };
132
133        // Special handling for Chrome user-agent reduction cases
134        // https://www.chromium.org/updates/ua-reduction/
135        match (result.os, &*result.os_version) {
136            // Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/533.88 (KHTML, like Gecko)
137            // Chrome/109.1.2342.76 Safari/533.88
138            ("Windows 10", "NT 10.0") if user_agent.contains("Windows NT 10.0; Win64; x64") => {
139                result.os = "Windows";
140                result.os_version = VALUE_UNKNOWN.into();
141            }
142
143            // Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)
144            // Chrome/100.0.0.0 Safari/537.36
145            ("Linux", _) if user_agent.contains("X11; Linux x86_64") => {
146                result.os = "Linux";
147                result.os_version = VALUE_UNKNOWN.into();
148            }
149
150            // Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko)
151            // Chrome/107.0.0.0 Safari/537.36
152            ("ChromeOS", _) if user_agent.contains("X11; CrOS x86_64 14541.0.0") => {
153                result.os = "Chrome OS";
154                result.os_version = VALUE_UNKNOWN.into();
155            }
156
157            // Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko)
158            // Chrome/100.0.0.0 Mobile Safari/537.36
159            ("Android", "10") if user_agent.contains("Linux; Android 10; K") => {
160                result.os = "Android";
161                result.os_version = VALUE_UNKNOWN.into();
162            }
163
164            // Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like
165            // Gecko) Chrome/100.0.4896.133 Safari/537.36
166            // Safari also freezes the OS version
167            // Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like
168            // Gecko) Version/17.3.1 Safari/605.1.15
169            ("Mac OSX", "10.15.7") if user_agent.contains("Macintosh; Intel Mac OS X 10_15_7") => {
170                result.os = "macOS";
171                result.os_version = VALUE_UNKNOWN.into();
172            }
173
174            // Woothee identifies iPhone and iPod in the OS, but we want to map them to iOS and use
175            // them as model
176            ("iPhone" | "iPod", _) => {
177                model = Some(result.os.to_owned());
178                result.os = "iOS";
179            }
180
181            ("iPad", _) => {
182                model = Some(result.os.to_owned());
183                device_type = DeviceType::Tablet;
184                result.os = "iPadOS";
185            }
186
187            // Also map `Mac OSX` to `macOS`
188            ("Mac OSX", _) => {
189                result.os = "macOS";
190            }
191
192            _ => {}
193        }
194
195        // For some reason, the version on Windows is on the OS field
196        // This transforms `Windows 10` into `Windows` and `10`
197        if let Some(version) = result.os.strip_prefix("Windows ") {
198            result.os = "Windows";
199            result.os_version = version.into();
200        }
201
202        // Special handling for Electron applications e.g. Element Desktop
203        if user_agent.contains("Electron/") {
204            if let Some(app) = UserAgent::parse_electron(&user_agent) {
205                result.name = app.0;
206                result.version = app.1;
207            }
208        }
209
210        Self {
211            name: (result.name != VALUE_UNKNOWN).then(|| result.name.to_owned()),
212            version: (result.version != VALUE_UNKNOWN).then(|| result.version.to_owned()),
213            os: (result.os != VALUE_UNKNOWN).then(|| result.os.to_owned()),
214            os_version: (result.os_version != VALUE_UNKNOWN)
215                .then(|| result.os_version.into_owned()),
216            device_type,
217            model,
218            raw: user_agent,
219        }
220    }
221}