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::node_bridge::NodeBridgePage;
15use crate::core::engine::EngineType;
16use crate::downloads::{attach_downloads, DownloadSetting};
17
18#[derive(Debug, Clone)]
20pub struct ConnectOptions {
21 pub engine: EngineType,
23 pub cdp_endpoint: Option<String>,
25 pub ws_endpoint: Option<String>,
27 pub slow_mo: u64,
29 pub timeout: Option<Duration>,
31 pub protocol_timeout: Option<Duration>,
33 pub seed_cookies: Vec<Value>,
35 pub verbose: bool,
37 pub node_executable: Option<PathBuf>,
39 pub node_working_dir: Option<PathBuf>,
41 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 pub fn chromiumoxide() -> Self {
71 Self::default()
72 }
73
74 pub fn playwright() -> Self {
76 Self {
77 engine: EngineType::Playwright,
78 ..Self::default()
79 }
80 }
81
82 pub fn puppeteer() -> Self {
84 Self {
85 engine: EngineType::Puppeteer,
86 ..Self::default()
87 }
88 }
89
90 pub fn cdp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
92 self.cdp_endpoint = Some(endpoint.into());
93 self
94 }
95
96 pub fn ws_endpoint(mut self, endpoint: impl Into<String>) -> Self {
98 self.ws_endpoint = Some(endpoint.into());
99 self
100 }
101
102 pub fn slow_mo(mut self, milliseconds: u64) -> Self {
104 self.slow_mo = milliseconds;
105 self
106 }
107
108 pub fn timeout(mut self, timeout: Duration) -> Self {
110 self.timeout = Some(timeout);
111 self
112 }
113
114 pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
116 self.protocol_timeout = Some(timeout);
117 self
118 }
119
120 pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
122 self.seed_cookies = cookies;
123 self
124 }
125
126 pub fn verbose(mut self, verbose: bool) -> Self {
128 self.verbose = verbose;
129 self
130 }
131
132 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
134 self.node_executable = Some(executable.into());
135 self
136 }
137
138 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 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
165pub 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 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 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}