Skip to main content

browser_commander/browser/
system_browser.rs

1//! Discovery of installed Chrome-family browsers and their default profiles.
2//!
3//! Mirrors `js/src/browser/system-browser.js`. Split out of `real_browser`
4//! so the launcher itself stays small.
5
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9/// Return Browser Commander's managed dedicated profile for a channel.
10///
11/// Launches no longer use it on their own (issue #103: a fresh temporary
12/// profile is the default); pass it as `user_data_dir` to keep a persistent
13/// profile between runs.
14pub fn default_real_browser_user_data_dir(channel: &str) -> PathBuf {
15    let directory_name: String = channel
16        .chars()
17        .map(|character| {
18            if character.is_ascii_alphanumeric() || "_.-".contains(character) {
19                character
20            } else {
21                '-'
22            }
23        })
24        .collect();
25    dirs::home_dir()
26        .unwrap_or_else(|| PathBuf::from("."))
27        .join(".browser-commander")
28        .join("real-browser")
29        .join(directory_name)
30}
31
32/// Default user data directories of the installed browsers on this platform.
33///
34/// Chrome 136 and newer ignore remote-debugging switches for these, and a
35/// person's everyday profile is never meant to be exposed over CDP.
36pub fn known_default_user_data_dirs() -> Vec<PathBuf> {
37    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
38
39    #[cfg(target_os = "macos")]
40    {
41        let support = home.join("Library").join("Application Support");
42        return vec![
43            support.join("Google/Chrome"),
44            support.join("Google/Chrome Beta"),
45            support.join("Google/Chrome Canary"),
46            support.join("Google/Chrome Dev"),
47            support.join("Chromium"),
48            support.join("BraveSoftware/Brave-Browser"),
49            support.join("BraveSoftware/Brave-Browser-Beta"),
50            support.join("BraveSoftware/Brave-Browser-Nightly"),
51            support.join("Microsoft Edge"),
52            support.join("Microsoft Edge Beta"),
53            support.join("Microsoft Edge Canary"),
54            support.join("Microsoft Edge Dev"),
55        ];
56    }
57
58    #[cfg(target_os = "windows")]
59    {
60        let local = std::env::var_os("LOCALAPPDATA")
61            .map(PathBuf::from)
62            .unwrap_or_else(|| home.join("AppData/Local"));
63        return vec![
64            local.join("Google/Chrome/User Data"),
65            local.join("Google/Chrome Beta/User Data"),
66            local.join("Google/Chrome Dev/User Data"),
67            local.join("Google/Chrome SxS/User Data"),
68            local.join("Chromium/User Data"),
69            local.join("BraveSoftware/Brave-Browser/User Data"),
70            local.join("BraveSoftware/Brave-Browser-Beta/User Data"),
71            local.join("BraveSoftware/Brave-Browser-Nightly/User Data"),
72            local.join("Microsoft/Edge/User Data"),
73            local.join("Microsoft/Edge Beta/User Data"),
74            local.join("Microsoft/Edge Dev/User Data"),
75            local.join("Microsoft/Edge SxS/User Data"),
76        ];
77    }
78
79    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
80    {
81        vec![
82            home.join(".config/google-chrome"),
83            home.join(".config/google-chrome-beta"),
84            home.join(".config/google-chrome-unstable"),
85            home.join(".config/chromium"),
86            home.join(".config/BraveSoftware/Brave-Browser"),
87            home.join(".config/BraveSoftware/Brave-Browser-Beta"),
88            home.join(".config/BraveSoftware/Brave-Browser-Nightly"),
89            home.join(".config/microsoft-edge"),
90            home.join(".config/microsoft-edge-beta"),
91            home.join(".config/microsoft-edge-dev"),
92        ]
93    }
94}
95
96fn normalize_for_comparison(path: &Path) -> PathBuf {
97    let normalized = std::fs::canonicalize(path).unwrap_or_else(|_| {
98        if path.is_absolute() {
99            path.to_path_buf()
100        } else {
101            std::env::current_dir()
102                .unwrap_or_else(|_| PathBuf::from("."))
103                .join(path)
104        }
105    });
106
107    #[cfg(target_os = "windows")]
108    {
109        return PathBuf::from(normalized.to_string_lossy().to_lowercase());
110    }
111
112    #[cfg(not(target_os = "windows"))]
113    {
114        normalized
115    }
116}
117
118/// Ensure Chrome is not asked to expose a known default profile over CDP.
119pub fn assert_dedicated_user_data_dir(user_data_dir: &Path) -> Result<(), anyhow::Error> {
120    let requested = normalize_for_comparison(user_data_dir);
121    if known_default_user_data_dirs()
122        .iter()
123        .any(|default| normalize_for_comparison(default) == requested)
124    {
125        return Err(anyhow::anyhow!(
126            "launch_real_browser requires a dedicated user_data_dir, not a browser default profile"
127        ));
128    }
129    Ok(())
130}
131
132fn channel_executable_names(channel: &str) -> Result<&'static [&'static str], anyhow::Error> {
133    match channel {
134        "brave" => Ok(&["brave-browser", "brave-browser-stable", "brave"]),
135        "chrome" => Ok(&["google-chrome", "google-chrome-stable", "chrome"]),
136        "chrome-beta" => Ok(&["google-chrome-beta"]),
137        "chrome-canary" => Ok(&["google-chrome-canary"]),
138        "chrome-dev" => Ok(&["google-chrome-unstable"]),
139        "chromium" => Ok(&["chromium", "chromium-browser"]),
140        "msedge" => Ok(&["microsoft-edge", "microsoft-edge-stable", "msedge"]),
141        "msedge-beta" => Ok(&["microsoft-edge-beta"]),
142        "msedge-canary" => Ok(&["microsoft-edge-canary"]),
143        "msedge-dev" => Ok(&["microsoft-edge-dev"]),
144        _ => Err(anyhow::anyhow!(
145            "unknown browser channel: {channel}; expected chrome, chrome-beta, chrome-canary, chrome-dev, chromium, brave, msedge, msedge-beta, msedge-canary, or msedge-dev"
146        )),
147    }
148}
149
150fn browser_install_candidates(channel: &str) -> Result<Vec<PathBuf>, anyhow::Error> {
151    let names = channel_executable_names(channel)?;
152    let mut candidates = Vec::new();
153
154    #[cfg(target_os = "macos")]
155    {
156        let relative = match channel {
157            "brave" => "Brave Browser.app/Contents/MacOS/Brave Browser",
158            "chrome" => "Google Chrome.app/Contents/MacOS/Google Chrome",
159            "chrome-beta" => "Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
160            "chrome-canary" => "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
161            "chrome-dev" => "Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
162            "chromium" => "Chromium.app/Contents/MacOS/Chromium",
163            "msedge" => "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
164            "msedge-beta" => "Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
165            "msedge-canary" => "Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
166            "msedge-dev" => "Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
167            _ => unreachable!("channel was validated above"),
168        };
169        candidates.push(Path::new("/Applications").join(relative));
170        if let Some(home) = dirs::home_dir() {
171            candidates.push(home.join("Applications").join(relative));
172        }
173    }
174
175    #[cfg(target_os = "windows")]
176    {
177        let relative: &[&str] = match channel {
178            "brave" => &["BraveSoftware", "Brave-Browser", "Application", "brave.exe"],
179            "chrome" => &["Google", "Chrome", "Application", "chrome.exe"],
180            "chrome-beta" => &["Google", "Chrome Beta", "Application", "chrome.exe"],
181            "chrome-canary" => &["Google", "Chrome SxS", "Application", "chrome.exe"],
182            "chrome-dev" => &["Google", "Chrome Dev", "Application", "chrome.exe"],
183            "chromium" => &["Chromium", "Application", "chrome.exe"],
184            "msedge" => &["Microsoft", "Edge", "Application", "msedge.exe"],
185            "msedge-beta" => &["Microsoft", "Edge Beta", "Application", "msedge.exe"],
186            "msedge-canary" => &["Microsoft", "Edge SxS", "Application", "msedge.exe"],
187            "msedge-dev" => &["Microsoft", "Edge Dev", "Application", "msedge.exe"],
188            _ => unreachable!("channel was validated above"),
189        };
190        for key in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] {
191            if let Some(root) = std::env::var_os(key) {
192                let mut candidate = PathBuf::from(root);
193                candidate.extend(relative);
194                candidates.push(candidate);
195            }
196        }
197    }
198
199    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
200    {
201        for name in names {
202            candidates.push(Path::new("/usr/bin").join(name));
203            candidates.push(Path::new("/usr/local/bin").join(name));
204        }
205        if channel == "chrome" {
206            candidates.push(PathBuf::from("/opt/google/chrome/google-chrome"));
207        }
208    }
209
210    if let Some(path) = std::env::var_os("PATH") {
211        for directory in std::env::split_paths(&path) {
212            for name in names {
213                #[cfg(target_os = "windows")]
214                let executable_name = format!("{name}.exe");
215                #[cfg(not(target_os = "windows"))]
216                let executable_name = (*name).to_string();
217                candidates.push(directory.join(executable_name));
218            }
219        }
220    }
221
222    let mut seen = HashSet::new();
223    candidates.retain(|candidate| seen.insert(candidate.clone()));
224    Ok(candidates)
225}
226
227fn is_executable(path: &Path) -> bool {
228    if !path.is_file() {
229        return false;
230    }
231
232    #[cfg(unix)]
233    {
234        use std::os::unix::fs::PermissionsExt;
235        path.metadata()
236            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
237            .unwrap_or(false)
238    }
239
240    #[cfg(not(unix))]
241    {
242        true
243    }
244}
245
246/// Resolve a genuine installed Chrome-family browser executable for a
247/// channel, or check an explicit executable.
248pub fn resolve_browser_executable(
249    channel: &str,
250    executable_path: Option<&Path>,
251) -> Result<PathBuf, anyhow::Error> {
252    let candidates = if let Some(executable_path) = executable_path {
253        vec![normalize_for_comparison(executable_path)]
254    } else {
255        browser_install_candidates(channel)?
256    };
257
258    for candidate in candidates {
259        if is_executable(&candidate) {
260            return Ok(candidate);
261        }
262    }
263
264    if let Some(executable_path) = executable_path {
265        Err(anyhow::anyhow!(
266            "browser executable is not accessible: {}",
267            executable_path.display()
268        ))
269    } else {
270        Err(anyhow::anyhow!(
271            "could not find an installed {channel} browser; provide executable_path"
272        ))
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn rejects_the_current_platform_default_profiles() {
282        for profile in known_default_user_data_dirs() {
283            let error = assert_dedicated_user_data_dir(&profile).unwrap_err();
284            assert!(error.to_string().contains("dedicated user_data_dir"));
285        }
286        let dedicated = std::env::temp_dir().join("browser-commander-dedicated");
287        assert!(assert_dedicated_user_data_dir(&dedicated).is_ok());
288    }
289
290    #[test]
291    fn all_required_channels_have_discovery_candidates() {
292        for channel in ["chrome", "chromium", "brave", "msedge"] {
293            assert!(!browser_install_candidates(channel).unwrap().is_empty());
294        }
295
296        let chrome = browser_install_candidates("chrome").unwrap();
297        let has_platform_default = if cfg!(target_os = "macos") {
298            chrome.contains(&PathBuf::from(
299                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
300            ))
301        } else if cfg!(target_os = "windows") {
302            chrome.iter().any(|candidate| {
303                candidate.ends_with(Path::new("Google/Chrome/Application/chrome.exe"))
304            })
305        } else {
306            chrome.contains(&PathBuf::from("/usr/bin/google-chrome"))
307        };
308        assert!(has_platform_default);
309    }
310
311    #[test]
312    fn reports_unknown_channels_and_missing_executables() {
313        let error = resolve_browser_executable("netscape", None).unwrap_err();
314        assert!(
315            error.to_string().contains("unknown browser channel"),
316            "{error}"
317        );
318        let error = resolve_browser_executable("chrome", Some(Path::new("/nonexistent/chrome")))
319            .unwrap_err();
320        assert!(error.to_string().contains("not accessible"), "{error}");
321    }
322}