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::core::engine::EngineType;
17use crate::downloads::{attach_downloads, DownloadSetting};
18use crate::fingerprint::apply::{apply_fingerprint, ApplyOptions};
19use crate::fingerprint::profile::FingerprintProfile;
20
21#[derive(Debug, Clone)]
23pub struct ConnectOptions {
24 pub engine: EngineType,
26 pub cdp_endpoint: Option<String>,
28 pub ws_endpoint: Option<String>,
30 pub slow_mo: u64,
32 pub timeout: Option<Duration>,
34 pub protocol_timeout: Option<Duration>,
36 pub seed_cookies: Vec<Value>,
38 pub verbose: bool,
40 pub node_executable: Option<PathBuf>,
42 pub node_working_dir: Option<PathBuf>,
44 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 pub fn chromiumoxide() -> Self {
74 Self::default()
75 }
76
77 pub fn playwright() -> Self {
79 Self {
80 engine: EngineType::Playwright,
81 ..Self::default()
82 }
83 }
84
85 pub fn puppeteer() -> Self {
87 Self {
88 engine: EngineType::Puppeteer,
89 ..Self::default()
90 }
91 }
92
93 pub fn cdp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
95 self.cdp_endpoint = Some(endpoint.into());
96 self
97 }
98
99 pub fn ws_endpoint(mut self, endpoint: impl Into<String>) -> Self {
101 self.ws_endpoint = Some(endpoint.into());
102 self
103 }
104
105 pub fn slow_mo(mut self, milliseconds: u64) -> Self {
107 self.slow_mo = milliseconds;
108 self
109 }
110
111 pub fn timeout(mut self, timeout: Duration) -> Self {
113 self.timeout = Some(timeout);
114 self
115 }
116
117 pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
119 self.protocol_timeout = Some(timeout);
120 self
121 }
122
123 pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
125 self.seed_cookies = cookies;
126 self
127 }
128
129 pub fn verbose(mut self, verbose: bool) -> Self {
131 self.verbose = verbose;
132 self
133 }
134
135 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
137 self.node_executable = Some(executable.into());
138 self
139 }
140
141 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 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
168pub async fn connect_browser(options: ConnectOptions) -> Result<LaunchResult, anyhow::Error> {
175 connect_browser_with(options, AttachSettings::default()).await
176}
177
178#[derive(Debug, Clone, Copy, Default)]
181pub(crate) struct AttachSettings<'a> {
182 pub(crate) fingerprint: Option<&'a FingerprintProfile>,
185 pub(crate) color_scheme: Option<&'a ColorScheme>,
187}
188
189pub(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
208pub(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 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 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 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
332async 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}