Skip to main content

chromiumoxide/
detection.rs

1use std::env;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone)]
5pub struct DetectionOptions {
6    /// Detect Microsoft Edge
7    pub msedge: bool,
8    /// Detect unstable installations (beta, dev, unstable)
9    pub unstable: bool,
10    /// Prefer Microsoft Edge over Chrome/Chromium when both are installed.
11    ///
12    /// By default Chrome/Chromium binaries are checked first, so a system with
13    /// both Chrome and Edge resolves to Chrome even when [`Self::msedge`] is
14    /// set. Enabling this checks Edge binaries first and falls back to
15    /// Chrome/Chromium only when no Edge install is found. Has no effect unless
16    /// [`Self::msedge`] is also enabled.
17    pub prefer_msedge: bool,
18}
19
20impl Default for DetectionOptions {
21    fn default() -> Self {
22        Self {
23            msedge: true,
24            unstable: false,
25            prefer_msedge: false,
26        }
27    }
28}
29
30/// Returns the path to Chrome's executable.
31///
32/// The following elements will be checked:
33///   - `CHROME` environment variable, plus `MSEDGE`/`EDGE` when
34///     [`DetectionOptions::msedge`] is enabled
35///   - Usual filenames in the user path
36///   - (Windows) Registry
37///   - (Windows & MacOS) Usual installations paths
38///     If all of the above fail, an error is returned.
39///
40/// When [`DetectionOptions::prefer_msedge`] is enabled, every stage is swept
41/// for an Edge install before the regular Chrome-first pipeline runs, so a
42/// Chrome binary found early (e.g. on the `PATH`) cannot shadow an Edge
43/// install that is only discoverable via the registry or known paths.
44pub fn default_executable(options: DetectionOptions) -> Result<std::path::PathBuf, String> {
45    if let Some(path) = get_by_env_var(&options) {
46        return Ok(path);
47    }
48
49    if options.msedge && options.prefer_msedge {
50        if let Some(path) = get_edge(&options) {
51            return Ok(path);
52        }
53    }
54
55    if let Some(path) = get_by_name(&options) {
56        return Ok(path);
57    }
58
59    #[cfg(windows)]
60    if let Some(path) = get_by_registry(&options) {
61        return Ok(path);
62    }
63
64    if let Some(path) = get_by_path(&options) {
65        return Ok(path);
66    }
67
68    Err("Could not auto detect a chrome executable".to_string())
69}
70
71/// Sweep every detection stage for an Edge install only, in the same stage
72/// order as [`default_executable`]. Environment variables are handled by the
73/// caller so an explicit `CHROME` override still outranks auto-detection.
74fn get_edge(options: &DetectionOptions) -> Option<PathBuf> {
75    if let Some(path) = get_edge_by_name(options) {
76        return Some(path);
77    }
78
79    #[cfg(windows)]
80    if let Some(path) = get_edge_by_registry() {
81        return Some(path);
82    }
83
84    search_paths(&edge_path_candidates(options))
85}
86
87/// Executable path from an environment variable; requires a real file so a
88/// stray variable (e.g. `EDGE` pointing at a directory) is never returned.
89fn env_file(var: &str) -> Option<PathBuf> {
90    let path = env::var(var).ok()?;
91    if Path::new(&path).is_file() {
92        Some(path.into())
93    } else {
94        None
95    }
96}
97
98fn get_by_env_var(options: &DetectionOptions) -> Option<PathBuf> {
99    // Historical behavior: any existing path set via `CHROME` is honored.
100    let chrome = || {
101        let path = env::var("CHROME").ok()?;
102        if Path::new(&path).exists() {
103            Some(PathBuf::from(path))
104        } else {
105            None
106        }
107    };
108    let edge = || {
109        if options.msedge {
110            env_file("MSEDGE").or_else(|| env_file("EDGE"))
111        } else {
112            None
113        }
114    };
115
116    if options.msedge && options.prefer_msedge {
117        edge().or_else(chrome)
118    } else {
119        chrome().or_else(edge)
120    }
121}
122
123#[cfg(feature = "auto-detect-executable")]
124fn chrome_name_candidates(options: &DetectionOptions) -> [(&'static str, bool); 9] {
125    [
126        ("chrome", true),
127        ("chrome-browser", true),
128        ("google-chrome-stable", true),
129        ("google-chrome-beta", options.unstable),
130        ("google-chrome-dev", options.unstable),
131        ("google-chrome-unstable", options.unstable),
132        ("chromium", true),
133        ("chromium-browser", true),
134        ("brave", true),
135    ]
136}
137
138#[cfg(feature = "auto-detect-executable")]
139fn edge_name_candidates(options: &DetectionOptions) -> [(&'static str, bool); 5] {
140    [
141        ("msedge", options.msedge),
142        ("microsoft-edge", options.msedge),
143        ("microsoft-edge-stable", options.msedge),
144        ("microsoft-edge-beta", options.msedge && options.unstable),
145        ("microsoft-edge-dev", options.msedge && options.unstable),
146    ]
147}
148
149/// Ordered list of `which`-resolvable binary names with their allowed flags.
150///
151/// Chrome/Chromium candidates come first by default; when
152/// [`DetectionOptions::prefer_msedge`] is set the Edge candidates are moved
153/// ahead of them. Disallowed entries are retained (flagged `false`) so the
154/// caller skips them — keeping the relative order stable for either branch.
155#[cfg(feature = "auto-detect-executable")]
156fn name_candidates(options: &DetectionOptions) -> Vec<(&'static str, bool)> {
157    let chrome_apps = chrome_name_candidates(options);
158    let edge_apps = edge_name_candidates(options);
159
160    if options.prefer_msedge {
161        edge_apps.into_iter().chain(chrome_apps).collect()
162    } else {
163        chrome_apps.into_iter().chain(edge_apps).collect()
164    }
165}
166
167#[cfg(feature = "auto-detect-executable")]
168fn which_first(candidates: impl IntoIterator<Item = (&'static str, bool)>) -> Option<PathBuf> {
169    for (app, allowed) in candidates {
170        if !allowed {
171            continue;
172        }
173        if let Ok(path) = which::which(app) {
174            return Some(path);
175        }
176    }
177
178    None
179}
180
181#[cfg(feature = "auto-detect-executable")]
182fn get_by_name(options: &DetectionOptions) -> Option<PathBuf> {
183    which_first(name_candidates(options))
184}
185
186#[cfg(not(feature = "auto-detect-executable"))]
187fn get_by_name(_options: &DetectionOptions) -> Option<PathBuf> {
188    None
189}
190
191#[cfg(feature = "auto-detect-executable")]
192fn get_edge_by_name(options: &DetectionOptions) -> Option<PathBuf> {
193    which_first(edge_name_candidates(options))
194}
195
196#[cfg(not(feature = "auto-detect-executable"))]
197fn get_edge_by_name(_options: &DetectionOptions) -> Option<PathBuf> {
198    None
199}
200
201#[allow(unused_variables)]
202fn chrome_path_candidates(options: &DetectionOptions) -> Vec<(&'static str, bool)> {
203    #[cfg(all(unix, not(target_os = "macos")))]
204    return vec![
205        ("/opt/chromium.org/chromium", true),
206        ("/opt/google/chrome", true),
207        // test for lambda
208        ("/tmp/aws/lib", true),
209    ];
210
211    #[cfg(windows)]
212    return Vec::new();
213
214    #[cfg(target_os = "macos")]
215    return vec![
216        (
217            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
218            true,
219        ),
220        (
221            "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
222            options.unstable,
223        ),
224        (
225            "/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
226            options.unstable,
227        ),
228        (
229            "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
230            options.unstable,
231        ),
232        ("/Applications/Chromium.app/Contents/MacOS/Chromium", true),
233    ];
234}
235
236#[allow(unused_variables)]
237fn edge_path_candidates(options: &DetectionOptions) -> Vec<(&'static str, bool)> {
238    #[cfg(all(unix, not(target_os = "macos")))]
239    return Vec::new();
240
241    #[cfg(windows)]
242    return vec![
243        // 64-bit default install location first, then the 32-bit fallback.
244        (
245            r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
246            options.msedge,
247        ),
248        (
249            r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
250            options.msedge,
251        ),
252    ];
253
254    #[cfg(target_os = "macos")]
255    return vec![
256        (
257            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
258            options.msedge,
259        ),
260        (
261            "/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
262            options.msedge && options.unstable,
263        ),
264        (
265            "/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
266            options.msedge && options.unstable,
267        ),
268        (
269            "/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
270            options.msedge && options.unstable,
271        ),
272    ];
273}
274
275fn search_paths(paths: &[(&str, bool)]) -> Option<PathBuf> {
276    for &(path, allowed) in paths {
277        if !allowed {
278            continue;
279        }
280        if Path::new(path).exists() {
281            return Some(path.into());
282        }
283    }
284
285    None
286}
287
288fn get_by_path(options: &DetectionOptions) -> Option<PathBuf> {
289    let chrome_paths = chrome_path_candidates(options);
290    let edge_paths = edge_path_candidates(options);
291
292    if options.prefer_msedge {
293        search_paths(&edge_paths).or_else(|| search_paths(&chrome_paths))
294    } else {
295        search_paths(&chrome_paths).or_else(|| search_paths(&edge_paths))
296    }
297}
298
299/// Resolve an executable from the Windows `App Paths` registry entry,
300/// checking HKLM first and then HKCU.
301#[cfg(windows)]
302fn registry_app_path(exe: &str) -> Option<PathBuf> {
303    let subkey = format!("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\{exe}");
304
305    winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE)
306        .open_subkey(&subkey)
307        .or_else(|_| winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER).open_subkey(&subkey))
308        .and_then(|key| key.get_value::<String, _>(""))
309        .map(PathBuf::from)
310        .ok()
311}
312
313/// Edge's `App Paths` entry, validated on disk so a stale key left behind by
314/// an uninstall cannot shadow a working Chrome fallback.
315#[cfg(windows)]
316fn get_edge_by_registry() -> Option<PathBuf> {
317    registry_app_path("msedge.exe").filter(|path| path.exists())
318}
319
320#[cfg(windows)]
321fn get_by_registry(options: &DetectionOptions) -> Option<PathBuf> {
322    let chrome = || registry_app_path("chrome.exe");
323    let edge = || {
324        if options.msedge {
325            get_edge_by_registry()
326        } else {
327            None
328        }
329    };
330
331    if options.prefer_msedge {
332        edge().or_else(chrome)
333    } else {
334        chrome().or_else(edge)
335    }
336}
337
338#[cfg(all(test, feature = "auto-detect-executable"))]
339mod tests {
340    use super::*;
341
342    fn names(options: &DetectionOptions) -> Vec<&'static str> {
343        name_candidates(options)
344            .into_iter()
345            .map(|(name, _)| name)
346            .collect()
347    }
348
349    /// Regression guard: the default order checks Chrome before Edge, matching
350    /// historical behavior so existing detections resolve identically.
351    #[test]
352    fn default_order_checks_chrome_before_edge() {
353        let order = names(&DetectionOptions::default());
354        let chrome = order.iter().position(|n| *n == "chrome").unwrap();
355        let edge = order.iter().position(|n| *n == "msedge").unwrap();
356        assert!(chrome < edge);
357        assert_eq!(order.first(), Some(&"chrome"));
358    }
359
360    /// `prefer_msedge` moves every Edge candidate ahead of Chrome/Chromium.
361    #[test]
362    fn prefer_msedge_checks_edge_before_chrome() {
363        let options = DetectionOptions {
364            msedge: true,
365            unstable: false,
366            prefer_msedge: true,
367        };
368        let order = names(&options);
369        let chrome = order.iter().position(|n| *n == "chrome").unwrap();
370        let edge = order.iter().position(|n| *n == "msedge").unwrap();
371        assert!(edge < chrome);
372        assert_eq!(order.first(), Some(&"msedge"));
373    }
374
375    /// Reordering only swaps group order — no candidate is dropped, so Chrome
376    /// remains a fallback when no Edge install is present.
377    #[test]
378    fn prefer_msedge_keeps_all_candidates() {
379        let base = DetectionOptions {
380            msedge: true,
381            unstable: true,
382            prefer_msedge: false,
383        };
384        let preferred = DetectionOptions {
385            prefer_msedge: true,
386            ..base.clone()
387        };
388
389        let mut base_sorted = names(&base);
390        let mut preferred_sorted = names(&preferred);
391        base_sorted.sort_unstable();
392        preferred_sorted.sort_unstable();
393
394        assert_eq!(base_sorted, preferred_sorted);
395        assert!(preferred_sorted.contains(&"chrome"));
396        assert!(preferred_sorted.contains(&"brave"));
397    }
398
399    /// The Edge-only sweep never resolves Chrome/Chromium names, and disabling
400    /// [`DetectionOptions::msedge`] disables every Edge candidate.
401    #[test]
402    fn edge_candidates_respect_msedge_flag() {
403        let enabled = DetectionOptions {
404            msedge: true,
405            unstable: false,
406            prefer_msedge: true,
407        };
408        assert!(edge_name_candidates(&enabled)
409            .iter()
410            .all(|(name, _)| name.contains("edge")));
411
412        let disabled = DetectionOptions {
413            msedge: false,
414            unstable: true,
415            prefer_msedge: false,
416        };
417        assert!(edge_name_candidates(&disabled)
418            .iter()
419            .all(|(_, allowed)| !allowed));
420        assert!(edge_path_candidates(&disabled)
421            .iter()
422            .all(|(_, allowed)| !allowed));
423    }
424
425    /// The 64-bit Edge install directory is probed first on Windows, with the
426    /// 32-bit location kept as a fallback.
427    #[test]
428    #[cfg(windows)]
429    fn windows_edge_paths_include_64_bit_install() {
430        let paths: Vec<&str> = edge_path_candidates(&DetectionOptions::default())
431            .into_iter()
432            .map(|(path, _)| path)
433            .collect();
434        assert_eq!(
435            paths.first(),
436            Some(&r"C:\Program Files\Microsoft\Edge\Application\msedge.exe")
437        );
438        assert!(paths.contains(&r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"));
439    }
440}