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::node_bridge::NodeBridgePage;
15use crate::core::engine::EngineType;
16use crate::downloads::{attach_downloads, DownloadSetting};
17
18/// Options for attaching to a running browser over CDP.
19#[derive(Debug, Clone)]
20pub struct ConnectOptions {
21    /// Browser automation engine used for the connection.
22    pub engine: EngineType,
23    /// HTTP DevTools endpoint, for example `http://127.0.0.1:9222`.
24    pub cdp_endpoint: Option<String>,
25    /// DevTools browser WebSocket endpoint.
26    pub ws_endpoint: Option<String>,
27    /// Slow down Playwright/Puppeteer operations by this many milliseconds.
28    pub slow_mo: u64,
29    /// Optional connection timeout.
30    pub timeout: Option<Duration>,
31    /// Optional Puppeteer timeout for individual CDP calls.
32    pub protocol_timeout: Option<Duration>,
33    /// Cookies to seed after attaching, in CDP/Playwright cookie format.
34    pub seed_cookies: Vec<Value>,
35    /// Enable verbose bridge logging.
36    pub verbose: bool,
37    /// Node.js executable for Playwright/Puppeteer bridge engines.
38    pub node_executable: Option<PathBuf>,
39    /// Directory where Node resolves the Playwright/Puppeteer package.
40    pub node_working_dir: Option<PathBuf>,
41    /// Manage the attached browser's downloads.
42    ///
43    /// The same setting and the same manager as
44    /// [`LaunchOptions`](super::launcher::LaunchOptions), because a browser
45    /// somebody else started downloads files the same way — including the ones
46    /// a person clicks by hand. See [`downloads`](crate::downloads).
47    pub downloads: DownloadSetting,
48}
49
50impl Default for ConnectOptions {
51    fn default() -> Self {
52        Self {
53            engine: EngineType::Chromiumoxide,
54            cdp_endpoint: None,
55            ws_endpoint: None,
56            slow_mo: 0,
57            timeout: None,
58            protocol_timeout: None,
59            seed_cookies: Vec::new(),
60            verbose: false,
61            node_executable: None,
62            node_working_dir: None,
63            downloads: DownloadSetting::Off,
64        }
65    }
66}
67
68impl ConnectOptions {
69    /// Create Chromiumoxide connection options.
70    pub fn chromiumoxide() -> Self {
71        Self::default()
72    }
73
74    /// Create Playwright bridge connection options.
75    pub fn playwright() -> Self {
76        Self {
77            engine: EngineType::Playwright,
78            ..Self::default()
79        }
80    }
81
82    /// Create Puppeteer bridge connection options.
83    pub fn puppeteer() -> Self {
84        Self {
85            engine: EngineType::Puppeteer,
86            ..Self::default()
87        }
88    }
89
90    /// Select an HTTP DevTools endpoint.
91    pub fn cdp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
92        self.cdp_endpoint = Some(endpoint.into());
93        self
94    }
95
96    /// Select a DevTools browser WebSocket endpoint.
97    pub fn ws_endpoint(mut self, endpoint: impl Into<String>) -> Self {
98        self.ws_endpoint = Some(endpoint.into());
99        self
100    }
101
102    /// Set the engine operation delay.
103    pub fn slow_mo(mut self, milliseconds: u64) -> Self {
104        self.slow_mo = milliseconds;
105        self
106    }
107
108    /// Set the connection timeout.
109    pub fn timeout(mut self, timeout: Duration) -> Self {
110        self.timeout = Some(timeout);
111        self
112    }
113
114    /// Set Puppeteer's timeout for individual CDP calls.
115    pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
116        self.protocol_timeout = Some(timeout);
117        self
118    }
119
120    /// Seed cookies immediately after the connection is established.
121    pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
122        self.seed_cookies = cookies;
123        self
124    }
125
126    /// Enable verbose connection logging.
127    pub fn verbose(mut self, verbose: bool) -> Self {
128        self.verbose = verbose;
129        self
130    }
131
132    /// Override the Node.js executable for bridge engines.
133    pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
134        self.node_executable = Some(executable.into());
135        self
136    }
137
138    /// Set the directory where Node resolves Playwright or Puppeteer.
139    pub fn node_working_dir(mut self, directory: impl Into<PathBuf>) -> Self {
140        self.node_working_dir = Some(directory.into());
141        self
142    }
143
144    /// Manage the attached browser's downloads.
145    ///
146    /// # Arguments
147    ///
148    /// * `downloads` - `true` for the defaults, `false` for none, or
149    ///   [`DownloadOptions`](crate::downloads::DownloadOptions)
150    pub fn downloads(mut self, downloads: impl Into<DownloadSetting>) -> Self {
151        self.downloads = downloads.into();
152        self
153    }
154
155    pub(crate) fn endpoint(&self) -> Result<&str, anyhow::Error> {
156        match (&self.cdp_endpoint, &self.ws_endpoint) {
157            (Some(endpoint), None) | (None, Some(endpoint)) if !endpoint.is_empty() => Ok(endpoint),
158            _ => Err(anyhow::anyhow!(
159                "connect_browser requires exactly one of cdp_endpoint or ws_endpoint"
160            )),
161        }
162    }
163}
164
165/// Attach to a running Chromium-family browser over CDP.
166///
167/// Chromiumoxide connects natively. Playwright and Puppeteer use the same
168/// official Node.js packages as [`launch_browser`](super::launcher::launch_browser).
169/// The returned page implements the crate's shared [`EngineAdapter`](crate::core::EngineAdapter)
170/// API. The browser's profile and process remain externally managed.
171pub async fn connect_browser(options: ConnectOptions) -> Result<LaunchResult, anyhow::Error> {
172    let endpoint = options.endpoint()?.to_string();
173    if options.verbose {
174        tracing::info!(engine = %options.engine, %endpoint, "connecting to browser");
175    }
176
177    match options.engine {
178        EngineType::Chromiumoxide => connect_chromiumoxide(options, endpoint).await,
179        EngineType::Playwright | EngineType::Puppeteer => {
180            let engine = options.engine;
181            let timeout = options.timeout;
182            // Refused before the connection is made, for the same reason the
183            // launcher refuses it: the bridge has no CDP route, so a manager
184            // here would watch a directory the browser never writes into.
185            crate::downloads::normalize_download_options(options.downloads.clone())
186                .map(|_| crate::downloads::supported_engine(engine))
187                .transpose()
188                .map_err(|error| anyhow::anyhow!("{error}"))?;
189            let connection = NodeBridgePage::connect(options);
190            let page = if let Some(timeout) = timeout {
191                tokio::time::timeout(timeout, connection)
192                    .await
193                    .map_err(|_| anyhow::anyhow!("timed out connecting to browser"))??
194            } else {
195                connection.await?
196            };
197            Ok(LaunchResult {
198                browser: Browser {
199                    engine,
200                    user_data_dir: PathBuf::new(),
201                    headless: false,
202                },
203                page: Arc::new(page),
204                downloads: None,
205            })
206        }
207        EngineType::Fantoccini => Err(anyhow::anyhow!(
208            "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer"
209        )),
210    }
211}
212
213async fn connect_chromiumoxide(
214    options: ConnectOptions,
215    endpoint: String,
216) -> Result<LaunchResult, anyhow::Error> {
217    let connection = CdpBrowser::connect(endpoint);
218    let (browser, mut handler) = if let Some(timeout) = options.timeout {
219        tokio::time::timeout(timeout, connection)
220            .await
221            .map_err(|_| anyhow::anyhow!("timed out connecting to browser"))??
222    } else {
223        connection.await?
224    };
225
226    let handler_task = tokio::spawn(async move {
227        while let Some(event) = handler.next().await {
228            if let Err(error) = event {
229                tracing::debug!(%error, "chromiumoxide handler event error");
230            }
231        }
232    });
233
234    if !options.seed_cookies.is_empty() {
235        let cookies = options
236            .seed_cookies
237            .iter()
238            .cloned()
239            .map(serde_json::from_value::<CookieParam>)
240            .collect::<Result<Vec<_>, _>>()
241            .map_err(|error| anyhow::anyhow!("invalid seed cookie: {error}"))?;
242        browser.set_cookies(cookies).await?;
243    }
244
245    let page = match browser.pages().await?.into_iter().next() {
246        Some(page) => page,
247        None => browser.new_page("about:blank").await?,
248    };
249    let engine = options.engine;
250    let adapter = ChromiumoxidePage::new(page, browser, handler_task, PathBuf::new());
251
252    // `Browser.setDownloadBehavior` is browser-wide, so an attached browser's
253    // downloads are managed even when a person starts them from the window
254    // rather than from automation. That is the point of managing a *connected*
255    // browser at all.
256    let downloads = attach_downloads(engine, &adapter, options.downloads.clone())
257        .await
258        .map_err(|error| anyhow::anyhow!("{error}"))?;
259
260    Ok(LaunchResult {
261        browser: Browser {
262            engine,
263            user_data_dir: PathBuf::new(),
264            headless: false,
265        },
266        page: Arc::new(adapter),
267        downloads,
268    })
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use serde_json::json;
275
276    #[test]
277    fn connect_options_carry_a_download_setting() {
278        assert!(matches!(
279            ConnectOptions::default().downloads,
280            DownloadSetting::Off
281        ));
282        assert!(matches!(
283            ConnectOptions::chromiumoxide().downloads(true).downloads,
284            DownloadSetting::On
285        ));
286    }
287
288    #[tokio::test]
289    async fn connect_playwright_refuses_downloads_it_cannot_manage() {
290        let options = ConnectOptions::playwright()
291            .cdp_endpoint("http://127.0.0.1:9222")
292            .downloads(true);
293
294        let error = connect_browser(options).await.unwrap_err();
295
296        assert!(
297            error
298                .to_string()
299                .contains("managed downloads are not supported"),
300            "unexpected message: {error}"
301        );
302    }
303
304    #[test]
305    fn connect_options_builders_preserve_endpoints_and_cookies() {
306        let cookies = vec![json!({"name": "SID", "value": "saved", "domain": ".example.com"})];
307        let options = ConnectOptions::playwright()
308            .cdp_endpoint("http://127.0.0.1:9222")
309            .slow_mo(25)
310            .seed_cookies(cookies.clone())
311            .node_working_dir("../js");
312
313        assert_eq!(options.engine, EngineType::Playwright);
314        assert_eq!(options.endpoint().unwrap(), "http://127.0.0.1:9222");
315        assert_eq!(options.slow_mo, 25);
316        assert_eq!(options.seed_cookies, cookies);
317        assert_eq!(options.node_working_dir, Some(PathBuf::from("../js")));
318    }
319
320    #[test]
321    fn connect_options_require_exactly_one_endpoint() {
322        assert!(ConnectOptions::default().endpoint().is_err());
323        assert!(ConnectOptions::puppeteer()
324            .cdp_endpoint("http://127.0.0.1:9222")
325            .ws_endpoint("ws://127.0.0.1:9222/devtools/browser/id")
326            .endpoint()
327            .is_err());
328    }
329}