Skip to main content

browser_commander/browser/
connector.rs

1//! Connect to an already-running Chromium-family browser over CDP.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use chromiumoxide::browser::Browser as CdpBrowser;
8use chromiumoxide::cdp::browser_protocol::network::CookieParam;
9use futures::StreamExt;
10use serde_json::Value;
11
12use crate::browser::chromiumoxide_adapter::ChromiumoxidePage;
13use crate::browser::launcher::{Browser, LaunchResult};
14use crate::browser::media::ColorScheme;
15use crate::browser::node_bridge::NodeBridgePage;
16use crate::browser::playwright_driver_page::{PlaywrightConnect, PlaywrightDriverPage};
17use crate::browser::storage_state::{StorageState, StorageStateInput};
18use crate::core::engine::{EngineAdapter, EngineType};
19use crate::downloads::{attach_downloads, DownloadSetting};
20use crate::fingerprint::apply::{apply_fingerprint, ApplyOptions};
21use crate::fingerprint::profile::FingerprintProfile;
22use crate::playwright::{DriverOptions, PlaywrightDriver};
23
24/// Options for attaching to a running browser over CDP.
25#[derive(Debug, Clone)]
26pub struct ConnectOptions {
27    /// Browser automation engine used for the connection.
28    pub engine: EngineType,
29    /// HTTP DevTools endpoint, for example `http://127.0.0.1:9222`.
30    pub cdp_endpoint: Option<String>,
31    /// DevTools browser WebSocket endpoint.
32    pub ws_endpoint: Option<String>,
33    /// Slow down Playwright/Puppeteer operations by this many milliseconds.
34    pub slow_mo: u64,
35    /// Optional connection timeout.
36    pub timeout: Option<Duration>,
37    /// Optional Puppeteer timeout for individual CDP calls.
38    pub protocol_timeout: Option<Duration>,
39    /// Cookies to seed after attaching, in CDP/Playwright cookie format.
40    pub seed_cookies: Vec<Value>,
41    /// Playwright-compatible cookies and origin-scoped localStorage to restore.
42    pub storage_state: Option<StorageStateInput>,
43    /// Enable verbose bridge logging.
44    pub verbose: bool,
45    /// Node.js executable for Playwright/Puppeteer bridge engines.
46    pub node_executable: Option<PathBuf>,
47    /// Directory where Node resolves the Playwright/Puppeteer package.
48    pub node_working_dir: Option<PathBuf>,
49    /// Manage the attached browser's downloads.
50    ///
51    /// The same setting and the same manager as
52    /// [`LaunchOptions`](super::launcher::LaunchOptions), because a browser
53    /// somebody else started downloads files the same way — including the ones
54    /// a person clicks by hand. See [`downloads`](crate::downloads).
55    pub downloads: DownloadSetting,
56}
57
58impl Default for ConnectOptions {
59    fn default() -> Self {
60        Self {
61            engine: EngineType::Chromiumoxide,
62            cdp_endpoint: None,
63            ws_endpoint: None,
64            slow_mo: 0,
65            timeout: None,
66            protocol_timeout: None,
67            seed_cookies: Vec::new(),
68            storage_state: None,
69            verbose: false,
70            node_executable: None,
71            node_working_dir: None,
72            downloads: DownloadSetting::Off,
73        }
74    }
75}
76
77impl ConnectOptions {
78    /// Create Chromiumoxide connection options.
79    pub fn chromiumoxide() -> Self {
80        Self::default()
81    }
82
83    /// Create Playwright bridge connection options.
84    pub fn playwright() -> Self {
85        Self {
86            engine: EngineType::Playwright,
87            ..Self::default()
88        }
89    }
90
91    /// Create Puppeteer bridge connection options.
92    pub fn puppeteer() -> Self {
93        Self {
94            engine: EngineType::Puppeteer,
95            ..Self::default()
96        }
97    }
98
99    /// Select an HTTP DevTools endpoint.
100    pub fn cdp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
101        self.cdp_endpoint = Some(endpoint.into());
102        self
103    }
104
105    /// Select a DevTools browser WebSocket endpoint.
106    pub fn ws_endpoint(mut self, endpoint: impl Into<String>) -> Self {
107        self.ws_endpoint = Some(endpoint.into());
108        self
109    }
110
111    /// Set the engine operation delay.
112    pub fn slow_mo(mut self, milliseconds: u64) -> Self {
113        self.slow_mo = milliseconds;
114        self
115    }
116
117    /// Set the connection timeout.
118    pub fn timeout(mut self, timeout: Duration) -> Self {
119        self.timeout = Some(timeout);
120        self
121    }
122
123    /// Set Puppeteer's timeout for individual CDP calls.
124    pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
125        self.protocol_timeout = Some(timeout);
126        self
127    }
128
129    /// Seed cookies immediately after the connection is established.
130    pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
131        self.seed_cookies = cookies;
132        self
133    }
134
135    /// Restore portable state before returning the connected page.
136    pub fn storage_state(mut self, state: impl Into<StorageStateInput>) -> Self {
137        self.storage_state = Some(state.into());
138        self
139    }
140
141    /// Enable verbose connection logging.
142    pub fn verbose(mut self, verbose: bool) -> Self {
143        self.verbose = verbose;
144        self
145    }
146
147    /// Override the Node.js executable for bridge engines.
148    pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
149        self.node_executable = Some(executable.into());
150        self
151    }
152
153    /// Set the directory where Node resolves Playwright or Puppeteer.
154    pub fn node_working_dir(mut self, directory: impl Into<PathBuf>) -> Self {
155        self.node_working_dir = Some(directory.into());
156        self
157    }
158
159    /// Manage the attached browser's downloads.
160    ///
161    /// # Arguments
162    ///
163    /// * `downloads` - `true` for the defaults, `false` for none, or
164    ///   [`DownloadOptions`](crate::downloads::DownloadOptions)
165    pub fn downloads(mut self, downloads: impl Into<DownloadSetting>) -> Self {
166        self.downloads = downloads.into();
167        self
168    }
169
170    pub(crate) fn endpoint(&self) -> Result<&str, anyhow::Error> {
171        match (&self.cdp_endpoint, &self.ws_endpoint) {
172            (Some(endpoint), None) | (None, Some(endpoint)) if !endpoint.is_empty() => Ok(endpoint),
173            _ => Err(anyhow::anyhow!(
174                "connect_browser requires exactly one of cdp_endpoint or ws_endpoint"
175            )),
176        }
177    }
178}
179
180/// Attach to a running Chromium-family browser over CDP.
181///
182/// Chromiumoxide connects natively. Playwright and Puppeteer use the same
183/// official Node.js packages as [`launch_browser`](super::launcher::launch_browser).
184/// The returned page implements the crate's shared [`EngineAdapter`]
185/// API. The browser's profile and process remain externally managed.
186pub async fn connect_browser(options: ConnectOptions) -> Result<LaunchResult, anyhow::Error> {
187    connect_browser_with(options, AttachSettings::default()).await
188}
189
190/// What [`launch_browser`](super::launcher::launch_browser) sets up on the
191/// page it attaches to, before the caller gets it.
192#[derive(Debug, Clone, Copy, Default)]
193pub(crate) struct AttachSettings<'a> {
194    /// Applied before the caller can navigate, so the first document already
195    /// sees the configured environment.
196    pub(crate) fingerprint: Option<&'a FingerprintProfile>,
197    /// Emulated on the attached page (best-effort).
198    pub(crate) color_scheme: Option<&'a ColorScheme>,
199}
200
201/// Refuse a fingerprint profile an engine cannot apply.
202///
203/// The node bridge speaks its own command protocol rather than CDP, so a
204/// profile handed to it would be silently dropped and the page would report
205/// the real machine. Failing is the honest answer.
206pub(crate) fn refuse_unappliable_fingerprint(
207    engine: EngineType,
208    fingerprint: Option<&FingerprintProfile>,
209) -> Result<(), anyhow::Error> {
210    if fingerprint.is_some() && engine != EngineType::Chromiumoxide {
211        return Err(anyhow::anyhow!(
212            "the {engine} engine cannot apply a fingerprint profile yet; \
213             use EngineType::Chromiumoxide, or apply the profile from the \
214             JavaScript package, which drives Playwright and Puppeteer directly"
215        ));
216    }
217    Ok(())
218}
219
220/// [`connect_browser`] with the page set up as `settings` asks. Used by
221/// [`launch_browser`](super::launcher::launch_browser) to attach to the
222/// browser it started.
223pub(crate) async fn connect_browser_with(
224    options: ConnectOptions,
225    settings: AttachSettings<'_>,
226) -> Result<LaunchResult, anyhow::Error> {
227    let endpoint = options.endpoint()?.to_string();
228    let storage_state = options
229        .storage_state
230        .as_ref()
231        .map(StorageStateInput::load)
232        .transpose()?;
233    refuse_unappliable_fingerprint(options.engine, settings.fingerprint)?;
234    if options.verbose {
235        tracing::info!(engine = %options.engine, %endpoint, "connecting to browser");
236    }
237
238    match options.engine {
239        EngineType::Chromiumoxide => {
240            connect_chromiumoxide(options, endpoint, settings, storage_state).await
241        }
242        EngineType::Playwright | EngineType::Puppeteer => {
243            let engine = options.engine;
244            let timeout = options.timeout;
245            let seed_cookies = options.seed_cookies.clone();
246            // Refused before the connection is made, for the same reason the
247            // launcher refuses it: the bridge has no CDP route, so a manager
248            // here would watch a directory the browser never writes into.
249            crate::downloads::normalize_download_options(options.downloads.clone())
250                .map(|_| crate::downloads::supported_engine(engine))
251                .transpose()
252                .map_err(|error| anyhow::anyhow!("{error}"))?;
253            let connection = connect_node_engine(options, endpoint, settings.color_scheme);
254            let page = if let Some(timeout) = timeout {
255                tokio::time::timeout(timeout, connection)
256                    .await
257                    .map_err(|_| anyhow::anyhow!("timed out connecting to browser"))??
258            } else {
259                connection.await?
260            };
261            if let Some(mut state) = storage_state {
262                state.cookies.extend(seed_cookies);
263                let value = serde_json::to_value(state)?;
264                if let Err(error) = page.adapter().restore_storage_state(value).await {
265                    page.close().await;
266                    return Err(error.into());
267                }
268            }
269            Ok(LaunchResult::attached(
270                Browser {
271                    engine,
272                    user_data_dir: PathBuf::new(),
273                    headless: false,
274                },
275                page.into_adapter(),
276                None,
277            ))
278        }
279        EngineType::Fantoccini => Err(anyhow::anyhow!(
280            "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer"
281        )),
282    }
283}
284
285/// A Playwright or Puppeteer page attached to a running browser.
286enum NodeEngine {
287    Driver(PlaywrightDriverPage),
288    Bridge(NodeBridgePage),
289}
290
291impl NodeEngine {
292    fn adapter(&self) -> &dyn EngineAdapter {
293        match self {
294            Self::Driver(page) => page,
295            Self::Bridge(page) => page,
296        }
297    }
298
299    fn into_adapter(self) -> Arc<dyn EngineAdapter> {
300        match self {
301            Self::Driver(page) => Arc::new(page),
302            Self::Bridge(page) => Arc::new(page),
303        }
304    }
305
306    async fn close(&self) {
307        match self {
308            Self::Driver(page) => {
309                let _ = page.close().await;
310            }
311            Self::Bridge(page) => {
312                let _ = page.close().await;
313            }
314        }
315    }
316}
317
318/// Playwright attaches through its official driver; Puppeteer, and
319/// Playwright without a matching driver, through the Node bridge.
320async fn connect_node_engine(
321    options: ConnectOptions,
322    endpoint: String,
323    color_scheme: Option<&ColorScheme>,
324) -> Result<NodeEngine, anyhow::Error> {
325    if options.engine == EngineType::Playwright {
326        let driver = PlaywrightDriver::launch(DriverOptions {
327            node: options.node_executable.clone(),
328            working_dir: options.node_working_dir.clone(),
329            verbose: options.verbose,
330        })
331        .await;
332        match driver {
333            Ok(driver) => {
334                let connect = PlaywrightConnect {
335                    driver: DriverOptions::default(),
336                    endpoint,
337                    slow_mo: options.slow_mo,
338                    timeout: options.timeout,
339                    seed_cookies: options.seed_cookies.clone(),
340                    color_scheme: color_scheme.map(|cs| cs.as_str().to_string()),
341                };
342                let page = PlaywrightDriverPage::connect_with(driver, connect).await?;
343                return Ok(NodeEngine::Driver(page));
344            }
345            Err(error) => {
346                tracing::info!(%error, "Playwright driver unavailable; using the Node bridge");
347            }
348        }
349    }
350    Ok(NodeEngine::Bridge(
351        NodeBridgePage::connect(options, color_scheme).await?,
352    ))
353}
354
355async fn connect_chromiumoxide(
356    options: ConnectOptions,
357    endpoint: String,
358    settings: AttachSettings<'_>,
359    storage_state: Option<StorageState>,
360) -> Result<LaunchResult, anyhow::Error> {
361    let connection = CdpBrowser::connect(endpoint);
362    let (browser, mut handler) = if let Some(timeout) = options.timeout {
363        tokio::time::timeout(timeout, connection)
364            .await
365            .map_err(|_| anyhow::anyhow!("timed out connecting to browser"))??
366    } else {
367        connection.await?
368    };
369
370    let handler_task = tokio::spawn(async move {
371        while let Some(event) = handler.next().await {
372            if let Err(error) = event {
373                tracing::debug!(%error, "chromiumoxide handler event error");
374            }
375        }
376    });
377
378    if !options.seed_cookies.is_empty() {
379        let cookies = options
380            .seed_cookies
381            .iter()
382            .cloned()
383            .map(serde_json::from_value::<CookieParam>)
384            .collect::<Result<Vec<_>, _>>()
385            .map_err(|error| anyhow::anyhow!("invalid seed cookie: {error}"))?;
386        browser.set_cookies(cookies).await?;
387    }
388
389    let page = match pick_foreground_page(browser.pages().await?).await {
390        Some(page) => page,
391        None => browser.new_page("about:blank").await?,
392    };
393    let engine = options.engine;
394    let adapter = ChromiumoxidePage::new(page, browser, handler_task, PathBuf::new());
395
396    if let Some(mut state) = storage_state {
397        state.cookies.extend(options.seed_cookies.clone());
398        if let Err(error) = adapter
399            .restore_storage_state(serde_json::to_value(state)?)
400            .await
401        {
402            let _ = adapter.close().await;
403            return Err(error.into());
404        }
405    }
406
407    // A failure here is fatal rather than best-effort: a half-applied profile
408    // describes a machine that does not exist, which is louder than none.
409    if let Some(profile) = settings.fingerprint {
410        apply_fingerprint(&adapter, profile, ApplyOptions::default()).await?;
411        if options.verbose {
412            tracing::info!("Fingerprint profile applied");
413        }
414    }
415    if let Some(color_scheme) = settings.color_scheme {
416        if let Err(error) = adapter.set_color_scheme(Some(color_scheme)).await {
417            if options.verbose {
418                tracing::warn!(%error, "could not set color scheme");
419            }
420        }
421    }
422
423    // `Browser.setDownloadBehavior` is browser-wide, so an attached browser's
424    // downloads are managed even when a person starts them from the window
425    // rather than from automation. That is the point of managing a *connected*
426    // browser at all.
427    let downloads = attach_downloads(engine, &adapter, options.downloads.clone())
428        .await
429        .map_err(|error| anyhow::anyhow!("{error}"))?;
430
431    Ok(LaunchResult::attached(
432        Browser {
433            engine,
434            user_data_dir: PathBuf::new(),
435            headless: false,
436        },
437        Arc::new(adapter),
438        downloads,
439    ))
440}
441
442/// Pick the tab that is on screen.
443///
444/// A browser attached after it started can already have several tabs - a
445/// fresh profile can open a welcome tab next to the New Tab page - and CDP
446/// does not list them in a stable order. Driving a background tab would make
447/// `document.hidden` true where a person's first navigation sees a visible
448/// page, so the visible tab wins and the first tab is the fallback.
449async fn pick_foreground_page(pages: Vec<chromiumoxide::Page>) -> Option<chromiumoxide::Page> {
450    for page in &pages {
451        let state = page
452            .evaluate("document.visibilityState")
453            .await
454            .ok()
455            .and_then(|result| result.into_value::<String>().ok());
456        if state.as_deref() == Some("visible") {
457            return Some(page.clone());
458        }
459    }
460    pages.into_iter().next()
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use serde_json::json;
467
468    #[test]
469    fn connect_options_carry_a_download_setting() {
470        assert!(matches!(
471            ConnectOptions::default().downloads,
472            DownloadSetting::Off
473        ));
474        assert!(matches!(
475            ConnectOptions::chromiumoxide().downloads(true).downloads,
476            DownloadSetting::On
477        ));
478    }
479
480    #[tokio::test]
481    async fn connect_playwright_refuses_downloads_it_cannot_manage() {
482        let options = ConnectOptions::playwright()
483            .cdp_endpoint("http://127.0.0.1:9222")
484            .downloads(true);
485
486        let error = connect_browser(options).await.unwrap_err();
487
488        assert!(
489            error
490                .to_string()
491                .contains("managed downloads are not supported"),
492            "unexpected message: {error}"
493        );
494    }
495
496    #[test]
497    fn connect_options_builders_preserve_endpoints_and_cookies() {
498        let cookies = vec![json!({"name": "SID", "value": "saved", "domain": ".example.com"})];
499        let options = ConnectOptions::playwright()
500            .cdp_endpoint("http://127.0.0.1:9222")
501            .slow_mo(25)
502            .seed_cookies(cookies.clone())
503            .node_working_dir("../js");
504
505        assert_eq!(options.engine, EngineType::Playwright);
506        assert_eq!(options.endpoint().unwrap(), "http://127.0.0.1:9222");
507        assert_eq!(options.slow_mo, 25);
508        assert_eq!(options.seed_cookies, cookies);
509        assert_eq!(options.node_working_dir, Some(PathBuf::from("../js")));
510    }
511
512    #[test]
513    fn connect_options_require_exactly_one_endpoint() {
514        assert!(ConnectOptions::default().endpoint().is_err());
515        assert!(ConnectOptions::puppeteer()
516            .cdp_endpoint("http://127.0.0.1:9222")
517            .ws_endpoint("ws://127.0.0.1:9222/devtools/browser/id")
518            .endpoint()
519            .is_err());
520    }
521}