use async_trait::async_trait;
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, FocusParams, GetBoxModelParams};
use chromiumoxide::cdp::browser_protocol::input::{
DispatchKeyEventParams, DispatchKeyEventType, DispatchMouseEventParams, DispatchMouseEventType,
MouseButton,
};
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::Page;
use futures::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::backend::{BrowserBackend, BrowserError};
use crate::models::{A11yNode, Bounds, Modifier, Viewport, WaitCondition};
type AxNodeCache = HashMap<String, BackendNodeId>;
pub struct ChromiumBackend {
page: Arc<RwLock<Option<Page>>>,
_browser: Arc<RwLock<Option<Browser>>>,
viewport_width: u32,
viewport_height: u32,
cached_url: std::sync::RwLock<String>,
ax_node_cache: std::sync::RwLock<AxNodeCache>,
_profile_dir: Option<tempfile::TempDir>,
}
fn current_origin_matches(url: &str, origin: &str) -> bool {
if url.is_empty() || url.starts_with("about:") {
return true;
}
let extract = |s: &str| {
let (scheme, rest) = s.split_once("://")?;
let host_part = rest.split('/').next().unwrap_or("");
Some(format!("{}://{}", scheme, host_part))
};
match (extract(url), extract(origin)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
fn modifiers_to_cdp_flags(modifiers: &[Modifier]) -> i64 {
let mut flags: i64 = 0;
for m in modifiers {
flags |= match m {
Modifier::Alt => 1,
Modifier::Control => 2,
Modifier::Meta => 4,
Modifier::Shift => 8,
};
}
flags
}
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub width: u32,
pub height: u32,
pub headless: bool,
pub extra_args: Vec<String>,
}
impl Default for LaunchOptions {
fn default() -> Self {
Self {
width: 1280,
height: 720,
headless: true,
extra_args: Vec::new(),
}
}
}
impl ChromiumBackend {
pub async fn launch() -> Result<Self, BrowserError> {
Self::launch_with_viewport(1280, 720).await
}
pub async fn launch_with_viewport(width: u32, height: u32) -> Result<Self, BrowserError> {
Self::launch_with_options(LaunchOptions {
width,
height,
headless: true,
extra_args: Vec::new(),
})
.await
}
pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, BrowserError> {
let mut builder = BrowserConfig::builder().window_size(opts.width, opts.height);
builder = if opts.headless {
builder.new_headless_mode()
} else {
builder.with_head()
};
if !opts.extra_args.is_empty() {
builder = builder.args(opts.extra_args.iter().map(String::as_str));
}
let (profile_dir, profile_handle) = match std::env::var("CAR_BROWSER_PROFILE_DIR") {
Ok(path) if !path.is_empty() => (std::path::PathBuf::from(path), None),
_ => {
let td = tempfile::Builder::new()
.prefix("car-browser-profile-")
.tempdir()
.map_err(|e| {
BrowserError::NotAvailable(format!("create per-instance profile dir: {e}"))
})?;
(td.path().to_path_buf(), Some(td))
}
};
builder = builder.user_data_dir(&profile_dir);
let config = builder
.build()
.map_err(|e| BrowserError::NotAvailable(format!("Config error: {}", e)))?;
let (browser, mut handler) = Browser::launch(config)
.await
.map_err(|e| BrowserError::NotAvailable(format!("Failed to launch Chrome: {}", e)))?;
tokio::spawn(async move { while let Some(_event) = handler.next().await {} });
let page = browser
.new_page("about:blank")
.await
.map_err(|e| BrowserError::NotAvailable(format!("Failed to create page: {}", e)))?;
Ok(Self {
page: Arc::new(RwLock::new(Some(page))),
_browser: Arc::new(RwLock::new(Some(browser))),
viewport_width: opts.width,
viewport_height: opts.height,
cached_url: std::sync::RwLock::new("about:blank".to_string()),
ax_node_cache: std::sync::RwLock::new(HashMap::new()),
_profile_dir: profile_handle,
})
}
async fn get_page(&self) -> Result<Page, BrowserError> {
self.page
.read()
.await
.clone()
.ok_or(BrowserError::NotAvailable("Page closed".into()))
}
async fn refresh_cached_url(&self) {
if let Ok(page) = self.get_page().await {
if let Ok(Some(url)) = page.url().await {
if let Ok(mut cached) = self.cached_url.write() {
*cached = url;
}
}
}
}
fn resolve_backend_node_id(&self, node_id: &str) -> Result<BackendNodeId, BrowserError> {
let cache = self.ax_node_cache.read().map_err(|e| {
BrowserError::PlatformInternal(format!("Failed to read ax_node_cache: {}", e))
})?;
cache.get(node_id).copied().ok_or_else(|| {
BrowserError::ElementNotFound(format!(
"No cached BackendNodeId for '{}'. Call get_accessibility_tree() first.",
node_id
))
})
}
async fn get_element_center(
&self,
backend_node_id: BackendNodeId,
) -> Result<(f64, f64), BrowserError> {
let page = self.get_page().await?;
let params = GetBoxModelParams::builder()
.backend_node_id(backend_node_id)
.build();
let result = page
.execute(params)
.await
.map_err(|e| BrowserError::ElementNotFound(format!("DOM.getBoxModel failed: {}", e)))?;
let quad = result.result.model.content.inner();
if quad.len() < 8 {
return Err(BrowserError::PlatformInternal(
"Content quad has fewer than 8 values".into(),
));
}
let cx = (quad[0] + quad[2] + quad[4] + quad[6]) / 4.0;
let cy = (quad[1] + quad[3] + quad[5] + quad[7]) / 4.0;
Ok((cx, cy))
}
async fn focus_by_backend_node_id(
&self,
backend_node_id: BackendNodeId,
) -> Result<(), BrowserError> {
let page = self.get_page().await?;
let params = FocusParams::builder()
.backend_node_id(backend_node_id)
.build();
page.execute(params)
.await
.map_err(|e| BrowserError::InputFailed(format!("DOM.focus failed: {}", e)))?;
Ok(())
}
}
#[async_trait]
impl BrowserBackend for ChromiumBackend {
async fn capture_screenshot(&self) -> Result<Vec<u8>, BrowserError> {
let page = self.get_page().await?;
page.screenshot(
chromiumoxide::page::ScreenshotParams::builder()
.format(CaptureScreenshotFormat::Png)
.build(),
)
.await
.map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))
}
async fn get_accessibility_tree(&self) -> Result<Vec<A11yNode>, BrowserError> {
let page = self.get_page().await?;
let result = page
.execute(GetFullAxTreeParams::default())
.await
.map_err(|e| BrowserError::AccessibilityFailed(e.to_string()))?;
self.refresh_cached_url().await;
let mut new_cache = AxNodeCache::new();
let mut nodes: Vec<A11yNode> = Vec::new();
for (i, n) in result.result.nodes.iter().enumerate() {
if n.ignored {
continue;
}
let ax_id = format!("ax_{}", i);
if let Some(backend_id) = n.backend_dom_node_id {
new_cache.insert(ax_id.clone(), backend_id);
}
let role = n
.role
.as_ref()
.and_then(|r| r.value.as_ref())
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let name = n
.name
.as_ref()
.and_then(|v| v.value.as_ref())
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let value = n
.value
.as_ref()
.and_then(|v| v.value.as_ref())
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let children: Vec<String> = n
.child_ids
.as_ref()
.map(|ids| ids.iter().map(|id| format!("ax_{}", id.as_ref())).collect())
.unwrap_or_default();
let bounds = if let Some(backend_id) = n.backend_dom_node_id {
let bm_params = GetBoxModelParams::builder()
.backend_node_id(backend_id)
.build();
if let Ok(bm_result) = page.execute(bm_params).await {
let quad = bm_result.result.model.content.inner();
if quad.len() >= 8 {
let x = quad[0];
let y = quad[1];
let width = quad[2] - quad[0];
let height = quad[5] - quad[1];
Bounds::new(x, y, width.max(0.0), height.max(0.0))
} else {
Bounds::new(0.0, 0.0, 0.0, 0.0)
}
} else {
Bounds::new(0.0, 0.0, 0.0, 0.0)
}
} else {
Bounds::new(0.0, 0.0, 0.0, 0.0)
};
nodes.push(A11yNode {
node_id: ax_id,
role,
name,
value,
bounds,
children,
focusable: true,
focused: false,
disabled: false,
});
}
if let Ok(mut cache) = self.ax_node_cache.write() {
*cache = new_cache;
}
Ok(nodes)
}
fn get_viewport(&self) -> Result<Viewport, BrowserError> {
Ok(Viewport {
width: self.viewport_width,
height: self.viewport_height,
device_pixel_ratio: 1.0,
})
}
fn get_current_url(&self) -> Result<String, BrowserError> {
self.cached_url
.read()
.map(|url| url.clone())
.map_err(|e| BrowserError::PlatformInternal(format!("URL cache lock poisoned: {}", e)))
}
async fn get_page_title(&self) -> Result<String, BrowserError> {
let page = self.get_page().await?;
page.evaluate("document.title")
.await
.map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
.into_value::<String>()
.map_err(|e| BrowserError::PlatformInternal(e.to_string()))
}
async fn navigate(&self, url: &str) -> Result<(), BrowserError> {
let page = self.get_page().await?;
page.goto(url)
.await
.map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
page.wait_for_navigation()
.await
.map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
if let Ok(mut cached) = self.cached_url.write() {
*cached = url.to_string();
}
self.refresh_cached_url().await;
Ok(())
}
async fn inject_click(&self, x: f64, y: f64) -> Result<(), BrowserError> {
let page = self.get_page().await?;
page.execute(
DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(x)
.y(y)
.button(MouseButton::Left)
.click_count(1)
.build()
.unwrap(),
)
.await
.map_err(|e| BrowserError::InputFailed(e.to_string()))?;
page.execute(
DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(x)
.y(y)
.button(MouseButton::Left)
.click_count(1)
.build()
.unwrap(),
)
.await
.map_err(|e| BrowserError::InputFailed(e.to_string()))?;
Ok(())
}
async fn inject_text(&self, text: &str) -> Result<(), BrowserError> {
let page = self.get_page().await?;
for ch in text.chars() {
page.execute(
DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::Char)
.text(ch.to_string())
.build()
.unwrap(),
)
.await
.map_err(|e| BrowserError::InputFailed(e.to_string()))?;
}
Ok(())
}
async fn inject_keypress(&self, key: &str, modifiers: &[Modifier]) -> Result<(), BrowserError> {
let page = self.get_page().await?;
let cdp_modifiers = modifiers_to_cdp_flags(modifiers);
page.execute(
DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::KeyDown)
.key(key.to_string())
.modifiers(cdp_modifiers)
.build()
.unwrap(),
)
.await
.map_err(|e| BrowserError::InputFailed(e.to_string()))?;
page.execute(
DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::KeyUp)
.key(key.to_string())
.modifiers(cdp_modifiers)
.build()
.unwrap(),
)
.await
.map_err(|e| BrowserError::InputFailed(e.to_string()))?;
Ok(())
}
async fn inject_scroll(&self, delta_y: i32) -> Result<(), BrowserError> {
let page = self.get_page().await?;
page.execute(
DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseWheel)
.x(self.viewport_width as f64 / 2.0)
.y(self.viewport_height as f64 / 2.0)
.delta_x(0.0)
.delta_y(delta_y as f64)
.build()
.unwrap(),
)
.await
.map_err(|e| BrowserError::InputFailed(e.to_string()))?;
Ok(())
}
async fn click_element(&self, node_id: &str) -> Result<(), BrowserError> {
let backend_node_id = self.resolve_backend_node_id(node_id)?;
let (cx, cy) = self.get_element_center(backend_node_id).await?;
self.inject_click(cx, cy).await
}
async fn type_into_element(&self, node_id: &str, text: &str) -> Result<(), BrowserError> {
let backend_node_id = self.resolve_backend_node_id(node_id)?;
self.focus_by_backend_node_id(backend_node_id).await?;
self.inject_text(text).await
}
async fn focus_element(&self, node_id: &str) -> Result<(), BrowserError> {
let backend_node_id = self.resolve_backend_node_id(node_id)?;
self.focus_by_backend_node_id(backend_node_id).await
}
async fn is_page_loaded(&self) -> Result<bool, BrowserError> {
let page = self.get_page().await?;
let state = page
.evaluate("document.readyState")
.await
.map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
.into_value::<String>()
.unwrap_or_default();
Ok(state == "complete")
}
async fn wait_until(
&self,
condition: &WaitCondition,
timeout_ms: u64,
) -> Result<bool, BrowserError> {
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
let entry_url = self.get_current_url().unwrap_or_default();
loop {
let met = match condition {
WaitCondition::PageLoaded => self.is_page_loaded().await?,
WaitCondition::UrlChanged => {
let now = self.get_current_url().unwrap_or_default();
!now.is_empty() && now != entry_url
}
WaitCondition::A11yContainsText { text } => {
let needle = text.to_lowercase();
let nodes = self.get_accessibility_tree().await?;
nodes.iter().any(|n| {
n.name
.as_ref()
.map(|name| name.to_lowercase().contains(&needle))
.unwrap_or(false)
})
}
WaitCondition::ElementWithName {
name_contains,
role,
} => {
self.element_exists_a11y(name_contains, role.as_deref())
.await?
}
};
if met {
return Ok(true);
}
if tokio::time::Instant::now() >= deadline {
return Ok(false);
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
}
async fn element_exists_a11y(
&self,
name_contains: &str,
role: Option<&str>,
) -> Result<bool, BrowserError> {
let nodes = self.get_accessibility_tree().await?;
Ok(nodes.iter().any(|n| {
let name_match = n
.name
.as_ref()
.map(|name| name.to_lowercase().contains(&name_contains.to_lowercase()))
.unwrap_or(false);
if !name_match {
return false;
}
match role {
Some(r) => n.role.to_lowercase() == r.to_lowercase(),
None => true,
}
}))
}
async fn set_cookies(
&self,
cookies: &[crate::models::CookieParam],
) -> Result<(), BrowserError> {
let page = self.get_page().await?;
for cookie in cookies {
let mut cdp_cookie = chromiumoxide::cdp::browser_protocol::network::CookieParam::new(
&cookie.name,
&cookie.value,
);
cdp_cookie.domain = Some(cookie.domain.clone());
cdp_cookie.path = Some(cookie.path.clone());
if cookie.secure {
cdp_cookie.secure = Some(true);
}
if cookie.http_only {
cdp_cookie.http_only = Some(true);
}
page.set_cookie(cdp_cookie)
.await
.map_err(|e| BrowserError::PlatformInternal(format!("set_cookie failed: {}", e)))?;
}
Ok(())
}
async fn set_local_storage(
&self,
origin: &str,
items: &[(String, String)],
) -> Result<(), BrowserError> {
let page = self.get_page().await?;
let current = self.get_current_url().unwrap_or_default();
if !current_origin_matches(¤t, origin) {
return Err(BrowserError::PlatformInternal(format!(
"set_local_storage: page must be at origin '{}' first (currently '{}'). \
Add a `navigate` op before set_local_storage, or call set_local_storage \
before any navigate (pre-page state).",
origin, current
)));
}
for (key, value) in items {
let k = serde_json::to_string(key)
.map_err(|e| BrowserError::PlatformInternal(format!("encode key: {}", e)))?;
let v = serde_json::to_string(value)
.map_err(|e| BrowserError::PlatformInternal(format!("encode value: {}", e)))?;
let js = format!("localStorage.setItem({}, {})", k, v);
page.evaluate(js).await.map_err(|e| {
BrowserError::PlatformInternal(format!("localStorage.setItem failed: {}", e))
})?;
}
Ok(())
}
async fn set_extra_headers(&self, headers: &[(String, String)]) -> Result<(), BrowserError> {
let page = self.get_page().await?;
page.execute(chromiumoxide::cdp::browser_protocol::network::EnableParams::default())
.await
.map_err(|e| BrowserError::PlatformInternal(format!("network enable failed: {}", e)))?;
let header_obj: serde_json::Value = headers
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect::<serde_json::Map<String, serde_json::Value>>()
.into();
let params = chromiumoxide::cdp::browser_protocol::network::SetExtraHttpHeadersParams::new(
chromiumoxide::cdp::browser_protocol::network::Headers::new(header_obj),
);
page.execute(params).await.map_err(|e| {
BrowserError::PlatformInternal(format!("set_extra_headers failed: {}", e))
})?;
Ok(())
}
async fn shutdown(&self) -> Result<(), BrowserError> {
if let Some(page) = self.page.write().await.take() {
let _ = page.close().await;
}
self._browser.write().await.take();
Ok(())
}
}