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