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