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