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 std::sync::Mutex as StdMutex;
20use std::time::Duration;
21use tokio::sync::RwLock;
22use tokio::task::JoinHandle;
23use tokio::time::timeout;
24
25use crate::backend::{BrowserBackend, BrowserError};
26use crate::models::{A11yNode, Bounds, Modifier, Viewport, WaitCondition};
27
28type AxNodeCache = HashMap<String, BackendNodeId>;
32
33pub struct ChromiumBackend {
35 page: Arc<RwLock<Option<Page>>>,
36 _browser: Arc<RwLock<Option<Browser>>>,
37 viewport_width: u32,
38 viewport_height: u32,
39 cached_url: std::sync::RwLock<String>,
42 ax_node_cache: std::sync::RwLock<AxNodeCache>,
46 _profile_dir: Option<tempfile::TempDir>,
55 chrome_pid: Option<u32>,
63 handler_task: StdMutex<Option<JoinHandle<()>>>,
68}
69
70fn current_origin_matches(url: &str, origin: &str) -> bool {
77 if url.is_empty() || url.starts_with("about:") {
82 return true;
83 }
84 let extract = |s: &str| {
85 let (scheme, rest) = s.split_once("://")?;
86 let host_part = rest.split('/').next().unwrap_or("");
87 Some(format!("{}://{}", scheme, host_part))
88 };
89 match (extract(url), extract(origin)) {
90 (Some(a), Some(b)) => a == b,
91 _ => false,
92 }
93}
94
95fn modifiers_to_cdp_flags(modifiers: &[Modifier]) -> i64 {
98 let mut flags: i64 = 0;
99 for m in modifiers {
100 flags |= match m {
101 Modifier::Alt => 1,
102 Modifier::Control => 2,
103 Modifier::Meta => 4,
104 Modifier::Shift => 8,
105 };
106 }
107 flags
108}
109
110#[derive(Debug, Clone)]
117pub struct LaunchOptions {
118 pub width: u32,
119 pub height: u32,
120 pub headless: bool,
121 pub extra_args: Vec<String>,
130}
131
132impl Default for LaunchOptions {
133 fn default() -> Self {
134 Self {
135 width: 1280,
136 height: 720,
137 headless: true,
138 extra_args: Vec::new(),
139 }
140 }
141}
142
143impl ChromiumBackend {
144 pub async fn launch() -> Result<Self, BrowserError> {
146 Self::launch_with_viewport(1280, 720).await
147 }
148
149 pub async fn launch_with_viewport(width: u32, height: u32) -> Result<Self, BrowserError> {
151 Self::launch_with_options(LaunchOptions {
152 width,
153 height,
154 headless: true,
155 extra_args: Vec::new(),
156 })
157 .await
158 }
159
160 pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, BrowserError> {
165 let mut builder = BrowserConfig::builder().window_size(opts.width, opts.height);
166 builder = if opts.headless {
167 builder.new_headless_mode()
168 } else {
169 builder.with_head()
170 };
171 if !opts.extra_args.is_empty() {
172 builder = builder.args(opts.extra_args.iter().map(String::as_str));
176 }
177
178 let (profile_dir, profile_handle) = match std::env::var("CAR_BROWSER_PROFILE_DIR") {
193 Ok(path) if !path.is_empty() => (std::path::PathBuf::from(path), None),
194 _ => {
195 let td = tempfile::Builder::new()
196 .prefix("car-browser-profile-")
197 .tempdir()
198 .map_err(|e| {
199 BrowserError::NotAvailable(format!("create per-instance profile dir: {e}"))
200 })?;
201 (td.path().to_path_buf(), Some(td))
202 }
203 };
204 builder = builder.user_data_dir(&profile_dir);
205
206 let config = builder
207 .build()
208 .map_err(|e| BrowserError::NotAvailable(format!("Config error: {}", e)))?;
209
210 let (mut browser, mut handler) = Browser::launch(config)
211 .await
212 .map_err(|e| BrowserError::NotAvailable(format!("Failed to launch Chrome: {}", e)))?;
213
214 let chrome_pid = browser
219 .get_mut_child()
220 .and_then(|c| c.inner.id());
221
222 let handler_task = tokio::spawn(async move {
228 while let Some(_event) = handler.next().await {}
229 });
230
231 let page = browser
232 .new_page("about:blank")
233 .await
234 .map_err(|e| BrowserError::NotAvailable(format!("Failed to create page: {}", e)))?;
235
236 Ok(Self {
237 page: Arc::new(RwLock::new(Some(page))),
238 _browser: Arc::new(RwLock::new(Some(browser))),
239 viewport_width: opts.width,
240 viewport_height: opts.height,
241 cached_url: std::sync::RwLock::new("about:blank".to_string()),
242 ax_node_cache: std::sync::RwLock::new(HashMap::new()),
243 _profile_dir: profile_handle,
244 chrome_pid,
245 handler_task: StdMutex::new(Some(handler_task)),
246 })
247 }
248
249 pub fn chrome_pid(&self) -> Option<u32> {
257 self.chrome_pid
258 }
259
260 async fn get_page(&self) -> Result<Page, BrowserError> {
261 self.page
262 .read()
263 .await
264 .clone()
265 .ok_or(BrowserError::NotAvailable("Page closed".into()))
266 }
267
268 async fn refresh_cached_url(&self) {
270 if let Ok(page) = self.get_page().await {
271 if let Ok(Some(url)) = page.url().await {
272 if let Ok(mut cached) = self.cached_url.write() {
273 *cached = url;
274 }
275 }
276 }
277 }
278
279 fn resolve_backend_node_id(&self, node_id: &str) -> Result<BackendNodeId, BrowserError> {
281 let cache = self.ax_node_cache.read().map_err(|e| {
282 BrowserError::PlatformInternal(format!("Failed to read ax_node_cache: {}", e))
283 })?;
284 cache.get(node_id).copied().ok_or_else(|| {
285 BrowserError::ElementNotFound(format!(
286 "No cached BackendNodeId for '{}'. Call get_accessibility_tree() first.",
287 node_id
288 ))
289 })
290 }
291
292 async fn get_element_center(
294 &self,
295 backend_node_id: BackendNodeId,
296 ) -> Result<(f64, f64), BrowserError> {
297 let page = self.get_page().await?;
298 let params = GetBoxModelParams::builder()
299 .backend_node_id(backend_node_id)
300 .build();
301 let result = page
302 .execute(params)
303 .await
304 .map_err(|e| BrowserError::ElementNotFound(format!("DOM.getBoxModel failed: {}", e)))?;
305
306 let quad = result.result.model.content.inner();
308 if quad.len() < 8 {
309 return Err(BrowserError::PlatformInternal(
310 "Content quad has fewer than 8 values".into(),
311 ));
312 }
313 let cx = (quad[0] + quad[2] + quad[4] + quad[6]) / 4.0;
315 let cy = (quad[1] + quad[3] + quad[5] + quad[7]) / 4.0;
316 Ok((cx, cy))
317 }
318
319 async fn focus_by_backend_node_id(
321 &self,
322 backend_node_id: BackendNodeId,
323 ) -> Result<(), BrowserError> {
324 let page = self.get_page().await?;
325 let params = FocusParams::builder()
326 .backend_node_id(backend_node_id)
327 .build();
328 page.execute(params)
329 .await
330 .map_err(|e| BrowserError::InputFailed(format!("DOM.focus failed: {}", e)))?;
331 Ok(())
332 }
333}
334
335#[async_trait]
336impl BrowserBackend for ChromiumBackend {
337 async fn capture_screenshot(&self) -> Result<Vec<u8>, BrowserError> {
338 let page = self.get_page().await?;
339 page.screenshot(
340 chromiumoxide::page::ScreenshotParams::builder()
341 .format(CaptureScreenshotFormat::Png)
342 .build(),
343 )
344 .await
345 .map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))
346 }
347
348 async fn get_accessibility_tree(&self) -> Result<Vec<A11yNode>, BrowserError> {
349 let page = self.get_page().await?;
350 let result = page
351 .execute(GetFullAxTreeParams::default())
352 .await
353 .map_err(|e| BrowserError::AccessibilityFailed(e.to_string()))?;
354
355 self.refresh_cached_url().await;
357
358 let mut new_cache = AxNodeCache::new();
359
360 let mut nodes: Vec<A11yNode> = Vec::new();
361 for (i, n) in result.result.nodes.iter().enumerate() {
362 if n.ignored {
363 continue;
364 }
365
366 let ax_id = format!("ax_{}", i);
367
368 if let Some(backend_id) = n.backend_dom_node_id {
370 new_cache.insert(ax_id.clone(), backend_id);
371 }
372
373 let role = n
374 .role
375 .as_ref()
376 .and_then(|r| r.value.as_ref())
377 .and_then(|v| v.as_str())
378 .unwrap_or("unknown")
379 .to_string();
380
381 let name = n
382 .name
383 .as_ref()
384 .and_then(|v| v.value.as_ref())
385 .and_then(|v| v.as_str())
386 .filter(|s| !s.is_empty())
387 .map(|s| s.to_string());
388
389 let value = n
390 .value
391 .as_ref()
392 .and_then(|v| v.value.as_ref())
393 .and_then(|v| v.as_str())
394 .filter(|s| !s.is_empty())
395 .map(|s| s.to_string());
396
397 let children: Vec<String> = n
398 .child_ids
399 .as_ref()
400 .map(|ids| ids.iter().map(|id| format!("ax_{}", id.as_ref())).collect())
401 .unwrap_or_default();
402
403 let bounds = if let Some(backend_id) = n.backend_dom_node_id {
406 let bm_params = GetBoxModelParams::builder()
407 .backend_node_id(backend_id)
408 .build();
409 if let Ok(bm_result) = page.execute(bm_params).await {
410 let quad = bm_result.result.model.content.inner();
411 if quad.len() >= 8 {
412 let x = quad[0];
413 let y = quad[1];
414 let width = quad[2] - quad[0];
415 let height = quad[5] - quad[1];
416 Bounds::new(x, y, width.max(0.0), height.max(0.0))
417 } else {
418 Bounds::new(0.0, 0.0, 0.0, 0.0)
419 }
420 } else {
421 Bounds::new(0.0, 0.0, 0.0, 0.0)
422 }
423 } else {
424 Bounds::new(0.0, 0.0, 0.0, 0.0)
425 };
426
427 nodes.push(A11yNode {
428 node_id: ax_id,
429 role,
430 name,
431 value,
432 bounds,
433 children,
434 focusable: true,
435 focused: false,
436 disabled: false,
437 });
438 }
439
440 if let Ok(mut cache) = self.ax_node_cache.write() {
442 *cache = new_cache;
443 }
444
445 Ok(nodes)
446 }
447
448 fn get_viewport(&self) -> Result<Viewport, BrowserError> {
449 Ok(Viewport {
450 width: self.viewport_width,
451 height: self.viewport_height,
452 device_pixel_ratio: 1.0,
453 })
454 }
455
456 fn get_current_url(&self) -> Result<String, BrowserError> {
457 self.cached_url
458 .read()
459 .map(|url| url.clone())
460 .map_err(|e| BrowserError::PlatformInternal(format!("URL cache lock poisoned: {}", e)))
461 }
462
463 async fn get_page_title(&self) -> Result<String, BrowserError> {
464 let page = self.get_page().await?;
465 page.evaluate("document.title")
466 .await
467 .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
468 .into_value::<String>()
469 .map_err(|e| BrowserError::PlatformInternal(e.to_string()))
470 }
471
472 async fn navigate(&self, url: &str) -> Result<(), BrowserError> {
473 let page = self.get_page().await?;
474 page.goto(url)
475 .await
476 .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
477 page.wait_for_navigation()
478 .await
479 .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
480
481 if let Ok(mut cached) = self.cached_url.write() {
483 *cached = url.to_string();
484 }
485 self.refresh_cached_url().await;
487
488 Ok(())
489 }
490
491 async fn inject_click(&self, x: f64, y: f64) -> Result<(), BrowserError> {
492 let page = self.get_page().await?;
493 page.execute(
494 DispatchMouseEventParams::builder()
495 .r#type(DispatchMouseEventType::MousePressed)
496 .x(x)
497 .y(y)
498 .button(MouseButton::Left)
499 .click_count(1)
500 .build()
501 .unwrap(),
502 )
503 .await
504 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
505
506 page.execute(
507 DispatchMouseEventParams::builder()
508 .r#type(DispatchMouseEventType::MouseReleased)
509 .x(x)
510 .y(y)
511 .button(MouseButton::Left)
512 .click_count(1)
513 .build()
514 .unwrap(),
515 )
516 .await
517 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
518
519 Ok(())
520 }
521
522 async fn inject_text(&self, text: &str) -> Result<(), BrowserError> {
523 let page = self.get_page().await?;
524 for ch in text.chars() {
525 page.execute(
526 DispatchKeyEventParams::builder()
527 .r#type(DispatchKeyEventType::Char)
528 .text(ch.to_string())
529 .build()
530 .unwrap(),
531 )
532 .await
533 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
534 }
535 Ok(())
536 }
537
538 async fn inject_keypress(&self, key: &str, modifiers: &[Modifier]) -> Result<(), BrowserError> {
539 let page = self.get_page().await?;
540 let cdp_modifiers = modifiers_to_cdp_flags(modifiers);
541
542 page.execute(
543 DispatchKeyEventParams::builder()
544 .r#type(DispatchKeyEventType::KeyDown)
545 .key(key.to_string())
546 .modifiers(cdp_modifiers)
547 .build()
548 .unwrap(),
549 )
550 .await
551 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
552
553 page.execute(
554 DispatchKeyEventParams::builder()
555 .r#type(DispatchKeyEventType::KeyUp)
556 .key(key.to_string())
557 .modifiers(cdp_modifiers)
558 .build()
559 .unwrap(),
560 )
561 .await
562 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
563
564 Ok(())
565 }
566
567 async fn inject_scroll(&self, delta_y: i32) -> Result<(), BrowserError> {
568 let page = self.get_page().await?;
569 page.execute(
570 DispatchMouseEventParams::builder()
571 .r#type(DispatchMouseEventType::MouseWheel)
572 .x(self.viewport_width as f64 / 2.0)
573 .y(self.viewport_height as f64 / 2.0)
574 .delta_x(0.0)
575 .delta_y(delta_y as f64)
576 .build()
577 .unwrap(),
578 )
579 .await
580 .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
581 Ok(())
582 }
583
584 async fn click_element(&self, node_id: &str) -> Result<(), BrowserError> {
585 let backend_node_id = self.resolve_backend_node_id(node_id)?;
586 let (cx, cy) = self.get_element_center(backend_node_id).await?;
587 self.inject_click(cx, cy).await
588 }
589
590 async fn type_into_element(&self, node_id: &str, text: &str) -> Result<(), BrowserError> {
591 let backend_node_id = self.resolve_backend_node_id(node_id)?;
592 self.focus_by_backend_node_id(backend_node_id).await?;
593 self.inject_text(text).await
594 }
595
596 async fn focus_element(&self, node_id: &str) -> Result<(), BrowserError> {
597 let backend_node_id = self.resolve_backend_node_id(node_id)?;
598 self.focus_by_backend_node_id(backend_node_id).await
599 }
600
601 async fn is_page_loaded(&self) -> Result<bool, BrowserError> {
602 let page = self.get_page().await?;
603 let state = page
604 .evaluate("document.readyState")
605 .await
606 .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
607 .into_value::<String>()
608 .unwrap_or_default();
609 Ok(state == "complete")
610 }
611
612 async fn wait_until(
613 &self,
614 condition: &WaitCondition,
615 timeout_ms: u64,
616 ) -> Result<bool, BrowserError> {
617 let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
618
619 let entry_url = self.get_current_url().unwrap_or_default();
623
624 loop {
625 let met = match condition {
626 WaitCondition::PageLoaded => self.is_page_loaded().await?,
627 WaitCondition::UrlChanged => {
628 let now = self.get_current_url().unwrap_or_default();
629 !now.is_empty() && now != entry_url
630 }
631 WaitCondition::A11yContainsText { text } => {
632 let needle = text.to_lowercase();
633 let nodes = self.get_accessibility_tree().await?;
634 nodes.iter().any(|n| {
635 n.name
636 .as_ref()
637 .map(|name| name.to_lowercase().contains(&needle))
638 .unwrap_or(false)
639 })
640 }
641 WaitCondition::ElementWithName {
642 name_contains,
643 role,
644 } => {
645 self.element_exists_a11y(name_contains, role.as_deref())
646 .await?
647 }
648 };
649 if met {
650 return Ok(true);
651 }
652 if tokio::time::Instant::now() >= deadline {
653 return Ok(false);
654 }
655 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
656 }
657 }
658
659 async fn element_exists_a11y(
660 &self,
661 name_contains: &str,
662 role: Option<&str>,
663 ) -> Result<bool, BrowserError> {
664 let nodes = self.get_accessibility_tree().await?;
665 Ok(nodes.iter().any(|n| {
666 let name_match = n
667 .name
668 .as_ref()
669 .map(|name| name.to_lowercase().contains(&name_contains.to_lowercase()))
670 .unwrap_or(false);
671 if !name_match {
672 return false;
673 }
674 match role {
675 Some(r) => n.role.to_lowercase() == r.to_lowercase(),
676 None => true,
677 }
678 }))
679 }
680
681 async fn set_cookies(
682 &self,
683 cookies: &[crate::models::CookieParam],
684 ) -> Result<(), BrowserError> {
685 let page = self.get_page().await?;
686 for cookie in cookies {
687 let mut cdp_cookie = chromiumoxide::cdp::browser_protocol::network::CookieParam::new(
688 &cookie.name,
689 &cookie.value,
690 );
691 cdp_cookie.domain = Some(cookie.domain.clone());
692 cdp_cookie.path = Some(cookie.path.clone());
693 if cookie.secure {
694 cdp_cookie.secure = Some(true);
695 }
696 if cookie.http_only {
697 cdp_cookie.http_only = Some(true);
698 }
699 page.set_cookie(cdp_cookie)
700 .await
701 .map_err(|e| BrowserError::PlatformInternal(format!("set_cookie failed: {}", e)))?;
702 }
703 Ok(())
704 }
705
706 async fn set_local_storage(
707 &self,
708 origin: &str,
709 items: &[(String, String)],
710 ) -> Result<(), BrowserError> {
711 let page = self.get_page().await?;
712 let current = self.get_current_url().unwrap_or_default();
718 if !current_origin_matches(¤t, origin) {
719 return Err(BrowserError::PlatformInternal(format!(
720 "set_local_storage: page must be at origin '{}' first (currently '{}'). \
721 Add a `navigate` op before set_local_storage, or call set_local_storage \
722 before any navigate (pre-page state).",
723 origin, current
724 )));
725 }
726
727 for (key, value) in items {
731 let k = serde_json::to_string(key)
732 .map_err(|e| BrowserError::PlatformInternal(format!("encode key: {}", e)))?;
733 let v = serde_json::to_string(value)
734 .map_err(|e| BrowserError::PlatformInternal(format!("encode value: {}", e)))?;
735 let js = format!("localStorage.setItem({}, {})", k, v);
736 page.evaluate(js).await.map_err(|e| {
737 BrowserError::PlatformInternal(format!("localStorage.setItem failed: {}", e))
738 })?;
739 }
740 Ok(())
741 }
742
743 async fn set_extra_headers(&self, headers: &[(String, String)]) -> Result<(), BrowserError> {
744 let page = self.get_page().await?;
745 page.execute(chromiumoxide::cdp::browser_protocol::network::EnableParams::default())
747 .await
748 .map_err(|e| BrowserError::PlatformInternal(format!("network enable failed: {}", e)))?;
749
750 let header_obj: serde_json::Value = headers
751 .iter()
752 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
753 .collect::<serde_json::Map<String, serde_json::Value>>()
754 .into();
755 let params = chromiumoxide::cdp::browser_protocol::network::SetExtraHttpHeadersParams::new(
756 chromiumoxide::cdp::browser_protocol::network::Headers::new(header_obj),
757 );
758 page.execute(params).await.map_err(|e| {
759 BrowserError::PlatformInternal(format!("set_extra_headers failed: {}", e))
760 })?;
761 Ok(())
762 }
763
764 async fn shutdown(&self) -> Result<(), BrowserError> {
765 if let Some(page) = self.page.write().await.take() {
776 let _ = timeout(Duration::from_secs(2), page.close()).await;
777 }
778 if let Some(h) = self
779 .handler_task
780 .lock()
781 .ok()
782 .and_then(|mut g| g.take())
783 {
784 h.abort();
785 }
786 if let Some(mut browser) = self._browser.write().await.take() {
787 let close_ok = timeout(Duration::from_secs(2), browser.close())
792 .await
793 .map(|r| r.is_ok())
794 .unwrap_or(false);
795 if !close_ok {
796 let _ = timeout(Duration::from_secs(2), browser.kill()).await;
797 }
798 let _ = timeout(Duration::from_secs(2), browser.wait()).await;
799 }
800 Ok(())
801 }
802}
803
804impl Drop for ChromiumBackend {
805 fn drop(&mut self) {
818 if let Some(h) = self
821 .handler_task
822 .get_mut()
823 .ok()
824 .and_then(|g| g.take())
825 {
826 h.abort();
827 }
828 #[cfg(unix)]
832 if let Some(pid) = self.chrome_pid {
833 unsafe {
837 if libc::kill(pid as libc::pid_t, 0) == 0 {
838 libc::kill(pid as libc::pid_t, libc::SIGKILL);
839 }
840 }
841 }
842 }
847}