1use 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
85pub 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 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
140fn 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_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 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}