1use crate::{
2 browser::discovery::{get_executable_version, is_executable, which},
3 error::{CdpError, Result},
4};
5
6#[cfg(target_os = "macos")]
7use crate::browser::discovery::find_app_bundle_for_exec;
8use std::{
9 fs, io,
10 path::{Path, PathBuf},
11 process::{Child, Command, Stdio},
12};
13use tempfile::TempDir;
14use tracing::info;
15
16#[cfg(windows)]
17use std::ffi::OsStr;
18
19#[derive(Debug)]
20pub struct BrowserTypeInfo {
21 pub browser: BrowserType,
22 pub path: PathBuf,
23 pub version: Option<String>,
24}
25
26#[derive(Debug)]
27pub struct LaunchedBrowser {
28 pub browser: BrowserType,
29 pub exec_path: PathBuf,
30 pub user_data_dir: PathBuf,
31 pub debug_port: u16,
32 pub child: Child,
33 _temp_dir: Option<TempDir>,
34}
35
36#[derive(Debug, Clone, Default)]
37pub struct BrowserLaunchOptions {
38 pub disable_image_loading: bool,
39 pub mute_audio: bool,
40 pub incognito: bool,
41 pub user_data_dir: Option<PathBuf>,
42 pub profile_directory: Option<String>,
43 pub extension_paths: Vec<PathBuf>,
44 pub extension_keep_list: Vec<String>,
45 pub remove_default_flags: Vec<String>,
46 pub additional_args: Vec<String>,
47 flag_overrides: Vec<FlagOverride>,
48 pub enable_features: Vec<String>,
49 pub disable_features: Vec<String>,
50 pub force_field_trials: Vec<String>,
51}
52
53#[derive(Debug, Clone)]
54struct FlagOverride {
55 name: String,
56 value: Option<String>,
57}
58
59impl BrowserLaunchOptions {
60 pub fn new() -> Self {
61 Self::default()
62 }
63
64 pub fn add_extension<P: Into<PathBuf>>(&mut self, path: P) {
65 let path = path.into();
66 if !self
67 .extension_paths
68 .iter()
69 .any(|existing| existing == &path)
70 {
71 self.extension_paths.push(path);
72 }
73 }
74
75 pub fn remove_extension<P: AsRef<Path>>(&mut self, path: P) {
76 let target = path.as_ref();
77 self.extension_paths
78 .retain(|existing| existing.as_path() != target);
79 }
80
81 pub fn clear_extensions(&mut self) {
82 self.extension_paths.clear();
83 }
84
85 pub fn disable_extensions_except<I, S>(&mut self, ids: I)
86 where
87 I: IntoIterator<Item = S>,
88 S: Into<String>,
89 {
90 self.extension_keep_list.clear();
91 for id in ids {
92 let value = id.into();
93 if !value.is_empty() && !self.extension_keep_list.contains(&value) {
94 self.extension_keep_list.push(value);
95 }
96 }
97 }
98
99 pub fn add_arg<S: Into<String>>(&mut self, arg: S) {
100 let arg = arg.into();
101 if !self.additional_args.contains(&arg) {
102 self.additional_args.push(arg);
103 }
104 }
105
106 pub fn remove_default_flag<S: Into<String>>(&mut self, flag: S) {
107 let raw = flag.into();
108 let canonical = canonical_switch_name(&raw);
109 if !self
110 .remove_default_flags
111 .iter()
112 .any(|existing| existing == &canonical)
113 {
114 self.remove_default_flags.push(canonical);
115 }
116 }
117
118 pub fn set_switch_flag<S: Into<String>>(&mut self, switch: S) {
119 let raw = switch.into();
120 let canonical = canonical_switch_name(&raw);
121 self.upsert_switch(canonical, None);
122 }
123
124 pub fn set_switch_value<S, V>(&mut self, switch: S, value: V)
125 where
126 S: Into<String>,
127 V: Into<String>,
128 {
129 let raw = switch.into();
130 let canonical = canonical_switch_name(&raw);
131 self.upsert_switch(canonical, Some(value.into()));
132 }
133
134 pub fn clear_switch<S: Into<String>>(&mut self, switch: S) {
135 let raw = switch.into();
136 let canonical = canonical_switch_name(&raw);
137 self.flag_overrides.retain(|flag| flag.name != canonical);
138 }
139
140 fn upsert_switch(&mut self, name: String, value: Option<String>) {
141 if let Some(existing) = self
142 .flag_overrides
143 .iter_mut()
144 .find(|flag| flag.name == name)
145 {
146 existing.value = value;
147 } else {
148 self.flag_overrides.push(FlagOverride { name, value });
149 }
150 }
151
152 pub fn enable_feature<S: Into<String>>(&mut self, feature: S) {
153 let feature = feature.into();
154 if !feature.is_empty() && !self.enable_features.contains(&feature) {
155 self.enable_features.push(feature);
156 }
157 }
158
159 pub fn disable_feature<S: Into<String>>(&mut self, feature: S) {
160 let feature = feature.into();
161 if !feature.is_empty() && !self.disable_features.contains(&feature) {
162 self.disable_features.push(feature);
163 }
164 }
165
166 pub fn force_field_trial<S: Into<String>>(&mut self, trial: S) {
167 let trial = trial.into();
168 if !trial.is_empty() && !self.force_field_trials.contains(&trial) {
169 self.force_field_trials.push(trial);
170 }
171 }
172
173 pub fn has_override<S: AsRef<str>>(&self, switch: S) -> bool {
174 let canonical = canonical_switch_name(switch.as_ref());
175 self.flag_overrides
176 .iter()
177 .any(|flag| flag.name == canonical)
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum BrowserType {
183 Chrome,
184 Chromium,
185 Edge,
186}
187
188#[cfg(target_os = "macos")]
189const CHROME_EXPECTED_EXEC_PATHS: &[&str] =
190 &["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"];
191#[cfg(target_os = "windows")]
192const CHROME_EXPECTED_EXEC_PATHS: &[&str] = &[
193 r"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
194 r"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
195];
196#[cfg(all(unix, not(target_os = "macos")))]
197const CHROME_EXPECTED_EXEC_PATHS: &[&str] = &["/usr/bin/google-chrome", "/usr/bin/chrome"];
198#[cfg(not(any(
199 target_os = "macos",
200 target_os = "windows",
201 all(unix, not(target_os = "macos"))
202)))]
203const CHROME_EXPECTED_EXEC_PATHS: &[&str] = &[];
204
205#[cfg(target_os = "macos")]
206const CHROMIUM_EXPECTED_EXEC_PATHS: &[&str] =
207 &["/Applications/Chromium.app/Contents/MacOS/Chromium"];
208#[cfg(target_os = "windows")]
209const CHROMIUM_EXPECTED_EXEC_PATHS: &[&str] = &[
210 r"C:\\Program Files\\Chromium\\Application\\chrome.exe",
211 r"C:\\Program Files (x86)\\Chromium\\Application\\chrome.exe",
212];
213#[cfg(all(unix, not(target_os = "macos")))]
214const CHROMIUM_EXPECTED_EXEC_PATHS: &[&str] = &["/usr/bin/chromium", "/usr/bin/chromium-browser"];
215#[cfg(not(any(
216 target_os = "macos",
217 target_os = "windows",
218 all(unix, not(target_os = "macos"))
219)))]
220const CHROMIUM_EXPECTED_EXEC_PATHS: &[&str] = &[];
221
222#[cfg(target_os = "macos")]
223const EDGE_EXPECTED_EXEC_PATHS: &[&str] =
224 &["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"];
225#[cfg(target_os = "windows")]
226const EDGE_EXPECTED_EXEC_PATHS: &[&str] = &[
227 r"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
228 r"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
229];
230#[cfg(all(unix, not(target_os = "macos")))]
231const EDGE_EXPECTED_EXEC_PATHS: &[&str] =
232 &["/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable"];
233#[cfg(not(any(
234 target_os = "macos",
235 target_os = "windows",
236 all(unix, not(target_os = "macos"))
237)))]
238const EDGE_EXPECTED_EXEC_PATHS: &[&str] = &[];
239
240const DEFAULT_FLAGS: &[&str] = &[
241 "--no-first-run",
242 "--no-default-browser-check",
243 "--disable-default-apps",
244 "--disable-extensions",
245 "--disable-component-extensions-with-background-pages",
246 "--disable-background-networking",
247 "--disable-sync",
248 "--disable-translate",
249 "--metrics-recording-only",
250 "--safebrowsing-disable-auto-update",
251];
252
253impl BrowserType {
254 pub fn as_str(&self) -> &'static str {
255 match self {
256 BrowserType::Chrome => "chrome",
257 BrowserType::Chromium => "chromium",
258 BrowserType::Edge => "edge",
259 }
260 }
261
262 pub fn installed_variants() -> Vec<BrowserTypeInfo> {
263 const ORDER: [BrowserType; 3] = [
264 BrowserType::Chrome,
265 BrowserType::Chromium,
266 BrowserType::Edge,
267 ];
268
269 ORDER
270 .iter()
271 .filter_map(|browser| {
272 browser
273 .find_browser_executable()
274 .map(|path| BrowserTypeInfo {
275 browser: *browser,
276 version: get_executable_version(&path).ok(),
277 path,
278 })
279 })
280 .collect()
281 }
282
283 pub fn launch(&self, debug_port: u16) -> Result<LaunchedBrowser> {
285 self.launch_with_options(debug_port, BrowserLaunchOptions::default())
286 }
287
288 pub fn launch_with_options(
290 &self,
291 debug_port: u16,
292 options: BrowserLaunchOptions,
293 ) -> Result<LaunchedBrowser> {
294 let exec_path = self.find_browser_executable().ok_or_else(|| {
295 CdpError::tool(format!(
296 "Browser executable for '{}' not found",
297 self.as_str()
298 ))
299 })?;
300
301 for extension_path in &options.extension_paths {
302 let metadata = fs::metadata(extension_path).map_err(|err| {
303 CdpError::tool(format!(
304 "Failed to access extension '{}': {err}",
305 extension_path.display()
306 ))
307 })?;
308
309 if !metadata.is_dir() {
310 return Err(CdpError::tool(format!(
311 "Extension path '{}' is not a directory",
312 extension_path.display()
313 )));
314 }
315 }
316
317 let (user_data_dir, temp_dir_guard) = match options.user_data_dir.clone() {
318 Some(custom_dir) => {
319 fs::create_dir_all(&custom_dir).map_err(|err| {
320 CdpError::tool(format!(
321 "Failed to create user-data-dir '{}': {err}",
322 custom_dir.display()
323 ))
324 })?;
325 (custom_dir, None)
326 }
327 None => {
328 let tempdir = tempfile::Builder::new()
329 .prefix(&format!("{}-remote-", self.as_str()))
330 .tempdir()
331 .map_err(|err| {
332 CdpError::tool(format!("Failed to create temporary user-data-dir: {err}"))
333 })?;
334 (tempdir.path().to_path_buf(), Some(tempdir))
335 }
336 };
337
338 let args = build_launch_args(debug_port, &user_data_dir, &options);
339
340 info!("launching {:?} with args {:?}", exec_path, args);
341
342 let child = spawn_browser(&exec_path, &args).map_err(|err| {
343 CdpError::tool(format!("Failed to launch {}: {err}", exec_path.display()))
344 })?;
345
346 std::thread::sleep(std::time::Duration::from_secs(3));
347 Ok(LaunchedBrowser {
348 browser: *self,
349 exec_path,
350 user_data_dir,
351 debug_port,
352 child,
353 _temp_dir: temp_dir_guard,
354 })
355 }
356
357 pub fn find_browser_executable(&self) -> Option<PathBuf> {
358 for candidate in self.candidates_for_browser() {
359 let path = Path::new(candidate);
360 if path.is_absolute() {
361 if path.exists() && is_executable(path) {
362 return Some(path.to_path_buf());
363 }
364 } else if let Some(resolved) = which(candidate) {
365 return Some(resolved);
366 }
367 }
368
369 for name in self.generic_names() {
370 if let Some(path) = which(name) {
371 return Some(path);
372 }
373 }
374
375 None
376 }
377
378 pub fn expected_exec_paths(&self) -> &'static [&'static str] {
379 match self {
380 BrowserType::Chrome => CHROME_EXPECTED_EXEC_PATHS,
381 BrowserType::Chromium => CHROMIUM_EXPECTED_EXEC_PATHS,
382 BrowserType::Edge => EDGE_EXPECTED_EXEC_PATHS,
383 }
384 }
385
386 fn candidates_for_browser(&self) -> Vec<&'static str> {
387 #[allow(unused_mut)]
388 let mut candidates = self.expected_exec_paths().to_vec();
389 match self {
390 BrowserType::Chrome => {
391 #[cfg(all(unix, not(target_os = "macos")))]
392 {
393 candidates.push("google-chrome");
394 }
395 }
396 BrowserType::Chromium => {
397 #[cfg(all(unix, not(target_os = "macos")))]
398 {
399 candidates.push("chromium");
400 }
401 }
402 BrowserType::Edge => {
403 #[cfg(all(unix, not(target_os = "macos")))]
404 {
405 candidates.push("microsoft-edge");
406 }
407 }
408 }
409 candidates
410 }
411
412 fn generic_names(&self) -> &'static [&'static str] {
413 match self {
414 BrowserType::Chrome => &["google-chrome", "google-chrome-stable", "chrome"],
415 BrowserType::Chromium => &["chromium", "chromium-browser"],
416 BrowserType::Edge => &["microsoft-edge", "microsoft-edge-stable", "msedge"],
417 }
418 }
419}
420
421fn push_unique(args: &mut Vec<String>, arg: String) {
422 if !args.iter().any(|existing| existing == &arg) {
423 args.push(arg);
424 }
425}
426
427fn format_switch(name: &str, value: Option<&String>) -> String {
428 match value {
429 Some(v) => format!("{name}={v}"),
430 None => name.to_string(),
431 }
432}
433
434fn canonical_switch_name(value: &str) -> String {
435 let trimmed = value.trim();
436 let without_value = match trimmed.split_once('=') {
437 Some((name, _)) => name.trim(),
438 None => trimmed,
439 };
440 let without_prefix = without_value.trim_start_matches('-');
441 if without_prefix.is_empty() {
442 "--".to_string()
443 } else {
444 format!("--{}", without_prefix)
445 }
446}
447
448fn build_launch_args(
449 debug_port: u16,
450 user_data_dir: &Path,
451 options: &BrowserLaunchOptions,
452) -> Vec<String> {
453 let mut args = Vec::new();
454
455 push_unique(&mut args, format!("--remote-debugging-port={}", debug_port));
456 push_unique(
457 &mut args,
458 format!("--user-data-dir={}", user_data_dir.to_string_lossy()),
459 );
460
461 let mut defaults: Vec<String> = DEFAULT_FLAGS
462 .iter()
463 .map(|flag| (*flag).to_string())
464 .collect();
465 defaults.retain(|flag| {
466 let canonical = canonical_switch_name(flag);
467 if options
468 .remove_default_flags
469 .iter()
470 .any(|remove| remove == &canonical)
471 {
472 return false;
473 }
474 if options
475 .flag_overrides
476 .iter()
477 .any(|override_flag| override_flag.name == canonical)
478 {
479 return false;
480 }
481 true
482 });
483
484 if !options.extension_paths.is_empty() || !options.extension_keep_list.is_empty() {
485 let disable_extensions_key = canonical_switch_name("--disable-extensions");
486 let disable_component_key =
487 canonical_switch_name("--disable-component-extensions-with-background-pages");
488 defaults.retain(|flag| {
489 let canonical = canonical_switch_name(flag);
490 canonical != disable_extensions_key && canonical != disable_component_key
491 });
492 }
493
494 args.extend(defaults);
495
496 if options.disable_image_loading {
497 push_unique(
498 &mut args,
499 "--blink-settings=imagesEnabled=false".to_string(),
500 );
501 }
502 if options.mute_audio {
503 push_unique(&mut args, "--mute-audio".to_string());
504 }
505 if options.incognito {
506 push_unique(&mut args, "--incognito".to_string());
507 }
508 if let Some(profile_directory) = options.profile_directory.as_ref()
509 && !profile_directory.trim().is_empty()
510 && !options.has_override("--profile-directory")
511 {
512 push_unique(
513 &mut args,
514 format!("--profile-directory={}", profile_directory.trim()),
515 );
516 }
517 if !options.extension_paths.is_empty() && !options.has_override("--load-extension") {
518 let joined = options
519 .extension_paths
520 .iter()
521 .map(|path| path.to_string_lossy().to_string())
522 .collect::<Vec<_>>()
523 .join(",");
524 if !joined.is_empty() {
525 push_unique(&mut args, format!("--load-extension={joined}"));
526 }
527 }
528 if !options.extension_keep_list.is_empty()
529 && !options.has_override("--disable-extensions-except")
530 {
531 let joined = options.extension_keep_list.join(",");
532 if !joined.is_empty() {
533 push_unique(&mut args, format!("--disable-extensions-except={joined}"));
534 }
535 }
536 if !options.enable_features.is_empty() && !options.has_override("--enable-features") {
537 let joined = options.enable_features.join(",");
538 if !joined.is_empty() {
539 push_unique(&mut args, format!("--enable-features={joined}"));
540 }
541 }
542 if !options.disable_features.is_empty() && !options.has_override("--disable-features") {
543 let joined = options.disable_features.join(",");
544 if !joined.is_empty() {
545 push_unique(&mut args, format!("--disable-features={joined}"));
546 }
547 }
548 if !options.force_field_trials.is_empty() && !options.has_override("--force-fieldtrials") {
549 let joined = options.force_field_trials.join(",");
550 if !joined.is_empty() {
551 push_unique(&mut args, format!("--force-fieldtrials={joined}"));
552 }
553 }
554
555 for override_switch in &options.flag_overrides {
556 let formatted = format_switch(&override_switch.name, override_switch.value.as_ref());
557 push_unique(&mut args, formatted);
558 }
559
560 for arg in &options.additional_args {
561 push_unique(&mut args, arg.clone());
562 }
563
564 push_unique(&mut args, "about:blank".to_string());
565 args
566}
567
568#[cfg(target_os = "macos")]
569fn spawn_browser(exec_path: &Path, args: &[String]) -> Result<Child> {
570 if let Some(app_bundle) = find_app_bundle_for_exec(exec_path) {
571 let mut cmd = Command::new("open");
572 cmd.arg("-a").arg(app_bundle).arg("--args");
573 cmd.args(args)
574 .stdin(Stdio::null())
575 .stdout(Stdio::null())
576 .stderr(Stdio::null());
577 return cmd.spawn().map_err(|err| {
578 CdpError::tool(format!(
579 "Failed to launch {} via open: {err}",
580 exec_path.display()
581 ))
582 });
583 }
584
585 spawn_direct(exec_path, args).map_err(|err| {
586 CdpError::tool(format!(
587 "Failed to launch {} directly: {err}",
588 exec_path.display()
589 ))
590 })
591}
592
593#[cfg(target_os = "windows")]
594fn spawn_browser(exec_path: &Path, args: &[String]) -> Result<Child> {
595 match spawn_direct(exec_path, args) {
596 Ok(child) => Ok(child),
597 Err(primary_err) => {
598 let mut cmd = Command::new("cmd");
599 cmd.arg("/C")
600 .arg("start")
601 .arg("")
602 .arg(exec_path.as_os_str());
603 cmd.args(args)
604 .stdin(Stdio::null())
605 .stdout(Stdio::null())
606 .stderr(Stdio::null());
607 cmd.spawn().map_err(|fallback_err| {
608 CdpError::tool(format!(
609 "Failed to launch {} directly ({primary_err}) and via cmd start ({fallback_err})",
610 exec_path.display()
611 ))
612 })
613 }
614 }
615}
616
617#[cfg(all(unix, not(target_os = "macos")))]
618fn spawn_browser(exec_path: &Path, args: &[String]) -> Result<Child> {
619 spawn_direct(exec_path, args)
620 .map_err(|err| CdpError::tool(format!("Failed to launch {}: {err}", exec_path.display())))
621}
622
623fn spawn_direct(exec_path: &Path, args: &[String]) -> io::Result<Child> {
624 let mut cmd = Command::new(exec_path);
625 cmd.args(args)
626 .stdin(Stdio::null())
627 .stdout(Stdio::null())
628 .stderr(Stdio::null());
629 cmd.spawn()
630}