Skip to main content

browser_commander/browser/
real_browser.rs

1//! Launch genuine installed Chrome-family browsers and attach over CDP.
2
3use std::collections::HashSet;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command, ExitStatus, Stdio};
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use serde_json::Value;
11use tokio::io::{AsyncReadExt, AsyncWriteExt};
12use tokio::net::TcpStream;
13
14use crate::browser::connector::{connect_browser, ConnectOptions};
15use crate::browser::launcher::{Browser, LaunchResult};
16use crate::core::engine::{EngineAdapter, EngineType};
17
18const MANAGED_ARGUMENTS: [&str; 3] = [
19    "--remote-debugging-address",
20    "--remote-debugging-port",
21    "--user-data-dir",
22];
23
24/// Options for launching an installed browser and attaching over CDP.
25#[derive(Debug, Clone)]
26pub struct RealBrowserOptions {
27    /// Browser Commander engine used after the browser starts.
28    pub engine: EngineType,
29    /// Installed Chrome-family channel to discover.
30    pub channel: String,
31    /// Explicit installed-browser executable, bypassing channel discovery.
32    pub executable_path: Option<PathBuf>,
33    /// Dedicated, non-default browser profile.
34    pub user_data_dir: Option<PathBuf>,
35    /// Loopback CDP port. Zero lets Chrome choose an available port.
36    pub remote_debugging_port: u16,
37    /// Run the installed browser headlessly.
38    pub headless: bool,
39    /// Additional browser arguments.
40    pub args: Vec<String>,
41    /// Maximum time to wait for Chrome's `/json/version` endpoint.
42    pub startup_timeout: Duration,
43    /// Delay Playwright/Puppeteer operations by this many milliseconds.
44    pub slow_mo: u64,
45    /// Optional connection timeout.
46    pub timeout: Option<Duration>,
47    /// Optional Puppeteer timeout for individual CDP calls.
48    pub protocol_timeout: Option<Duration>,
49    /// Cookies to seed immediately after attaching.
50    pub seed_cookies: Vec<Value>,
51    /// Enable browser and connector logging.
52    pub verbose: bool,
53    /// Node.js executable for Playwright/Puppeteer bridge engines.
54    pub node_executable: Option<PathBuf>,
55    /// Directory where Node resolves Playwright/Puppeteer.
56    pub node_working_dir: Option<PathBuf>,
57}
58
59impl Default for RealBrowserOptions {
60    fn default() -> Self {
61        Self {
62            engine: EngineType::Chromiumoxide,
63            channel: "chrome".to_string(),
64            executable_path: None,
65            user_data_dir: None,
66            remote_debugging_port: 0,
67            headless: false,
68            args: Vec::new(),
69            startup_timeout: Duration::from_secs(30),
70            slow_mo: 0,
71            timeout: None,
72            protocol_timeout: None,
73            seed_cookies: Vec::new(),
74            verbose: false,
75            node_executable: None,
76            node_working_dir: None,
77        }
78    }
79}
80
81impl RealBrowserOptions {
82    /// Create native Chromiumoxide options.
83    pub fn chromiumoxide() -> Self {
84        Self::default()
85    }
86
87    /// Create Playwright bridge options.
88    pub fn playwright() -> Self {
89        Self {
90            engine: EngineType::Playwright,
91            slow_mo: 150,
92            ..Self::default()
93        }
94    }
95
96    /// Create Puppeteer bridge options.
97    pub fn puppeteer() -> Self {
98        Self {
99            engine: EngineType::Puppeteer,
100            ..Self::default()
101        }
102    }
103
104    /// Select an installed browser channel.
105    pub fn channel(mut self, channel: impl Into<String>) -> Self {
106        self.channel = channel.into();
107        self
108    }
109
110    /// Select an explicit installed-browser executable.
111    pub fn executable_path(mut self, executable_path: impl Into<PathBuf>) -> Self {
112        self.executable_path = Some(executable_path.into());
113        self
114    }
115
116    /// Select a dedicated browser profile.
117    pub fn user_data_dir(mut self, user_data_dir: impl Into<PathBuf>) -> Self {
118        self.user_data_dir = Some(user_data_dir.into());
119        self
120    }
121
122    /// Select a loopback CDP port. Zero asks Chrome to allocate one.
123    pub fn remote_debugging_port(mut self, port: u16) -> Self {
124        self.remote_debugging_port = port;
125        self
126    }
127
128    /// Enable or disable headless mode.
129    pub fn headless(mut self, headless: bool) -> Self {
130        self.headless = headless;
131        self
132    }
133
134    /// Set additional browser arguments.
135    pub fn with_args(mut self, args: Vec<String>) -> Self {
136        self.args = args;
137        self
138    }
139
140    /// Set the CDP readiness timeout.
141    pub fn startup_timeout(mut self, timeout: Duration) -> Self {
142        self.startup_timeout = timeout;
143        self
144    }
145
146    /// Set the engine operation delay.
147    pub fn slow_mo(mut self, milliseconds: u64) -> Self {
148        self.slow_mo = milliseconds;
149        self
150    }
151
152    /// Set the connection timeout.
153    pub fn timeout(mut self, timeout: Duration) -> Self {
154        self.timeout = Some(timeout);
155        self
156    }
157
158    /// Set Puppeteer's timeout for individual CDP calls.
159    pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
160        self.protocol_timeout = Some(timeout);
161        self
162    }
163
164    /// Seed cookies after attaching.
165    pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
166        self.seed_cookies = cookies;
167        self
168    }
169
170    /// Enable launch and connection logging.
171    pub fn verbose(mut self, verbose: bool) -> Self {
172        self.verbose = verbose;
173        self
174    }
175
176    /// Override the Node.js executable for bridge engines.
177    pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
178        self.node_executable = Some(executable.into());
179        self
180    }
181
182    /// Set the directory where Node resolves Playwright/Puppeteer.
183    pub fn node_working_dir(mut self, directory: impl Into<PathBuf>) -> Self {
184        self.node_working_dir = Some(directory.into());
185        self
186    }
187
188    /// Resolve the configured or managed dedicated profile path.
189    pub fn get_user_data_dir(&self) -> PathBuf {
190        self.user_data_dir
191            .clone()
192            .unwrap_or_else(|| default_real_browser_user_data_dir(&self.channel))
193    }
194}
195
196/// Owned installed-browser process. Dropping it terminates the spawned browser.
197pub struct BrowserProcess {
198    child: Child,
199}
200
201impl BrowserProcess {
202    fn new(child: Child) -> Self {
203        Self { child }
204    }
205
206    /// Operating-system process identifier.
207    pub fn id(&self) -> u32 {
208        self.child.id()
209    }
210
211    /// Return the exit status if the browser has stopped.
212    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
213        self.child.try_wait()
214    }
215
216    /// Terminate and reap the installed-browser process.
217    pub fn kill(&mut self) -> io::Result<()> {
218        if self.child.try_wait()?.is_some() {
219            return Ok(());
220        }
221        self.child.kill()?;
222        self.child.wait()?;
223        Ok(())
224    }
225}
226
227impl std::fmt::Debug for BrowserProcess {
228    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        formatter
230            .debug_struct("BrowserProcess")
231            .field("id", &self.id())
232            .finish()
233    }
234}
235
236impl Drop for BrowserProcess {
237    fn drop(&mut self) {
238        let _ = self.kill();
239    }
240}
241
242/// Browser/page handles plus metadata for the spawned installed browser.
243pub struct RealBrowserLaunchResult {
244    /// Browser metadata matching [`LaunchResult`].
245    pub browser: Browser,
246    /// Shared engine adapter matching [`LaunchResult`].
247    pub page: Arc<dyn EngineAdapter>,
248    /// Resolved loopback DevTools endpoint.
249    pub cdp_endpoint: String,
250    /// Resolved installed-browser executable.
251    pub executable_path: PathBuf,
252    /// Dedicated profile used by the browser.
253    pub user_data_dir: PathBuf,
254    /// Owned process handle. Dropping the result terminates the browser.
255    pub browser_process: BrowserProcess,
256}
257
258impl std::fmt::Debug for RealBrowserLaunchResult {
259    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        formatter
261            .debug_struct("RealBrowserLaunchResult")
262            .field("browser", &self.browser)
263            .field("page", &"<dyn EngineAdapter>")
264            .field("cdp_endpoint", &self.cdp_endpoint)
265            .field("executable_path", &self.executable_path)
266            .field("user_data_dir", &self.user_data_dir)
267            .field("browser_process", &self.browser_process)
268            .finish()
269    }
270}
271
272/// Return Browser Commander's managed dedicated profile for a channel.
273pub fn default_real_browser_user_data_dir(channel: &str) -> PathBuf {
274    let directory_name: String = channel
275        .chars()
276        .map(|character| {
277            if character.is_ascii_alphanumeric() || "_.-".contains(character) {
278                character
279            } else {
280                '-'
281            }
282        })
283        .collect();
284    dirs::home_dir()
285        .unwrap_or_else(|| PathBuf::from("."))
286        .join(".browser-commander")
287        .join("real-browser")
288        .join(directory_name)
289}
290
291fn known_default_user_data_dirs() -> Vec<PathBuf> {
292    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
293
294    #[cfg(target_os = "macos")]
295    {
296        let support = home.join("Library").join("Application Support");
297        return vec![
298            support.join("Google/Chrome"),
299            support.join("Google/Chrome Beta"),
300            support.join("Google/Chrome Canary"),
301            support.join("Google/Chrome Dev"),
302            support.join("Chromium"),
303            support.join("BraveSoftware/Brave-Browser"),
304            support.join("BraveSoftware/Brave-Browser-Beta"),
305            support.join("BraveSoftware/Brave-Browser-Nightly"),
306            support.join("Microsoft Edge"),
307            support.join("Microsoft Edge Beta"),
308            support.join("Microsoft Edge Canary"),
309            support.join("Microsoft Edge Dev"),
310        ];
311    }
312
313    #[cfg(target_os = "windows")]
314    {
315        let local = std::env::var_os("LOCALAPPDATA")
316            .map(PathBuf::from)
317            .unwrap_or_else(|| home.join("AppData/Local"));
318        return vec![
319            local.join("Google/Chrome/User Data"),
320            local.join("Google/Chrome Beta/User Data"),
321            local.join("Google/Chrome Dev/User Data"),
322            local.join("Google/Chrome SxS/User Data"),
323            local.join("Chromium/User Data"),
324            local.join("BraveSoftware/Brave-Browser/User Data"),
325            local.join("BraveSoftware/Brave-Browser-Beta/User Data"),
326            local.join("BraveSoftware/Brave-Browser-Nightly/User Data"),
327            local.join("Microsoft/Edge/User Data"),
328            local.join("Microsoft/Edge Beta/User Data"),
329            local.join("Microsoft/Edge Dev/User Data"),
330            local.join("Microsoft/Edge SxS/User Data"),
331        ];
332    }
333
334    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
335    {
336        vec![
337            home.join(".config/google-chrome"),
338            home.join(".config/google-chrome-beta"),
339            home.join(".config/google-chrome-unstable"),
340            home.join(".config/chromium"),
341            home.join(".config/BraveSoftware/Brave-Browser"),
342            home.join(".config/BraveSoftware/Brave-Browser-Beta"),
343            home.join(".config/BraveSoftware/Brave-Browser-Nightly"),
344            home.join(".config/microsoft-edge"),
345            home.join(".config/microsoft-edge-beta"),
346            home.join(".config/microsoft-edge-dev"),
347        ]
348    }
349}
350
351fn normalize_for_comparison(path: &Path) -> PathBuf {
352    let normalized = std::fs::canonicalize(path).unwrap_or_else(|_| {
353        if path.is_absolute() {
354            path.to_path_buf()
355        } else {
356            std::env::current_dir()
357                .unwrap_or_else(|_| PathBuf::from("."))
358                .join(path)
359        }
360    });
361
362    #[cfg(target_os = "windows")]
363    {
364        return PathBuf::from(normalized.to_string_lossy().to_lowercase());
365    }
366
367    #[cfg(not(target_os = "windows"))]
368    {
369        normalized
370    }
371}
372
373/// Ensure Chrome is not asked to expose a known default profile over CDP.
374pub fn assert_dedicated_user_data_dir(user_data_dir: &Path) -> Result<(), anyhow::Error> {
375    let requested = normalize_for_comparison(user_data_dir);
376    if known_default_user_data_dirs()
377        .iter()
378        .any(|default| normalize_for_comparison(default) == requested)
379    {
380        return Err(anyhow::anyhow!(
381            "launch_real_browser requires a dedicated user_data_dir, not a browser default profile"
382        ));
383    }
384    Ok(())
385}
386
387fn channel_executable_names(channel: &str) -> Result<&'static [&'static str], anyhow::Error> {
388    match channel {
389        "brave" => Ok(&["brave-browser", "brave-browser-stable", "brave"]),
390        "chrome" => Ok(&["google-chrome", "google-chrome-stable", "chrome"]),
391        "chrome-beta" => Ok(&["google-chrome-beta"]),
392        "chrome-canary" => Ok(&["google-chrome-canary"]),
393        "chrome-dev" => Ok(&["google-chrome-unstable"]),
394        "chromium" => Ok(&["chromium", "chromium-browser"]),
395        "msedge" => Ok(&["microsoft-edge", "microsoft-edge-stable", "msedge"]),
396        "msedge-beta" => Ok(&["microsoft-edge-beta"]),
397        "msedge-canary" => Ok(&["microsoft-edge-canary"]),
398        "msedge-dev" => Ok(&["microsoft-edge-dev"]),
399        _ => Err(anyhow::anyhow!(
400            "unknown browser channel: {channel}; expected chrome, chrome-beta, chrome-canary, chrome-dev, chromium, brave, msedge, msedge-beta, msedge-canary, or msedge-dev"
401        )),
402    }
403}
404
405fn browser_install_candidates(channel: &str) -> Result<Vec<PathBuf>, anyhow::Error> {
406    let names = channel_executable_names(channel)?;
407    let mut candidates = Vec::new();
408
409    #[cfg(target_os = "macos")]
410    {
411        let relative = match channel {
412            "brave" => "Brave Browser.app/Contents/MacOS/Brave Browser",
413            "chrome" => "Google Chrome.app/Contents/MacOS/Google Chrome",
414            "chrome-beta" => "Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
415            "chrome-canary" => "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
416            "chrome-dev" => "Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
417            "chromium" => "Chromium.app/Contents/MacOS/Chromium",
418            "msedge" => "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
419            "msedge-beta" => "Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
420            "msedge-canary" => "Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
421            "msedge-dev" => "Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
422            _ => unreachable!("channel was validated above"),
423        };
424        candidates.push(Path::new("/Applications").join(relative));
425        if let Some(home) = dirs::home_dir() {
426            candidates.push(home.join("Applications").join(relative));
427        }
428    }
429
430    #[cfg(target_os = "windows")]
431    {
432        let relative: &[&str] = match channel {
433            "brave" => &["BraveSoftware", "Brave-Browser", "Application", "brave.exe"],
434            "chrome" => &["Google", "Chrome", "Application", "chrome.exe"],
435            "chrome-beta" => &["Google", "Chrome Beta", "Application", "chrome.exe"],
436            "chrome-canary" => &["Google", "Chrome SxS", "Application", "chrome.exe"],
437            "chrome-dev" => &["Google", "Chrome Dev", "Application", "chrome.exe"],
438            "chromium" => &["Chromium", "Application", "chrome.exe"],
439            "msedge" => &["Microsoft", "Edge", "Application", "msedge.exe"],
440            "msedge-beta" => &["Microsoft", "Edge Beta", "Application", "msedge.exe"],
441            "msedge-canary" => &["Microsoft", "Edge SxS", "Application", "msedge.exe"],
442            "msedge-dev" => &["Microsoft", "Edge Dev", "Application", "msedge.exe"],
443            _ => unreachable!("channel was validated above"),
444        };
445        for key in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] {
446            if let Some(root) = std::env::var_os(key) {
447                let mut candidate = PathBuf::from(root);
448                candidate.extend(relative);
449                candidates.push(candidate);
450            }
451        }
452    }
453
454    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
455    {
456        for name in names {
457            candidates.push(Path::new("/usr/bin").join(name));
458            candidates.push(Path::new("/usr/local/bin").join(name));
459        }
460        if channel == "chrome" {
461            candidates.push(PathBuf::from("/opt/google/chrome/google-chrome"));
462        }
463    }
464
465    if let Some(path) = std::env::var_os("PATH") {
466        for directory in std::env::split_paths(&path) {
467            for name in names {
468                #[cfg(target_os = "windows")]
469                let executable_name = format!("{name}.exe");
470                #[cfg(not(target_os = "windows"))]
471                let executable_name = (*name).to_string();
472                candidates.push(directory.join(executable_name));
473            }
474        }
475    }
476
477    let mut seen = HashSet::new();
478    candidates.retain(|candidate| seen.insert(candidate.clone()));
479    Ok(candidates)
480}
481
482fn is_executable(path: &Path) -> bool {
483    if !path.is_file() {
484        return false;
485    }
486
487    #[cfg(unix)]
488    {
489        use std::os::unix::fs::PermissionsExt;
490        path.metadata()
491            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
492            .unwrap_or(false)
493    }
494
495    #[cfg(not(unix))]
496    {
497        true
498    }
499}
500
501/// Resolve a genuine installed Chrome-family browser executable.
502pub fn resolve_system_browser_executable(
503    options: &RealBrowserOptions,
504) -> Result<PathBuf, anyhow::Error> {
505    let candidates = if let Some(executable_path) = &options.executable_path {
506        vec![normalize_for_comparison(executable_path)]
507    } else {
508        browser_install_candidates(&options.channel)?
509    };
510
511    for candidate in candidates {
512        if is_executable(&candidate) {
513            return Ok(candidate);
514        }
515    }
516
517    if let Some(executable_path) = &options.executable_path {
518        Err(anyhow::anyhow!(
519            "browser executable is not accessible: {}",
520            executable_path.display()
521        ))
522    } else {
523        Err(anyhow::anyhow!(
524            "could not find an installed {} browser; provide executable_path",
525            options.channel
526        ))
527    }
528}
529
530/// Build the protected command line for an installed browser process.
531pub fn build_real_browser_args(options: &RealBrowserOptions) -> Result<Vec<String>, anyhow::Error> {
532    for argument in &options.args {
533        if MANAGED_ARGUMENTS
534            .iter()
535            .any(|managed| argument == managed || argument.starts_with(&format!("{managed}=")))
536        {
537            return Err(anyhow::anyhow!(
538                "{argument} is managed by launch_real_browser"
539            ));
540        }
541    }
542
543    let mut arguments = vec![
544        "--remote-debugging-address=127.0.0.1".to_string(),
545        format!("--remote-debugging-port={}", options.remote_debugging_port),
546        format!("--user-data-dir={}", options.get_user_data_dir().display()),
547        "--no-first-run".to_string(),
548        "--no-default-browser-check".to_string(),
549    ];
550    if options.headless {
551        arguments.push("--headless=new".to_string());
552    }
553    arguments.extend(options.args.clone());
554    Ok(arguments)
555}
556
557fn response_has_cdp_websocket(response: &[u8]) -> bool {
558    if !(response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200")) {
559        return false;
560    }
561    let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") else {
562        return false;
563    };
564    serde_json::from_slice::<Value>(&response[header_end + 4..])
565        .ok()
566        .and_then(|value| value.get("webSocketDebuggerUrl").cloned())
567        .and_then(|value| value.as_str().map(str::to_owned))
568        .is_some()
569}
570
571async fn fetch_cdp_version(port: u16, timeout: Duration) -> bool {
572    let request = format!(
573        "GET /json/version HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
574    );
575    let request_future = async {
576        let mut stream = TcpStream::connect(("127.0.0.1", port)).await?;
577        stream.write_all(request.as_bytes()).await?;
578        let mut response = Vec::new();
579        let mut chunk = [0_u8; 4096];
580        loop {
581            let bytes_read = stream.read(&mut chunk).await?;
582            if bytes_read == 0 {
583                break;
584            }
585            response.extend_from_slice(&chunk[..bytes_read]);
586            if response_has_cdp_websocket(&response) {
587                return Ok::<bool, io::Error>(true);
588            }
589            if response.len() > 1024 * 1024 {
590                return Ok(false);
591            }
592        }
593        Ok(response_has_cdp_websocket(&response))
594    };
595    matches!(
596        tokio::time::timeout(timeout, request_future).await,
597        Ok(Ok(true))
598    )
599}
600
601async fn wait_for_cdp_endpoint(
602    options: &RealBrowserOptions,
603    user_data_dir: &Path,
604    browser_process: &mut BrowserProcess,
605) -> Result<String, anyhow::Error> {
606    let started = Instant::now();
607    let active_port_path = user_data_dir.join("DevToolsActivePort");
608
609    while started.elapsed() < options.startup_timeout {
610        if let Some(status) = browser_process.try_wait()? {
611            return Err(anyhow::anyhow!(
612                "browser exited before its DevTools endpoint was ready ({status})"
613            ));
614        }
615
616        let mut port = options.remote_debugging_port;
617        if port == 0 {
618            port = std::fs::read_to_string(&active_port_path)
619                .ok()
620                .and_then(|contents| contents.lines().next()?.parse::<u16>().ok())
621                .unwrap_or(0);
622        }
623
624        if port > 0 {
625            let remaining = options.startup_timeout.saturating_sub(started.elapsed());
626            if fetch_cdp_version(port, remaining.min(Duration::from_millis(500))).await {
627                return Ok(format!("http://127.0.0.1:{port}"));
628            }
629        }
630        tokio::time::sleep(Duration::from_millis(100)).await;
631    }
632
633    Err(anyhow::anyhow!(
634        "timed out after {}ms waiting for the DevTools endpoint",
635        options.startup_timeout.as_millis()
636    ))
637}
638
639fn connection_options(
640    options: &RealBrowserOptions,
641    endpoint: &str,
642) -> Result<ConnectOptions, anyhow::Error> {
643    let mut connection = match options.engine {
644        EngineType::Chromiumoxide => ConnectOptions::chromiumoxide(),
645        EngineType::Playwright => ConnectOptions::playwright(),
646        EngineType::Puppeteer => ConnectOptions::puppeteer(),
647        EngineType::Fantoccini => {
648            return Err(anyhow::anyhow!(
649                "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer"
650            ));
651        }
652    };
653    connection.cdp_endpoint = Some(endpoint.to_string());
654    connection.slow_mo = options.slow_mo;
655    connection.timeout = options.timeout;
656    connection.protocol_timeout = options.protocol_timeout;
657    connection.seed_cookies = options.seed_cookies.clone();
658    connection.verbose = options.verbose;
659    connection.node_executable = options.node_executable.clone();
660    connection.node_working_dir = options.node_working_dir.clone();
661    Ok(connection)
662}
663
664/// Launch a genuine installed browser with an isolated profile and attach.
665///
666/// Chrome 136 and newer ignore remote-debugging switches for default profiles,
667/// so this helper rejects known default profile roots. It only binds CDP to
668/// loopback, verifies `/json/version`, and then delegates to [`connect_browser`].
669pub async fn launch_real_browser(
670    options: RealBrowserOptions,
671) -> Result<RealBrowserLaunchResult, anyhow::Error> {
672    if options.engine == EngineType::Fantoccini {
673        return Err(anyhow::anyhow!(
674            "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer"
675        ));
676    }
677
678    let user_data_dir = options.get_user_data_dir();
679    assert_dedicated_user_data_dir(&user_data_dir)?;
680    std::fs::create_dir_all(&user_data_dir)?;
681
682    let executable_path = resolve_system_browser_executable(&options)?;
683    let arguments = build_real_browser_args(&options)?;
684    let output = if options.verbose {
685        Stdio::inherit()
686    } else {
687        Stdio::null()
688    };
689    let child = Command::new(&executable_path)
690        .args(arguments)
691        .stdin(Stdio::null())
692        .stdout(output)
693        .stderr(if options.verbose {
694            Stdio::inherit()
695        } else {
696            Stdio::null()
697        })
698        .spawn()
699        .map_err(|error| {
700            anyhow::anyhow!(
701                "failed to start installed browser {}: {error}",
702                executable_path.display()
703            )
704        })?;
705    let mut browser_process = BrowserProcess::new(child);
706
707    let cdp_endpoint =
708        match wait_for_cdp_endpoint(&options, &user_data_dir, &mut browser_process).await {
709            Ok(endpoint) => endpoint,
710            Err(error) => {
711                let _ = browser_process.kill();
712                return Err(error);
713            }
714        };
715
716    let connect_options = connection_options(&options, &cdp_endpoint)?;
717    let LaunchResult { mut browser, page } = match connect_browser(connect_options).await {
718        Ok(connection) => connection,
719        Err(error) => {
720            let _ = browser_process.kill();
721            return Err(error);
722        }
723    };
724    browser.user_data_dir = user_data_dir.clone();
725    browser.headless = options.headless;
726
727    Ok(RealBrowserLaunchResult {
728        browser,
729        page,
730        cdp_endpoint,
731        executable_path,
732        user_data_dir,
733        browser_process,
734    })
735}
736
737/// Descriptive alias for [`launch_real_browser`].
738pub async fn launch_and_connect_real_browser(
739    options: RealBrowserOptions,
740) -> Result<RealBrowserLaunchResult, anyhow::Error> {
741    launch_real_browser(options).await
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    #[test]
749    fn default_options_use_native_engine_and_managed_profile() {
750        let options = RealBrowserOptions::default();
751        assert_eq!(options.engine, EngineType::Chromiumoxide);
752        assert_eq!(options.channel, "chrome");
753        assert_eq!(options.remote_debugging_port, 0);
754        assert!(options
755            .get_user_data_dir()
756            .to_string_lossy()
757            .contains("real-browser"));
758    }
759
760    #[test]
761    fn rejects_the_current_platform_default_profiles() {
762        for profile in known_default_user_data_dirs() {
763            let error = assert_dedicated_user_data_dir(&profile).unwrap_err();
764            assert!(error.to_string().contains("dedicated user_data_dir"));
765        }
766    }
767
768    #[test]
769    fn all_required_channels_have_discovery_candidates() {
770        for channel in ["chrome", "chromium", "brave", "msedge"] {
771            assert!(!browser_install_candidates(channel).unwrap().is_empty());
772        }
773
774        let chrome = browser_install_candidates("chrome").unwrap();
775        #[cfg(target_os = "macos")]
776        assert!(chrome.contains(&PathBuf::from(
777            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
778        )));
779        #[cfg(target_os = "windows")]
780        assert!(chrome.iter().any(
781            |candidate| candidate.ends_with(Path::new("Google/Chrome/Application/chrome.exe"))
782        ));
783        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
784        assert!(chrome.contains(&PathBuf::from("/usr/bin/google-chrome")));
785    }
786
787    #[test]
788    fn rejects_fantoccini_before_connecting() {
789        let options = RealBrowserOptions {
790            engine: EngineType::Fantoccini,
791            ..RealBrowserOptions::default()
792        };
793        assert!(connection_options(&options, "http://127.0.0.1:9222").is_err());
794    }
795
796    #[tokio::test]
797    async fn rejects_fantoccini_before_starting_a_browser() {
798        let options = RealBrowserOptions {
799            engine: EngineType::Fantoccini,
800            executable_path: Some(PathBuf::from("missing-browser")),
801            ..RealBrowserOptions::default()
802        };
803
804        let error = launch_real_browser(options).await.unwrap_err();
805        assert!(error.to_string().contains("does not connect over CDP"));
806    }
807
808    #[tokio::test]
809    async fn cdp_probe_does_not_wait_for_the_server_to_close_the_connection() {
810        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
811            .await
812            .unwrap();
813        let port = listener.local_addr().unwrap().port();
814        let response_body = r#"{"webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser/id"}"#;
815        let response = format!(
816            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{response_body}",
817            response_body.len()
818        );
819        let server = tokio::spawn(async move {
820            let (mut stream, _) = listener.accept().await.unwrap();
821            let mut request = [0_u8; 1024];
822            let _ = stream.read(&mut request).await.unwrap();
823            stream.write_all(response.as_bytes()).await.unwrap();
824            tokio::time::sleep(Duration::from_secs(1)).await;
825        });
826
827        assert!(fetch_cdp_version(port, Duration::from_millis(200)).await);
828        server.abort();
829    }
830}