use std::path::{Path, PathBuf};
pub(crate) trait FsProbe {
fn is_file(&self, path: &Path) -> bool;
fn home_dir(&self) -> Option<PathBuf>;
}
pub(crate) struct StdFsProbe;
impl FsProbe for StdFsProbe {
fn is_file(&self, path: &Path) -> bool {
path.is_file()
}
fn home_dir(&self) -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
}
#[must_use]
pub fn detect() -> Option<PathBuf> {
detect_with(&StdFsProbe)
}
pub(crate) fn detect_with<P: FsProbe>(probe: &P) -> Option<PathBuf> {
if !cfg!(target_os = "macos") {
return None;
}
let candidates = macos_candidates(probe);
let selected = candidates.into_iter().find(|p| probe.is_file(p));
if let Some(path) = selected.as_ref() {
tracing::info!(chrome_path = %path.display(), "selected chromium binary");
}
selected
}
fn macos_candidates<P: FsProbe>(probe: &P) -> Vec<PathBuf> {
let mut paths = vec![
PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
PathBuf::from("/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"),
PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
];
if let Some(home) = probe.home_dir() {
paths.push(home.join("Applications/Google Chrome.app/Contents/MacOS/Google Chrome"));
paths.push(
home.join("Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"),
);
paths.push(home.join("Applications/Chromium.app/Contents/MacOS/Chromium"));
}
paths
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use super::macos_candidates;
use super::{FsProbe, detect_with};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
struct FakeFs {
present: HashSet<PathBuf>,
home: Option<PathBuf>,
}
impl FakeFs {
fn new(paths: &[&str], home: Option<&str>) -> Self {
Self {
present: paths.iter().map(PathBuf::from).collect(),
home: home.map(PathBuf::from),
}
}
}
impl FsProbe for FakeFs {
fn is_file(&self, path: &Path) -> bool {
self.present.contains(path)
}
fn home_dir(&self) -> Option<PathBuf> {
self.home.clone()
}
}
#[cfg(unix)]
#[test]
fn macos_candidate_order_lists_system_apps_then_user_apps() {
let fs = FakeFs::new(&[], Some("/Users/example"));
let candidates = macos_candidates(&fs);
let display: Vec<String> = candidates.iter().map(|p| p.display().to_string()).collect();
assert_eq!(display.len(), 6);
assert!(display[0].contains("/Applications/Google Chrome.app"));
assert!(display[1].contains("/Applications/Google Chrome Canary.app"));
assert!(display[2].contains("/Applications/Chromium.app"));
assert!(display[3].starts_with("/Users/example/Applications/Google Chrome.app"));
assert!(display[4].starts_with("/Users/example/Applications/Google Chrome Canary.app"));
assert!(display[5].starts_with("/Users/example/Applications/Chromium.app"));
}
#[cfg(target_os = "macos")]
#[test]
fn selects_google_chrome_when_present() {
let fs = FakeFs::new(
&["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
None,
);
let selected = detect_with(&fs).expect("Google Chrome present");
assert!(
selected
.display()
.to_string()
.contains("/Applications/Google Chrome.app")
);
}
#[cfg(target_os = "macos")]
#[test]
fn falls_through_to_canary_when_chrome_missing() {
let fs = FakeFs::new(
&["/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"],
None,
);
let selected = detect_with(&fs).expect("Canary present");
assert!(
selected
.display()
.to_string()
.contains("Google Chrome Canary")
);
}
#[cfg(target_os = "macos")]
#[test]
fn falls_through_to_chromium_app_when_chrome_and_canary_missing() {
let fs = FakeFs::new(
&["/Applications/Chromium.app/Contents/MacOS/Chromium"],
None,
);
let selected = detect_with(&fs).expect("Chromium.app present");
assert!(
selected
.display()
.to_string()
.contains("/Applications/Chromium.app/Contents/MacOS/Chromium")
);
}
#[cfg(target_os = "macos")]
#[test]
fn user_chrome_app_beats_user_chromium_app() {
let fs = FakeFs::new(
&[
"/Users/example/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Users/example/Applications/Chromium.app/Contents/MacOS/Chromium",
],
Some("/Users/example"),
);
let selected = detect_with(&fs).expect("user-install present");
assert!(
selected
.display()
.to_string()
.contains("Google Chrome.app/Contents/MacOS/Google Chrome")
);
}
#[cfg(target_os = "macos")]
#[test]
fn system_chrome_beats_user_chromium() {
let fs = FakeFs::new(
&[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Users/example/Applications/Chromium.app/Contents/MacOS/Chromium",
],
Some("/Users/example"),
);
let selected = detect_with(&fs).expect("system Chrome present");
assert!(
selected
.display()
.to_string()
.starts_with("/Applications/Google Chrome.app")
);
}
#[cfg(target_os = "macos")]
#[test]
fn returns_none_when_no_apps_installed() {
let fs = FakeFs::new(&[], Some("/Users/example"));
assert!(detect_with(&fs).is_none());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn non_macos_always_returns_none() {
let fs = FakeFs::new(
&["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
None,
);
assert!(detect_with(&fs).is_none());
}
}