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