//! Daemon IPC client for camoufox-browser. //! //! Communicates with the camoufox-browser daemon via JSON-line protocol //! over a Unix socket at `~/.camoufox-browser/.sock`. //! //! The daemon is started by `camoufox-browser@latest` (npx) which spawns //! a background Node.js process that auto-launches the camoufox browser //! on the first browser-dependent command. use std::path::PathBuf; use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tracing::{debug, warn}; // ─── Constants ────────────────────────────────────────────────────────── /// Default session name. pub const DEFAULT_SESSION: &str = "default"; /// Default daemon socket directory inside `~/.camoufox-browser/`. fn default_socket_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) .join(".camoufox-browser") } /// Default daemon socket path. pub fn default_socket_path(session: &str) -> PathBuf { default_socket_dir().join(format!("{}.sock", session)) } // ─── Error ────────────────────────────────────────────────────────────── #[derive(Debug, thiserror::Error)] pub enum BrowserError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Daemon error: {0}")] Daemon(String), #[error("Socket not found: {0}")] SocketNotFound(String), #[error("Daemon response missing 'data' field")] MissingData, #[error("Tab not found: {0}")] TabNotFound(String), #[error("Navigation failed: {0}")] NavigationFailed(String), #[error("Snapshot unavailable")] SnapshotUnavailable, } pub type BrowserResult = Result; // ─── IPC Protocol Types ───────────────────────────────────────────────── /// A command sent to the daemon as JSON-line. #[derive(Debug, Serialize)] struct DaemonCommand { id: String, action: String, #[serde(skip_serializing_if = "Option::is_none")] url: Option, #[serde(skip_serializing_if = "Option::is_none")] query: Option, #[serde(skip_serializing_if = "Option::is_none")] macro_name: Option, #[serde(skip_serializing_if = "Option::is_none")] wait: Option, #[serde(skip_serializing_if = "Option::is_none")] selector: Option, #[serde(skip_serializing_if = "Option::is_none")] r#ref: Option, #[serde(skip_serializing_if = "Option::is_none")] text: Option, #[serde(skip_serializing_if = "Option::is_none")] press_enter: Option, #[serde(skip_serializing_if = "Option::is_none")] headless: Option, #[serde(skip_serializing_if = "Option::is_none")] index: Option, #[serde(skip_serializing_if = "Option::is_none")] direction: Option, #[serde(skip_serializing_if = "Option::is_none")] amount: Option, #[serde(skip_serializing_if = "Option::is_none")] interactive: Option, #[serde(skip_serializing_if = "Option::is_none")] compact: Option, } /// A response from the daemon. #[derive(Debug, Deserialize)] struct DaemonResponse { #[allow(dead_code)] id: String, success: bool, #[serde(default)] data: Option, #[serde(default)] error: Option, } /// Health status returned by the daemon. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthResponse { pub ok: bool, pub engine: String, #[serde(default)] pub browser_connected: bool, #[serde(default)] pub browser_running: bool, #[serde(default)] pub active_tabs: u32, #[serde(default)] pub active_sessions: u32, } /// Navigation result from the daemon. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NavigationResult { pub url: String, pub title: String, } /// Snapshot of a page (accessibility tree). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PageSnapshot { pub snapshot: String, #[serde(default)] pub url: String, #[serde(default)] pub title: String, #[serde(default)] pub screenshot: Option, #[serde(default)] pub elements: std::collections::HashMap, } /// Element info in a snapshot. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ElementInfo { #[serde(default)] pub tag: String, #[serde(default)] pub text: String, #[serde(default)] pub href: Option, #[serde(default)] pub interactive: bool, } impl PageSnapshot { pub fn extract_refs(&self) -> Vec { let mut refs = Vec::new(); for line in self.snapshot.lines() { if let Some(start) = line.find("[e") { let after = &line[start + 2..]; if let Some(end) = after.find(']') { let ref_str = line[start..=start + 1 + end].to_string(); if !refs.contains(&ref_str) { refs.push(ref_str); } } } } refs.sort(); refs } } /// Interaction result (click/type). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InteractionResult { #[serde(default)] pub ok: bool, #[serde(default)] pub snapshot: Option, } /// Tab information. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TabInfo { pub index: u32, pub title: String, pub url: String, pub active: bool, } // ─── BrowserClient ────────────────────────────────────────────────────── /// Client that talks to the camoufox-browser daemon over Unix socket. #[derive(Debug, Clone)] pub struct BrowserClient { /// Session name. session: String, /// Socket path. socket_path: PathBuf, /// Timeout for daemon responses. timeout: Duration, } impl BrowserClient { /// Create a new client for the default session. pub fn new() -> Self { Self { session: DEFAULT_SESSION.to_string(), socket_path: default_socket_path(DEFAULT_SESSION), timeout: Duration::from_secs(30), } } /// Create a client with a custom session name. pub fn with_session(mut self, session: &str) -> Self { self.session = session.to_string(); self.socket_path = default_socket_path(session); self } /// Create a client with a custom socket path. pub fn with_socket(mut self, path: &str) -> Self { self.socket_path = PathBuf::from(path); self } /// Get the socket path. pub fn socket_path(&self) -> &PathBuf { &self.socket_path } /// Get the session name. pub fn session(&self) -> &str { &self.session } // ─── Low-level IPC ────────────────────────────────────────────── /// Send a command to the daemon and receive a response. async fn send_command(&self, action: &str, build: impl FnOnce(&mut DaemonCommand)) -> BrowserResult { let id = format!("g{}", chrono::Utc::now().timestamp_millis()); let mut cmd = DaemonCommand { id: id.clone(), action: action.to_string(), url: None, query: None, macro_name: None, wait: None, selector: None, r#ref: None, text: None, press_enter: None, headless: None, index: None, direction: None, amount: None, interactive: None, compact: None, }; build(&mut cmd); let payload = serde_json::to_string(&cmd) .map_err(|e| BrowserError::Daemon(format!("Serialize command: {}", e)))?; debug!("camoufox IPC → {} (action={})", payload, action); // Connect and send let stream = tokio::time::timeout( Duration::from_secs(5), UnixStream::connect(&self.socket_path), ) .await .map_err(|_| BrowserError::SocketNotFound(self.socket_path.display().to_string()))? .map_err(BrowserError::Io)?; let (reader, mut writer) = stream.into_split(); // Write command writer .write_all(format!("{}\n", payload).as_bytes()) .await .map_err(BrowserError::Io)?; writer.shutdown().await.map_err(BrowserError::Io)?; // Read response let mut buf_reader = BufReader::new(reader); let mut line = String::new(); tokio::time::timeout(self.timeout, buf_reader.read_line(&mut line)) .await .map_err(|_| BrowserError::Daemon("Response timeout".to_string()))? .map_err(BrowserError::Io)?; if line.is_empty() { return Err(BrowserError::Daemon("Empty response from daemon".to_string())); } let response: DaemonResponse = serde_json::from_str(line.trim()) .map_err(|e| BrowserError::Daemon(format!("Parse response: {} — payload: {}", e, line.trim())))?; debug!("camoufox IPC ← {:?}", response); Ok(response) } /// Extract data payload from a successful response. fn check_response(&self, resp: DaemonResponse) -> BrowserResult { if !resp.success { let err = resp.error.unwrap_or_else(|| "Unknown error".to_string()); return Err(BrowserError::Daemon(err)); } resp.data.ok_or(BrowserError::MissingData) } // ─── Health ───────────────────────────────────────────────────── /// Check daemon health by sending a quick snapshot command. /// The daemon has no dedicated /health endpoint — we check socket existence + connectivity. pub async fn health(&self) -> BrowserResult { // Check if socket exists if !self.socket_path.exists() { return Ok(HealthResponse { ok: false, engine: "camoufox".to_string(), browser_connected: false, browser_running: false, active_tabs: 0, active_sessions: 0, }); } // Try a quick "tab_list" command as health check match self.send_command("tab_list", |_| {}).await { Ok(resp) => { match self.check_response(resp) { Ok(data) => { let tabs = data["tabs"].as_array().map(|a| a.len() as u32).unwrap_or(0); Ok(HealthResponse { ok: true, engine: "camoufox".to_string(), browser_connected: true, browser_running: true, active_tabs: tabs, active_sessions: 1, }) } Err(e) => { warn!("Health check daemon error: {}", e); Ok(HealthResponse { ok: true, engine: "camoufox".to_string(), browser_connected: false, browser_running: false, active_tabs: 0, active_sessions: 0, }) } } } Err(e) => { debug!("Health check failed: {}", e); Ok(HealthResponse { ok: false, engine: "camoufox".to_string(), browser_connected: false, browser_running: false, active_tabs: 0, active_sessions: 0, }) } } } // ─── Navigation ───────────────────────────────────────────────── /// Navigate to a URL. The daemon auto-launches the browser on first call. pub async fn navigate(&self, url: &str) -> BrowserResult { let resp = self.send_command("navigate", |cmd| { cmd.url = Some(url.to_string()); }).await?; let data = self.check_response(resp)?; Ok(NavigationResult { url: data["url"].as_str().unwrap_or(url).to_string(), title: data["title"].as_str().unwrap_or("").to_string(), }) } /// Navigate back. pub async fn go_back(&self) -> BrowserResult { let resp = self.send_command("back", |_| {}).await?; let data = self.check_response(resp)?; Ok(NavigationResult { url: data["url"].as_str().unwrap_or("").to_string(), title: "".to_string(), }) } /// Navigate forward. pub async fn go_forward(&self) -> BrowserResult { let resp = self.send_command("forward", |_| {}).await?; let data = self.check_response(resp)?; Ok(NavigationResult { url: data["url"].as_str().unwrap_or("").to_string(), title: "".to_string(), }) } /// Refresh the current page. pub async fn refresh(&self) -> BrowserResult { let resp = self.send_command("reload", |_| {}).await?; let data = self.check_response(resp)?; Ok(NavigationResult { url: data["url"].as_str().unwrap_or("").to_string(), title: "".to_string(), }) } // ─── Snapshot ─────────────────────────────────────────────────── /// Get an accessibility snapshot of the current page. pub async fn snapshot(&self, interactive: bool) -> BrowserResult { let resp = self.send_command("snapshot", |cmd| { cmd.interactive = Some(interactive); cmd.compact = Some(false); }).await?; let data = self.check_response(resp)?; let raw_snapshot = data["snapshot"].as_str().unwrap_or("").to_string(); Ok(PageSnapshot { snapshot: raw_snapshot, url: data["url"].as_str().unwrap_or("").to_string(), title: data["title"].as_str().unwrap_or("").to_string(), screenshot: data["screenshot"].as_str().map(|s| s.to_string()), elements: std::collections::HashMap::new(), }) } // ─── Interaction ──────────────────────────────────────────────── /// Click an element by its ref (e.g., "e1"). pub async fn click(&self, element_ref: &str) -> BrowserResult { let resp = self.send_command("click", |cmd| { cmd.selector = Some(format!("[ref=\"{}\"]", element_ref)); }).await?; let data = self.check_response(resp)?; Ok(InteractionResult { ok: true, snapshot: self.extract_optional_snapshot(&data), }) } /// Type text into an element and optionally press Enter. pub async fn type_text(&self, element_ref: &str, text: &str, press_enter: bool) -> BrowserResult { let resp = self.send_command("fill", |cmd| { cmd.selector = Some(format!("[ref=\"{}\"]", element_ref)); cmd.text = Some(text.to_string()); }).await?; let data = self.check_response(resp)?; if press_enter { self.press("Enter").await?; } Ok(InteractionResult { ok: true, snapshot: self.extract_optional_snapshot(&data), }) } /// Press a keyboard key. pub async fn press(&self, key: &str) -> BrowserResult<()> { let resp = self.send_command("press", |cmd| { cmd.text = Some(key.to_string()); }).await?; self.check_response(resp)?; Ok(()) } /// Scroll the page. pub async fn scroll(&self, direction: &str, amount: u32) -> BrowserResult<()> { let resp = self.send_command("scroll", |cmd| { cmd.direction = Some(direction.to_string()); cmd.amount = Some(amount); }).await?; self.check_response(resp)?; Ok(()) } // ─── Tab Management ───────────────────────────────────────────── /// List all open tabs. pub async fn list_tabs(&self) -> BrowserResult> { let resp = self.send_command("tab_list", |_| {}).await?; let data = self.check_response(resp)?; let tabs = data["tabs"].as_array() .map(|arr| { arr.iter().map(|t| TabInfo { index: t["index"].as_u64().unwrap_or(0) as u32, title: t["title"].as_str().unwrap_or("").to_string(), url: t["url"].as_str().unwrap_or("").to_string(), active: t["active"].as_bool().unwrap_or(false), }).collect() }) .unwrap_or_default(); Ok(tabs) } /// Create a new tab with an optional URL. pub async fn new_tab(&self, url: Option<&str>) -> BrowserResult { let resp = self.send_command("tab_new", |cmd| { cmd.url = url.map(|s| s.to_string()); }).await?; let data = self.check_response(resp)?; let idx = data["index"].as_u64().unwrap_or(0) as u32; Ok(TabInfo { index: idx, title: String::new(), url: url.unwrap_or("about:blank").to_string(), active: true, }) } /// Switch to a tab by index. pub async fn switch_tab(&self, index: u32) -> BrowserResult { let resp = self.send_command("tab_switch", |cmd| { cmd.index = Some(index); }).await?; let data = self.check_response(resp)?; Ok(TabInfo { index, title: data["title"].as_str().unwrap_or("").to_string(), url: data["url"].as_str().unwrap_or("").to_string(), active: true, }) } /// Close the browser. pub async fn close_browser(&self) -> BrowserResult<()> { let resp = self.send_command("close", |_| {}).await; match resp { Ok(_) => Ok(()), Err(e) => { warn!("Close browser returned: {}", e); Ok(()) } } } // ─── Search Macros ────────────────────────────────────────────── /// Execute a search using a built-in daemon macro. pub async fn search(&self, macro_name: &str, query: &str) -> BrowserResult { let resp = self.send_command("navigate", |cmd| { cmd.macro_name = Some(macro_name.to_string()); cmd.query = Some(query.to_string()); cmd.wait = Some(3000); }).await?; let data = self.check_response(resp)?; Ok(NavigationResult { url: data["url"].as_str().unwrap_or("").to_string(), title: data["title"].as_str().unwrap_or("").to_string(), }) } // ─── Helpers ──────────────────────────────────────────────────── fn extract_optional_snapshot(&self, data: &Value) -> Option { data["snapshot"].as_str().map(|snap_text| PageSnapshot { snapshot: snap_text.to_string(), url: data["url"].as_str().unwrap_or("").to_string(), title: data["title"].as_str().unwrap_or("").to_string(), screenshot: None, elements: std::collections::HashMap::new(), }) } /// Wait for a selector or text to appear. pub async fn wait(&self, timeout_ms: u64) -> BrowserResult<()> { let resp = self.send_command("wait", |cmd| { cmd.selector = Some(format!("{}", timeout_ms)); }).await?; self.check_response(resp)?; Ok(()) } } impl Default for BrowserClient { fn default() -> Self { Self::new() } } // ─── Connection / Daemon Management ──────────────────────────────── /// Check if the daemon socket is reachable. pub async fn check_daemon_socket(session: &str) -> bool { let path = default_socket_path(session); if !path.exists() { return false; } // Quick connect test match tokio::time::timeout(Duration::from_secs(1), UnixStream::connect(&path)).await { Ok(Ok(_)) => true, _ => false, } } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_socket_path() { let path = default_socket_path("default"); assert!(path.to_string_lossy().ends_with(".camoufox-browser/default.sock")); } #[test] fn test_extract_refs() { let snap = PageSnapshot { snapshot: "- heading \"Hello\" [level=1]\n- link \"Click\" [e1]\n- paragraph: text [e2]\n".into(), url: String::new(), title: String::new(), screenshot: None, elements: std::collections::HashMap::new(), }; let refs = snap.extract_refs(); assert!(refs.contains(&"[e1]".to_string())); assert!(refs.contains(&"[e2]".to_string())); } #[test] fn test_navigation_result_deserialize() { let json = r#"{"url":"https://example.com","title":"Example"}"#; let result: NavigationResult = serde_json::from_str(json).unwrap(); assert_eq!(result.url, "https://example.com"); } #[tokio::test] async fn test_health_no_daemon_running() { // With no daemon running, health should report not connected // Don't fail — just verify the shape let client = BrowserClient::new(); let health = client.health().await.unwrap_or(HealthResponse { ok: false, engine: "camoufox".to_string(), browser_connected: false, browser_running: false, active_tabs: 0, active_sessions: 0, }); assert_eq!(health.engine, "camoufox"); } }