use std::sync::Arc;
use car_browser::backend::BrowserBackend;
use car_browser::chromium::{ChromiumBackend, LaunchOptions};
use car_browser::models::{CookieParam, Modifier};
pub use car_browser::chromium::LaunchOptions as BrowserLaunchOptions;
use car_browser::perception::pipeline::{BasicPerceptionPipeline, PerceptionPipeline};
use car_browser::perception::ui_map::UiMap;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::sync::Mutex;
#[derive(Default)]
pub struct BrowserSessionSlot {
session: Mutex<Option<Arc<BrowserSession>>>,
}
impl BrowserSessionSlot {
pub fn new() -> Self {
Self::default()
}
pub async fn get_or_launch(&self, opts: LaunchOptions) -> Result<Arc<BrowserSession>, String> {
let mut guard = self.session.lock().await;
if let Some(session) = guard.as_ref() {
return Ok(session.clone());
}
let session = Arc::new(BrowserSession::launch_with_options(opts).await?);
*guard = Some(session.clone());
Ok(session)
}
pub async fn close(&self) -> Result<bool, String> {
let session = self.session.lock().await.take();
if let Some(session) = session {
session.close().await?;
Ok(true)
} else {
Ok(false)
}
}
}
pub struct BrowserSession {
backend: Arc<ChromiumBackend>,
pipeline: BasicPerceptionPipeline,
last_ui_map: Mutex<Option<UiMap>>,
width: u32,
height: u32,
closed: std::sync::atomic::AtomicBool,
}
impl BrowserSession {
pub async fn launch(width: u32, height: u32) -> Result<Self, String> {
Self::launch_with_options(LaunchOptions {
width,
height,
headless: true,
extra_args: Vec::new(),
})
.await
}
pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, String> {
let width = opts.width;
let height = opts.height;
let backend = Arc::new(
ChromiumBackend::launch_with_options(opts)
.await
.map_err(|e| format!("launch chrome: {}", e))?,
);
Ok(Self {
backend,
pipeline: BasicPerceptionPipeline::new(),
last_ui_map: Mutex::new(None),
width,
height,
closed: std::sync::atomic::AtomicBool::new(false),
})
}
pub fn viewport(&self) -> (u32, u32) {
(self.width, self.height)
}
pub async fn close(&self) -> Result<(), String> {
use std::sync::atomic::Ordering;
if self.closed.swap(true, Ordering::SeqCst) {
return Ok(());
}
self.backend
.shutdown()
.await
.map_err(|e| format!("browser shutdown: {}", e))
}
pub async fn run(&self, script_json: &str) -> Result<String, String> {
let script: BrowseScript =
serde_json::from_str(script_json).map_err(|e| format!("parse script: {}", e))?;
let expanded = expand_session_ref(script.session_ref.as_ref(), script.operations)?;
let mut steps = Vec::with_capacity(expanded.len());
for op in expanded {
let start = std::time::Instant::now();
let (name, result) = self.run_op(op).await;
let elapsed = start.elapsed().as_millis() as u64;
let (status, data, error) = match result {
Ok(d) => ("ok", d, None),
Err(e) => ("error", None, Some(e)),
};
let failed = status == "error";
steps.push(json!({
"op": name,
"status": status,
"data": data,
"error": error,
"duration_ms": elapsed,
}));
if failed {
break;
}
}
Ok(json!({ "steps": steps }).to_string())
}
async fn run_op(&self, op: BrowseOp) -> (&'static str, Result<Option<Value>, String>) {
match op {
BrowseOp::Navigate { url } => (
"navigate",
self.backend
.navigate(&url)
.await
.map(|_| Some(json!({ "url": url })))
.map_err(|e| e.to_string()),
),
BrowseOp::Observe => {
let res: Result<Option<Value>, String> = async {
let tree = self
.backend
.get_accessibility_tree()
.await
.map_err(|e| e.to_string())?;
let viewport = self.backend.get_viewport().map_err(|e| e.to_string())?;
let url = self.backend.get_current_url().map_err(|e| e.to_string())?;
let title = self.backend.get_page_title().await.unwrap_or_default();
let screenshot = self
.backend
.capture_screenshot()
.await
.map_err(|e| e.to_string())?;
let ui_map = self
.pipeline
.perceive(&screenshot, &tree, &url, viewport)
.await
.map_err(|e| e.to_string())?;
let summary = ui_map.format_summary();
let element_count = ui_map.elements.len();
*self.last_ui_map.lock().await = Some(ui_map);
Ok(Some(json!({
"url": url,
"title": title,
"element_count": element_count,
"summary": summary,
})))
}
.await;
("observe", res)
}
BrowseOp::Click { element_id } => {
let ax_id = self.resolve_element(&element_id).await;
(
"click",
self.backend
.click_element(&ax_id)
.await
.map(|_| Some(json!({ "element_id": element_id, "resolved_to": ax_id })))
.map_err(|e| e.to_string()),
)
}
BrowseOp::Type { element_id, text } => {
let ax_id = self.resolve_element(&element_id).await;
(
"type",
self.backend
.type_into_element(&ax_id, &text)
.await
.map(|_| Some(json!({ "element_id": element_id, "resolved_to": ax_id })))
.map_err(|e| e.to_string()),
)
}
BrowseOp::Scroll { delta_y } => (
"scroll",
self.backend
.inject_scroll(delta_y)
.await
.map(|_| Some(json!({ "delta_y": delta_y })))
.map_err(|e| e.to_string()),
),
BrowseOp::Keypress { key, modifiers } => {
let parsed: Vec<Modifier> = modifiers
.iter()
.filter_map(|m| match m.to_lowercase().as_str() {
"alt" => Some(Modifier::Alt),
"control" | "ctrl" => Some(Modifier::Control),
"meta" | "command" | "cmd" => Some(Modifier::Meta),
"shift" => Some(Modifier::Shift),
_ => None,
})
.collect();
(
"keypress",
self.backend
.inject_keypress(&key, &parsed)
.await
.map(|_| Some(json!({ "key": key, "modifiers": modifiers })))
.map_err(|e| e.to_string()),
)
}
BrowseOp::SetCookies { cookies } => {
let count = cookies.len();
(
"set_cookies",
self.backend
.set_cookies(&cookies)
.await
.map(|_| Some(json!({ "cookies_set": count })))
.map_err(|e| e.to_string()),
)
}
BrowseOp::SetExtraHeaders { headers } => {
let count = headers.len();
let pairs: Vec<(String, String)> = headers.into_iter().collect();
(
"set_extra_headers",
self.backend
.set_extra_headers(&pairs)
.await
.map(|_| Some(json!({ "headers_set": count })))
.map_err(|e| e.to_string()),
)
}
BrowseOp::SetLocalStorage { origin, items } => {
let count = items.len();
let pairs: Vec<(String, String)> = items.into_iter().collect();
(
"set_local_storage",
self.backend
.set_local_storage(&origin, &pairs)
.await
.map(|_| Some(json!({ "origin": origin, "items_set": count })))
.map_err(|e| e.to_string()),
)
}
BrowseOp::Wait {
condition,
timeout_ms,
} => {
let parsed = match parse_wait_condition(&condition) {
Some(c) => c,
None => {
return (
"wait",
Err(format!("unknown wait condition: {}", condition)),
);
}
};
(
"wait",
self.backend
.wait_until(&parsed, timeout_ms)
.await
.map(|success| Some(json!({ "condition": condition, "success": success })))
.map_err(|e| e.to_string()),
)
}
}
}
}
impl Drop for BrowserSession {
fn drop(&mut self) {
use std::sync::atomic::Ordering;
if self.closed.load(Ordering::SeqCst) {
return;
}
let backend = self.backend.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let _ = backend.shutdown().await;
});
}
}
}
impl BrowserSession {
async fn resolve_element(&self, element_id: &str) -> String {
let guard = self.last_ui_map.lock().await;
if let Some(map) = guard.as_ref() {
if let Some(el) = map.get_element(element_id) {
if let Some(ref ax) = el.ax_ref {
return ax.clone();
}
}
}
element_id.to_string()
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BrowseScript {
operations: Vec<BrowseOp>,
#[serde(default)]
session_ref: Option<SessionRef>,
}
#[derive(Deserialize)]
pub struct SessionRef {
#[serde(default)]
pub service: Option<String>,
pub key: String,
}
#[derive(Deserialize, serde::Serialize)]
pub struct SessionBundle {
#[serde(default)]
pub cookies: Vec<CookieParam>,
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
#[serde(default)]
pub local_storage: std::collections::HashMap<String, std::collections::HashMap<String, String>>,
}
fn expand_session_ref(
session_ref: Option<&SessionRef>,
ops: Vec<BrowseOp>,
) -> Result<Vec<BrowseOp>, String> {
let sref = match session_ref {
Some(s) => s,
None => return Ok(ops),
};
let raw = crate::secrets::read_raw(sref.service.as_deref(), &sref.key)
.map_err(|e| format!("session_ref: {}", e))?;
let bundle: SessionBundle = serde_json::from_str(&raw)
.map_err(|e| format!("session_ref value is not a valid SessionBundle: {}", e))?;
let mut expanded = Vec::with_capacity(ops.len() + 2);
if !bundle.cookies.is_empty() {
expanded.push(BrowseOp::SetCookies {
cookies: bundle.cookies,
});
}
for (origin, items) in bundle.local_storage {
expanded.push(BrowseOp::SetLocalStorage { origin, items });
}
expanded.extend(ops);
Ok(expanded)
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
enum BrowseOp {
Navigate {
url: String,
},
Observe,
Click {
element_id: String,
},
Type {
element_id: String,
text: String,
},
Scroll {
delta_y: i32,
},
Keypress {
key: String,
#[serde(default)]
modifiers: Vec<String>,
},
Wait {
condition: String,
#[serde(default = "default_wait_timeout_ms")]
timeout_ms: u64,
},
SetCookies {
cookies: Vec<CookieParam>,
},
SetExtraHeaders {
headers: std::collections::HashMap<String, String>,
},
SetLocalStorage {
origin: String,
items: std::collections::HashMap<String, String>,
},
}
fn default_wait_timeout_ms() -> u64 {
30_000
}
fn parse_wait_condition(s: &str) -> Option<car_browser::models::WaitCondition> {
use car_browser::models::WaitCondition;
match s {
"page_loaded" => Some(WaitCondition::PageLoaded),
"url_changed" => Some(WaitCondition::UrlChanged),
s if s.starts_with("element_with_name:") => Some(WaitCondition::ElementWithName {
name_contains: s[18..].to_string(),
role: None,
}),
s if s.starts_with("a11y_contains_text:") => Some(WaitCondition::A11yContainsText {
text: s[19..].to_string(),
}),
_ => None,
}
}