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 super::browser_profile_files::physical_path;
7use super::browser_profiles::current_platform;
8use super::browser_sources::{
9    browser_sources, current_environment, find_browser_source, resolve_browser_executables,
10    resolve_browser_roots,
11};
12use std::path::{Path, PathBuf};
13
14/// Reject known non-CDP families before profile preparation or process launch.
15pub(crate) fn assert_cdp_browser(channel: &str) -> Result<(), anyhow::Error> {
16    if find_browser_source(channel).is_some_and(|source| source.family != "chromium") {
17        return Err(anyhow::anyhow!(
18            "{channel} does not support CDP; use its documented WebDriver setup when available"
19        ));
20    }
21    Ok(())
22}
23
24/// Return Browser Commander's managed dedicated profile for a channel.
25///
26/// Launches no longer use it on their own (issue #103: a fresh temporary
27/// profile is the default); pass it as `user_data_dir` to keep a persistent
28/// profile between runs.
29pub fn default_real_browser_user_data_dir(channel: &str) -> PathBuf {
30    let directory_name: String = channel
31        .chars()
32        .map(|character| {
33            if character.is_ascii_alphanumeric() || "_.-".contains(character) {
34                character
35            } else {
36                '-'
37            }
38        })
39        .collect();
40    dirs::home_dir()
41        .unwrap_or_else(|| PathBuf::from("."))
42        .join(".browser-commander")
43        .join("real-browser")
44        .join(directory_name)
45}
46
47/// Default user data directories of the installed browsers on this platform.
48///
49/// Chrome 136 and newer ignore remote-debugging switches for these, and a
50/// person's everyday profile is never meant to be exposed over CDP.
51pub fn known_default_user_data_dirs() -> Vec<PathBuf> {
52    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
53
54    let environment = current_environment();
55    browser_sources()
56        .iter()
57        .flat_map(|browser| {
58            resolve_browser_roots(
59                &browser.id,
60                current_platform(),
61                &home.to_string_lossy(),
62                &environment,
63            )
64            .unwrap_or_default()
65        })
66        .collect()
67}
68
69fn normalize_for_comparison(path: &Path) -> Result<PathBuf, anyhow::Error> {
70    let normalized = physical_path(path)?;
71
72    #[cfg(target_os = "windows")]
73    {
74        Ok(PathBuf::from(normalized.to_string_lossy().to_lowercase()))
75    }
76
77    #[cfg(not(target_os = "windows"))]
78    {
79        Ok(normalized)
80    }
81}
82
83/// Ensure Chrome is not asked to expose a known default profile over CDP.
84pub fn assert_dedicated_user_data_dir(user_data_dir: &Path) -> Result<(), anyhow::Error> {
85    let requested = normalize_for_comparison(user_data_dir)?;
86    for default in known_default_user_data_dirs() {
87        if requested.starts_with(normalize_for_comparison(&default)?) {
88            return Err(anyhow::anyhow!(
89                "launch_real_browser requires a dedicated user_data_dir, not a browser default profile"
90            ));
91        }
92    }
93    Ok(())
94}
95
96fn browser_install_candidates(channel: &str) -> Result<Vec<PathBuf>, anyhow::Error> {
97    let source = find_browser_source(channel)
98        .ok_or_else(|| anyhow::anyhow!("unknown browser channel: {channel}"))?;
99    if source.executable_names.is_empty() {
100        return Err(anyhow::anyhow!(
101            "No supported installed executable for {channel}"
102        ));
103    }
104    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
105    resolve_browser_executables(
106        channel,
107        current_platform(),
108        &home.to_string_lossy(),
109        &current_environment(),
110    )
111}
112
113fn is_executable(path: &Path) -> bool {
114    if !path.is_file() {
115        return false;
116    }
117
118    #[cfg(unix)]
119    {
120        use std::os::unix::fs::PermissionsExt;
121        path.metadata()
122            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
123            .unwrap_or(false)
124    }
125
126    #[cfg(not(unix))]
127    {
128        true
129    }
130}
131
132/// Resolve a genuine installed Chrome-family browser executable for a
133/// channel, or check an explicit executable.
134pub fn resolve_browser_executable(
135    channel: &str,
136    executable_path: Option<&Path>,
137) -> Result<PathBuf, anyhow::Error> {
138    let candidates = if let Some(executable_path) = executable_path {
139        vec![normalize_for_comparison(executable_path)?]
140    } else {
141        browser_install_candidates(channel)?
142    };
143
144    for candidate in candidates {
145        if is_executable(&candidate) {
146            return Ok(candidate);
147        }
148    }
149
150    if let Some(executable_path) = executable_path {
151        Err(anyhow::anyhow!(
152            "browser executable is not accessible: {}",
153            executable_path.display()
154        ))
155    } else {
156        Err(anyhow::anyhow!(
157            "could not find an installed {channel} browser; provide executable_path"
158        ))
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::browser::browser_sources::Environment;
166
167    #[cfg(unix)]
168    #[test]
169    fn normalizes_new_profiles_beneath_symlinks() {
170        let root = crate::browser::create_temporary_user_data_dir(None).unwrap();
171        let actual = root.join("default-profile");
172        std::fs::create_dir(&actual).unwrap();
173        let alias = root.join("profile-alias");
174        std::os::unix::fs::symlink(&actual, &alias).unwrap();
175        assert_eq!(
176            normalize_for_comparison(&alias.join("new-profile")).unwrap(),
177            actual.canonicalize().unwrap().join("new-profile")
178        );
179        std::fs::remove_dir_all(root).unwrap();
180    }
181
182    #[test]
183    fn rejects_the_current_platform_default_profiles() {
184        for profile in known_default_user_data_dirs() {
185            let error = assert_dedicated_user_data_dir(&profile).unwrap_err();
186            assert!(error.to_string().contains("dedicated user_data_dir"));
187            assert!(assert_dedicated_user_data_dir(&profile.join("Profile 1")).is_err());
188        }
189        let dedicated = std::env::temp_dir().join("browser-commander-dedicated");
190        assert!(assert_dedicated_user_data_dir(&dedicated).is_ok());
191    }
192
193    #[test]
194    fn all_required_channels_have_discovery_candidates() {
195        for channel in ["chrome", "chromium", "brave", "msedge"] {
196            assert!(!browser_install_candidates(channel).unwrap().is_empty());
197        }
198
199        let chrome = browser_install_candidates("chrome").unwrap();
200        let has_platform_default = if cfg!(target_os = "macos") {
201            chrome.contains(&PathBuf::from(
202                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
203            ))
204        } else if cfg!(target_os = "windows") {
205            chrome.iter().any(|candidate| {
206                candidate.ends_with(Path::new("Google/Chrome/Application/chrome.exe"))
207            })
208        } else {
209            chrome.contains(&PathBuf::from("/usr/bin/google-chrome"))
210        };
211        assert!(has_platform_default);
212    }
213
214    #[test]
215    fn reports_unknown_channels_and_missing_executables() {
216        let error = resolve_browser_executable("netscape", None).unwrap_err();
217        assert!(
218            error.to_string().contains("unknown browser channel"),
219            "{error}"
220        );
221        let error = resolve_browser_executable("chrome", Some(Path::new("/nonexistent/chrome")))
222            .unwrap_err();
223        assert!(error.to_string().contains("not accessible"), "{error}");
224    }
225
226    #[test]
227    fn derives_all_platform_executables_and_aliases_from_the_catalogue() {
228        let env = Environment::from([
229            ("LOCALAPPDATA".into(), "C:\\Local".into()),
230            ("PROGRAMFILES".into(), "C:\\Programs".into()),
231        ]);
232        for browser in browser_sources()
233            .iter()
234            .filter(|entry| entry.family == "chromium")
235        {
236            assert!(!browser.executable_names.is_empty(), "{}", browser.id);
237            for alias in &browser.aliases {
238                assert_eq!(
239                    find_browser_source(alias).unwrap().executable_names,
240                    browser.executable_names
241                );
242            }
243            for platform in browser.roots.keys() {
244                assert!(
245                    !resolve_browser_executables(&browser.id, platform, "/test", &env)
246                        .unwrap()
247                        .is_empty(),
248                    "{} {platform}",
249                    browser.id
250                );
251            }
252        }
253        assert!(
254            resolve_browser_executables("opera", "win32", "C:\\User", &env)
255                .unwrap()
256                .contains(&PathBuf::from("C:\\Local\\Programs\\Opera\\opera.exe"))
257        );
258    }
259}