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 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 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}