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,
InsertTextParams, MouseButton,
};
use chromiumoxide::cdp::browser_protocol::page::{
CaptureScreenshotFormat, GetNavigationHistoryParams, NavigateToHistoryEntryParams, ReloadParams,
};
use chromiumoxide::Page;
use futures::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
const NAV_STATE_TIMEOUT: Duration = Duration::from_secs(2);
use tokio::sync::{watch, RwLock};
use tokio::task::JoinHandle;
use tokio::time::timeout;
use crate::backend::{BrowserBackend, BrowserError};
use crate::models::{A11yNode, Bounds, Modifier, Viewport, WaitCondition};
use crate::tabs::{TabId, TabInfo, TabRegistry, TabsSnapshot};
type AxNodeCache = HashMap<String, BackendNodeId>;
pub struct ChromiumBackend {
tabs: std::sync::RwLock<TabRegistry<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>,
_profile_claim: Option<crate::profile::ProfileClaim>,
chrome_pid: Option<u32>,
handler_task: StdMutex<Option<JoinHandle<()>>>,
reopen: tokio::sync::Mutex<()>,
}
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
}
fn tabs_lock_poisoned<E: std::fmt::Display>(e: E) -> BrowserError {
BrowserError::PlatformInternal(format!("tabs lock poisoned: {e}"))
}
async fn fetch_nav_state(page: &Page) -> Option<(String, String, bool, bool)> {
let history = page
.execute(GetNavigationHistoryParams::default())
.await
.ok()?;
let idx = history.result.current_index;
let entries = &history.result.entries;
let current = usize::try_from(idx).ok().and_then(|i| entries.get(i))?;
let floor = history_floor(entries.first().map(|e| e.url.as_str()));
let can_go_back = usize::try_from(idx).is_ok_and(|i| i > floor);
let can_go_forward = idx < entries.len() as i64 - 1;
Some((
current.url.clone(),
current.title.clone(),
can_go_back,
can_go_forward,
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistoryStep {
Back,
Forward,
}
impl HistoryStep {
fn nothing_there(self) -> &'static str {
match self {
HistoryStep::Back => "no page to go back to",
HistoryStep::Forward => "no page to go forward to",
}
}
}
fn adjacent_entry_id(
current_index: i64,
entry_ids: &[i64],
step: HistoryStep,
floor: usize,
) -> Option<i64> {
let current = usize::try_from(current_index).ok()?;
if current >= entry_ids.len() {
return None;
}
let target = match step {
HistoryStep::Back => {
let target = current.checked_sub(1)?;
if target < floor {
return None;
}
target
}
HistoryStep::Forward => current + 1,
};
entry_ids.get(target).copied()
}
fn history_floor(first_url: Option<&str>) -> usize {
match first_url {
Some(url) if url == "about:blank" || url.is_empty() => 1,
_ => 0,
}
}
fn needs_a_tab_before_navigating<P: Clone>(tabs: &TabRegistry<P>) -> bool {
tabs.active_page().is_none()
}
fn is_profile_in_use(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("singletonlock")
|| (lower.contains("profile") && lower.contains("already"))
|| lower.contains("cannot create a profile directory")
}
async fn launch_chromium(
opts: &LaunchOptions,
profile_dir: &std::path::Path,
) -> Result<(Browser, chromiumoxide::handler::Handler), String> {
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 config = builder
.user_data_dir(profile_dir)
.build()
.map_err(|e| format!("Config error: {e}"))?;
Browser::launch(config).await.map_err(|e| e.to_string())
}
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub width: u32,
pub height: u32,
pub headless: bool,
pub extra_args: Vec<String>,
pub profile_dir: Option<std::path::PathBuf>,
}
impl Default for LaunchOptions {
fn default() -> Self {
Self {
width: 1280,
height: 720,
headless: true,
extra_args: Vec::new(),
profile_dir: None,
}
}
}
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(),
profile_dir: None,
})
.await
}
pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, BrowserError> {
let requested = opts.profile_dir.clone().or_else(|| {
std::env::var("CAR_BROWSER_PROFILE_DIR")
.ok()
.filter(|p| !p.is_empty())
.map(std::path::PathBuf::from)
});
let claim = requested.as_deref().and_then(|dir| {
let claim = crate::profile::ProfileClaim::acquire(dir);
if claim.is_none() {
tracing::warn!(
profile_dir = %dir.display(),
"another browser in this process already holds this Chromium profile; \
launching against a throwaway profile instead — this browser starts \
signed out"
);
}
claim
});
let (profile_dir, profile_handle) = match (&requested, &claim) {
(Some(dir), Some(_)) => (dir.clone(), 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))
}
};
let (browser, handler, _launched_in, profile_handle, claim) =
match launch_chromium(&opts, &profile_dir).await {
Ok((browser, handler)) => (browser, handler, profile_dir, profile_handle, claim),
Err(error) if profile_handle.is_none() && is_profile_in_use(&error) => {
tracing::warn!(
profile_dir = %profile_dir.display(),
%error,
"another process already holds this Chromium profile; relaunching \
against a throwaway profile — this browser starts signed out"
);
let td = tempfile::Builder::new()
.prefix("car-browser-profile-")
.tempdir()
.map_err(|e| {
BrowserError::NotAvailable(format!(
"create per-instance profile dir: {e}"
))
})?;
let fallback = td.path().to_path_buf();
let (browser, handler) =
launch_chromium(&opts, &fallback).await.map_err(|e| {
BrowserError::NotAvailable(format!("Failed to launch Chrome: {e}"))
})?;
(browser, handler, fallback, Some(td), None)
}
Err(e) => {
return Err(BrowserError::NotAvailable(format!(
"Failed to launch Chrome: {e}"
)))
}
};
let mut browser = browser;
let mut handler = handler;
let chrome_pid = browser.get_mut_child().and_then(|c| c.inner.id());
let handler_task =
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)))?;
let (mut tabs, _initial_rx) = TabRegistry::new();
tabs.open(page, "about:blank", "");
Ok(Self {
tabs: std::sync::RwLock::new(tabs),
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,
_profile_claim: claim,
chrome_pid,
handler_task: StdMutex::new(Some(handler_task)),
reopen: tokio::sync::Mutex::new(()),
})
}
pub fn chrome_pid(&self) -> Option<u32> {
self.chrome_pid
}
async fn get_page(&self) -> Result<Page, BrowserError> {
self.tabs
.read()
.map_err(tabs_lock_poisoned)?
.active_page()
.ok_or(BrowserError::NotAvailable("Page closed".into()))
}
pub async fn page_handle(&self) -> Result<Page, BrowserError> {
self.get_page().await
}
pub async fn list_tabs(&self) -> Result<Vec<TabInfo>, BrowserError> {
let pages: Vec<(TabId, Page)> = self.tabs.read().map_err(tabs_lock_poisoned)?.pages();
let refreshed = futures::future::join_all(pages.iter().map(|(id, page)| async move {
let state = tokio::time::timeout(NAV_STATE_TIMEOUT, fetch_nav_state(page))
.await
.ok()
.flatten();
(*id, state)
}))
.await;
if let Ok(mut tabs) = self.tabs.write() {
for (id, state) in refreshed {
if let Some((url, title, can_back, can_fwd)) = state {
tabs.update_nav_state(id, url, title, can_back, can_fwd);
}
}
}
self.tabs
.read()
.map(|tabs| tabs.list())
.map_err(tabs_lock_poisoned)
}
pub async fn open_tab(&self) -> Result<TabId, BrowserError> {
let page = {
let guard = self.browser.read().await;
let browser = guard
.as_ref()
.ok_or_else(|| BrowserError::NotAvailable("Browser closed".into()))?;
browser
.new_page("about:blank")
.await
.map_err(|e| BrowserError::PlatformInternal(format!("open tab: {e}")))?
};
let id = self
.tabs
.write()
.map_err(tabs_lock_poisoned)?
.open(page, "about:blank", "");
self.on_active_page_changed();
Ok(id)
}
pub async fn close_tab(&self, id: TabId) -> Result<(), BrowserError> {
let (page, active_changed) = {
let mut tabs = self.tabs.write().map_err(tabs_lock_poisoned)?;
let before = tabs.active_id();
let page = tabs.close(id);
(page, before != tabs.active_id())
};
if let Some(page) = page {
let _ = timeout(Duration::from_secs(2), page.close()).await;
}
if active_changed {
self.on_active_page_changed();
}
Ok(())
}
pub async fn switch_tab(&self, id: TabId) -> Result<(), BrowserError> {
let active_changed = {
let mut tabs = self.tabs.write().map_err(tabs_lock_poisoned)?;
let before = tabs.active_id();
tabs.switch(id)
.map_err(|e| BrowserError::NotAvailable(e.to_string()))?;
before != tabs.active_id()
};
if active_changed {
self.on_active_page_changed();
}
Ok(())
}
pub fn active_tab_id(&self) -> Option<TabId> {
self.tabs.read().ok().and_then(|t| t.active_id())
}
pub async fn step_history(&self, step: HistoryStep) -> Result<(), BrowserError> {
let page = self.get_page().await?;
let history = page
.execute(GetNavigationHistoryParams::default())
.await
.map_err(|e| BrowserError::NavigationFailed(format!("getNavigationHistory: {e}")))?;
let entries = &history.result.entries;
let floor = history_floor(entries.first().map(|e| e.url.as_str()));
let entry_ids: Vec<i64> = entries.iter().map(|e| e.id).collect();
let entry_id = adjacent_entry_id(history.result.current_index, &entry_ids, step, floor)
.ok_or_else(|| BrowserError::NavigationFailed(step.nothing_there().to_string()))?;
page.execute(NavigateToHistoryEntryParams::new(entry_id))
.await
.map_err(|e| BrowserError::NavigationFailed(format!("navigateToHistoryEntry: {e}")))?;
self.settle_after_history_move(&page).await;
Ok(())
}
pub async fn reload(&self) -> Result<(), BrowserError> {
let page = self.get_page().await?;
page.execute(ReloadParams::default())
.await
.map_err(|e| BrowserError::NavigationFailed(format!("reload: {e}")))?;
self.settle_after_history_move(&page).await;
Ok(())
}
async fn settle_after_history_move(&self, page: &Page) {
let _ = timeout(Duration::from_secs(10), page.wait_for_navigation()).await;
self.refresh_cached_url().await;
self.sync_active_tab_nav_state(page).await;
}
pub fn subscribe_tabs(&self) -> watch::Receiver<TabsSnapshot> {
self.tabs.read().expect("tabs lock poisoned").subscribe()
}
fn on_active_page_changed(&self) {
if let Ok(mut cache) = self.ax_node_cache.write() {
cache.clear();
}
let active_url = self.tabs.read().ok().and_then(|t| t.active_url());
if let Some(url) = active_url {
if let Ok(mut cached) = self.cached_url.write() {
*cached = url;
}
}
}
async fn refresh_cached_url(&self) {
if let Ok(page) = self.get_page().await {
if let Ok(Ok(Some(url))) = timeout(NAV_STATE_TIMEOUT, page.url()).await {
if let Ok(mut cached) = self.cached_url.write() {
*cached = url;
}
}
}
}
async fn sync_active_tab_nav_state(&self, page: &Page) {
let Some(id) = self.active_tab_id() else {
return;
};
if let Ok(Some((url, title, can_back, can_fwd))) =
timeout(NAV_STATE_TIMEOUT, fetch_nav_state(page)).await
{
if let Ok(mut tabs) = self.tabs.write() {
tabs.update_nav_state(id, url, title, can_back, can_fwd);
}
}
}
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 _reopen = self.reopen.lock().await;
let needs_tab = {
let tabs = self.tabs.read().map_err(tabs_lock_poisoned)?;
needs_a_tab_before_navigating(&tabs)
};
if needs_tab {
self.open_tab().await?;
}
drop(_reopen);
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;
self.sync_active_tab_nav_state(&page).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);
let held = modifiers
.iter()
.any(|m| matches!(m, Modifier::Control | Modifier::Meta));
let d = crate::keymap::describe_key(key, held);
for kind in [DispatchKeyEventType::KeyDown, DispatchKeyEventType::KeyUp] {
let mut builder = DispatchKeyEventParams::builder()
.r#type(kind.clone())
.key(d.key.clone())
.modifiers(cdp_modifiers);
if let Some(code) = d.code {
builder = builder.code(code.to_string());
}
if let Some(vk) = d.virtual_key_code {
builder = builder
.windows_virtual_key_code(vk)
.native_virtual_key_code(vk);
}
if kind == DispatchKeyEventType::KeyDown {
if let Some(text) = &d.text {
builder = builder.text(text.clone()).unmodified_text(text.clone());
}
}
page.execute(builder.build().unwrap())
.await
.map_err(|e| BrowserError::InputFailed(e.to_string()))?;
}
Ok(())
}
async fn insert_text(&self, text: &str) -> Result<(), BrowserError> {
let page = self.get_page().await?;
page.execute(InsertTextParams::new(text.to_string()))
.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> {
let pages: Vec<Page> = self
.tabs
.write()
.map(|mut tabs| tabs.close_all())
.unwrap_or_default();
futures::future::join_all(
pages
.into_iter()
.map(|page| async move { timeout(Duration::from_secs(2), page.close()).await }),
)
.await;
if let Some(h) = self.handler_task.lock().ok().and_then(|mut g| g.take()) {
h.abort();
}
if let Some(mut browser) = self.browser.write().await.take() {
let close_ok = timeout(Duration::from_secs(2), browser.close())
.await
.map(|r| r.is_ok())
.unwrap_or(false);
if !close_ok {
let _ = timeout(Duration::from_secs(2), browser.kill()).await;
}
let _ = timeout(Duration::from_secs(2), browser.wait()).await;
}
Ok(())
}
}
impl Drop for ChromiumBackend {
fn drop(&mut self) {
if let Some(h) = self.handler_task.get_mut().ok().and_then(|g| g.take()) {
h.abort();
}
#[cfg(unix)]
if let Some(pid) = self.chrome_pid {
unsafe {
if libc::kill(pid as libc::pid_t, 0) == 0 {
libc::kill(pid as libc::pid_t, libc::SIGKILL);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SINGLETON_LOCK_FAILURE: &str = "Failed to create /Users/x/.car/browser-profile/SingletonLock: File exists (17) Aborting now to avoid profile corruption.";
#[test]
fn a_singleton_lock_failure_is_recognised_as_the_profile_being_in_use() {
assert!(is_profile_in_use(SINGLETON_LOCK_FAILURE));
assert!(is_profile_in_use(
"Failed to create /tmp/p/SingletonLock: File exists (17)"
));
assert!(is_profile_in_use(
"The profile appears to be in use by another Chromium process already"
));
assert!(is_profile_in_use("Cannot create a profile directory"));
}
#[test]
fn unrelated_launch_failures_do_not_trigger_the_fallback() {
for error in [
"Failed to launch Chrome: No such file or directory (os error 2)",
"Config error: could not find chrome executable",
"Connection closed before the DevTools handshake completed",
"Timed out waiting for the browser to start",
] {
assert!(!is_profile_in_use(error), "must not react to: {error}");
}
}
#[test]
fn only_a_shared_profile_hitting_the_lock_falls_back() {
let decide = |was_ephemeral: bool, error: &str| !was_ephemeral && is_profile_in_use(error);
assert!(
decide(false, SINGLETON_LOCK_FAILURE),
"a shared profile held by another process falls back to a throwaway one"
);
assert!(
!decide(true, SINGLETON_LOCK_FAILURE),
"already on a throwaway dir: a second lock failure is not contention, and retrying would loop"
);
assert!(
!decide(false, "Failed to launch Chrome: No such file or directory"),
"a missing Chrome is not a profile collision"
);
}
const IDS: [i64; 4] = [7, 12, 30, 31];
#[test]
fn a_fresh_tab_can_go_neither_way() {
assert_eq!(history_floor(Some("about:blank")), 1);
assert!(!can_go_back_at(0, 1, Some("about:blank")));
}
#[test]
fn one_navigation_does_not_enable_back() {
assert!(!can_go_back_at(1, 2, Some("about:blank")));
assert_eq!(
adjacent_entry_id(1, &[7, 12], HistoryStep::Back, 1),
None,
"and Back has nowhere to go rather than landing on about:blank"
);
}
#[test]
fn a_second_navigation_enables_back_onto_the_first_real_page() {
assert!(can_go_back_at(2, 3, Some("about:blank")));
assert_eq!(
adjacent_entry_id(2, &[7, 12, 30], HistoryStep::Back, 1),
Some(12),
"back lands on the FIRST REAL page, not the birth entry"
);
}
#[test]
fn a_deliberate_later_about_blank_is_a_real_entry() {
assert_eq!(history_floor(Some("http://example.test/")), 0);
assert!(can_go_back_at(1, 2, Some("http://example.test/")));
assert_eq!(
adjacent_entry_id(1, &[7, 12], HistoryStep::Back, 0),
Some(7)
);
}
fn can_go_back_at(current_index: i64, _len: usize, first_url: Option<&str>) -> bool {
let floor = history_floor(first_url);
usize::try_from(current_index).is_ok_and(|i| i > floor)
}
#[test]
fn back_picks_the_previous_entry_id() {
assert_eq!(adjacent_entry_id(2, &IDS, HistoryStep::Back, 0), Some(12));
assert_eq!(adjacent_entry_id(1, &IDS, HistoryStep::Back, 0), Some(7));
}
#[test]
fn forward_picks_the_next_entry_id() {
assert_eq!(
adjacent_entry_id(0, &IDS, HistoryStep::Forward, 0),
Some(12)
);
assert_eq!(
adjacent_entry_id(2, &IDS, HistoryStep::Forward, 0),
Some(31)
);
}
#[test]
fn back_from_the_first_entry_has_nowhere_to_go() {
assert_eq!(adjacent_entry_id(0, &IDS, HistoryStep::Back, 0), None);
}
#[test]
fn forward_from_the_last_entry_has_nowhere_to_go() {
assert_eq!(adjacent_entry_id(3, &IDS, HistoryStep::Forward, 0), None);
}
#[test]
fn an_empty_history_has_nothing_in_either_direction() {
assert_eq!(adjacent_entry_id(0, &[], HistoryStep::Back, 0), None);
assert_eq!(adjacent_entry_id(0, &[], HistoryStep::Forward, 0), None);
assert_eq!(history_floor(None), 0);
}
#[test]
fn an_out_of_range_current_index_is_refused_not_indexed() {
assert_eq!(adjacent_entry_id(-1, &IDS, HistoryStep::Back, 0), None);
assert_eq!(adjacent_entry_id(-1, &IDS, HistoryStep::Forward, 0), None);
assert_eq!(adjacent_entry_id(9, &IDS, HistoryStep::Back, 0), None);
assert_eq!(adjacent_entry_id(9, &IDS, HistoryStep::Forward, 0), None);
assert_eq!(
adjacent_entry_id(i64::MAX, &IDS, HistoryStep::Forward, 0),
None
);
}
#[test]
fn each_direction_names_what_was_missing() {
assert_eq!(HistoryStep::Back.nothing_there(), "no page to go back to");
assert_eq!(
HistoryStep::Forward.nothing_there(),
"no page to go forward to"
);
}
type FakePage = &'static str;
#[test]
fn a_fresh_empty_registry_needs_a_tab_before_navigating() {
let (reg, _rx) = TabRegistry::<FakePage>::new();
assert!(needs_a_tab_before_navigating(®));
}
#[test]
fn an_open_tab_needs_no_reopening_before_navigating() {
let (mut reg, _rx) = TabRegistry::<FakePage>::new();
reg.open("page-a", "http://a", "A");
assert!(!needs_a_tab_before_navigating(®));
}
#[test]
fn closing_the_last_tab_needs_a_tab_again() {
let (mut reg, _rx) = TabRegistry::<FakePage>::new();
let only = reg.open("page-a", "http://a", "A");
reg.close(only);
assert!(needs_a_tab_before_navigating(®));
}
#[test]
fn closing_a_background_tab_still_needs_no_reopening() {
let (mut reg, _rx) = TabRegistry::<FakePage>::new();
let first = reg.open("page-a", "http://a", "A");
reg.open("page-b", "http://b", "B");
reg.close(first);
assert!(!needs_a_tab_before_navigating(®));
}
}