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
150/// Kinds among `installed` that currently have a live OS process, matched by
151/// exact executable path. Used to prefer a browser the user already has open
152/// (even if browser-control never launched or registered it) over the
153/// hardcoded detection-order fallback in [`crate::cli::start::ensure_started`].
154pub fn list_running_kinds(installed: &[Installed]) -> std::collections::HashSet<Kind> {
155    // A live process reports its resolved binary (`/proc/<pid>/exe`), while
156    // the installed path may be a symlink into it (`/bin -> /usr/bin` on
157    // merged-usr Linux, a launcher symlink in /usr/local/bin). Compare
158    // canonical paths so both spellings match.
159    let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
160    let wanted: Vec<(std::path::PathBuf, Kind)> = installed
161        .iter()
162        .map(|i| (canon(&i.executable), i.kind))
163        .collect();
164    let mut sys = sysinfo::System::new();
165    sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
166    let mut running = std::collections::HashSet::new();
167    for proc in sys.processes().values() {
168        let Some(exe) = proc.exe() else { continue };
169        let exe = canon(exe);
170        if let Some((_, kind)) = wanted.iter().find(|(p, _)| *p == exe) {
171            running.insert(*kind);
172        }
173    }
174    running
175}
176
177pub fn list_installed() -> Vec<Installed> {
178    list_installed_with(&RealProbe)
179}
180
181pub fn list_installed_with<P: Probe>(probe: &P) -> Vec<Installed> {
182    #[cfg(target_os = "macos")]
183    {
184        crate::detect::macos::detect(probe)
185    }
186    #[cfg(target_os = "linux")]
187    {
188        crate::detect::linux::detect(probe)
189    }
190    #[cfg(target_os = "windows")]
191    {
192        crate::detect::windows::detect(probe)
193    }
194    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
195    {
196        let _ = probe;
197        Vec::new()
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::collections::{HashMap, HashSet};
205
206    #[test]
207    fn list_running_kinds_matches_live_process_by_exact_exe_path() {
208        // `sleep` stands in for a "browser" here: we only care that
209        // `list_running_kinds` matches a live process's exe path against
210        // `Installed.executable`, not that it's an actual browser.
211        let exe = PathBuf::from(if cfg!(windows) {
212            r"C:\Windows\System32\timeout.exe"
213        } else {
214            "/bin/sleep"
215        });
216        assert!(exe.exists(), "test requires {exe:?} to exist");
217
218        let mut child = if cfg!(windows) {
219            std::process::Command::new(&exe)
220                .arg("5")
221                .spawn()
222                .expect("spawn timeout")
223        } else {
224            std::process::Command::new(&exe)
225                .arg("5")
226                .spawn()
227                .expect("spawn sleep")
228        };
229
230        let installed = vec![Installed {
231            kind: Kind::Brave,
232            executable: exe.clone(),
233            version: "unknown".to_string(),
234            engine: Kind::Brave.engine(),
235        }];
236
237        let running = list_running_kinds(&installed);
238
239        let _ = child.kill();
240        let _ = child.wait();
241
242        assert!(
243            running.contains(&Kind::Brave),
244            "expected {exe:?} (pid {}) to be detected as a running Brave process",
245            child.id()
246        );
247    }
248
249    #[test]
250    fn list_running_kinds_empty_for_kind_with_no_live_process() {
251        let installed = vec![Installed {
252            kind: Kind::Firefox,
253            executable: PathBuf::from("/definitely/not/a/real/browser/path"),
254            version: "unknown".to_string(),
255            engine: Kind::Firefox.engine(),
256        }];
257        assert!(list_running_kinds(&installed).is_empty());
258    }
259
260    #[derive(Default)]
261    struct FakeProbe {
262        existing: HashSet<PathBuf>,
263        path_map: HashMap<String, PathBuf>,
264        versions: HashMap<PathBuf, String>,
265    }
266
267    impl Probe for FakeProbe {
268        fn exists(&self, p: &Path) -> bool {
269            self.existing.contains(p)
270        }
271        fn run_version(&self, exe: &Path) -> Option<String> {
272            self.versions.get(exe).cloned()
273        }
274        fn which(&self, name: &str) -> Option<PathBuf> {
275            self.path_map.get(name).cloned()
276        }
277    }
278
279    #[test]
280    fn kind_parse_roundtrip() {
281        for k in [
282            Kind::Chrome,
283            Kind::Edge,
284            Kind::Chromium,
285            Kind::Brave,
286            Kind::Firefox,
287        ] {
288            assert_eq!(Kind::parse(k.as_str()), Some(k));
289            assert_eq!(k.to_string(), k.as_str());
290            assert_eq!(k.as_str().parse::<Kind>().unwrap(), k);
291        }
292        assert_eq!(Kind::parse("CHROME"), Some(Kind::Chrome));
293        assert_eq!(Kind::parse("Firefox"), Some(Kind::Firefox));
294        assert_eq!(Kind::parse("safari"), None);
295    }
296
297    #[test]
298    fn kind_engine_mapping() {
299        assert_eq!(Kind::Firefox.engine(), Engine::Bidi);
300        assert_eq!(Kind::Chrome.engine(), Engine::Cdp);
301        assert_eq!(Kind::Edge.engine(), Engine::Cdp);
302        assert_eq!(Kind::Chromium.engine(), Engine::Cdp);
303        assert_eq!(Kind::Brave.engine(), Engine::Cdp);
304        assert!(Kind::Chrome.is_chromium());
305        assert!(!Kind::Firefox.is_chromium());
306    }
307
308    #[test]
309    fn parse_version_takes_last_token() {
310        assert_eq!(
311            parse_version("Google Chrome 130.0.6723.91\n").as_deref(),
312            Some("130.0.6723.91")
313        );
314        assert_eq!(
315            parse_version("Mozilla Firefox 131.0").as_deref(),
316            Some("131.0")
317        );
318        assert_eq!(parse_version(""), None);
319    }
320
321    #[cfg(target_os = "macos")]
322    #[test]
323    fn macos_finds_chrome_and_firefox() {
324        let chrome = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
325        let firefox = PathBuf::from("/Applications/Firefox.app/Contents/MacOS/firefox");
326        let mut probe = FakeProbe::default();
327        probe.existing.insert(chrome.clone());
328        probe.existing.insert(firefox.clone());
329        probe
330            .versions
331            .insert(chrome.clone(), "130.0.6723.91".to_string());
332        probe.versions.insert(firefox.clone(), "131.0".to_string());
333
334        let found = super::macos::detect(&probe);
335        assert_eq!(found.len(), 2);
336        let chrome_entry = found.iter().find(|i| i.kind == Kind::Chrome).unwrap();
337        assert_eq!(chrome_entry.executable, chrome);
338        assert_eq!(chrome_entry.version, "130.0.6723.91");
339        assert_eq!(chrome_entry.engine, Engine::Cdp);
340        let ff_entry = found.iter().find(|i| i.kind == Kind::Firefox).unwrap();
341        assert_eq!(ff_entry.executable, firefox);
342        assert_eq!(ff_entry.engine, Engine::Bidi);
343    }
344
345    #[cfg(target_os = "macos")]
346    #[test]
347    fn macos_unknown_version_when_run_fails() {
348        let chrome = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
349        let mut probe = FakeProbe::default();
350        probe.existing.insert(chrome.clone());
351        let found = super::macos::detect(&probe);
352        assert_eq!(found.len(), 1);
353        assert_eq!(found[0].version, "unknown");
354    }
355
356    #[cfg(target_os = "linux")]
357    #[test]
358    fn linux_finds_chrome_via_which_and_firefox_absolute() {
359        let chrome = PathBuf::from("/usr/local/bin/google-chrome");
360        let firefox = PathBuf::from("/usr/bin/firefox");
361        let mut probe = FakeProbe::default();
362        probe
363            .path_map
364            .insert("google-chrome".to_string(), chrome.clone());
365        probe.existing.insert(firefox.clone());
366        probe
367            .versions
368            .insert(chrome.clone(), "130.0.6723.91".to_string());
369        probe.versions.insert(firefox.clone(), "131.0".to_string());
370
371        let found = super::linux::detect(&probe);
372        let chrome_entry = found.iter().find(|i| i.kind == Kind::Chrome).unwrap();
373        assert_eq!(chrome_entry.executable, chrome);
374        assert_eq!(chrome_entry.engine, Engine::Cdp);
375        let ff_entry = found.iter().find(|i| i.kind == Kind::Firefox).unwrap();
376        assert_eq!(ff_entry.executable, firefox);
377        assert_eq!(ff_entry.engine, Engine::Bidi);
378    }
379
380    #[cfg(target_os = "linux")]
381    #[test]
382    fn linux_takes_first_match_per_kind() {
383        let chrome_a = PathBuf::from("/usr/local/bin/google-chrome");
384        let chrome_b = PathBuf::from("/usr/bin/google-chrome");
385        let mut probe = FakeProbe::default();
386        probe
387            .path_map
388            .insert("google-chrome".to_string(), chrome_a.clone());
389        probe.existing.insert(chrome_b.clone());
390        let found = super::linux::detect(&probe);
391        let chrome_entries: Vec<_> = found.iter().filter(|i| i.kind == Kind::Chrome).collect();
392        assert_eq!(chrome_entries.len(), 1);
393        assert_eq!(chrome_entries[0].executable, chrome_a);
394    }
395
396    #[cfg(target_os = "windows")]
397    #[test]
398    fn windows_finds_chrome_and_firefox() {
399        let chrome = PathBuf::from(r"C:\Program Files\Google\Chrome\Application\chrome.exe");
400        let firefox = PathBuf::from(r"C:\Program Files\Mozilla Firefox\firefox.exe");
401        let mut probe = FakeProbe::default();
402        probe.existing.insert(chrome.clone());
403        probe.existing.insert(firefox.clone());
404        probe
405            .versions
406            .insert(chrome.clone(), "130.0.6723.91".to_string());
407        probe.versions.insert(firefox.clone(), "131.0".to_string());
408
409        let found = super::windows::detect(&probe);
410        assert!(found
411            .iter()
412            .any(|i| i.kind == Kind::Chrome && i.executable == chrome));
413        assert!(found
414            .iter()
415            .any(|i| i.kind == Kind::Firefox && i.executable == firefox));
416    }
417}