browser_commander/browser/
system_browser.rs1use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9pub 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
32pub 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
118pub 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
246pub 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}