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