use std::net::TcpStream; use std::process::Stdio; use std::time::Duration; use anyhow::Context; use async_trait::async_trait; use base64::Engine; use fantoccini::actions::{InputSource, MOUSE_BUTTON_LEFT, MouseActions, PointerAction}; use fantoccini::key::Key; use fantoccini::{Client, ClientBuilder, Locator}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use tracing; use crate::config::BrowserConfig; use crate::tools::traits::{Tool, ToolResult}; const CHROME_CANDIDATES: &[&str] = &[ "google-chrome", "chromium-browser", "chromium", "google-chrome-stable", "chrome", ]; const CHROMEDRIVER_CANDIDATES: &[&str] = &["chromedriver"]; pub struct BrowserTool { webdriver_url: String, headless: bool, chrome_path: Option, state: tokio::sync::Mutex, driver: std::sync::Mutex>, } struct BrowserState { client: Option, } impl Drop for BrowserTool { fn drop(&mut self) { if let Ok(mut driver) = self.driver.lock() && let Some(ref mut child) = driver.take() { tracing::debug!("Stopping chromedriver process"); let _ = child.start_kill(); } } } impl BrowserTool { pub fn new(config: &BrowserConfig) -> Self { Self { webdriver_url: config.webdriver_url.clone(), headless: config.headless, chrome_path: config.chrome_path.clone(), state: tokio::sync::Mutex::new(BrowserState { client: None }), driver: std::sync::Mutex::new(None), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BrowserAction { Open { url: String, }, Snapshot { #[serde(default)] interactive_only: bool, #[serde(default)] compact: bool, #[serde(default)] depth: Option, }, Click { selector: String, }, Fill { selector: String, value: String, }, Type { selector: Option, text: String, }, GetText { selector: String, }, GetTitle, GetUrl, Screenshot { #[serde(default)] path: Option, #[serde(default)] return_base64: bool, }, Focus { selector: String, }, Wait { #[serde(default)] selector: Option, #[serde(default)] ms: Option, #[serde(default)] text: Option, }, Press { key: String, }, Hover { selector: String, }, ClickAt { x: u32, y: u32, }, Scroll { direction: String, #[serde(default)] pixels: Option, }, Close, } fn parse_browser_action(action_str: &str, args: &Value) -> anyhow::Result { match action_str { "open" => { let url = args .get("url") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?; Ok(BrowserAction::Open { url: url.to_string(), }) } "snapshot" => Ok(BrowserAction::Snapshot { interactive_only: args .get("interactive_only") .and_then(Value::as_bool) .unwrap_or(true), compact: args.get("compact").and_then(Value::as_bool).unwrap_or(true), depth: args.get("depth").and_then(|v| v.as_i64()), }), "click" => { let selector = args .get("selector") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for click"))?; Ok(BrowserAction::Click { selector: selector.to_string(), }) } "fill" => { let selector = args .get("selector") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for fill"))?; let value = args .get("value") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'value' for fill"))?; Ok(BrowserAction::Fill { selector: selector.to_string(), value: value.to_string(), }) } "type" => { let selector = args .get("selector") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let text = args .get("text") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'text' for type"))?; Ok(BrowserAction::Type { selector, text: text.to_string(), }) } "get_text" => { let selector = args .get("selector") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for get_text"))?; Ok(BrowserAction::GetText { selector: selector.to_string(), }) } "get_title" => Ok(BrowserAction::GetTitle), "get_url" => Ok(BrowserAction::GetUrl), "screenshot" => Ok(BrowserAction::Screenshot { path: args.get("path").and_then(|v| v.as_str()).map(String::from), return_base64: args .get("return_base64") .and_then(Value::as_bool) .unwrap_or(false), }), "focus" => { let selector = args .get("selector") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for focus"))?; Ok(BrowserAction::Focus { selector: selector.to_string(), }) } "wait" => Ok(BrowserAction::Wait { selector: args .get("selector") .and_then(|v| v.as_str()) .map(String::from), ms: args.get("ms").and_then(|v| v.as_u64()), text: args.get("text").and_then(|v| v.as_str()).map(String::from), }), "press" => { let key = args .get("key") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'key' for press"))?; Ok(BrowserAction::Press { key: key.to_string(), }) } "hover" => { let selector = args .get("selector") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for hover"))?; Ok(BrowserAction::Hover { selector: selector.to_string(), }) } "scroll" => { let direction = args .get("direction") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'direction' for scroll"))?; Ok(BrowserAction::Scroll { direction: direction.to_string(), pixels: args .get("pixels") .and_then(|v| v.as_u64()) .map(|p| u32::try_from(p).unwrap_or(u32::MAX)), }) } "close" => Ok(BrowserAction::Close), "click_at" => { let x = args .get("x") .and_then(|v| v.as_u64()) .ok_or_else(|| anyhow::anyhow!("Missing 'x' for click_at"))? as u32; let y = args .get("y") .and_then(|v| v.as_u64()) .ok_or_else(|| anyhow::anyhow!("Missing 'y' for click_at"))? as u32; Ok(BrowserAction::ClickAt { x, y }) } other => anyhow::bail!("Unsupported browser action: {}", other), } } #[async_trait] impl Tool for BrowserTool { fn name(&self) -> &str { "browser" } fn description(&self) -> &str { "Automate browser interactions via WebDriver. \ Actions: open, snapshot, click, fill, type, get_text, get_title, \ get_url, screenshot, wait, press, hover, scroll, close, focus, click_at. \ Each session holds a single page; calling open again navigates \ the current page (does not open a new tab). \ Selectors: CSS, @e1 refs (from snapshot), text=... for text content, \ label=... for