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::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    pub(crate) closer: Arc<dyn BrowserCloser>,
463    /// Native Safari session, including the complete typed W3C client.
464    pub webdriver: Option<Arc<crate::browser::webdriver::ManagedWebDriver>>,
465}
466
467impl RealBrowserLaunchResult {
468    /// Close the browser: ask it to shut down over CDP, wait up to
469    /// `close_timeout`, kill it if it is still running, then delete a
470    /// temporary profile. Calling it again does nothing.
471    pub async fn close(&self) -> Result<()> {
472        self.closer.close().await
473    }
474}
475
476impl std::fmt::Debug for RealBrowserLaunchResult {
477    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478        formatter
479            .debug_struct("RealBrowserLaunchResult")
480            .field("browser", &self.browser)
481            .field("page", &"<dyn EngineAdapter>")
482            .field("cdp_endpoint", &self.cdp_endpoint)
483            .field("remote_debugging_port", &self.remote_debugging_port)
484            .field("executable_path", &self.executable_path)
485            .field("user_data_dir", &self.user_data_dir)
486            .field("temporary_profile", &self.temporary_profile)
487            .field("args", &self.args)
488            .field("browser_process", &self.browser_process)
489            .field("downloads", &self.downloads)
490            .field("migration", &self.migration)
491            .finish()
492    }
493}
494
495/// Resolve a genuine installed Chrome-family browser executable.
496pub fn resolve_system_browser_executable(options: &RealBrowserOptions) -> Result<PathBuf> {
497    resolve_browser_executable(&options.channel, options.executable_path.as_deref())
498}
499
500fn assert_no_managed_arguments(options: &RealBrowserOptions) -> Result<()> {
501    for argument in options.args.iter().chain(&options.extra_args) {
502        if MANAGED_ARGUMENTS
503            .iter()
504            .any(|managed| argument == managed || argument.starts_with(&format!("{managed}=")))
505        {
506            return Err(anyhow!("{argument} is managed by launch_real_browser"));
507        }
508    }
509    Ok(())
510}
511
512/// The page a real launch opens, as Puppeteer and Playwright do.
513///
514/// Without a URL, Chrome opens its New Tab page and Microsoft Edge opens the
515/// MSN New Tab page plus `edge://welcome-new-profile/`, whose flow closes the
516/// window and exits the browser a few seconds after launch (measured with
517/// Edge 153, experiments/issue-103/edge-headful.mjs). A URL is not a switch,
518/// so both engines see the same command line a person gets from `chrome
519/// about:blank`. A start URL among the caller's arguments replaces it.
520pub const START_URL: &str = "about:blank";
521
522pub(crate) fn browser_args(
523    options: &RealBrowserOptions,
524    user_data_dir: &Path,
525    remote_debugging_port: u16,
526) -> Result<Vec<String>> {
527    let port = assert_fixed_debugging_port(remote_debugging_port)?;
528    assert_no_managed_arguments(options)?;
529    let mut arguments = vec![
530        format!("--user-data-dir={}", user_data_dir.display()),
531        format!("--remote-debugging-port={port}"),
532    ];
533    if options.headless {
534        arguments.push("--headless=new".to_string());
535    }
536    arguments.extend(resolve_restrictions(&options.restrictions)?.args);
537    arguments.extend(options.args.iter().cloned());
538    arguments.extend(options.extra_args.iter().cloned());
539    let arguments = merge_feature_switches(&arguments);
540    let mut arguments = if options.automation_parity
541        && !detect_automation_controlled_triggers(&arguments).is_empty()
542    {
543        apply_automation_parity_args(&arguments)
544    } else {
545        arguments
546    };
547    if arguments.iter().all(|argument| argument.starts_with('-')) {
548        arguments.push(START_URL.to_string());
549    }
550    Ok(arguments)
551}
552
553/// Build the exact command line for an installed browser process.
554///
555/// `--user-data-dir` and `--remote-debugging-port` come first, then
556/// `--headless=new` when headless, then the opt-in restrictions and the
557/// caller's arguments; repeated feature-list switches are merged, and
558/// [`START_URL`] closes the list unless the caller passed a URL. Both
559/// `user_data_dir` and `remote_debugging_port` must be set - a launch picks
560/// them itself when they are not.
561pub fn build_real_browser_args(options: &RealBrowserOptions) -> Result<Vec<String>> {
562    let user_data_dir = options.user_data_dir.as_deref().ok_or_else(|| {
563        anyhow!("build_real_browser_args needs user_data_dir; launch_real_browser creates a temporary profile when it is not set")
564    })?;
565    let port = options.remote_debugging_port.ok_or_else(|| {
566        anyhow!("build_real_browser_args needs remote_debugging_port; launch_real_browser reserves a free port when it is not set")
567    })?;
568    browser_args(options, user_data_dir, port)
569}
570
571fn validate_launch_request(options: &RealBrowserOptions) -> Result<()> {
572    assert_cdp_browser(&options.channel)?;
573    if options.engine == EngineType::Fantoccini {
574        return Err(anyhow!(FANTOCCINI_OVER_CDP));
575    }
576    if let Some(port) = options.remote_debugging_port {
577        assert_fixed_debugging_port(port)?;
578    }
579    browser_args(
580        options,
581        options
582            .user_data_dir
583            .as_deref()
584            .unwrap_or_else(|| Path::new("validation")),
585        options.remote_debugging_port.unwrap_or(1),
586    )?;
587    if let Some(user_data_dir) = &options.user_data_dir {
588        assert_dedicated_user_data_dir(user_data_dir)?;
589    }
590    Ok(())
591}
592
593fn browser_environment(options: &RealBrowserOptions) -> Result<Option<HashMap<String, String>>> {
594    let mut env = resolve_restrictions(&options.restrictions)?.env;
595    if let Some(extra) = &options.env {
596        env.extend(
597            extra
598                .iter()
599                .map(|(key, value)| (key.clone(), value.clone())),
600        );
601    }
602    Ok((!env.is_empty() || options.env.is_some()).then_some(env))
603}
604
605/// A spawned browser and, when its stderr is captured, the watcher that sees
606/// its `DevTools listening on ...` line.
607pub(crate) struct SpawnedBrowser {
608    pub(crate) process: BrowserProcess,
609    pub(crate) dev_tools_output: Option<DevToolsOutputWatcher>,
610}
611
612/// The side effects of a launch, replaceable in tests.
613#[async_trait]
614pub(crate) trait LaunchHooks: Send + Sync {
615    fn resolve_executable(&self, options: &RealBrowserOptions) -> Result<PathBuf> {
616        resolve_system_browser_executable(options)
617    }
618
619    fn reserve_port(&self) -> Result<u16> {
620        reserve_loopback_port()
621    }
622
623    async fn spawn_browser(
624        &self,
625        executable_path: &Path,
626        args: &[String],
627        env: Option<HashMap<String, String>>,
628        verbose: bool,
629    ) -> Result<SpawnedBrowser> {
630        let file = executable_path.to_str().ok_or_else(|| {
631            anyhow!(
632                "browser executable path is not valid UTF-8: {}",
633                executable_path.display()
634            )
635        })?;
636        let watcher = DevToolsOutputWatcher::new();
637        let process = start_process(
638            file,
639            args,
640            StartProcessOptions {
641                env,
642                forward_output: verbose,
643                on_stderr: vec![watcher.listener()],
644                ..StartProcessOptions::default()
645            },
646        )
647        .await
648        .map_err(|error| anyhow!("failed to start installed browser {file}: {error}"))?;
649        Ok(SpawnedBrowser {
650            process: BrowserProcess::from_managed(process),
651            dev_tools_output: Some(watcher),
652        })
653    }
654
655    async fn wait_for_endpoint(&self, request: CdpEndpointRequest<'_>) -> Result<String> {
656        wait_for_cdp_endpoint(request).await
657    }
658
659    /// Ask the browser to shut down (`Browser.close` over CDP).
660    async fn request_close(&self, cdp_endpoint: &str, timeout: Duration) -> Result<()> {
661        let endpoint = cdp_endpoint.to_owned();
662        let close = async move {
663            let (mut browser, mut handler) = CdpBrowser::connect(endpoint).await?;
664            let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });
665            let result = browser.close().await;
666            handler_task.abort();
667            result.map(|_| ()).map_err(anyhow::Error::from)
668        };
669        tokio::time::timeout(timeout, close)
670            .await
671            .map_err(|_| anyhow!("Browser.close did not answer within {timeout:?}"))?
672    }
673}
674
675/// The real side effects.
676pub(crate) struct SystemLaunchHooks;
677
678impl LaunchHooks for SystemLaunchHooks {}
679
680pub(crate) struct RealBrowserCloser {
681    hooks: Arc<dyn LaunchHooks>,
682    process: BrowserProcess,
683    cdp_endpoint: String,
684    user_data_dir: PathBuf,
685    temporary_profile: bool,
686    close_timeout: Duration,
687    closed: tokio::sync::OnceCell<()>,
688}
689
690#[async_trait]
691impl BrowserCloser for RealBrowserCloser {
692    async fn close(&self) -> Result<()> {
693        self.closed
694            .get_or_try_init(|| async {
695                if self.process.is_running() {
696                    // A browser that is gone already, or that does not
697                    // answer, is handled by the kill below.
698                    let _ = self
699                        .hooks
700                        .request_close(&self.cdp_endpoint, self.close_timeout)
701                        .await;
702                }
703                if self
704                    .process
705                    .wait_timeout(self.close_timeout)
706                    .await
707                    .is_none()
708                {
709                    self.process.kill();
710                    self.process.wait_timeout(self.close_timeout).await;
711                }
712                if self.temporary_profile {
713                    remove_user_data_dir(&self.user_data_dir).await?;
714                }
715                Ok::<(), anyhow::Error>(())
716            })
717            .await
718            .map(|_| ())
719    }
720}
721
722/// Everything a launch produced besides the engine connection.
723pub(crate) struct LaunchedRealBrowser {
724    pub(crate) cdp_endpoint: String,
725    pub(crate) remote_debugging_port: u16,
726    pub(crate) executable_path: PathBuf,
727    pub(crate) user_data_dir: PathBuf,
728    pub(crate) temporary_profile: bool,
729    pub(crate) args: Vec<String>,
730    pub(crate) browser_process: BrowserProcess,
731    pub(crate) migration: Option<MigrationSummary>,
732    pub(crate) closer: Arc<RealBrowserCloser>,
733}
734
735impl std::fmt::Debug for LaunchedRealBrowser {
736    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
737        formatter
738            .debug_struct("LaunchedRealBrowser")
739            .field("cdp_endpoint", &self.cdp_endpoint)
740            .field("remote_debugging_port", &self.remote_debugging_port)
741            .field("executable_path", &self.executable_path)
742            .field("user_data_dir", &self.user_data_dir)
743            .field("temporary_profile", &self.temporary_profile)
744            .field("args", &self.args)
745            .field("browser_process", &self.browser_process)
746            .field("migration", &self.migration)
747            .finish()
748    }
749}
750
751struct Spawned {
752    process: BrowserProcess,
753    cdp_endpoint: String,
754    port: u16,
755    args: Vec<String>,
756}
757
758async fn spawn_on_free_port(
759    options: &RealBrowserOptions,
760    hooks: &dyn LaunchHooks,
761    executable_path: &Path,
762    user_data_dir: &Path,
763    env: Option<HashMap<String, String>>,
764) -> Result<Spawned> {
765    let attempts = match options.remote_debugging_port {
766        Some(_) => 1,
767        None => options.port_attempts.max(1),
768    };
769    let mut attempt = 1;
770    loop {
771        let port = match options.remote_debugging_port {
772            Some(port) => port,
773            None => hooks.reserve_port()?,
774        };
775        let args = browser_args(options, user_data_dir, port)?;
776        if options.verbose {
777            tracing::info!(executable = %executable_path.display(), ?args, "starting installed browser");
778        }
779        let spawned = hooks
780            .spawn_browser(executable_path, &args, env.clone(), options.verbose)
781            .await?;
782        let waited = hooks
783            .wait_for_endpoint(CdpEndpointRequest {
784                remote_debugging_port: port,
785                user_data_dir,
786                browser_process: &spawned.process,
787                dev_tools_output: spawned.dev_tools_output.as_ref(),
788                timeout: options.startup_timeout,
789            })
790            .await;
791        match waited {
792            Ok(cdp_endpoint) => {
793                return Ok(Spawned {
794                    process: spawned.process,
795                    cdp_endpoint,
796                    port,
797                    args,
798                })
799            }
800            Err(error) => {
801                spawned.process.kill();
802                if error.downcast_ref::<PortRaceError>().is_none() || attempt >= attempts {
803                    return Err(error);
804                }
805                if options.verbose {
806                    tracing::info!("{error}; retrying with a new port");
807                }
808                spawned.process.wait_timeout(RACE_EXIT_WAIT).await;
809                attempt += 1;
810            }
811        }
812    }
813}
814
815/// Launch with an optional pre-created profile owned by the browser lifecycle.
816pub(crate) async fn launch_real_browser_with_owned<T, C, F>(
817    options: &RealBrowserOptions,
818    hooks: Arc<dyn LaunchHooks>,
819    connect: C,
820    owned_profile: bool,
821) -> Result<(T, LaunchedRealBrowser)>
822where
823    C: FnOnce(ConnectOptions) -> F,
824    F: Future<Output = Result<T>>,
825{
826    validate_launch_request(options)?;
827    if let Some(state) = &options.storage_state {
828        state.load()?;
829    }
830    let executable_path = hooks.resolve_executable(options)?;
831    let temporary_profile = options.user_data_dir.is_none() || owned_profile;
832    let user_data_dir = match &options.user_data_dir {
833        Some(user_data_dir) => {
834            prepare_user_data_dir_with_first_run(user_data_dir, options.first_run)?
835        }
836        None => create_temporary_user_data_dir_with_first_run(None, options.first_run)?,
837    };
838    let (migration, migrated_cookies) = if let Some(source) = options.migrate_from.clone() {
839        let target = user_data_dir.join("Default");
840        let mut migrate_options = MigrateProfileOptions::new(source, target);
841        migrate_options.target_browser = Some(options.channel.clone());
842        if let Some(include) = &options.migrate_include {
843            migrate_options.include = include.clone();
844        }
845        migrate_options.domains = options.migrate_domains.clone();
846        migrate_options.password_csv = options.migrate_password_csv.clone();
847        migrate_options.include_payment_cards = options.migrate_include_payment_cards;
848        let result = tokio::task::spawn_blocking(move || migrate_profile(migrate_options)).await;
849        let report = match result {
850            Ok(Ok(report)) => report,
851            Ok(Err(error)) => {
852                if temporary_profile {
853                    let _ = remove_user_data_dir(&user_data_dir).await;
854                }
855                return Err(error);
856            }
857            Err(error) => {
858                if temporary_profile {
859                    let _ = remove_user_data_dir(&user_data_dir).await;
860                }
861                return Err(error.into());
862            }
863        };
864        let (cookies, summary) = report.into_parts();
865        let values = cookies
866            .into_iter()
867            .map(serde_json::to_value)
868            .collect::<std::result::Result<Vec<_>, _>>()?;
869        (Some(summary), values)
870    } else {
871        (None, Vec::new())
872    };
873    if let Err(error) = configure_user_data_dir_for_profile(
874        &user_data_dir,
875        &options.profile_directory,
876        options.default_browser_check,
877        &options.preferences,
878        &options.local_state,
879    ) {
880        if temporary_profile {
881            let _ = remove_user_data_dir(&user_data_dir).await;
882        }
883        return Err(error);
884    }
885    let env = browser_environment(options)?;
886
887    let spawned = match spawn_on_free_port(
888        options,
889        hooks.as_ref(),
890        &executable_path,
891        &user_data_dir,
892        env,
893    )
894    .await
895    {
896        Ok(spawned) => spawned,
897        Err(error) => {
898            if temporary_profile {
899                let _ = remove_user_data_dir(&user_data_dir).await;
900            }
901            return Err(error);
902        }
903    };
904    let process = spawned.process;
905    if temporary_profile {
906        // The profile goes away with the browser, also when the user closes
907        // the window instead of the caller calling close().
908        let exited = process.exited();
909        let directory = user_data_dir.clone();
910        tokio::spawn(async move {
911            exited.await;
912            let _ = remove_user_data_dir(&directory).await;
913        });
914    }
915
916    let connection = match connection_options(options, &spawned.cdp_endpoint) {
917        Ok(mut connect_options) => {
918            connect_options.seed_cookies.extend(migrated_cookies);
919            connect(connect_options).await
920        }
921        Err(error) => Err(error),
922    };
923    let connection = match connection {
924        Ok(connection) => connection,
925        Err(error) => {
926            process.kill();
927            process.wait_timeout(options.close_timeout).await;
928            if temporary_profile {
929                let _ = remove_user_data_dir(&user_data_dir).await;
930            }
931            return Err(error);
932        }
933    };
934
935    let closer = Arc::new(RealBrowserCloser {
936        hooks,
937        process: process.clone(),
938        cdp_endpoint: spawned.cdp_endpoint.clone(),
939        user_data_dir: user_data_dir.clone(),
940        temporary_profile,
941        close_timeout: options.close_timeout,
942        closed: tokio::sync::OnceCell::new(),
943    });
944    Ok((
945        connection,
946        LaunchedRealBrowser {
947            cdp_endpoint: spawned.cdp_endpoint,
948            remote_debugging_port: spawned.port,
949            executable_path,
950            user_data_dir,
951            temporary_profile,
952            args: spawned.args,
953            browser_process: process,
954            migration,
955            closer,
956        },
957    ))
958}
959
960/// Launch a genuine installed browser and attach.
961///
962/// The command line is exactly `--user-data-dir=<profile>
963/// --remote-debugging-port=<port> about:blank` (plus `--headless=new`, restrictions and
964/// the caller's arguments when asked for), so the browser behaves like one a
965/// person started by hand and `navigator.webdriver` stays false. Without
966/// `user_data_dir` a fresh temporary profile is used and deleted when the
967/// browser exits; known default profiles are refused because Chrome 136 and
968/// newer ignore remote-debugging switches for them.
969pub async fn launch_real_browser(options: RealBrowserOptions) -> Result<RealBrowserLaunchResult> {
970    if crate::browser::safari::is_safari_channel(&options.channel) {
971        return crate::browser::safari::launch_safari_real(options).await;
972    }
973    launch_real_browser_owned(options, false).await
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#[path = "real_browser_result.rs"]
984mod result;
985pub(crate) use result::{connection_options, launch_real_browser_owned, launch_real_browser_with};
986
987#[cfg(test)]
988#[path = "real_browser_tests.rs"]
989mod tests;