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