browser_commander/browser/
connector.rs1use 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#[derive(Debug, Clone)]
26pub struct ConnectOptions {
27 pub engine: EngineType,
29 pub cdp_endpoint: Option<String>,
31 pub ws_endpoint: Option<String>,
33 pub slow_mo: u64,
35 pub timeout: Option<Duration>,
37 pub protocol_timeout: Option<Duration>,
39 pub seed_cookies: Vec<Value>,
41 pub storage_state: Option<StorageStateInput>,
43 pub verbose: bool,
45 pub node_executable: Option<PathBuf>,
47 pub node_working_dir: Option<PathBuf>,
49 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 pub fn chromiumoxide() -> Self {
80 Self::default()
81 }
82
83 pub fn playwright() -> Self {
85 Self {
86 engine: EngineType::Playwright,
87 ..Self::default()
88 }
89 }
90
91 pub fn puppeteer() -> Self {
93 Self {
94 engine: EngineType::Puppeteer,
95 ..Self::default()
96 }
97 }
98
99 pub fn cdp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
101 self.cdp_endpoint = Some(endpoint.into());
102 self
103 }
104
105 pub fn ws_endpoint(mut self, endpoint: impl Into<String>) -> Self {
107 self.ws_endpoint = Some(endpoint.into());
108 self
109 }
110
111 pub fn slow_mo(mut self, milliseconds: u64) -> Self {
113 self.slow_mo = milliseconds;
114 self
115 }
116
117 pub fn timeout(mut self, timeout: Duration) -> Self {
119 self.timeout = Some(timeout);
120 self
121 }
122
123 pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
125 self.protocol_timeout = Some(timeout);
126 self
127 }
128
129 pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
131 self.seed_cookies = cookies;
132 self
133 }
134
135 pub fn storage_state(mut self, state: impl Into<StorageStateInput>) -> Self {
137 self.storage_state = Some(state.into());
138 self
139 }
140
141 pub fn verbose(mut self, verbose: bool) -> Self {
143 self.verbose = verbose;
144 self
145 }
146
147 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
149 self.node_executable = Some(executable.into());
150 self
151 }
152
153 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 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
180pub async fn connect_browser(options: ConnectOptions) -> Result<LaunchResult, anyhow::Error> {
187 connect_browser_with(options, AttachSettings::default()).await
188}
189
190#[derive(Debug, Clone, Copy, Default)]
193pub(crate) struct AttachSettings<'a> {
194 pub(crate) fingerprint: Option<&'a FingerprintProfile>,
197 pub(crate) color_scheme: Option<&'a ColorScheme>,
199}
200
201pub(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
220pub(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 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
285enum 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
318async 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 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 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
442async 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}