Skip to main content

browser_commander/browser/
real_browser.rs

1//! Launch genuine installed Chrome-family browsers and attach over CDP.
2//!
3//! The browser is started exactly like a person who wants to attach a
4//! debugger would start it (issues #101 and #103):
5//!
6//! ```text
7//! chrome --user-data-dir=<fresh temporary profile> --remote-debugging-port=<reserved port> about:blank
8//! ```
9//!
10//! and nothing else. The port is a fixed one reserved on loopback (port 0 and
11//! `--remote-debugging-pipe` make Chrome turn `AutomationControlled` on), its
12//! ownership is confirmed from the browser's own `DevTools listening on ...`
13//! line before the endpoint is trusted, and a lost race is retried on a new
14//! port. Every switch the library used to add on its own is an explicit
15//! opt-in through [`RealBrowserOptions::restrictions`].
16
17use std::collections::HashMap;
18use std::future::Future;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::Duration;
22
23use anyhow::{anyhow, Result};
24use async_trait::async_trait;
25use chromiumoxide::Browser as CdpBrowser;
26use futures::StreamExt;
27use serde_json::Value;
28
29use crate::browser::browser_process::{BrowserCloser, BrowserProcess};
30use crate::browser::cdp_endpoint::{wait_for_cdp_endpoint, CdpEndpointRequest};
31use crate::browser::connector::{connect_browser, ConnectOptions};
32use crate::browser::debugging_port::{
33    assert_fixed_debugging_port, reserve_loopback_port, DevToolsOutputWatcher, PortRaceError,
34};
35use crate::browser::launcher::Browser;
36use crate::browser::migration::{
37    migrate_profile, MigrateProfileOptions, MigrationSource, MigrationSummary,
38};
39use crate::browser::profile_directory::{
40    configure_user_data_dir_for_profile, create_temporary_user_data_dir_with_first_run,
41    prepare_user_data_dir_with_first_run, remove_user_data_dir,
42};
43use crate::browser::restrictions::{merge_feature_switches, resolve_restrictions};
44use crate::browser::storage_state::StorageStateInput;
45use crate::core::engine::{EngineAdapter, EngineType};
46use crate::downloads::{DownloadManager, DownloadSetting};
47use crate::fingerprint::automation_parity::{
48    apply_automation_parity_args, detect_automation_controlled_triggers,
49};
50use crate::utilities::{start_process, StartProcessOptions};
51
52use crate::browser::system_browser::{assert_cdp_browser, resolve_browser_executable};
53pub use crate::browser::system_browser::{
54    assert_dedicated_user_data_dir, default_real_browser_user_data_dir,
55};
56
57const MANAGED_ARGUMENTS: [&str; 4] = [
58    "--remote-debugging-address",
59    "--remote-debugging-port",
60    "--remote-debugging-pipe",
61    "--user-data-dir",
62];
63
64/// Ports tried when the launcher reserves the port itself.
65pub const DEFAULT_PORT_ATTEMPTS: u32 = 3;
66
67/// How long [`RealBrowserLaunchResult::close`] waits for each shutdown step.
68pub const DEFAULT_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
69
70/// How long a browser that lost a port race gets to exit before the retry.
71const RACE_EXIT_WAIT: Duration = Duration::from_secs(5);
72
73const FANTOCCINI_OVER_CDP: &str =
74    "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer";
75
76/// Options for launching an installed browser and attaching over CDP.
77#[derive(Debug, Clone)]
78pub struct RealBrowserOptions {
79    /// Browser Commander engine used after the browser starts.
80    pub engine: EngineType,
81    /// Installed Chrome-family channel to discover.
82    pub channel: String,
83    /// Explicit installed-browser executable, bypassing channel discovery.
84    pub executable_path: Option<PathBuf>,
85    /// Dedicated, non-default browser profile. When `None` a fresh temporary
86    /// profile is created for the launch and deleted when the browser exits.
87    pub user_data_dir: Option<PathBuf>,
88    /// Profile whose Preferences are seeded before launch.
89    pub profile_directory: String,
90    /// Whether to ask to become the OS default browser. Defaults to false.
91    pub default_browser_check: Option<bool>,
92    /// Allow the browser's first-run flow in a fresh profile.
93    pub first_run: bool,
94    /// JSON object deep-merged into Default/Preferences before launch.
95    pub preferences: Value,
96    /// JSON object deep-merged into Local State before launch.
97    pub local_state: Value,
98    /// Playwright-compatible cookie and localStorage state to restore.
99    pub storage_state: Option<StorageStateInput>,
100    /// Fixed loopback CDP port. When `None` a free port is reserved (and a
101    /// lost port race retried). Zero is refused: it makes Chrome enable
102    /// `AutomationControlled`.
103    pub remote_debugging_port: Option<u16>,
104    /// Ports to try when the port is reserved by the launcher.
105    pub port_attempts: u32,
106    /// Run the installed browser headlessly (`--headless=new`).
107    pub headless: bool,
108    /// Opt-in restrictions from the shared catalogue, such as
109    /// `no-extensions` or the `legacy-defaults` preset. See
110    /// [`restrictions`](super::restrictions).
111    pub restrictions: Vec<String>,
112    /// Additional browser arguments.
113    pub args: Vec<String>,
114    /// Additional browser arguments appended after the compatibility `args`.
115    pub extra_args: Vec<String>,
116    /// Ignored since issue #103: there are no Browser Commander defaults left
117    /// to omit. Kept so existing code compiles.
118    pub ignore_default_args: Vec<String>,
119    /// Ignored since issue #103; see [`ignore_default_args`](Self::ignore_default_args).
120    pub ignore_all_default_args: bool,
121    /// Extra environment for the browser process only. The caller's process
122    /// environment is never modified.
123    pub env: Option<HashMap<String, String>>,
124    /// Add `--disable-blink-features=AutomationControlled` when the command
125    /// line contains a switch that would turn `navigator.webdriver` on (such
126    /// as a caller-supplied `--enable-automation`). A plain launch, headful or
127    /// headless, has none, so its command line stays exactly as typed.
128    pub automation_parity: bool,
129    /// Maximum time to wait for Chrome's DevTools endpoint.
130    pub startup_timeout: Duration,
131    /// Time allowed for browser exit before [`RealBrowserLaunchResult::close`] kills it.
132    pub close_timeout: Duration,
133    /// Delay Playwright/Puppeteer operations by this many milliseconds.
134    pub slow_mo: u64,
135    /// Optional connection timeout.
136    pub timeout: Option<Duration>,
137    /// Optional Puppeteer timeout for individual CDP calls.
138    pub protocol_timeout: Option<Duration>,
139    /// Cookies to seed immediately after attaching.
140    pub seed_cookies: Vec<Value>,
141    /// Read-only source profile to copy into the dedicated profile before launch.
142    pub migrate_from: Option<MigrationSource>,
143    /// Data classes to copy. `None` selects every supported class.
144    pub migrate_include: Option<Vec<String>>,
145    /// Host/subdomain filters for migrated per-site data.
146    pub migrate_domains: Vec<String>,
147    /// Explicit Safari/Passwords CSV export to import before launching.
148    pub migrate_password_csv: Option<PathBuf>,
149    /// Separate explicit consent to import payment cards before launching.
150    pub migrate_include_payment_cards: bool,
151    /// Enable browser and connector logging; the browser's output is mirrored.
152    pub verbose: bool,
153    /// Node.js executable for Playwright/Puppeteer bridge engines.
154    pub node_executable: Option<PathBuf>,
155    /// Directory where Node resolves Playwright/Puppeteer.
156    pub node_working_dir: Option<PathBuf>,
157    /// Manage the installed browser's downloads.
158    ///
159    /// The same setting and the same manager as
160    /// [`LaunchOptions`](super::launcher::LaunchOptions) and
161    /// [`ConnectOptions`], which is what makes a download started by hand in a
162    /// visible window land where an automated one does. See
163    /// [`downloads`](crate::downloads).
164    pub downloads: DownloadSetting,
165}
166
167impl Default for RealBrowserOptions {
168    fn default() -> Self {
169        Self {
170            engine: EngineType::Chromiumoxide,
171            channel: "chrome".to_string(),
172            executable_path: None,
173            user_data_dir: None,
174            profile_directory: "Default".into(),
175            default_browser_check: None,
176            first_run: false,
177            preferences: serde_json::json!({}),
178            local_state: serde_json::json!({}),
179            storage_state: None,
180            remote_debugging_port: None,
181            port_attempts: DEFAULT_PORT_ATTEMPTS,
182            headless: false,
183            restrictions: Vec::new(),
184            args: Vec::new(),
185            extra_args: Vec::new(),
186            ignore_default_args: Vec::new(),
187            ignore_all_default_args: false,
188            env: None,
189            automation_parity: true,
190            startup_timeout: Duration::from_secs(30),
191            close_timeout: DEFAULT_CLOSE_TIMEOUT,
192            slow_mo: 0,
193            timeout: None,
194            protocol_timeout: None,
195            seed_cookies: Vec::new(),
196            migrate_from: None,
197            migrate_include: None,
198            migrate_domains: Vec::new(),
199            migrate_password_csv: None,
200            migrate_include_payment_cards: false,
201            verbose: false,
202            node_executable: None,
203            node_working_dir: None,
204            downloads: DownloadSetting::Off,
205        }
206    }
207}
208
209impl RealBrowserOptions {
210    /// Create native Chromiumoxide options.
211    pub fn chromiumoxide() -> Self {
212        Self::default()
213    }
214
215    /// Create Playwright bridge options.
216    pub fn playwright() -> Self {
217        Self {
218            engine: EngineType::Playwright,
219            ..Self::default()
220        }
221    }
222
223    /// Create Puppeteer bridge options.
224    pub fn puppeteer() -> Self {
225        Self {
226            engine: EngineType::Puppeteer,
227            ..Self::default()
228        }
229    }
230
231    /// Select an installed browser channel.
232    pub fn channel(mut self, channel: impl Into<String>) -> Self {
233        self.channel = channel.into();
234        self
235    }
236
237    /// Select an explicit installed-browser executable.
238    pub fn executable_path(mut self, executable_path: impl Into<PathBuf>) -> Self {
239        self.executable_path = Some(executable_path.into());
240        self
241    }
242
243    /// Select a dedicated browser profile instead of a temporary one.
244    pub fn user_data_dir(mut self, user_data_dir: impl Into<PathBuf>) -> Self {
245        self.user_data_dir = Some(user_data_dir.into());
246        self
247    }
248    /// Restore portable cookies and origin-scoped localStorage after connecting.
249    pub fn storage_state(mut self, state: impl Into<StorageStateInput>) -> Self {
250        self.storage_state = Some(state.into());
251        self
252    }
253    /// Use a fixed loopback CDP port instead of a reserved one. Zero is
254    /// refused at launch.
255    pub fn remote_debugging_port(mut self, port: u16) -> Self {
256        self.remote_debugging_port = Some(port);
257        self
258    }
259
260    /// Set how many reserved ports to try when another process takes one.
261    pub fn port_attempts(mut self, attempts: u32) -> Self {
262        self.port_attempts = attempts;
263        self
264    }
265
266    /// Enable or disable headless mode.
267    pub fn headless(mut self, headless: bool) -> Self {
268        self.headless = headless;
269        self
270    }
271
272    /// Opt in to named launch restrictions or presets.
273    pub fn restrictions<I, S>(mut self, restrictions: I) -> Self
274    where
275        I: IntoIterator<Item = S>,
276        S: Into<String>,
277    {
278        self.restrictions = restrictions.into_iter().map(Into::into).collect();
279        self
280    }
281
282    /// Set additional browser arguments.
283    pub fn with_args(mut self, args: Vec<String>) -> Self {
284        self.args = args;
285        self
286    }
287
288    /// Add browser arguments after the compatibility `args` field.
289    pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
290        self.extra_args = args;
291        self
292    }
293
294    /// Has no effect since issue #103: Browser Commander adds no defaults.
295    #[deprecated(
296        since = "0.13.0",
297        note = "Browser Commander adds no default switches any more; opt in with `restrictions` instead"
298    )]
299    pub fn ignore_default_args(mut self, args: Vec<String>) -> Self {
300        self.ignore_default_args = args;
301        self
302    }
303
304    /// Has no effect since issue #103: Browser Commander adds no defaults.
305    #[deprecated(
306        since = "0.13.0",
307        note = "Browser Commander adds no default switches any more; opt in with `restrictions` instead"
308    )]
309    pub fn ignore_all_default_args(mut self) -> Self {
310        self.ignore_all_default_args = true;
311        self
312    }
313
314    /// Extra environment for the browser process only.
315    pub fn env(mut self, env: HashMap<String, String>) -> Self {
316        self.env = Some(env);
317        self
318    }
319
320    /// Keep `navigator.webdriver` false when a switch would turn it on.
321    pub fn automation_parity(mut self, enabled: bool) -> Self {
322        self.automation_parity = enabled;
323        self
324    }
325
326    /// Set the CDP readiness timeout.
327    pub fn startup_timeout(mut self, timeout: Duration) -> Self {
328        self.startup_timeout = timeout;
329        self
330    }
331
332    /// Set how long closing waits for the browser to exit before killing it.
333    pub fn close_timeout(mut self, timeout: Duration) -> Self {
334        self.close_timeout = timeout;
335        self
336    }
337
338    /// Set the engine operation delay.
339    pub fn slow_mo(mut self, milliseconds: u64) -> Self {
340        self.slow_mo = milliseconds;
341        self
342    }
343
344    /// Set the connection timeout.
345    pub fn timeout(mut self, timeout: Duration) -> Self {
346        self.timeout = Some(timeout);
347        self
348    }
349
350    /// Set Puppeteer's timeout for individual CDP calls.
351    pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
352        self.protocol_timeout = Some(timeout);
353        self
354    }
355
356    /// Seed cookies after attaching.
357    pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
358        self.seed_cookies = cookies;
359        self
360    }
361
362    /// Copy supported profile data before launch and seed its cookies over CDP.
363    #[must_use]
364    pub fn migrate_from(mut self, source: MigrationSource) -> Self {
365        self.migrate_from = Some(source);
366        self
367    }
368
369    /// Restrict a profile migration to the selected data classes.
370    #[must_use]
371    pub fn migrate_include<I, S>(mut self, include: I) -> Self
372    where
373        I: IntoIterator<Item = S>,
374        S: Into<String>,
375    {
376        self.migrate_include = Some(include.into_iter().map(Into::into).collect());
377        self
378    }
379
380    /// Restrict migrated cookies to the selected hosts.
381    #[must_use]
382    pub fn migrate_domains<I, S>(mut self, domains: I) -> Self
383    where
384        I: IntoIterator<Item = S>,
385        S: Into<String>,
386    {
387        self.migrate_domains = domains.into_iter().map(Into::into).collect();
388        self
389    }
390
391    /// Enable launch and connection logging.
392    pub fn verbose(mut self, verbose: bool) -> Self {
393        self.verbose = verbose;
394        self
395    }
396
397    /// Override the Node.js executable for bridge engines.
398    pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
399        self.node_executable = Some(executable.into());
400        self
401    }
402
403    /// Set the directory where Node resolves Playwright/Puppeteer.
404    pub fn node_working_dir(mut self, directory: impl Into<PathBuf>) -> Self {
405        self.node_working_dir = Some(directory.into());
406        self
407    }
408
409    /// Manage the installed browser's downloads.
410    ///
411    /// # Arguments
412    ///
413    /// * `downloads` - `true` for the defaults, `false` for none, or
414    ///   [`DownloadOptions`](crate::downloads::DownloadOptions)
415    pub fn downloads(mut self, downloads: impl Into<DownloadSetting>) -> Self {
416        self.downloads = downloads.into();
417        self
418    }
419
420    /// The profile a launch would use: the configured one, or Browser
421    /// Commander's old managed per-channel directory.
422    #[deprecated(
423        since = "0.13.0",
424        note = "launch_real_browser uses a fresh temporary profile unless user_data_dir is set; read RealBrowserLaunchResult::user_data_dir"
425    )]
426    pub fn get_user_data_dir(&self) -> PathBuf {
427        self.user_data_dir
428            .clone()
429            .unwrap_or_else(|| default_real_browser_user_data_dir(&self.channel))
430    }
431}
432
433/// Browser/page handles plus metadata for the spawned installed browser.
434pub struct RealBrowserLaunchResult {
435    /// Browser metadata matching [`crate::browser::launcher::LaunchResult`].
436    pub browser: Browser,
437    /// Shared engine adapter matching [`crate::browser::launcher::LaunchResult`].
438    pub page: Arc<dyn EngineAdapter>,
439    /// Resolved loopback DevTools endpoint.
440    pub cdp_endpoint: String,
441    /// The fixed port the browser listens on.
442    pub remote_debugging_port: u16,
443    /// Resolved installed-browser executable.
444    pub executable_path: PathBuf,
445    /// Profile used by the browser.
446    pub user_data_dir: PathBuf,
447    /// Whether `user_data_dir` is a temporary profile that is deleted when
448    /// the browser exits.
449    pub temporary_profile: bool,
450    /// The browser's exact command line (without the executable).
451    pub args: Vec<String>,
452    /// The spawned browser. Dropping the result (and every clone of this
453    /// handle) stops it; [`close`](Self::close) shuts it down gracefully.
454    pub browser_process: BrowserProcess,
455    /// The download manager, when the caller asked for managed downloads.
456    ///
457    /// A visible installed browser is where a person clicks a link themselves,
458    /// so this is the manager that sees those downloads too.
459    pub downloads: Option<Arc<DownloadManager>>,
460    /// Cookie-free profile migration report when `migrate_from` was set.
461    pub migration: Option<MigrationSummary>,
462    closer: Arc<RealBrowserCloser>,
463}
464
465impl RealBrowserLaunchResult {
466    /// Close the browser: ask it to shut down over CDP, wait up to
467    /// `close_timeout`, kill it if it is still running, then delete a
468    /// temporary profile. Calling it again does nothing.
469    pub async fn close(&self) -> Result<()> {
470        self.closer.close().await
471    }
472}
473
474impl std::fmt::Debug for RealBrowserLaunchResult {
475    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        formatter
477            .debug_struct("RealBrowserLaunchResult")
478            .field("browser", &self.browser)
479            .field("page", &"<dyn EngineAdapter>")
480            .field("cdp_endpoint", &self.cdp_endpoint)
481            .field("remote_debugging_port", &self.remote_debugging_port)
482            .field("executable_path", &self.executable_path)
483            .field("user_data_dir", &self.user_data_dir)
484            .field("temporary_profile", &self.temporary_profile)
485            .field("args", &self.args)
486            .field("browser_process", &self.browser_process)
487            .field("downloads", &self.downloads)
488            .field("migration", &self.migration)
489            .finish()
490    }
491}
492
493/// Resolve a genuine installed Chrome-family browser executable.
494pub fn resolve_system_browser_executable(options: &RealBrowserOptions) -> Result<PathBuf> {
495    resolve_browser_executable(&options.channel, options.executable_path.as_deref())
496}
497
498fn assert_no_managed_arguments(options: &RealBrowserOptions) -> Result<()> {
499    for argument in options.args.iter().chain(&options.extra_args) {
500        if MANAGED_ARGUMENTS
501            .iter()
502            .any(|managed| argument == managed || argument.starts_with(&format!("{managed}=")))
503        {
504            return Err(anyhow!("{argument} is managed by launch_real_browser"));
505        }
506    }
507    Ok(())
508}
509
510/// The page a real launch opens, as Puppeteer and Playwright do.
511///
512/// Without a URL, Chrome opens its New Tab page and Microsoft Edge opens the
513/// MSN New Tab page plus `edge://welcome-new-profile/`, whose flow closes the
514/// window and exits the browser a few seconds after launch (measured with
515/// Edge 153, experiments/issue-103/edge-headful.mjs). A URL is not a switch,
516/// so both engines see the same command line a person gets from `chrome
517/// about:blank`. A start URL among the caller's arguments replaces it.
518pub const START_URL: &str = "about:blank";
519
520pub(crate) fn browser_args(
521    options: &RealBrowserOptions,
522    user_data_dir: &Path,
523    remote_debugging_port: u16,
524) -> Result<Vec<String>> {
525    let port = assert_fixed_debugging_port(remote_debugging_port)?;
526    assert_no_managed_arguments(options)?;
527    let mut arguments = vec![
528        format!("--user-data-dir={}", user_data_dir.display()),
529        format!("--remote-debugging-port={port}"),
530    ];
531    if options.headless {
532        arguments.push("--headless=new".to_string());
533    }
534    arguments.extend(resolve_restrictions(&options.restrictions)?.args);
535    arguments.extend(options.args.iter().cloned());
536    arguments.extend(options.extra_args.iter().cloned());
537    let arguments = merge_feature_switches(&arguments);
538    let mut arguments = if options.automation_parity
539        && !detect_automation_controlled_triggers(&arguments).is_empty()
540    {
541        apply_automation_parity_args(&arguments)
542    } else {
543        arguments
544    };
545    if arguments.iter().all(|argument| argument.starts_with('-')) {
546        arguments.push(START_URL.to_string());
547    }
548    Ok(arguments)
549}
550
551/// Build the exact command line for an installed browser process.
552///
553/// `--user-data-dir` and `--remote-debugging-port` come first, then
554/// `--headless=new` when headless, then the opt-in restrictions and the
555/// caller's arguments; repeated feature-list switches are merged, and
556/// [`START_URL`] closes the list unless the caller passed a URL. Both
557/// `user_data_dir` and `remote_debugging_port` must be set - a launch picks
558/// them itself when they are not.
559pub fn build_real_browser_args(options: &RealBrowserOptions) -> Result<Vec<String>> {
560    let user_data_dir = options.user_data_dir.as_deref().ok_or_else(|| {
561        anyhow!("build_real_browser_args needs user_data_dir; launch_real_browser creates a temporary profile when it is not set")
562    })?;
563    let port = options.remote_debugging_port.ok_or_else(|| {
564        anyhow!("build_real_browser_args needs remote_debugging_port; launch_real_browser reserves a free port when it is not set")
565    })?;
566    browser_args(options, user_data_dir, port)
567}
568
569fn validate_launch_request(options: &RealBrowserOptions) -> Result<()> {
570    assert_cdp_browser(&options.channel)?;
571    if options.engine == EngineType::Fantoccini {
572        return Err(anyhow!(FANTOCCINI_OVER_CDP));
573    }
574    if let Some(port) = options.remote_debugging_port {
575        assert_fixed_debugging_port(port)?;
576    }
577    browser_args(
578        options,
579        options
580            .user_data_dir
581            .as_deref()
582            .unwrap_or_else(|| Path::new("validation")),
583        options.remote_debugging_port.unwrap_or(1),
584    )?;
585    if let Some(user_data_dir) = &options.user_data_dir {
586        assert_dedicated_user_data_dir(user_data_dir)?;
587    }
588    Ok(())
589}
590
591fn browser_environment(options: &RealBrowserOptions) -> Result<Option<HashMap<String, String>>> {
592    let mut env = resolve_restrictions(&options.restrictions)?.env;
593    if let Some(extra) = &options.env {
594        env.extend(
595            extra
596                .iter()
597                .map(|(key, value)| (key.clone(), value.clone())),
598        );
599    }
600    Ok((!env.is_empty() || options.env.is_some()).then_some(env))
601}
602
603/// A spawned browser and, when its stderr is captured, the watcher that sees
604/// its `DevTools listening on ...` line.
605pub(crate) struct SpawnedBrowser {
606    pub(crate) process: BrowserProcess,
607    pub(crate) dev_tools_output: Option<DevToolsOutputWatcher>,
608}
609
610/// The side effects of a launch, replaceable in tests.
611#[async_trait]
612pub(crate) trait LaunchHooks: Send + Sync {
613    fn resolve_executable(&self, options: &RealBrowserOptions) -> Result<PathBuf> {
614        resolve_system_browser_executable(options)
615    }
616
617    fn reserve_port(&self) -> Result<u16> {
618        reserve_loopback_port()
619    }
620
621    async fn spawn_browser(
622        &self,
623        executable_path: &Path,
624        args: &[String],
625        env: Option<HashMap<String, String>>,
626        verbose: bool,
627    ) -> Result<SpawnedBrowser> {
628        let file = executable_path.to_str().ok_or_else(|| {
629            anyhow!(
630                "browser executable path is not valid UTF-8: {}",
631                executable_path.display()
632            )
633        })?;
634        let watcher = DevToolsOutputWatcher::new();
635        let process = start_process(
636            file,
637            args,
638            StartProcessOptions {
639                env,
640                forward_output: verbose,
641                on_stderr: vec![watcher.listener()],
642                ..StartProcessOptions::default()
643            },
644        )
645        .await
646        .map_err(|error| anyhow!("failed to start installed browser {file}: {error}"))?;
647        Ok(SpawnedBrowser {
648            process: BrowserProcess::from_managed(process),
649            dev_tools_output: Some(watcher),
650        })
651    }
652
653    async fn wait_for_endpoint(&self, request: CdpEndpointRequest<'_>) -> Result<String> {
654        wait_for_cdp_endpoint(request).await
655    }
656
657    /// Ask the browser to shut down (`Browser.close` over CDP).
658    async fn request_close(&self, cdp_endpoint: &str, timeout: Duration) -> Result<()> {
659        let endpoint = cdp_endpoint.to_owned();
660        let close = async move {
661            let (mut browser, mut handler) = CdpBrowser::connect(endpoint).await?;
662            let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });
663            let result = browser.close().await;
664            handler_task.abort();
665            result.map(|_| ()).map_err(anyhow::Error::from)
666        };
667        tokio::time::timeout(timeout, close)
668            .await
669            .map_err(|_| anyhow!("Browser.close did not answer within {timeout:?}"))?
670    }
671}
672
673/// The real side effects.
674pub(crate) struct SystemLaunchHooks;
675
676impl LaunchHooks for SystemLaunchHooks {}
677
678pub(crate) struct RealBrowserCloser {
679    hooks: Arc<dyn LaunchHooks>,
680    process: BrowserProcess,
681    cdp_endpoint: String,
682    user_data_dir: PathBuf,
683    temporary_profile: bool,
684    close_timeout: Duration,
685    closed: tokio::sync::OnceCell<()>,
686}
687
688#[async_trait]
689impl BrowserCloser for RealBrowserCloser {
690    async fn close(&self) -> Result<()> {
691        self.closed
692            .get_or_try_init(|| async {
693                if self.process.is_running() {
694                    // A browser that is gone already, or that does not
695                    // answer, is handled by the kill below.
696                    let _ = self
697                        .hooks
698                        .request_close(&self.cdp_endpoint, self.close_timeout)
699                        .await;
700                }
701                if self
702                    .process
703                    .wait_timeout(self.close_timeout)
704                    .await
705                    .is_none()
706                {
707                    self.process.kill();
708                    self.process.wait_timeout(self.close_timeout).await;
709                }
710                if self.temporary_profile {
711                    remove_user_data_dir(&self.user_data_dir).await?;
712                }
713                Ok::<(), anyhow::Error>(())
714            })
715            .await
716            .map(|_| ())
717    }
718}
719
720/// Everything a launch produced besides the engine connection.
721pub(crate) struct LaunchedRealBrowser {
722    pub(crate) cdp_endpoint: String,
723    pub(crate) remote_debugging_port: u16,
724    pub(crate) executable_path: PathBuf,
725    pub(crate) user_data_dir: PathBuf,
726    pub(crate) temporary_profile: bool,
727    pub(crate) args: Vec<String>,
728    pub(crate) browser_process: BrowserProcess,
729    pub(crate) migration: Option<MigrationSummary>,
730    pub(crate) closer: Arc<RealBrowserCloser>,
731}
732
733impl std::fmt::Debug for LaunchedRealBrowser {
734    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
735        formatter
736            .debug_struct("LaunchedRealBrowser")
737            .field("cdp_endpoint", &self.cdp_endpoint)
738            .field("remote_debugging_port", &self.remote_debugging_port)
739            .field("executable_path", &self.executable_path)
740            .field("user_data_dir", &self.user_data_dir)
741            .field("temporary_profile", &self.temporary_profile)
742            .field("args", &self.args)
743            .field("browser_process", &self.browser_process)
744            .field("migration", &self.migration)
745            .finish()
746    }
747}
748
749struct Spawned {
750    process: BrowserProcess,
751    cdp_endpoint: String,
752    port: u16,
753    args: Vec<String>,
754}
755
756async fn spawn_on_free_port(
757    options: &RealBrowserOptions,
758    hooks: &dyn LaunchHooks,
759    executable_path: &Path,
760    user_data_dir: &Path,
761    env: Option<HashMap<String, String>>,
762) -> Result<Spawned> {
763    let attempts = match options.remote_debugging_port {
764        Some(_) => 1,
765        None => options.port_attempts.max(1),
766    };
767    let mut attempt = 1;
768    loop {
769        let port = match options.remote_debugging_port {
770            Some(port) => port,
771            None => hooks.reserve_port()?,
772        };
773        let args = browser_args(options, user_data_dir, port)?;
774        if options.verbose {
775            tracing::info!(executable = %executable_path.display(), ?args, "starting installed browser");
776        }
777        let spawned = hooks
778            .spawn_browser(executable_path, &args, env.clone(), options.verbose)
779            .await?;
780        let waited = hooks
781            .wait_for_endpoint(CdpEndpointRequest {
782                remote_debugging_port: port,
783                user_data_dir,
784                browser_process: &spawned.process,
785                dev_tools_output: spawned.dev_tools_output.as_ref(),
786                timeout: options.startup_timeout,
787            })
788            .await;
789        match waited {
790            Ok(cdp_endpoint) => {
791                return Ok(Spawned {
792                    process: spawned.process,
793                    cdp_endpoint,
794                    port,
795                    args,
796                })
797            }
798            Err(error) => {
799                spawned.process.kill();
800                if error.downcast_ref::<PortRaceError>().is_none() || attempt >= attempts {
801                    return Err(error);
802                }
803                if options.verbose {
804                    tracing::info!("{error}; retrying with a new port");
805                }
806                spawned.process.wait_timeout(RACE_EXIT_WAIT).await;
807                attempt += 1;
808            }
809        }
810    }
811}
812
813/// Launch with an optional pre-created profile owned by the browser lifecycle.
814pub(crate) async fn launch_real_browser_with_owned<T, C, F>(
815    options: &RealBrowserOptions,
816    hooks: Arc<dyn LaunchHooks>,
817    connect: C,
818    owned_profile: bool,
819) -> Result<(T, LaunchedRealBrowser)>
820where
821    C: FnOnce(ConnectOptions) -> F,
822    F: Future<Output = Result<T>>,
823{
824    validate_launch_request(options)?;
825    if let Some(state) = &options.storage_state {
826        state.load()?;
827    }
828    let executable_path = hooks.resolve_executable(options)?;
829    let temporary_profile = options.user_data_dir.is_none() || owned_profile;
830    let user_data_dir = match &options.user_data_dir {
831        Some(user_data_dir) => {
832            prepare_user_data_dir_with_first_run(user_data_dir, options.first_run)?
833        }
834        None => create_temporary_user_data_dir_with_first_run(None, options.first_run)?,
835    };
836    let (migration, migrated_cookies) = if let Some(source) = options.migrate_from.clone() {
837        let target = user_data_dir.join("Default");
838        let mut migrate_options = MigrateProfileOptions::new(source, target);
839        migrate_options.target_browser = Some(options.channel.clone());
840        if let Some(include) = &options.migrate_include {
841            migrate_options.include = include.clone();
842        }
843        migrate_options.domains = options.migrate_domains.clone();
844        migrate_options.password_csv = options.migrate_password_csv.clone();
845        migrate_options.include_payment_cards = options.migrate_include_payment_cards;
846        let result = tokio::task::spawn_blocking(move || migrate_profile(migrate_options)).await;
847        let report = match result {
848            Ok(Ok(report)) => report,
849            Ok(Err(error)) => {
850                if temporary_profile {
851                    let _ = remove_user_data_dir(&user_data_dir).await;
852                }
853                return Err(error);
854            }
855            Err(error) => {
856                if temporary_profile {
857                    let _ = remove_user_data_dir(&user_data_dir).await;
858                }
859                return Err(error.into());
860            }
861        };
862        let (cookies, summary) = report.into_parts();
863        let values = cookies
864            .into_iter()
865            .map(serde_json::to_value)
866            .collect::<std::result::Result<Vec<_>, _>>()?;
867        (Some(summary), values)
868    } else {
869        (None, Vec::new())
870    };
871    if let Err(error) = configure_user_data_dir_for_profile(
872        &user_data_dir,
873        &options.profile_directory,
874        options.default_browser_check,
875        &options.preferences,
876        &options.local_state,
877    ) {
878        if temporary_profile {
879            let _ = remove_user_data_dir(&user_data_dir).await;
880        }
881        return Err(error);
882    }
883    let env = browser_environment(options)?;
884
885    let spawned = match spawn_on_free_port(
886        options,
887        hooks.as_ref(),
888        &executable_path,
889        &user_data_dir,
890        env,
891    )
892    .await
893    {
894        Ok(spawned) => spawned,
895        Err(error) => {
896            if temporary_profile {
897                let _ = remove_user_data_dir(&user_data_dir).await;
898            }
899            return Err(error);
900        }
901    };
902    let process = spawned.process;
903    if temporary_profile {
904        // The profile goes away with the browser, also when the user closes
905        // the window instead of the caller calling close().
906        let exited = process.exited();
907        let directory = user_data_dir.clone();
908        tokio::spawn(async move {
909            exited.await;
910            let _ = remove_user_data_dir(&directory).await;
911        });
912    }
913
914    let connection = match connection_options(options, &spawned.cdp_endpoint) {
915        Ok(mut connect_options) => {
916            connect_options.seed_cookies.extend(migrated_cookies);
917            connect(connect_options).await
918        }
919        Err(error) => Err(error),
920    };
921    let connection = match connection {
922        Ok(connection) => connection,
923        Err(error) => {
924            process.kill();
925            process.wait_timeout(options.close_timeout).await;
926            if temporary_profile {
927                let _ = remove_user_data_dir(&user_data_dir).await;
928            }
929            return Err(error);
930        }
931    };
932
933    let closer = Arc::new(RealBrowserCloser {
934        hooks,
935        process: process.clone(),
936        cdp_endpoint: spawned.cdp_endpoint.clone(),
937        user_data_dir: user_data_dir.clone(),
938        temporary_profile,
939        close_timeout: options.close_timeout,
940        closed: tokio::sync::OnceCell::new(),
941    });
942    Ok((
943        connection,
944        LaunchedRealBrowser {
945            cdp_endpoint: spawned.cdp_endpoint,
946            remote_debugging_port: spawned.port,
947            executable_path,
948            user_data_dir,
949            temporary_profile,
950            args: spawned.args,
951            browser_process: process,
952            migration,
953            closer,
954        },
955    ))
956}
957
958/// Launch a genuine installed browser and attach.
959///
960/// The command line is exactly `--user-data-dir=<profile>
961/// --remote-debugging-port=<port> about:blank` (plus `--headless=new`, restrictions and
962/// the caller's arguments when asked for), so the browser behaves like one a
963/// person started by hand and `navigator.webdriver` stays false. Without
964/// `user_data_dir` a fresh temporary profile is used and deleted when the
965/// browser exits; known default profiles are refused because Chrome 136 and
966/// newer ignore remote-debugging switches for them.
967pub async fn launch_real_browser(options: RealBrowserOptions) -> Result<RealBrowserLaunchResult> {
968    launch_real_browser_owned(options, false).await
969}
970
971pub(crate) async fn launch_real_browser_owned(
972    options: RealBrowserOptions,
973    owned_profile: bool,
974) -> Result<RealBrowserLaunchResult> {
975    let (connection, launched) = launch_real_browser_with_owned(
976        &options,
977        Arc::new(SystemLaunchHooks),
978        connect_browser,
979        owned_profile,
980    )
981    .await?;
982    Ok(real_browser_result(connection, launched, options.headless))
983}
984
985/// Descriptive alias for [`launch_real_browser`].
986pub async fn launch_and_connect_real_browser(
987    options: RealBrowserOptions,
988) -> Result<RealBrowserLaunchResult> {
989    launch_real_browser(options).await
990}
991
992#[path = "real_browser_result.rs"]
993mod result;
994use result::real_browser_result;
995pub(crate) use result::{connection_options, launch_real_browser_with};
996
997#[cfg(test)]
998#[path = "real_browser_tests.rs"]
999mod tests;