libsubconverter/utils/
useragent.rs

1//! User agent matching utilities
2//!
3//! This module provides functionality for parsing and matching user agent strings
4//! to determine target formats and settings.
5
6use crate::models::SubconverterTarget;
7
8/// User agent profile structure
9pub struct UAProfile {
10    /// Beginning of user agent string to match
11    pub head: String,
12    /// Version string to look for
13    pub version_match: String,
14    /// Target version to compare with
15    pub version_target: String,
16    /// Target format to use
17    pub target: SubconverterTarget,
18    /// Whether to use new field names in Clash
19    /// None means indeterminate (equivalent to tribool indeterminate state)
20    pub clash_new_name: Option<bool>,
21    /// Surge version
22    pub surge_ver: i32,
23}
24
25impl UAProfile {
26    /// Create a new UAProfile
27    pub fn new(
28        head: &str,
29        version_match: &str,
30        version_target: &str,
31        target: SubconverterTarget,
32        clash_new_name: Option<bool>,
33        surge_ver: i32,
34    ) -> Self {
35        UAProfile {
36            head: head.to_string(),
37            version_match: version_match.to_string(),
38            version_target: version_target.to_string(),
39            target,
40            clash_new_name,
41            surge_ver,
42        }
43    }
44}
45
46/// Compare two version strings to check if source version is greater than or equal to target version
47///
48/// # Arguments
49///
50/// * `src_ver` - Source version string, like "1.2.3"
51/// * `target_ver` - Target version string to compare with
52///
53/// # Returns
54///
55/// `true` if the source version is greater than or equal to the target version
56pub fn ver_greater_equal(src_ver: &str, target_ver: &str) -> bool {
57    // Create iterators for both version strings, splitting by dots
58    let src_parts = src_ver.split('.').collect::<Vec<&str>>();
59    let target_parts = target_ver.split('.').collect::<Vec<&str>>();
60
61    // Compare each part of the version
62    let min_len = src_parts.len().min(target_parts.len());
63    for i in 0..min_len {
64        // Parse to integers, default to 0 if parsing fails
65        let src_part = src_parts[i].parse::<i32>().unwrap_or(0);
66        let target_part = target_parts[i].parse::<i32>().unwrap_or(0);
67
68        // If source part is greater, return true
69        if src_part > target_part {
70            return true;
71        }
72        // If source part is less, return false
73        else if src_part < target_part {
74            return false;
75        }
76        // If equal, continue to next part
77    }
78
79    // If all parts were equal, check if source has more parts than target
80    // For example, 1.2.3 is greater than 1.2
81    if src_parts.len() >= target_parts.len() {
82        return true;
83    }
84
85    false
86}
87
88/// Match user agent string to determine target format and settings
89///
90/// # Arguments
91///
92/// * `user_agent` - User agent string to match
93/// * `target` - Output parameter for target format
94/// * `clash_new_name` - Output parameter for Clash new name setting (None = indeterminate)
95/// * `surge_ver` - Output parameter for Surge version
96///
97/// # Returns
98///
99/// Updates the target, clash_new_name, and surge_ver parameters based on matching
100pub fn match_user_agent(
101    user_agent: &str,
102    target: &mut SubconverterTarget,
103    clash_new_name: &mut Option<bool>,
104    surge_ver: &mut i32,
105) {
106    // Define user agent profiles to match C++ UAMatchList
107    let ua_profiles = vec![
108        // ClashForAndroid profiles
109        UAProfile::new(
110            "clashforandroid",
111            "\\/([0-9.]+)",
112            "2.0",
113            SubconverterTarget::Clash,
114            Some(true), // True
115            -1,
116        ),
117        UAProfile::new(
118            "clashforandroid",
119            "\\/([0-9.]+)r",
120            "",
121            SubconverterTarget::ClashR,
122            Some(false), // False
123            -1,
124        ),
125        UAProfile::new(
126            "clashforandroid",
127            "",
128            "",
129            SubconverterTarget::Clash,
130            Some(false), // False
131            -1,
132        ),
133        // ClashForWindows profiles
134        UAProfile::new(
135            "clashforwindows",
136            "\\/([0-9.]+)",
137            "0.11",
138            SubconverterTarget::Clash,
139            Some(true), // True
140            -1,
141        ),
142        UAProfile::new(
143            "clashforwindows",
144            "",
145            "",
146            SubconverterTarget::Clash,
147            Some(false), // False
148            -1,
149        ),
150        // Clash Verge
151        UAProfile::new(
152            "clash-verge",
153            "",
154            "",
155            SubconverterTarget::Clash,
156            Some(true), // True
157            -1,
158        ),
159        // ClashX Pro
160        UAProfile::new(
161            "clashx pro",
162            "",
163            "",
164            SubconverterTarget::Clash,
165            Some(true), // True
166            -1,
167        ),
168        // ClashX
169        UAProfile::new(
170            "clashx",
171            "\\/([0-9.]+)",
172            "0.13",
173            SubconverterTarget::Clash,
174            Some(true), // True
175            -1,
176        ),
177        // Generic Clash
178        UAProfile::new(
179            "clash",
180            "",
181            "",
182            SubconverterTarget::Clash,
183            Some(true), // True
184            -1,
185        ),
186        // Kitsunebi
187        UAProfile::new(
188            "kitsunebi",
189            "",
190            "",
191            SubconverterTarget::V2Ray,
192            None, // Indeterminate
193            -1,
194        ),
195        // Loon
196        UAProfile::new(
197            "loon",
198            "",
199            "",
200            SubconverterTarget::Loon,
201            None, // Indeterminate
202            -1,
203        ),
204        // Pharos
205        UAProfile::new(
206            "pharos",
207            "",
208            "",
209            SubconverterTarget::Mixed,
210            None, // Indeterminate
211            -1,
212        ),
213        // Potatso
214        UAProfile::new(
215            "potatso",
216            "",
217            "",
218            SubconverterTarget::Mixed,
219            None, // Indeterminate
220            -1,
221        ),
222        // Quantumult X
223        UAProfile::new(
224            "quantumult%20x",
225            "",
226            "",
227            SubconverterTarget::QuantumultX,
228            None, // Indeterminate
229            -1,
230        ),
231        // Quantumult
232        UAProfile::new(
233            "quantumult",
234            "",
235            "",
236            SubconverterTarget::Quantumult,
237            None, // Indeterminate
238            -1,
239        ),
240        // Qv2ray
241        UAProfile::new(
242            "qv2ray",
243            "",
244            "",
245            SubconverterTarget::V2Ray,
246            None, // Indeterminate
247            -1,
248        ),
249        // Shadowrocket
250        UAProfile::new(
251            "shadowrocket",
252            "",
253            "",
254            SubconverterTarget::Mixed, // In original C++ it's "mixed"
255            None,                      // Indeterminate
256            -1,
257        ),
258        // Surfboard
259        UAProfile::new(
260            "surfboard",
261            "",
262            "",
263            SubconverterTarget::Surfboard,
264            None, // Indeterminate
265            -1,
266        ),
267        // Surge Mac x86
268        UAProfile::new(
269            "surge",
270            "\\/([0-9.]+).*x86",
271            "906",
272            SubconverterTarget::Surge(4),
273            Some(false), // False
274            4,
275        ),
276        UAProfile::new(
277            "surge",
278            "\\/([0-9.]+).*x86",
279            "368",
280            SubconverterTarget::Surge(3),
281            Some(false), // False
282            3,
283        ),
284        // Surge iOS
285        UAProfile::new(
286            "surge",
287            "\\/([0-9.]+)",
288            "1419",
289            SubconverterTarget::Surge(4),
290            Some(false), // False
291            4,
292        ),
293        UAProfile::new(
294            "surge",
295            "\\/([0-9.]+)",
296            "900",
297            SubconverterTarget::Surge(3),
298            Some(false), // False
299            3,
300        ),
301        // Fallback for any Surge version
302        UAProfile::new(
303            "surge",
304            "",
305            "",
306            SubconverterTarget::Surge(2),
307            Some(false), // False
308            2,
309        ),
310        // Trojan-Qt5
311        UAProfile::new(
312            "trojan-qt5",
313            "",
314            "",
315            SubconverterTarget::Trojan,
316            None, // Indeterminate
317            -1,
318        ),
319        // V2rayU
320        UAProfile::new(
321            "v2rayu",
322            "",
323            "",
324            SubconverterTarget::V2Ray,
325            None, // Indeterminate
326            -1,
327        ),
328        // V2RayX
329        UAProfile::new(
330            "v2rayx",
331            "",
332            "",
333            SubconverterTarget::V2Ray,
334            None, // Indeterminate
335            -1,
336        ),
337        // SingBox (not in original C++ list but keep it)
338        UAProfile::new(
339            "sing-box",
340            "",
341            "",
342            SubconverterTarget::SingBox,
343            None, // Indeterminate
344            -1,
345        ),
346    ];
347
348    // Convert the user agent to lowercase for case-insensitive matching
349    let user_agent_lower = user_agent.to_lowercase();
350
351    for profile in ua_profiles {
352        if user_agent_lower.contains(&profile.head) {
353            // If a version string is specified, check if it matches and is greater than or equal to target version
354            if !profile.version_match.is_empty() && !profile.version_target.is_empty() {
355                if let Some(version_pos) = user_agent_lower.find(&profile.version_match) {
356                    let version_begin = version_pos + profile.version_match.len();
357                    if let Some(version_end) = user_agent_lower[version_begin..].find(' ') {
358                        let version =
359                            &user_agent_lower[version_begin..(version_begin + version_end)];
360                        if ver_greater_equal(version, &profile.version_target) {
361                            *target = profile.target;
362                            *clash_new_name = profile.clash_new_name;
363                            *surge_ver = profile.surge_ver;
364                            return;
365                        }
366                    } else {
367                        let version = &user_agent_lower[version_begin..];
368                        if ver_greater_equal(version, &profile.version_target) {
369                            *target = profile.target;
370                            *clash_new_name = profile.clash_new_name;
371                            *surge_ver = profile.surge_ver;
372                            return;
373                        }
374                    }
375                }
376            } else {
377                // If no version string specified, just match the head
378                *target = profile.target;
379                *clash_new_name = profile.clash_new_name;
380                *surge_ver = profile.surge_ver;
381                return;
382            }
383        }
384    }
385}