#![cfg(feature = "chromium")]
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>,
}
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
}
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> {
let config = BrowserConfig::builder()
.window_size(width, height)
.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: width,
viewport_height: height,
cached_url: std::sync::RwLock::new("about:blank".to_string()),
ax_node_cache: std::sync::RwLock::new(HashMap::new()),
})
}
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);
while tokio::time::Instant::now() < deadline {
match condition {
WaitCondition::PageLoaded => {
if self.is_page_loaded().await? {
return Ok(true);
}
}
_ => return Ok(true),
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
Ok(false)
}
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 shutdown(&self) -> Result<(), BrowserError> {
if let Some(page) = self.page.write().await.take() {
let _ = page.close().await;
}
self._browser.write().await.take();
Ok(())
}
}