1use async_trait::async_trait;
7use chromiumoxide::browser::{Browser, BrowserConfig};
8use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
9use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, FocusParams, GetBoxModelParams};
10use chromiumoxide::cdp::browser_protocol::input::{
11 DispatchKeyEventParams, DispatchKeyEventType, DispatchMouseEventParams, DispatchMouseEventType,
12 MouseButton,
13};
14use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
15use chromiumoxide::Page;
16use futures::StreamExt;
17use std::collections::HashMap;
18use std::sync::Arc;
19use tokio::sync::RwLock;
20
21use crate::backend::{BrowserBackend, BrowserError};
22use crate::models::{A11yNode, Bounds, Modifier, Viewport, WaitCondition};
23
24type AxNodeCache = HashMap<String, BackendNodeId>;
28
29pub struct ChromiumBackend {
31 page: Arc<RwLock<Option<Page>>>,
32 _browser: Arc<RwLock<Option<Browser>>>,
33 viewport_width: u32,
34 viewport_height: u32,
35 cached_url: std::sync::RwLock<String>,
38 ax_node_cache: std::sync::RwLock<AxNodeCache>,
42 _profile_dir: Option<tempfile::TempDir>,
51}
52
53fn current_origin_matches(url: &str, origin: &str) -> bool {
60 if url.is_empty() || url.starts_with("about:") {
65 return true;
66 }
67 let extract = |s: &str| {
68 let (scheme, rest) = s.split_once("://")?;
69 let host_part = rest.split('/').next().unwrap_or("");
70 Some(format!("{}://{}", scheme, host_part))
71 };
72 match (extract(url), extract(origin)) {
73 (Some(a), Some(b)) => a == b,
74 _ => false,
75 }
76}
77
78fn modifiers_to_cdp_flags(modifiers: &[Modifier]) -> i64 {
81 let mut flags: i64 = 0;
82 for m in modifiers {
83 flags |= match m {
84 Modifier::Alt => 1,
85 Modifier::Control => 2,
86 Modifier::Meta => 4,
87 Modifier::Shift => 8,
88 };
89 }
90 flags
91}
92
93#[derive(Debug, Clone)]
100pub struct LaunchOptions {
101 pub width: u32,
102 pub height: u32,
103 pub headless: bool,
104 pub extra_args: Vec<String>,
113}
114
115impl Default for LaunchOptions {
116 fn default() -> Self {
117 Self {
118 width: 1280,
119 height: 720,
120 headless: true,
121 extra_args: Vec::new(),
122 }
123 }
124}
125
126impl ChromiumBackend {
127 pub async fn launch() -> Result<Self, BrowserError> {
129 Self::launch_with_viewport(1280, 720).await
130 }
131
132 pub async fn launch_with_viewport(width: u32, height: u32) -> Result<Self, BrowserError> {
134 Self::launch_with_options(LaunchOptions {
135 width,
136 height,
137 headless: true,
138 extra_args: Vec::new(),
139 })
140 .await
141 }
142
143 pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, BrowserError> {
148 let mut builder = BrowserConfig::builder().window_size(opts.width, opts.height);
149 builder = if opts.headless {
150 builder.new_headless_mode()
151 } else {
152 builder.with_head()
153 };
154 if !opts.extra_args.is_empty() {
155 builder = builder.args(opts.extra_args.iter().map(String::as_str));
159 }
160
161 let (profile_dir, profile_handle) = match std::env::var("CAR_BROWSER_PROFILE_DIR") {
176 Ok(path) if !path.is_empty() => (std::path::PathBuf::from(path), None),
177 _ => {
178 let td = tempfile::Builder::new()
179 .prefix("car-browser-profile-")
180 .tempdir()
181 .map_err(|e| {
182 BrowserError::NotAvailable(format!("create per-instance profile dir: {e}"))
183 })?;
184 (td.path().to_path_buf(), Some(td))
185 }
186 };
187 builder = builder.user_data_dir(&profile_dir);
188
189 let config = builder
190 .build()
191 .map_err(|e| BrowserError::NotAvailable(format!("Config error: {}", e)))?;
192
193 let (browser, mut handler) = Browser::launch(config)
194 .await
195 .map_err(|e| BrowserError::NotAvailable(format!("Failed to launch Chrome: {}", e)))?;
196
197 tokio::spawn(async move { while let Some(_event) = handler.next().await {} });
199
200 let page = browser
201 .new_page("about:blank")
202 .await
203 .map_err(|e| BrowserError::NotAvailable(format!("Failed to create page: {}", e)))?;
204
205 Ok(Self {
206 page: Arc::new(RwLock::new(Some(page))),
207 _browser: Arc::new(RwLock::new(Some(browser))),
208 viewport_width: opts.width,
209 viewport_height: opts.height,
210 cached_url: std::sync::RwLock::new("about:blank".to_string()),
211 ax_node_cache: std::sync::RwLock::new(HashMap::new()),
212 _profile_dir: profile_handle,
213 })
214 }
215
216 async fn get_page(&self) -> Result<Page, BrowserError> {
217 self.page
218 .read()
219 .await
220 .clone()
221 .ok_or(BrowserError::NotAvailable("Page closed".into()))
222 }
223
224 async fn refresh_cached_url(&self) {
226 if let Ok(page) = self.get_page().await {
227 if let Ok(Some(url)) = page.url().await {
228 if let Ok(mut cached) = self.cached_url.write() {
229 *cached = url;
230 }
231 }
232 }
233 }
234
235 fn resolve_backend_node_id(&self, node_id: &str) -> Result<BackendNodeId, BrowserError> {
237 let cache = self.ax_node_cache.read().map_err(|e| {
238 BrowserError::PlatformInternal(format!("Failed to read ax_node_cache: {}", e))
239 })?;
240 cache.get(node_id).copied().ok_or_else(|| {
241 BrowserError::ElementNotFound(format!(
242 "No cached BackendNodeId for '{}'. Call get_accessibility_tree() first.",
243 node_id
244 ))
245 })
246 }
247
248 async fn get_element_center(
250 &self,
251 backend_node_id: BackendNodeId,
252 ) -> Result<(f64, f64), BrowserError> {
253 let page = self.get_page().await?;
254 let params = GetBoxModelParams::builder()
255 .backend_node_id(backend_node_id)
256 .build();
257 let result = page
258 .execute(params)
259 .await
260 .map_err(|e| BrowserError::ElementNotFound(format!("DOM.getBoxModel failed: {}", e)))?;
261
262 let quad = result.result.model.content.inner();
264 if quad.len() < 8 {
265 return Err(BrowserError::PlatformInternal(
266 "Content quad has fewer than 8 values".into(),
267 ));
268 }
269 let cx = (quad[0] + quad[2] + quad[4] + quad[6]) / 4.0;
271 let cy = (quad[1] + quad[3] + quad[5] + quad[7]) / 4.0;
272 Ok((cx, cy))
273 }
274
275 async fn focus_by_backend_node_id(
277 &self,
278 backend_node_id: BackendNodeId,
279 ) -> Result<(), BrowserError> {
280 let page = self.get_page().await?;
281 let params = FocusParams::builder()
282 .backend_node_id(backend_node_id)
283 .build();
284 page.execute(params)
285 .await
286 .map_err(|e| BrowserError::InputFailed(format!("DOM.focus failed: {}", e)))?;
287 Ok(())
288 }
289}
290
291#[async_trait]
292impl BrowserBackend for ChromiumBackend {
293 async fn capture_screenshot(&self) -> Result<Vec<u8>, BrowserError> {
294 let page = self.get_page().await?;
295 page.screenshot(
296 chromiumoxide::page::ScreenshotParams::builder()
297 .format(CaptureScreenshotFormat::Png)
298 .build(),
299 )
300 .await
301 .map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))
302 }
303
304 async fn get_accessibility_tree(&self) -> Result<Vec<A11yNode>, BrowserError> {
305 let page = self.get_page().await?;
306 let result = page
307 .execute(GetFullAxTreeParams::default())
308 .await
309 .map_err(|e| BrowserError::AccessibilityFailed(e.to_string()))?;
310
311 self.refresh_cached_url().await;
313
314 let mut new_cache = AxNodeCache::new();
315
316 let mut nodes: Vec<A11yNode> = Vec::new();
317 for (i, n) in result.result.nodes.iter().enumerate() {
318 if n.ignored {
319 continue;
320 }
321
322 let ax_id = format!("ax_{}", i);
323
324 if let Some(backend_id) = n.backend_dom_node_id {
326 new_cache.insert(ax_id.clone(), backend_id);
327 }
328
329 let role = n
330 .role
331 .as_ref()
332 .and_then(|r| r.value.as_ref())
333 .and_then(|v| v.as_str())
334 .unwrap_or("unknown")
335 .to_string();
336
337 let name = n
338 .name
339 .as_ref()
340 .and_then(|v| v.value.as_ref())
341 .and_then(|v| v.as_str())
342 .filter(|s| !s.is_empty())
343 .map(|s| s.to_string());
344
345 let value = n
346 .value
347 .as_ref()
348 .and_then(|v| v.value.as_ref())
349 .and_then(|v| v.as_str())
350 .filter(|s| !s.is_empty())
351 .map(|s| s.to_string());
352
353 let children: Vec<String> = n
354 .child_ids
355 .as_ref()
356 .map(|ids| ids.iter().map(|id| format!("ax_{}", id.as_ref())).collect())
357 .unwrap_or_default();
358
359 let bounds = if let Some(backend_id) = n.backend_dom_node_id {
362 let bm_params = GetBoxModelParams::builder()
363 .backend_node_id(backend_id)
364 .build();
365 if let Ok(bm_result) = page.execute(bm_params).await {
366 let quad = bm_result.result.model.content.inner();
367 if quad.len() >= 8 {
368 let x = quad[0];
369 let y = quad[1];
370 let width = quad[2] - quad[0];
371 let height = quad[5] - quad[1];
372 Bounds::new(x, y, width.max(0.0), height.max(0.0))
373 } else {
374 Bounds::new(0.0, 0.0, 0.0, 0.0)
375 }
376 } else {
377 Bounds::new(0.0, 0.0, 0.0, 0.0)
378 }
379 } else {
380 Bounds::new(0.0, 0.0, 0.0, 0.0)
381 };
382
383 nodes.push(A11yNode {
384 node_id: ax_id,
385 role,
386 name,
387 value,
388 bounds,
389 children,
390 focusable: true,
391 focused: false,
392 disabled: false,
393 });
394 }
395
396 if let Ok(mut cache) = self.ax_node_cache.write() {
398 *cache = new_cache;
399 }
400
401 Ok(nodes)
402 }
403
404 fn get_viewport(&self) -> Result<Viewport, BrowserError> {
405 Ok(Viewport {
406 width: self.viewport_width,
407 height: self.viewport_height,
408 device_pixel_ratio: 1.0,
409 })
410 }
411
412 fn get_current_url(&self) -> Result<String, BrowserError> {
413 self.cached_url
414 .read()
415 .map(|url| url.clone())
416 .map_err(|e| BrowserError::PlatformInternal(format!("URL cache lock poisoned: {}", e)))
417 }
418
419 async fn get_page_title(&self) -> Result<String, BrowserError> {
420 let page = self.get_page().await?;
421 page.evaluate("document.title")
422 .await
423 .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
424 .into_value::<String>()
425 .map_err(|e| BrowserError::PlatformInternal(e.to_string()))
426 }
427
428 async fn navigate(&self, url: &str) -> Result<(), BrowserError> {
429 let page = self.get_page().await?;
430 page.goto(url)
431 .await
432 .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
433 page.wait_for_navigation()
434 .await
435 .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
436
437 if let Ok(mut cached) = self.cached_url.write() {
439 *cached = url.to_string();
440 }
441 self.refresh_cached_url().await;
443
444 Ok(())
445 }
446
447 async fn inject_click(&self, x: f64, y: f64) -> Result<(), BrowserError> {
448 let page = self.get_page().await?;
449 page.execute(
450 DispatchMouseEventParams::builder()
451 .r#type(DispatchMouseEventType::MousePressed)
452 .x(x)
453 .y(y)
454 .button(MouseButton::Left)
455 .click_count(1)
456 .build()
457 .unwrap(),
458 )
459 .await
460 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
461
462 page.execute(
463 DispatchMouseEventParams::builder()
464 .r#type(DispatchMouseEventType::MouseReleased)
465 .x(x)
466 .y(y)
467 .button(MouseButton::Left)
468 .click_count(1)
469 .build()
470 .unwrap(),
471 )
472 .await
473 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
474
475 Ok(())
476 }
477
478 async fn inject_text(&self, text: &str) -> Result<(), BrowserError> {
479 let page = self.get_page().await?;
480 for ch in text.chars() {
481 page.execute(
482 DispatchKeyEventParams::builder()
483 .r#type(DispatchKeyEventType::Char)
484 .text(ch.to_string())
485 .build()
486 .unwrap(),
487 )
488 .await
489 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
490 }
491 Ok(())
492 }
493
494 async fn inject_keypress(&self, key: &str, modifiers: &[Modifier]) -> Result<(), BrowserError> {
495 let page = self.get_page().await?;
496 let cdp_modifiers = modifiers_to_cdp_flags(modifiers);
497
498 page.execute(
499 DispatchKeyEventParams::builder()
500 .r#type(DispatchKeyEventType::KeyDown)
501 .key(key.to_string())
502 .modifiers(cdp_modifiers)
503 .build()
504 .unwrap(),
505 )
506 .await
507 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
508
509 page.execute(
510 DispatchKeyEventParams::builder()
511 .r#type(DispatchKeyEventType::KeyUp)
512 .key(key.to_string())
513 .modifiers(cdp_modifiers)
514 .build()
515 .unwrap(),
516 )
517 .await
518 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
519
520 Ok(())
521 }
522
523 async fn inject_scroll(&self, delta_y: i32) -> Result<(), BrowserError> {
524 let page = self.get_page().await?;
525 page.execute(
526 DispatchMouseEventParams::builder()
527 .r#type(DispatchMouseEventType::MouseWheel)
528 .x(self.viewport_width as f64 / 2.0)
529 .y(self.viewport_height as f64 / 2.0)
530 .delta_x(0.0)
531 .delta_y(delta_y as f64)
532 .build()
533 .unwrap(),
534 )
535 .await
536 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
537 Ok(())
538 }
539
540 async fn click_element(&self, node_id: &str) -> Result<(), BrowserError> {
541 let backend_node_id = self.resolve_backend_node_id(node_id)?;
542 let (cx, cy) = self.get_element_center(backend_node_id).await?;
543 self.inject_click(cx, cy).await
544 }
545
546 async fn type_into_element(&self, node_id: &str, text: &str) -> Result<(), BrowserError> {
547 let backend_node_id = self.resolve_backend_node_id(node_id)?;
548 self.focus_by_backend_node_id(backend_node_id).await?;
549 self.inject_text(text).await
550 }
551
552 async fn focus_element(&self, node_id: &str) -> Result<(), BrowserError> {
553 let backend_node_id = self.resolve_backend_node_id(node_id)?;
554 self.focus_by_backend_node_id(backend_node_id).await
555 }
556
557 async fn is_page_loaded(&self) -> Result<bool, BrowserError> {
558 let page = self.get_page().await?;
559 let state = page
560 .evaluate("document.readyState")
561 .await
562 .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
563 .into_value::<String>()
564 .unwrap_or_default();
565 Ok(state == "complete")
566 }
567
568 async fn wait_until(
569 &self,
570 condition: &WaitCondition,
571 timeout_ms: u64,
572 ) -> Result<bool, BrowserError> {
573 let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
574
575 let entry_url = self.get_current_url().unwrap_or_default();
579
580 loop {
581 let met = match condition {
582 WaitCondition::PageLoaded => self.is_page_loaded().await?,
583 WaitCondition::UrlChanged => {
584 let now = self.get_current_url().unwrap_or_default();
585 !now.is_empty() && now != entry_url
586 }
587 WaitCondition::A11yContainsText { text } => {
588 let needle = text.to_lowercase();
589 let nodes = self.get_accessibility_tree().await?;
590 nodes.iter().any(|n| {
591 n.name
592 .as_ref()
593 .map(|name| name.to_lowercase().contains(&needle))
594 .unwrap_or(false)
595 })
596 }
597 WaitCondition::ElementWithName {
598 name_contains,
599 role,
600 } => {
601 self.element_exists_a11y(name_contains, role.as_deref())
602 .await?
603 }
604 };
605 if met {
606 return Ok(true);
607 }
608 if tokio::time::Instant::now() >= deadline {
609 return Ok(false);
610 }
611 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
612 }
613 }
614
615 async fn element_exists_a11y(
616 &self,
617 name_contains: &str,
618 role: Option<&str>,
619 ) -> Result<bool, BrowserError> {
620 let nodes = self.get_accessibility_tree().await?;
621 Ok(nodes.iter().any(|n| {
622 let name_match = n
623 .name
624 .as_ref()
625 .map(|name| name.to_lowercase().contains(&name_contains.to_lowercase()))
626 .unwrap_or(false);
627 if !name_match {
628 return false;
629 }
630 match role {
631 Some(r) => n.role.to_lowercase() == r.to_lowercase(),
632 None => true,
633 }
634 }))
635 }
636
637 async fn set_cookies(
638 &self,
639 cookies: &[crate::models::CookieParam],
640 ) -> Result<(), BrowserError> {
641 let page = self.get_page().await?;
642 for cookie in cookies {
643 let mut cdp_cookie = chromiumoxide::cdp::browser_protocol::network::CookieParam::new(
644 &cookie.name,
645 &cookie.value,
646 );
647 cdp_cookie.domain = Some(cookie.domain.clone());
648 cdp_cookie.path = Some(cookie.path.clone());
649 if cookie.secure {
650 cdp_cookie.secure = Some(true);
651 }
652 if cookie.http_only {
653 cdp_cookie.http_only = Some(true);
654 }
655 page.set_cookie(cdp_cookie)
656 .await
657 .map_err(|e| BrowserError::PlatformInternal(format!("set_cookie failed: {}", e)))?;
658 }
659 Ok(())
660 }
661
662 async fn set_local_storage(
663 &self,
664 origin: &str,
665 items: &[(String, String)],
666 ) -> Result<(), BrowserError> {
667 let page = self.get_page().await?;
668 let current = self.get_current_url().unwrap_or_default();
674 if !current_origin_matches(¤t, origin) {
675 return Err(BrowserError::PlatformInternal(format!(
676 "set_local_storage: page must be at origin '{}' first (currently '{}'). \
677 Add a `navigate` op before set_local_storage, or call set_local_storage \
678 before any navigate (pre-page state).",
679 origin, current
680 )));
681 }
682
683 for (key, value) in items {
687 let k = serde_json::to_string(key)
688 .map_err(|e| BrowserError::PlatformInternal(format!("encode key: {}", e)))?;
689 let v = serde_json::to_string(value)
690 .map_err(|e| BrowserError::PlatformInternal(format!("encode value: {}", e)))?;
691 let js = format!("localStorage.setItem({}, {})", k, v);
692 page.evaluate(js).await.map_err(|e| {
693 BrowserError::PlatformInternal(format!("localStorage.setItem failed: {}", e))
694 })?;
695 }
696 Ok(())
697 }
698
699 async fn set_extra_headers(&self, headers: &[(String, String)]) -> Result<(), BrowserError> {
700 let page = self.get_page().await?;
701 page.execute(chromiumoxide::cdp::browser_protocol::network::EnableParams::default())
703 .await
704 .map_err(|e| BrowserError::PlatformInternal(format!("network enable failed: {}", e)))?;
705
706 let header_obj: serde_json::Value = headers
707 .iter()
708 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
709 .collect::<serde_json::Map<String, serde_json::Value>>()
710 .into();
711 let params = chromiumoxide::cdp::browser_protocol::network::SetExtraHttpHeadersParams::new(
712 chromiumoxide::cdp::browser_protocol::network::Headers::new(header_obj),
713 );
714 page.execute(params).await.map_err(|e| {
715 BrowserError::PlatformInternal(format!("set_extra_headers failed: {}", e))
716 })?;
717 Ok(())
718 }
719
720 async fn shutdown(&self) -> Result<(), BrowserError> {
721 if let Some(page) = self.page.write().await.take() {
722 let _ = page.close().await;
723 }
724 self._browser.write().await.take();
725 Ok(())
726 }
727}