use std::fmt;
use std::path::{Path, PathBuf};
pub const UNKNOWN_VERSION: &str = "version unavailable";
#[derive(Debug, Clone)]
pub enum BrowserCheck {
Found {
path: PathBuf,
version: String,
},
NotFound {
searched_paths: Vec<PathBuf>,
},
}
impl BrowserCheck {
#[must_use]
pub fn run() -> Self {
let candidates = browser_candidates();
let mut searched = Vec::new();
for path in &candidates {
if let Some(version) = probe_version(path) {
return Self::Found {
path: path.clone(),
version,
};
}
searched.push(path.clone());
}
Self::NotFound {
searched_paths: searched,
}
}
#[must_use]
pub const fn is_found(&self) -> bool {
matches!(self, Self::Found { .. })
}
}
impl fmt::Display for BrowserCheck {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Found { path, version } => {
write!(f, "Chromium found: {} ({})", path.display(), version)
}
Self::NotFound { searched_paths } => {
write!(
f,
"Chromium not found. Searched:\n{}",
searched_paths
.iter()
.map(|p| format!(" {}", p.display()))
.collect::<Vec<_>>()
.join("\n")
)?;
write!(
f,
"\n\nTo install on Ubuntu/Debian: apt-get install chromium-browser\n\
Or set the AUTUMN_CHROMIUM environment variable to the full binary path."
)
}
}
}
}
const PATH_BINARY_NAMES: &[&str] = &[
"chrome",
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
];
#[must_use]
pub fn browser_candidates() -> Vec<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(p) = std::env::var("AUTUMN_CHROMIUM") {
candidates.push(PathBuf::from(p));
}
if let Ok(base) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
let base = PathBuf::from(base);
if let Ok(entries) = std::fs::read_dir(&base) {
let mut pw_paths: Vec<PathBuf> = entries
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with("chromium-"))
.map(|e| {
if cfg!(target_os = "macos") {
e.path()
.join("chrome-mac")
.join("Chromium.app")
.join("Contents")
.join("MacOS")
.join("Chromium")
} else if cfg!(target_os = "windows") {
e.path().join("chrome-win").join("chrome.exe")
} else {
e.path().join("chrome-linux").join("chrome")
}
})
.collect();
pw_paths.sort();
pw_paths.reverse(); candidates.extend(pw_paths);
}
}
candidates.extend(path_candidates(std::env::var_os("PATH").as_deref()));
candidates.extend(well_known_paths());
let mut seen = std::collections::HashSet::new();
candidates.retain(|p| seen.insert(p.clone()));
candidates
}
fn path_candidates(path_var: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
let Some(path_var) = path_var else {
return Vec::new();
};
let mut found = Vec::new();
for name in PATH_BINARY_NAMES {
let file_name = if cfg!(target_os = "windows") {
format!("{name}.exe")
} else {
(*name).to_owned()
};
found.extend(
std::env::split_paths(path_var)
.map(|dir| dir.join(&file_name))
.filter(|p| p.is_file()),
);
}
found
}
fn well_known_paths() -> Vec<PathBuf> {
if cfg!(target_os = "windows") {
["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"]
.iter()
.filter_map(std::env::var_os)
.flat_map(|base| {
let base = PathBuf::from(base);
["Google\\Chrome", "Google\\Chrome Beta", "Chromium"]
.iter()
.map(move |vendor| base.join(vendor).join("Application").join("chrome.exe"))
})
.collect()
} else {
[
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/snap/bin/chromium",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
.iter()
.map(PathBuf::from)
.collect()
}
}
#[must_use]
pub fn find_chromium() -> Option<PathBuf> {
browser_candidates()
.into_iter()
.find(|path| probe_version(path).is_some())
}
#[derive(Debug, PartialEq, Eq)]
enum VersionProbe {
Ran(String),
Failed,
Skipped,
}
fn version_probe_args(user_data_dir: &Path) -> Vec<String> {
vec![
"--version".to_owned(),
format!("--user-data-dir={}", user_data_dir.display()),
]
}
fn unique_probe_dir() -> PathBuf {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
std::env::temp_dir().join(format!("autumn-browser-probe-{}-{n}", std::process::id()))
}
fn looks_like_a_browser_binary(path: &Path) -> bool {
let has_exe_extension = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe"));
let named_like_chrome = path
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.to_ascii_lowercase().contains("chrom"));
has_exe_extension && named_like_chrome
}
fn run_version_probe(path: &Path) -> VersionProbe {
if !path.is_file() {
return VersionProbe::Failed;
}
if cfg!(target_os = "windows") {
return if looks_like_a_browser_binary(path) {
VersionProbe::Skipped
} else {
VersionProbe::Failed
};
}
let probe_profile = unique_probe_dir();
let output = std::process::Command::new(path)
.args(version_probe_args(&probe_profile))
.output();
let _ = std::fs::remove_dir_all(&probe_profile);
match output {
Ok(o) if o.status.success() => {
VersionProbe::Ran(String::from_utf8_lossy(&o.stdout).into_owned())
}
_ => VersionProbe::Failed,
}
}
fn version_from_probe(probe: &VersionProbe) -> Option<String> {
match probe {
VersionProbe::Ran(stdout) => {
let trimmed = stdout.trim();
Some(if trimmed.is_empty() {
UNKNOWN_VERSION.to_owned()
} else {
trimmed.to_owned()
})
}
VersionProbe::Skipped => Some(UNKNOWN_VERSION.to_owned()),
VersionProbe::Failed => None,
}
}
#[must_use]
pub fn probe_version(path: &Path) -> Option<String> {
version_from_probe(&run_version_probe(path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn browser_check_not_found_message_has_hints() {
let check = BrowserCheck::NotFound {
searched_paths: vec![PathBuf::from("/no/such/path")],
};
let msg = check.to_string();
assert!(msg.contains("apt-get") || msg.contains("AUTUMN_CHROMIUM"));
}
#[test]
fn browser_candidates_includes_common_paths() {
let candidates = browser_candidates();
let as_strings: Vec<_> = candidates
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(
as_strings
.iter()
.any(|s| s.contains("chromium") || s.contains("chrome")),
"should have at least one chrome path; got {as_strings:?}"
);
}
#[test]
fn path_scan_keeps_every_match_not_just_the_first() {
let base = std::env::temp_dir().join(format!(
"autumn-path-scan-{}-{}",
std::process::id(),
line!()
));
let stale = base.join("stale");
let good = base.join("good");
std::fs::create_dir_all(&stale).unwrap();
std::fs::create_dir_all(&good).unwrap();
let binary = if cfg!(target_os = "windows") {
"chrome.exe"
} else {
"chrome"
};
std::fs::write(stale.join(binary), b"stale").unwrap();
std::fs::write(good.join(binary), b"good").unwrap();
let joined = std::env::join_paths([&stale, &good]).unwrap();
let found = path_candidates(Some(joined.as_os_str()));
assert_eq!(
found,
vec![stale.join(binary), good.join(binary)],
"both PATH hits must be candidates, in PATH order"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn path_scan_without_a_path_variable_is_empty() {
assert!(path_candidates(None).is_empty());
}
#[test]
fn candidates_are_deduplicated() {
let candidates = browser_candidates();
let mut seen = std::collections::HashSet::new();
for path in &candidates {
assert!(
seen.insert(path.clone()),
"duplicate candidate {path:?} wastes a probe and clutters the \
searched-paths list in the not-found error"
);
}
}
#[test]
fn well_known_paths_target_the_host_platform() {
let paths: Vec<String> = well_known_paths()
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(!paths.is_empty(), "every platform needs candidates");
if cfg!(target_os = "windows") {
assert!(
paths.iter().all(|p| p.ends_with("chrome.exe")),
"Windows candidates must name the real on-disk binary; got {paths:?}"
);
assert!(
paths.iter().any(|p| p.contains("Application")),
"Windows Chrome lives in <base>\\...\\Application\\chrome.exe; got {paths:?}"
);
} else {
assert!(
paths.iter().any(|p| p.starts_with('/')),
"POSIX candidates must be absolute; got {paths:?}"
);
}
}
#[test]
fn version_probe_isolates_the_profile_directory() {
let dir = PathBuf::from("/tmp/probe-profile");
let args = version_probe_args(&dir);
assert!(
args.iter().any(|a| a == "--version"),
"the probe must still ask for the version; got {args:?}"
);
let user_data_dir: Vec<_> = args
.iter()
.filter(|a| a.starts_with("--user-data-dir="))
.collect();
assert_eq!(
user_data_dir.len(),
1,
"the probe must pass exactly one --user-data-dir so it never \
rendezvouses with a Chrome already holding the real profile; \
got {args:?}"
);
assert!(
user_data_dir[0].contains("probe-profile"),
"--user-data-dir must point at the supplied private directory; got {args:?}"
);
}
#[test]
fn probe_dirs_are_never_reused() {
assert_ne!(
unique_probe_dir(),
unique_probe_dir(),
"two probes must not share a directory, or one's cleanup deletes \
the other's profile mid-probe"
);
}
#[test]
fn version_from_probe_prefers_reported_version() {
assert_eq!(
version_from_probe(&VersionProbe::Ran("Chromium 122.0.6261.111\n".to_owned())),
Some("Chromium 122.0.6261.111".to_owned()),
"reported version must be used verbatim (trimmed)"
);
}
#[test]
fn version_from_probe_accepts_a_binary_that_exits_zero_without_output() {
for stdout in ["", " \r\n"] {
assert_eq!(
version_from_probe(&VersionProbe::Ran(stdout.to_owned())),
Some(UNKNOWN_VERSION.to_owned()),
"stdout {stdout:?} exited 0, so the binary is usable"
);
}
}
#[test]
fn version_from_probe_skipped_resolves_without_running_anything() {
assert_eq!(
version_from_probe(&VersionProbe::Skipped),
Some(UNKNOWN_VERSION.to_owned()),
"an existing Windows chrome.exe must resolve as found, not as \
BrowserNotFound"
);
}
#[test]
fn version_from_probe_rejects_a_failed_probe() {
assert_eq!(
version_from_probe(&VersionProbe::Failed),
None,
"a broken binary must not be papered over"
);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn the_probe_actually_passes_its_arguments_to_the_process() {
let echo = ["/bin/echo", "/usr/bin/echo"]
.into_iter()
.map(Path::new)
.find(|p| p.is_file());
let Some(echo) = echo else {
return; };
let reported = probe_version(echo).expect("echo exits 0, so it is 'usable'");
assert!(
reported.contains("--version"),
"the probe must ask for the version; echo saw {reported:?}"
);
assert!(
reported.contains("--user-data-dir="),
"the probe must isolate the profile so a running Chrome cannot \
abort it; echo saw {reported:?}"
);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn a_non_zero_exit_is_not_a_browser() {
let Some(falsy) = ["/bin/false", "/usr/bin/false"]
.into_iter()
.map(Path::new)
.find(|p| p.is_file())
else {
return;
};
assert_eq!(
probe_version(falsy),
None,
"a binary that exits non-zero must not be accepted as a browser"
);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_accepts_an_existing_exe_without_running_it() {
let dir = std::env::temp_dir().join("autumn-browser-detect-test");
std::fs::create_dir_all(&dir).expect("create temp dir");
let fake_chrome = dir.join("chrome.exe");
std::fs::write(&fake_chrome, b"not really chrome").expect("write fake binary");
assert_eq!(
probe_version(&fake_chrome),
Some(UNKNOWN_VERSION.to_owned()),
"an existing chrome.exe must resolve as found, not BrowserNotFound"
);
let not_chrome = dir.join("notepad.exe");
std::fs::write(¬_chrome, b"not a browser").expect("write decoy");
assert_eq!(probe_version(¬_chrome), None);
assert_eq!(
probe_version(Path::new(r"C:\definitely\not\here\chrome.exe")),
None,
"a path that does not exist is still not a browser"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_files_never_probe() {
assert_eq!(
run_version_probe(Path::new("/definitely/not/a/browser/chrome")),
VersionProbe::Failed
);
assert_eq!(
probe_version(Path::new("/definitely/not/a/browser/chrome")),
None
);
}
#[test]
fn windows_candidates_must_look_like_a_browser() {
for real in [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Users\me\AppData\Local\Chromium\Application\chrome.exe",
"chrome.EXE", "CHROMIUM.exe", "chrome-headless-shell.exe",
"google-chrome.exe",
] {
assert!(
looks_like_a_browser_binary(Path::new(real)),
"{real:?} is a browser people legitimately point AUTUMN_CHROMIUM at"
);
}
for not_a_browser in [
"notepad.exe",
r"C:\Windows\System32\cmd.exe",
"chrome.lnk",
"chrome",
"README.md",
] {
assert!(
!looks_like_a_browser_binary(Path::new(not_a_browser)),
"{not_a_browser:?} must not end discovery — a wrong override has \
to fall through to the real candidates, and failing that yield \
BrowserNotFound with its searched-paths list"
);
}
}
}