//! Daemon IPC client for camoufox-browser. //! //! Communicates with the camoufox-browser daemon via JSON-line protocol //! over a Unix socket at `~/.camoufox-browser/.sock`. //! //! Also supports HTTP JSON transport for the Docker container on port 9377. //! Auto-detected — no user configuration needed. 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}; /// 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 types. #[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, #[error("HTTP error: {0}")] Http(String), } pub type BrowserResult = Result; /// Backend transport type. #[derive(Debug, Clone, PartialEq)] pub enum Transport { /// Unix socket daemon (camoufox-browser@0.1.0) UnixSocket { session: String, socket_path: PathBuf }, /// HTTP REST API (Docker container) Http { base_url: String }, } impl Transport { fn session(&self) -> &str { match self { Transport::UnixSocket { session, .. } => session, Transport::Http { .. } => "default", } } } /// 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. #[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. #[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. #[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, } /// Client that talks to camoufox-browser over Unix socket or HTTP. #[derive(Debug, Clone)] pub struct BrowserClient { transport: Transport, timeout: Duration, /// Whether this client was created via HTTP (Docker) transport. is_http: bool, } impl BrowserClient { /// Create a new client for the default Unix socket session. pub fn new() -> Self { Self { transport: Transport::UnixSocket { session: DEFAULT_SESSION.to_string(), socket_path: default_socket_path(DEFAULT_SESSION), }, timeout: Duration::from_secs(30), is_http: false, } } /// Create a client that talks to the Docker HTTP API. pub fn new_http(base_url: &str) -> Self { Self { transport: Transport::Http { base_url: base_url.trim_end_matches('/').to_string(), }, timeout: Duration::from_secs(30), is_http: true, } } /// Create a client with a custom session name. pub fn with_session(mut self, session: &str) -> Self { match &mut self.transport { Transport::UnixSocket { socket_path: _, .. } => { self.transport = Transport::UnixSocket { session: session.to_string(), socket_path: default_socket_path(session), }; } Transport::Http { .. } => {} // no-op for HTTP } self } /// Create a client with a custom socket path. pub fn with_socket(mut self, path: &str) -> Self { match &mut self.transport { Transport::UnixSocket { socket_path, .. } => { *socket_path = PathBuf::from(path); } Transport::Http { .. } => {} // no-op for HTTP } self } /// Get the socket path (for Unix socket transport). /// Returns None if using HTTP transport. pub fn socket_path(&self) -> Option<&PathBuf> { match &self.transport { Transport::UnixSocket { socket_path, .. } => Some(socket_path), Transport::Http { .. } => None, } } /// Get the session name. pub fn session(&self) -> &str { self.transport.session() } /// Returns true if this client uses HTTP transport (Docker). pub fn is_http_backend(&self) -> bool { self.is_http } /// Send a command to the daemon. async fn send_command(&self, action: &str, build: impl FnOnce(&mut DaemonCommand)) -> BrowserResult { match &self.transport { Transport::UnixSocket { .. } => self.send_unix(action, build).await, Transport::Http { base_url } => self.send_http(base_url, action, build).await, } } /// Send command via Unix socket (JSON-line IPC). async fn send_unix(&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: {}", e)))?; debug!("camoufox IPC → {} (action={})", payload, action); let socket_path = match &self.transport { Transport::UnixSocket { socket_path, .. } => socket_path.clone(), _ => return Err(BrowserError::Daemon("Not Unix transport".into())), }; let stream = tokio::time::timeout( Duration::from_secs(5), UnixStream::connect(&socket_path), ) .await .map_err(|_| BrowserError::SocketNotFound(socket_path.display().to_string()))? .map_err(BrowserError::Io)?; let (reader, mut writer) = stream.into_split(); writer.write_all(format!("{}\n", payload).as_bytes()).await.map_err(BrowserError::Io)?; // Don't call writer.shutdown() — it sends EOF to the Node.js daemon, // which causes the socket to close before the async response arrives. // The daemon's safeWrite() checks socket.destroyed and bails silently. // The socket pair (reader/writer) will be dropped after we read the response. drop(writer); 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".into()))? .map_err(BrowserError::Io)?; if line.is_empty() { return Err(BrowserError::Daemon("Empty response".into())); } let response: DaemonResponse = serde_json::from_str(line.trim()) .map_err(|e| BrowserError::Daemon(format!("Parse: {} — payload: {}", e, line.trim())))?; debug!("camoufox IPC ← {:?}", response); Ok(response) } /// Send command via HTTP (Docker REST API). async fn send_http(&self, base_url: &str, 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); debug!("camoufox HTTP → {} (action={})", serde_json::to_string(&cmd).unwrap_or_default(), action); // Build HTTP client let http_client = reqwest::Client::builder() .timeout(self.timeout) .build() .map_err(|e| BrowserError::Http(e.to_string()))?; // Map action to HTTP endpoint let url_str: String; let endpoint: &str = match action { "navigate" => "/navigate", "tab_list" => "/tabs", "tab_new" => "/navigate", "snapshot" => "/snapshot", "back" => "/navigate", "forward" => "/navigate", "reload" => "/navigate", _ => { url_str = format!("/{}", action); &url_str } }; let body = match action { "navigate" => serde_json::json!({ "url": cmd.url.unwrap_or_default(), "userId": "gyandoot" }), "tab_new" => serde_json::json!({ "url": cmd.url.unwrap_or("about:blank".into()), "userId": "gyandoot" }), "back" => serde_json::json!({ "url": "__history_back__", "userId": "gyandoot" }), "forward" => serde_json::json!({ "url": "__history_forward__", "userId": "gyandoot" }), "reload" => serde_json::json!({ "url": "__reload__", "userId": "gyandoot" }), _ => serde_json::json!({ "userId": "gyandoot" }), }; let method = match action { "tab_list" | "snapshot" => "GET", _ => "POST", }; let url = format!("{}{}", base_url, endpoint); let resp = match method { "GET" => http_client.get(&url).send().await, "POST" => http_client.post(&url).json(&body).send().await, _ => return Err(BrowserError::Http(format!("Unknown method: {}", method))), }; match resp { Ok(r) => { let status = r.status(); let text = r.text().await.unwrap_or_default(); // Parse response (the Docker API returns JSON directly) match serde_json::from_str::(&text) { Ok(json) => { let success = json.get("error").is_none() && status.is_success(); let error = json.get("error").and_then(|e| e.as_str()).map(|s| s.to_string()); let data = if success { Some(json) } else { None }; Ok(DaemonResponse { id, success, data, error, }) } Err(_) => { // Raw response Ok(DaemonResponse { id, success: status.is_success(), data: Some(Value::String(text)), error: None, }) } } } Err(e) => Err(BrowserError::Http(e.to_string())), } } /// 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) } /// Check daemon health. pub async fn health(&self) -> BrowserResult { match &self.transport { Transport::Http { base_url } => { let http_client = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .map_err(|e| BrowserError::Http(e.to_string()))?; match http_client.get(format!("{}/health", base_url)).send().await { Ok(resp) => { let text = resp.text().await.unwrap_or_default(); if let Ok(json) = serde_json::from_str::(&text) { return Ok(HealthResponse { ok: json["ok"].as_bool().unwrap_or(false), engine: "camoufox".to_string(), browser_connected: json["browserRunning"].as_bool().unwrap_or(false), browser_running: json["browserRunning"].as_bool().unwrap_or(false), active_tabs: json["activeTabs"].as_u64().unwrap_or(0) as u32, active_sessions: 1, }); } Ok(HealthResponse { ok: true, engine: "camoufox".to_string(), browser_connected: false, browser_running: false, active_tabs: 0, active_sessions: 0, }) } Err(_e) => Ok(HealthResponse { ok: false, engine: "camoufox".to_string(), browser_connected: false, browser_running: false, active_tabs: 0, active_sessions: 0, }), } } Transport::UnixSocket { socket_path, .. } => { if !socket_path.exists() { return Ok(HealthResponse { ok: false, engine: "camoufox".to_string(), browser_connected: false, browser_running: false, active_tabs: 0, active_sessions: 0, }); } 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, }) } } } } } /// Navigate to a URL. 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. 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(), }) } /// Get page snapshot. 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)?; Ok(PageSnapshot { snapshot: data["snapshot"].as_str().unwrap_or("").to_string(), 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(), }) } /// Click an element. 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. 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 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. 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(()) } /// List 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) } /// New tab. 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 tab. 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 browser. pub async fn close_browser(&self) -> BrowserResult<()> { let resp = self.send_command("close", |_| {}).await; match resp { Ok(_) => Ok(()), Err(e) => { warn!("Close returned: {}", e); Ok(()) } } } /// Search via macro — resolves macro names to actual search URLs /// since the camoufox-browser daemon does not handle macros internally. pub async fn search(&self, macro_name: &str, query: &str) -> BrowserResult { let url = match macro_name { "@google_search" | "google" => { format!("https://www.google.com/search?q={}&hl=en", urlencoding(query)) } "@youtube_search" | "youtube" => { format!("https://www.youtube.com/results?search_query={}", urlencoding(query)) } "@amazon_search" | "amazon" => { format!("https://www.amazon.com/s?k={}", urlencoding(query)) } "@reddit_search" | "reddit" => { format!("https://www.reddit.com/search/?q={}", urlencoding(query)) } "@wikipedia_search" | "wikipedia" => { format!("https://en.wikipedia.org/w/index.php?search={}", urlencoding(query)) } "@twitter_search" | "@x_search" | "twitter" | "x" => { format!("https://x.com/search?q={}&src=typed_query", urlencoding(query)) } "@yelp_search" | "yelp" => { format!("https://www.yelp.com/search?find_desc={}", urlencoding(query)) } "@spotify_search" | "spotify" => { format!("https://open.spotify.com/search/{}", urlencoding(query)) } "@linkedin_search" | "linkedin" => { format!("https://www.linkedin.com/search/results/all/?keywords={}", urlencoding(query)) } "@instagram_search" | "instagram" => { format!("https://www.instagram.com/explore/search/keyword/?q={}", urlencoding(query)) } "@tiktok_search" | "tiktok" => { format!("https://www.tiktok.com/search?q={}", urlencoding(query)) } "@twitch_search" | "twitch" => { format!("https://www.twitch.tv/search?term={}", urlencoding(query)) } // If it looks like a URL already, use it as-is s if s.starts_with("http://") || s.starts_with("https://") => s.to_string(), // Default: Google search _ => { warn!("Unknown macro '{}', falling back to Google search", macro_name); format!("https://www.google.com/search?q={}&hl=en", urlencoding(query)) } }; let resp = self.send_command("navigate", |cmd| { cmd.url = Some(url); cmd.wait = Some(5000); }).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(), }) } 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. 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(()) } } /// URL-encode a string for use in a search query. fn urlencoding(input: &str) -> String { // Simple URL encoding — replaces spaces with + and encodes special chars let mut result = String::with_capacity(input.len()); for byte in input.bytes() { match byte { b' ' => result.push('+'), b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { result.push(byte as char); } _ => { result.push_str(&format!("%{:02X}", byte)); } } } result } impl Default for BrowserClient { fn default() -> Self { Self::new() } } /// 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; } 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() { 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"); } }