Skip to main content

browser_control/detect/
mod.rs

1//! Cross-platform browser detection.
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6#[cfg(target_os = "linux")]
7pub mod linux;
8#[cfg(target_os = "macos")]
9pub mod macos;
10#[cfg(target_os = "windows")]
11pub mod windows;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Kind {
16    Chrome,
17    Edge,
18    Chromium,
19    Brave,
20    Firefox,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Engine {
26    Cdp,
27    Bidi,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Installed {
32    pub kind: Kind,
33    pub executable: PathBuf,
34    pub version: String,
35    pub engine: Engine,
36}
37
38impl Kind {
39    pub fn engine(self) -> Engine {
40        match self {
41            Kind::Firefox => Engine::Bidi,
42            _ => Engine::Cdp,
43        }
44    }
45
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Kind::Chrome => "chrome",
49            Kind::Edge => "edge",
50            Kind::Chromium => "chromium",
51            Kind::Brave => "brave",
52            Kind::Firefox => "firefox",
53        }
54    }
55
56    pub fn parse(s: &str) -> Option<Self> {
57        match s.to_ascii_lowercase().as_str() {
58            "chrome" => Some(Kind::Chrome),
59            "edge" => Some(Kind::Edge),
60            "chromium" => Some(Kind::Chromium),
61            "brave" => Some(Kind::Brave),
62            "firefox" => Some(Kind::Firefox),
63            _ => None,
64        }
65    }
66
67    pub fn is_chromium(self) -> bool {
68        !matches!(self, Kind::Firefox)
69    }
70}
71
72impl std::fmt::Display for Kind {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str(self.as_str())
75    }
76}
77
78impl std::str::FromStr for Kind {
79    type Err = ();
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        Kind::parse(s).ok_or(())
82    }
83}
84
85/// Filesystem/process injection trait for testability.
86pub trait Probe {
87    fn exists(&self, p: &Path) -> bool;
88    fn run_version(&self, exe: &Path) -> Option<String>;
89    fn which(&self, name: &str) -> Option<PathBuf>;
90}
91
92pub struct RealProbe;
93
94impl Probe for RealProbe {
95    fn exists(&self, p: &Path) -> bool {
96        p.exists()
97    }
98
99    fn run_version(&self, exe: &Path) -> Option<String> {
100        if !exe.exists() {
101            return None;
102        }
103        let output = std::process::Command::new(exe)
104            .arg("--version")
105            .output()
106            .ok()?;
107        if !output.status.success() {
108            return None;
109        }
110        let s = String::from_utf8_lossy(&output.stdout);
111        parse_version(&s)
112    }
113
114    fn which(&self, name: &str) -> Option<PathBuf> {
115        which::which(name).ok()
116    }
117}
118
119/// Take the last whitespace-separated token from version output as the version.
120fn parse_version(s: &str) -> Option<String> {
121    let line = s.lines().next()?.trim();
122    if line.is_empty() {
123        return None;
124    }
125    let token = line.split_whitespace().last()?;
126    Some(token.to_string())
127}
128
129pub fn list_installed() -> Vec<Installed> {
130    list_installed_with(&RealProbe)
131}
132
133pub fn list_installed_with<P: Probe>(probe: &P) -> Vec<Installed> {
134    #[cfg(target_os = "macos")]
135    {
136        crate::detect::macos::detect(probe)
137    }
138    #[cfg(target_os = "linux")]
139    {
140        crate::detect::linux::detect(probe)
141    }
142    #[cfg(target_os = "windows")]
143    {
144        crate::detect::windows::detect(probe)
145    }
146    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
147    {
148        let _ = probe;
149        Vec::new()
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use std::collections::{HashMap, HashSet};
157
158    #[derive(Default)]
159    struct FakeProbe {
160        existing: HashSet<PathBuf>,
161        path_map: HashMap<String, PathBuf>,
162        versions: HashMap<PathBuf, String>,
163    }
164
165    impl Probe for FakeProbe {
166        fn exists(&self, p: &Path) -> bool {
167            self.existing.contains(p)
168        }
169        fn run_version(&self, exe: &Path) -> Option<String> {
170            self.versions.get(exe).cloned()
171        }
172        fn which(&self, name: &str) -> Option<PathBuf> {
173            self.path_map.get(name).cloned()
174        }
175    }
176
177    #[test]
178    fn kind_parse_roundtrip() {
179        for k in [
180            Kind::Chrome,
181            Kind::Edge,
182            Kind::Chromium,
183            Kind::Brave,
184            Kind::Firefox,
185        ] {
186            assert_eq!(Kind::parse(k.as_str()), Some(k));
187            assert_eq!(k.to_string(), k.as_str());
188            assert_eq!(k.as_str().parse::<Kind>().unwrap(), k);
189        }
190        assert_eq!(Kind::parse("CHROME"), Some(Kind::Chrome));
191        assert_eq!(Kind::parse("Firefox"), Some(Kind::Firefox));
192        assert_eq!(Kind::parse("safari"), None);
193    }
194
195    #[test]
196    fn kind_engine_mapping() {
197        assert_eq!(Kind::Firefox.engine(), Engine::Bidi);
198        assert_eq!(Kind::Chrome.engine(), Engine::Cdp);
199        assert_eq!(Kind::Edge.engine(), Engine::Cdp);
200        assert_eq!(Kind::Chromium.engine(), Engine::Cdp);
201        assert_eq!(Kind::Brave.engine(), Engine::Cdp);
202        assert!(Kind::Chrome.is_chromium());
203        assert!(!Kind::Firefox.is_chromium());
204    }
205
206    #[test]
207    fn parse_version_takes_last_token() {
208        assert_eq!(
209            parse_version("Google Chrome 130.0.6723.91\n").as_deref(),
210            Some("130.0.6723.91")
211        );
212        assert_eq!(
213            parse_version("Mozilla Firefox 131.0").as_deref(),
214            Some("131.0")
215        );
216        assert_eq!(parse_version(""), None);
217    }
218
219    #[cfg(target_os = "macos")]
220    #[test]
221    fn macos_finds_chrome_and_firefox() {
222        let chrome = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
223        let firefox = PathBuf::from("/Applications/Firefox.app/Contents/MacOS/firefox");
224        let mut probe = FakeProbe::default();
225        probe.existing.insert(chrome.clone());
226        probe.existing.insert(firefox.clone());
227        probe
228            .versions
229            .insert(chrome.clone(), "130.0.6723.91".to_string());
230        probe.versions.insert(firefox.clone(), "131.0".to_string());
231
232        let found = super::macos::detect(&probe);
233        assert_eq!(found.len(), 2);
234        let chrome_entry = found.iter().find(|i| i.kind == Kind::Chrome).unwrap();
235        assert_eq!(chrome_entry.executable, chrome);
236        assert_eq!(chrome_entry.version, "130.0.6723.91");
237        assert_eq!(chrome_entry.engine, Engine::Cdp);
238        let ff_entry = found.iter().find(|i| i.kind == Kind::Firefox).unwrap();
239        assert_eq!(ff_entry.executable, firefox);
240        assert_eq!(ff_entry.engine, Engine::Bidi);
241    }
242
243    #[cfg(target_os = "macos")]
244    #[test]
245    fn macos_unknown_version_when_run_fails() {
246        let chrome = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
247        let mut probe = FakeProbe::default();
248        probe.existing.insert(chrome.clone());
249        let found = super::macos::detect(&probe);
250        assert_eq!(found.len(), 1);
251        assert_eq!(found[0].version, "unknown");
252    }
253
254    #[cfg(target_os = "linux")]
255    #[test]
256    fn linux_finds_chrome_via_which_and_firefox_absolute() {
257        let chrome = PathBuf::from("/usr/local/bin/google-chrome");
258        let firefox = PathBuf::from("/usr/bin/firefox");
259        let mut probe = FakeProbe::default();
260        probe
261            .path_map
262            .insert("google-chrome".to_string(), chrome.clone());
263        probe.existing.insert(firefox.clone());
264        probe
265            .versions
266            .insert(chrome.clone(), "130.0.6723.91".to_string());
267        probe.versions.insert(firefox.clone(), "131.0".to_string());
268
269        let found = super::linux::detect(&probe);
270        let chrome_entry = found.iter().find(|i| i.kind == Kind::Chrome).unwrap();
271        assert_eq!(chrome_entry.executable, chrome);
272        assert_eq!(chrome_entry.engine, Engine::Cdp);
273        let ff_entry = found.iter().find(|i| i.kind == Kind::Firefox).unwrap();
274        assert_eq!(ff_entry.executable, firefox);
275        assert_eq!(ff_entry.engine, Engine::Bidi);
276    }
277
278    #[cfg(target_os = "linux")]
279    #[test]
280    fn linux_takes_first_match_per_kind() {
281        let chrome_a = PathBuf::from("/usr/local/bin/google-chrome");
282        let chrome_b = PathBuf::from("/usr/bin/google-chrome");
283        let mut probe = FakeProbe::default();
284        probe
285            .path_map
286            .insert("google-chrome".to_string(), chrome_a.clone());
287        probe.existing.insert(chrome_b.clone());
288        let found = super::linux::detect(&probe);
289        let chrome_entries: Vec<_> = found.iter().filter(|i| i.kind == Kind::Chrome).collect();
290        assert_eq!(chrome_entries.len(), 1);
291        assert_eq!(chrome_entries[0].executable, chrome_a);
292    }
293
294    #[cfg(target_os = "windows")]
295    #[test]
296    fn windows_finds_chrome_and_firefox() {
297        let chrome = PathBuf::from(r"C:\Program Files\Google\Chrome\Application\chrome.exe");
298        let firefox = PathBuf::from(r"C:\Program Files\Mozilla Firefox\firefox.exe");
299        let mut probe = FakeProbe::default();
300        probe.existing.insert(chrome.clone());
301        probe.existing.insert(firefox.clone());
302        probe
303            .versions
304            .insert(chrome.clone(), "130.0.6723.91".to_string());
305        probe.versions.insert(firefox.clone(), "131.0".to_string());
306
307        let found = super::windows::detect(&probe);
308        assert!(found
309            .iter()
310            .any(|i| i.kind == Kind::Chrome && i.executable == chrome));
311        assert!(found
312            .iter()
313            .any(|i| i.kind == Kind::Firefox && i.executable == firefox));
314    }
315}