cdp_core/browser/
launcher.rs

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    /// Launch a browser with a fresh user-data-dir and remote debugging enabled.
284    pub fn launch(&self, debug_port: u16) -> Result<LaunchedBrowser> {
285        self.launch_with_options(debug_port, BrowserLaunchOptions::default())
286    }
287
288    /// Launch a browser with custom launch options applied.
289    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}