Skip to main content

browser_automation_cli/
install.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Chrome cache discovery for one-shot launches (no download / no apt).
3//!
4//! PRD: system Chrome/Chromium only — no embedded BrowserFetcher in MVP.
5//!
6//! # Workload
7//!
8//! **I/O-light sequential:** few version directories under XDG browsers cache.
9//! Rayon would cost more than a handful of `read_dir` entries (PAR-71).
10
11use std::fs;
12use std::io::{self, Write};
13use std::path::{Path, PathBuf};
14
15/// Cache directory for optional pre-placed Chrome builds (XDG cache).
16///
17/// Path: `$XDG_CACHE_HOME/browser-automation-cli/browsers` (via `directories`).
18pub fn get_browsers_dir() -> PathBuf {
19    crate::xdg::browsers_dir().unwrap_or_else(|_| {
20        std::env::temp_dir()
21            .join("browser-automation-cli")
22            .join("browsers")
23    })
24}
25
26/// Find a Chrome binary previously placed under the product browsers cache.
27pub fn find_installed_chrome() -> Option<PathBuf> {
28    let browsers_dir = get_browsers_dir();
29    // Debug diagnostics only when stderr is a terminal and --debug was used by the process
30    // (tracing layer). Avoid reading product env vars at runtime.
31    let debug = false;
32
33    if debug {
34        let _ = writeln!(
35            io::stderr(),
36            "[chrome-search] browsers_dir={}",
37            browsers_dir.display()
38        );
39    }
40
41    if !browsers_dir.exists() {
42        if debug {
43            let _ = writeln!(io::stderr(), "[chrome-search] browsers_dir does not exist");
44        }
45        return None;
46    }
47
48    let entries = match fs::read_dir(&browsers_dir) {
49        Ok(entries) => entries,
50        Err(e) => {
51            let _ = writeln!(
52                io::stderr(),
53                "Warning: cannot read Chrome cache directory {}: {}",
54                browsers_dir.display(),
55                e
56            );
57            return None;
58        }
59    };
60
61    let mut versions: Vec<_> = entries
62        .filter_map(|e| e.ok())
63        .filter(|e| {
64            let matches = e
65                .file_name()
66                .to_str()
67                .is_some_and(|n| n.starts_with("chrome-"));
68            if debug {
69                let _ = writeln!(
70                    io::stderr(),
71                    "[chrome-search] entry {:?} matches={}",
72                    e.file_name(),
73                    matches
74                );
75            }
76            matches
77        })
78        .collect();
79
80    versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
81
82    for entry in versions {
83        let dir = entry.path();
84        if let Some(bin) = chrome_binary_in_dir(&dir) {
85            let exists = bin.exists();
86            if debug {
87                let _ = writeln!(
88                    io::stderr(),
89                    "[chrome-search] candidate {} exists={}",
90                    bin.display(),
91                    exists
92                );
93            }
94            if exists {
95                return Some(bin);
96            }
97        } else if debug {
98            let _ = writeln!(
99                io::stderr(),
100                "[chrome-search] no binary found in {}",
101                dir.display()
102            );
103        }
104    }
105
106    if debug {
107        let _ = writeln!(io::stderr(), "[chrome-search] no installed Chrome found");
108    }
109    None
110}
111
112fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
113    #[cfg(target_os = "macos")]
114    {
115        let app =
116            dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
117        if app.exists() {
118            return Some(app);
119        }
120        let inner = dir.join(
121            "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
122        );
123        if inner.exists() {
124            return Some(inner);
125        }
126        let inner_x64 = dir.join(
127            "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
128        );
129        if inner_x64.exists() {
130            return Some(inner_x64);
131        }
132        None
133    }
134
135    #[cfg(target_os = "linux")]
136    {
137        let bin = dir.join("chrome");
138        if bin.exists() {
139            return Some(bin);
140        }
141        let inner = dir.join("chrome-linux64/chrome");
142        if inner.exists() {
143            return Some(inner);
144        }
145        None
146    }
147
148    #[cfg(target_os = "windows")]
149    {
150        let bin = dir.join("chrome.exe");
151        if bin.exists() {
152            return Some(bin);
153        }
154        let inner = dir.join("chrome-win64/chrome.exe");
155        if inner.exists() {
156            return Some(inner);
157        }
158        None
159    }
160
161    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
162    {
163        let _ = dir;
164        None
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::fs;
172
173    #[test]
174    fn get_browsers_dir_ends_with_product_path() {
175        let dir = get_browsers_dir();
176        let s = dir.to_string_lossy();
177        assert!(
178            s.contains("browser-automation-cli") && s.contains("browsers"),
179            "unexpected browsers dir: {s}"
180        );
181    }
182
183    #[test]
184    fn chrome_binary_in_dir_none_for_empty() {
185        let tmp = tempfile::tempdir().unwrap();
186        assert!(chrome_binary_in_dir(tmp.path()).is_none());
187    }
188
189    #[test]
190    #[cfg(target_os = "linux")]
191    fn chrome_binary_in_dir_finds_linux_layout() {
192        let tmp = tempfile::tempdir().unwrap();
193        let nested = tmp.path().join("chrome-linux64");
194        fs::create_dir_all(&nested).unwrap();
195        let bin = nested.join("chrome");
196        fs::write(&bin, b"x").unwrap();
197        assert_eq!(chrome_binary_in_dir(tmp.path()).as_ref(), Some(&bin));
198    }
199}