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 let output = std::process::Command::new(exe)
104 .arg("--version")
105 .output()
106 .ok()?;
107 if !output.status.success() {
108 return None;
109 }
110 let s = String::from_utf8_lossy(&output.stdout);
111 parse_version(&s)
112 }
113
114 fn which(&self, name: &str) -> Option<PathBuf> {
115 which::which(name).ok()
116 }
117}
118
119fn parse_version(s: &str) -> Option<String> {
121 let line = s.lines().next()?.trim();
122 if line.is_empty() {
123 return None;
124 }
125 let token = line.split_whitespace().last()?;
126 Some(token.to_string())
127}
128
129pub fn list_installed() -> Vec<Installed> {
130 list_installed_with(&RealProbe)
131}
132
133pub fn list_installed_with<P: Probe>(probe: &P) -> Vec<Installed> {
134 #[cfg(target_os = "macos")]
135 {
136 crate::detect::macos::detect(probe)
137 }
138 #[cfg(target_os = "linux")]
139 {
140 crate::detect::linux::detect(probe)
141 }
142 #[cfg(target_os = "windows")]
143 {
144 crate::detect::windows::detect(probe)
145 }
146 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
147 {
148 let _ = probe;
149 Vec::new()
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use std::collections::{HashMap, HashSet};
157
158 #[derive(Default)]
159 struct FakeProbe {
160 existing: HashSet<PathBuf>,
161 path_map: HashMap<String, PathBuf>,
162 versions: HashMap<PathBuf, String>,
163 }
164
165 impl Probe for FakeProbe {
166 fn exists(&self, p: &Path) -> bool {
167 self.existing.contains(p)
168 }
169 fn run_version(&self, exe: &Path) -> Option<String> {
170 self.versions.get(exe).cloned()
171 }
172 fn which(&self, name: &str) -> Option<PathBuf> {
173 self.path_map.get(name).cloned()
174 }
175 }
176
177 #[test]
178 fn kind_parse_roundtrip() {
179 for k in [
180 Kind::Chrome,
181 Kind::Edge,
182 Kind::Chromium,
183 Kind::Brave,
184 Kind::Firefox,
185 ] {
186 assert_eq!(Kind::parse(k.as_str()), Some(k));
187 assert_eq!(k.to_string(), k.as_str());
188 assert_eq!(k.as_str().parse::<Kind>().unwrap(), k);
189 }
190 assert_eq!(Kind::parse("CHROME"), Some(Kind::Chrome));
191 assert_eq!(Kind::parse("Firefox"), Some(Kind::Firefox));
192 assert_eq!(Kind::parse("safari"), None);
193 }
194
195 #[test]
196 fn kind_engine_mapping() {
197 assert_eq!(Kind::Firefox.engine(), Engine::Bidi);
198 assert_eq!(Kind::Chrome.engine(), Engine::Cdp);
199 assert_eq!(Kind::Edge.engine(), Engine::Cdp);
200 assert_eq!(Kind::Chromium.engine(), Engine::Cdp);
201 assert_eq!(Kind::Brave.engine(), Engine::Cdp);
202 assert!(Kind::Chrome.is_chromium());
203 assert!(!Kind::Firefox.is_chromium());
204 }
205
206 #[test]
207 fn parse_version_takes_last_token() {
208 assert_eq!(
209 parse_version("Google Chrome 130.0.6723.91\n").as_deref(),
210 Some("130.0.6723.91")
211 );
212 assert_eq!(
213 parse_version("Mozilla Firefox 131.0").as_deref(),
214 Some("131.0")
215 );
216 assert_eq!(parse_version(""), None);
217 }
218
219 #[cfg(target_os = "macos")]
220 #[test]
221 fn macos_finds_chrome_and_firefox() {
222 let chrome = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
223 let firefox = PathBuf::from("/Applications/Firefox.app/Contents/MacOS/firefox");
224 let mut probe = FakeProbe::default();
225 probe.existing.insert(chrome.clone());
226 probe.existing.insert(firefox.clone());
227 probe
228 .versions
229 .insert(chrome.clone(), "130.0.6723.91".to_string());
230 probe.versions.insert(firefox.clone(), "131.0".to_string());
231
232 let found = super::macos::detect(&probe);
233 assert_eq!(found.len(), 2);
234 let chrome_entry = found.iter().find(|i| i.kind == Kind::Chrome).unwrap();
235 assert_eq!(chrome_entry.executable, chrome);
236 assert_eq!(chrome_entry.version, "130.0.6723.91");
237 assert_eq!(chrome_entry.engine, Engine::Cdp);
238 let ff_entry = found.iter().find(|i| i.kind == Kind::Firefox).unwrap();
239 assert_eq!(ff_entry.executable, firefox);
240 assert_eq!(ff_entry.engine, Engine::Bidi);
241 }
242
243 #[cfg(target_os = "macos")]
244 #[test]
245 fn macos_unknown_version_when_run_fails() {
246 let chrome = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
247 let mut probe = FakeProbe::default();
248 probe.existing.insert(chrome.clone());
249 let found = super::macos::detect(&probe);
250 assert_eq!(found.len(), 1);
251 assert_eq!(found[0].version, "unknown");
252 }
253
254 #[cfg(target_os = "linux")]
255 #[test]
256 fn linux_finds_chrome_via_which_and_firefox_absolute() {
257 let chrome = PathBuf::from("/usr/local/bin/google-chrome");
258 let firefox = PathBuf::from("/usr/bin/firefox");
259 let mut probe = FakeProbe::default();
260 probe
261 .path_map
262 .insert("google-chrome".to_string(), chrome.clone());
263 probe.existing.insert(firefox.clone());
264 probe
265 .versions
266 .insert(chrome.clone(), "130.0.6723.91".to_string());
267 probe.versions.insert(firefox.clone(), "131.0".to_string());
268
269 let found = super::linux::detect(&probe);
270 let chrome_entry = found.iter().find(|i| i.kind == Kind::Chrome).unwrap();
271 assert_eq!(chrome_entry.executable, chrome);
272 assert_eq!(chrome_entry.engine, Engine::Cdp);
273 let ff_entry = found.iter().find(|i| i.kind == Kind::Firefox).unwrap();
274 assert_eq!(ff_entry.executable, firefox);
275 assert_eq!(ff_entry.engine, Engine::Bidi);
276 }
277
278 #[cfg(target_os = "linux")]
279 #[test]
280 fn linux_takes_first_match_per_kind() {
281 let chrome_a = PathBuf::from("/usr/local/bin/google-chrome");
282 let chrome_b = PathBuf::from("/usr/bin/google-chrome");
283 let mut probe = FakeProbe::default();
284 probe
285 .path_map
286 .insert("google-chrome".to_string(), chrome_a.clone());
287 probe.existing.insert(chrome_b.clone());
288 let found = super::linux::detect(&probe);
289 let chrome_entries: Vec<_> = found.iter().filter(|i| i.kind == Kind::Chrome).collect();
290 assert_eq!(chrome_entries.len(), 1);
291 assert_eq!(chrome_entries[0].executable, chrome_a);
292 }
293
294 #[cfg(target_os = "windows")]
295 #[test]
296 fn windows_finds_chrome_and_firefox() {
297 let chrome = PathBuf::from(r"C:\Program Files\Google\Chrome\Application\chrome.exe");
298 let firefox = PathBuf::from(r"C:\Program Files\Mozilla Firefox\firefox.exe");
299 let mut probe = FakeProbe::default();
300 probe.existing.insert(chrome.clone());
301 probe.existing.insert(firefox.clone());
302 probe
303 .versions
304 .insert(chrome.clone(), "130.0.6723.91".to_string());
305 probe.versions.insert(firefox.clone(), "131.0".to_string());
306
307 let found = super::windows::detect(&probe);
308 assert!(found
309 .iter()
310 .any(|i| i.kind == Kind::Chrome && i.executable == chrome));
311 assert!(found
312 .iter()
313 .any(|i| i.kind == Kind::Firefox && i.executable == firefox));
314 }
315}