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        use std::process::Stdio;
104        use std::time::Duration;
105        use wait_timeout::ChildExt;
106
107        let mut child = std::process::Command::new(exe)
108            .arg("--version")
109            .stdin(Stdio::null())
110            .stdout(Stdio::piped())
111            .stderr(Stdio::null())
112            .spawn()
113            .ok()?;
114
115        // Some browsers (notably GUI builds on Windows) never exit when invoked
116        // with --version. Cap the wait so detection cannot hang the caller.
117        match child.wait_timeout(Duration::from_secs(5)).ok()? {
118            Some(status) if status.success() => {}
119            Some(_) => {
120                let _ = child.wait();
121                return None;
122            }
123            None => {
124                let _ = child.kill();
125                let _ = child.wait();
126                return None;
127            }
128        }
129
130        let output = child.wait_with_output().ok()?;
131        let s = String::from_utf8_lossy(&output.stdout);
132        parse_version(&s)
133    }
134
135    fn which(&self, name: &str) -> Option<PathBuf> {
136        which::which(name).ok()
137    }
138}
139
140/// Take the last whitespace-separated token from version output as the version.
141fn parse_version(s: &str) -> Option<String> {
142    let line = s.lines().next()?.trim();
143    if line.is_empty() {
144        return None;
145    }
146    let token = line.split_whitespace().last()?;
147    Some(token.to_string())
148}
149
150pub fn list_installed() -> Vec<Installed> {
151    list_installed_with(&RealProbe)
152}
153
154pub fn list_installed_with<P: Probe>(probe: &P) -> Vec<Installed> {
155    #[cfg(target_os = "macos")]
156    {
157        crate::detect::macos::detect(probe)
158    }
159    #[cfg(target_os = "linux")]
160    {
161        crate::detect::linux::detect(probe)
162    }
163    #[cfg(target_os = "windows")]
164    {
165        crate::detect::windows::detect(probe)
166    }
167    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
168    {
169        let _ = probe;
170        Vec::new()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::collections::{HashMap, HashSet};
178
179    #[derive(Default)]
180    struct FakeProbe {
181        existing: HashSet<PathBuf>,
182        path_map: HashMap<String, PathBuf>,
183        versions: HashMap<PathBuf, String>,
184    }
185
186    impl Probe for FakeProbe {
187        fn exists(&self, p: &Path) -> bool {
188            self.existing.contains(p)
189        }
190        fn run_version(&self, exe: &Path) -> Option<String> {
191            self.versions.get(exe).cloned()
192        }
193        fn which(&self, name: &str) -> Option<PathBuf> {
194            self.path_map.get(name).cloned()
195        }
196    }
197
198    #[test]
199    fn kind_parse_roundtrip() {
200        for k in [
201            Kind::Chrome,
202            Kind::Edge,
203            Kind::Chromium,
204            Kind::Brave,
205            Kind::Firefox,
206        ] {
207            assert_eq!(Kind::parse(k.as_str()), Some(k));
208            assert_eq!(k.to_string(), k.as_str());
209            assert_eq!(k.as_str().parse::<Kind>().unwrap(), k);
210        }
211        assert_eq!(Kind::parse("CHROME"), Some(Kind::Chrome));
212        assert_eq!(Kind::parse("Firefox"), Some(Kind::Firefox));
213        assert_eq!(Kind::parse("safari"), None);
214    }
215
216    #[test]
217    fn kind_engine_mapping() {
218        assert_eq!(Kind::Firefox.engine(), Engine::Bidi);
219        assert_eq!(Kind::Chrome.engine(), Engine::Cdp);
220        assert_eq!(Kind::Edge.engine(), Engine::Cdp);
221        assert_eq!(Kind::Chromium.engine(), Engine::Cdp);
222        assert_eq!(Kind::Brave.engine(), Engine::Cdp);
223        assert!(Kind::Chrome.is_chromium());
224        assert!(!Kind::Firefox.is_chromium());
225    }
226
227    #[test]
228    fn parse_version_takes_last_token() {
229        assert_eq!(
230            parse_version("Google Chrome 130.0.6723.91\n").as_deref(),
231            Some("130.0.6723.91")
232        );
233        assert_eq!(
234            parse_version("Mozilla Firefox 131.0").as_deref(),
235            Some("131.0")
236        );
237        assert_eq!(parse_version(""), None);
238    }
239
240    #[cfg(target_os = "macos")]
241    #[test]
242    fn macos_finds_chrome_and_firefox() {
243        let chrome = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
244        let firefox = PathBuf::from("/Applications/Firefox.app/Contents/MacOS/firefox");
245        let mut probe = FakeProbe::default();
246        probe.existing.insert(chrome.clone());
247        probe.existing.insert(firefox.clone());
248        probe
249            .versions
250            .insert(chrome.clone(), "130.0.6723.91".to_string());
251        probe.versions.insert(firefox.clone(), "131.0".to_string());
252
253        let found = super::macos::detect(&probe);
254        assert_eq!(found.len(), 2);
255        let chrome_entry = found.iter().find(|i| i.kind == Kind::Chrome).unwrap();
256        assert_eq!(chrome_entry.executable, chrome);
257        assert_eq!(chrome_entry.version, "130.0.6723.91");
258        assert_eq!(chrome_entry.engine, Engine::Cdp);
259        let ff_entry = found.iter().find(|i| i.kind == Kind::Firefox).unwrap();
260        assert_eq!(ff_entry.executable, firefox);
261        assert_eq!(ff_entry.engine, Engine::Bidi);
262    }
263
264    #[cfg(target_os = "macos")]
265    #[test]
266    fn macos_unknown_version_when_run_fails() {
267        let chrome = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
268        let mut probe = FakeProbe::default();
269        probe.existing.insert(chrome.clone());
270        let found = super::macos::detect(&probe);
271        assert_eq!(found.len(), 1);
272        assert_eq!(found[0].version, "unknown");
273    }
274
275    #[cfg(target_os = "linux")]
276    #[test]
277    fn linux_finds_chrome_via_which_and_firefox_absolute() {
278        let chrome = PathBuf::from("/usr/local/bin/google-chrome");
279        let firefox = PathBuf::from("/usr/bin/firefox");
280        let mut probe = FakeProbe::default();
281        probe
282            .path_map
283            .insert("google-chrome".to_string(), chrome.clone());
284        probe.existing.insert(firefox.clone());
285        probe
286            .versions
287            .insert(chrome.clone(), "130.0.6723.91".to_string());
288        probe.versions.insert(firefox.clone(), "131.0".to_string());
289
290        let found = super::linux::detect(&probe);
291        let chrome_entry = found.iter().find(|i| i.kind == Kind::Chrome).unwrap();
292        assert_eq!(chrome_entry.executable, chrome);
293        assert_eq!(chrome_entry.engine, Engine::Cdp);
294        let ff_entry = found.iter().find(|i| i.kind == Kind::Firefox).unwrap();
295        assert_eq!(ff_entry.executable, firefox);
296        assert_eq!(ff_entry.engine, Engine::Bidi);
297    }
298
299    #[cfg(target_os = "linux")]
300    #[test]
301    fn linux_takes_first_match_per_kind() {
302        let chrome_a = PathBuf::from("/usr/local/bin/google-chrome");
303        let chrome_b = PathBuf::from("/usr/bin/google-chrome");
304        let mut probe = FakeProbe::default();
305        probe
306            .path_map
307            .insert("google-chrome".to_string(), chrome_a.clone());
308        probe.existing.insert(chrome_b.clone());
309        let found = super::linux::detect(&probe);
310        let chrome_entries: Vec<_> = found.iter().filter(|i| i.kind == Kind::Chrome).collect();
311        assert_eq!(chrome_entries.len(), 1);
312        assert_eq!(chrome_entries[0].executable, chrome_a);
313    }
314
315    #[cfg(target_os = "windows")]
316    #[test]
317    fn windows_finds_chrome_and_firefox() {
318        let chrome = PathBuf::from(r"C:\Program Files\Google\Chrome\Application\chrome.exe");
319        let firefox = PathBuf::from(r"C:\Program Files\Mozilla Firefox\firefox.exe");
320        let mut probe = FakeProbe::default();
321        probe.existing.insert(chrome.clone());
322        probe.existing.insert(firefox.clone());
323        probe
324            .versions
325            .insert(chrome.clone(), "130.0.6723.91".to_string());
326        probe.versions.insert(firefox.clone(), "131.0".to_string());
327
328        let found = super::windows::detect(&probe);
329        assert!(found
330            .iter()
331            .any(|i| i.kind == Kind::Chrome && i.executable == chrome));
332        assert!(found
333            .iter()
334            .any(|i| i.kind == Kind::Firefox && i.executable == firefox));
335    }
336}