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