browser_commander/browser/
system_browser.rs1use 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
14pub(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
24pub 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
47pub 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
83pub 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 ¤t_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
132pub 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}