diff --git a/Cargo.lock b/Cargo.lock index 5b6ad93..3e1e34f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,8 +697,8 @@ version = "2.0.0" dependencies = [ "anyhow", "chrono", + "dirs", "gyandoot-core", - "reqwest", "serde", "serde_json", "thiserror", diff --git a/crates/gyandoot-agent/src/browser_manager.rs b/crates/gyandoot-agent/src/browser_manager.rs index 14d3ff7..087c799 100644 --- a/crates/gyandoot-agent/src/browser_manager.rs +++ b/crates/gyandoot-agent/src/browser_manager.rs @@ -1,174 +1,145 @@ -//! BrowserManager — Auto-start, health-check, and lifecycle for Camoufox. +//! BrowserManager — Start, connect, and manage lifecycle of the camoufox-browser daemon. +//! +//! The `camoufox-browser@0.1.0` CLI auto-starts a background daemon +//! (Node.js process) on any command. The daemon listens on a Unix socket at +//! `~/.camoufox-browser/.sock` and auto-launches the camoufox browser +//! on the first browser-dependent command. //! //! On `gyandoot run`, the BrowserManager: -//! 1. Checks if Camoufox is already running (health endpoint) -//! 2. If not, starts it as a background child process -//! 3. Waits for it to become healthy (up to 30 seconds) +//! 1. Checks if the daemon socket already exists and is connected +//! 2. If not, runs a quick CLI command to trigger daemon auto-start +//! 3. Waits for the Unix socket to appear (up to 30 seconds) //! 4. Returns a ready-to-use `BrowserClient` -//! -//! Camoufox is installed globally via `npx camoufox-browser` by `install.sh`. use std::process::Stdio; use std::time::Duration; + use tokio::process::Command; -use tracing::{info, warn, error}; +use tracing::{info, warn}; -use gyandoot_browser::BrowserClient; +use gyandoot_browser::{check_daemon_socket, default_socket_path, BrowserClient}; -/// Default Camoufox API endpoint. -const CAMOFOX_URL: &str = "http://localhost:9377"; +/// Default session name. +const DEFAULT_SESSION: &str = "default"; -/// Maximum time to wait for Camoufox to become healthy. -const STARTUP_TIMEOUT_SECS: u64 = 30; +/// Maximum time to wait for the daemon socket to appear. +const DAEMON_STARTUP_TIMEOUT_SECS: u64 = 30; -/// Health-check retry interval. -const HEALTH_RETRY_MS: u64 = 500; +/// Poll interval while waiting for daemon to start. +const DAEMON_POLL_MS: u64 = 500; /// Result of browser initialization. #[derive(Debug)] pub enum BrowserInitResult { - /// Browser is running and ready. + /// Browser daemon is running and client is ready. Ready(BrowserClient), - /// Could not start browser (user needs to install/start manually). + /// Could not start daemon. Unavailable(String), } -/// Initialize the Camoufox browser: check health, start if needed. -/// -/// Returns a `BrowserInitResult` — either a ready client or an explanation. +/// Initialize the camoufox-browser daemon and return a ready client. pub async fn init_browser() -> BrowserInitResult { - let client = BrowserClient::new(); + let session = DEFAULT_SESSION; + let socket_path = default_socket_path(session); - // 1. Quick health check — is it already running? - match try_health_check(&client).await { - Ok(true) => { - info!("Camoufox already running on localhost:9377"); - return BrowserInitResult::Ready(client); - } - Ok(false) => { - info!("Camoufox health check returned not-ready, attempting to start..."); - } - Err(_) => { - info!("Camoufox not running, attempting to start..."); - } + // 1. Quick check — is the daemon already running? + if check_daemon_socket(session).await { + info!("Camoufox daemon already running on socket: {}", socket_path.display()); + let client = BrowserClient::new().with_session(session); + return BrowserInitResult::Ready(client); } - // 2. Try to start Camoufox as a background process - match start_camoufox().await { + info!("Camoufox daemon not running, triggering startup..."); + + // 2. Trigger daemon auto-start via CLI + // The CLI calls `ensureDaemonRunning()` internally which spawns the daemon + match trigger_daemon_start(session).await { Ok(()) => { - // 3. Wait for it to become healthy - info!("Waiting for Camoufox to become healthy..."); - match wait_for_health(&client).await { + // 3. Wait for socket to appear + info!("Waiting for daemon socket at {}...", socket_path.display()); + match wait_for_socket(session).await { Ok(true) => { - info!("Camoufox started successfully"); - return BrowserInitResult::Ready(client); + info!("Camoufox daemon started successfully"); + let client = BrowserClient::new().with_session(session); + // Brief pause for daemon to fully initialize + tokio::time::sleep(Duration::from_millis(1000)).await; + BrowserInitResult::Ready(client) } Ok(false) => { let msg = format!( - "Camoufox process started but did not become healthy within {}s. \ - Check the logs at ~/.gyandoot/camoufox.log", - STARTUP_TIMEOUT_SECS + "Daemon did not start within {}s — socket not found at {}", + DAEMON_STARTUP_TIMEOUT_SECS, + socket_path.display() ); warn!("{}", msg); - return BrowserInitResult::Unavailable(msg); + BrowserInitResult::Unavailable(msg) } Err(e) => { - let msg = format!("Camoufox health-check error after startup: {}", e); + let msg = format!("Daemon socket error: {}", e); warn!("{}", msg); - return BrowserInitResult::Unavailable(msg); + BrowserInitResult::Unavailable(msg) } } } Err(e) => { let msg = format!( - "Could not start Camoufox: {}\n\n\ - Install it manually:\n npm install -g camoufox-browser\n \ + "Could not start camoufox daemon: {}\n\n\ + Make sure Node.js v20+ and the package are installed:\n \ + npm install -g camoufox-browser\n \ npx camoufox-browser@latest install\n\n\ - Then start before running gyandoot:\n npx camoufox-browser start &", + Then try again.", e ); warn!("{}", msg); - return BrowserInitResult::Unavailable(msg); + BrowserInitResult::Unavailable(msg) } } } -/// Quick health check — returns true if browser is connected and running. -async fn try_health_check(client: &BrowserClient) -> Result { - match tokio::time::timeout(Duration::from_secs(3), client.health()).await { - Ok(Ok(health)) => { - Ok(health.browser_connected && health.browser_running) - } - Ok(Err(e)) => Err(e.to_string()), - Err(_) => Err("timeout".to_string()), - } -} - -/// Start Camoufox as a background child process. -async fn start_camoufox() -> Result<(), String> { - // Create log directory +/// Trigger daemon startup by running a quick CLI command. +/// +/// The `camoufox-browser@latest` CLI auto-starts the daemon via +/// `ensureDaemonRunning()` on any command. We run a harmless +/// `tab_list` which spawns the daemon in the background and +/// returns once complete. +async fn trigger_daemon_start(session: &str) -> Result<(), String> { + // Create log directory for any stderr output let home = dirs::home_dir().ok_or("Cannot determine home directory")?; let log_dir = home.join(".gyandoot"); - std::fs::create_dir_all(&log_dir).map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?; - let log_file = log_dir.join("camoufox.log"); + std::fs::create_dir_all(&log_dir) + .map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?; + let log_file = log_dir.join("camoufox-daemon-spawn.log"); let log = std::fs::File::create(&log_file) - .map_err(|e| format!("Cannot create log file {}: {}", log_file.display(), e))?; + .map_err(|e| format!("Cannot create log file: {}", e))?; - // Try npx first (npm global install), then npx direct - let result = Command::new("npx") + // Run a command — the CLI auto-starts the daemon as a side effect + let _output = Command::new("npx") .args(&[ "camoufox-browser@latest", - "start", - "--port", "9377", + "tab_list", + "--session", session, ]) .stdout(Stdio::from(log.try_clone().map_err(|e| e.to_string())?)) .stderr(Stdio::from(log)) - .kill_on_drop(true) - .spawn(); + .output() + .await + .map_err(|e| format!("npx failed: {}. Is Node.js installed?", e))?; - match result { - Ok(mut child) => { - // Detach — let it run in background - tokio::spawn(async move { - let status = child.wait().await; - if let Ok(s) = status { - if !s.success() { - warn!("Camoufox process exited with status: {}", s); - } - } - }); - // Give it a moment to bind - tokio::time::sleep(Duration::from_millis(1500)).await; - Ok(()) - } - Err(e) => { - // npx not available — try directly via node_modules - Err(format!("npx failed: {}. Is Node.js installed?", e)) - } - } + // Return immediately — socket polling in the caller will detect the daemon + Ok(()) } -/// Poll the health endpoint until the browser is ready or timeout. -async fn wait_for_health(client: &BrowserClient) -> Result { - let deadline = tokio::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS); - let mut last_error = String::new(); +/// Poll for the daemon Unix socket to appear. +async fn wait_for_socket(session: &str) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(DAEMON_STARTUP_TIMEOUT_SECS); while tokio::time::Instant::now() < deadline { - match try_health_check(client).await { - Ok(true) => return Ok(true), - Ok(false) => { - last_error = "browser not connected".to_string(); - } - Err(e) => { - last_error = e; - } + if check_daemon_socket(session).await { + return Ok(true); } - tokio::time::sleep(Duration::from_millis(HEALTH_RETRY_MS)).await; + tokio::time::sleep(Duration::from_millis(DAEMON_POLL_MS)).await; } - Err(format!( - "Timed out after {}s. Last status: {}", - STARTUP_TIMEOUT_SECS, last_error - )) + Ok(false) } \ No newline at end of file diff --git a/crates/gyandoot-agent/src/browser_tools.rs b/crates/gyandoot-agent/src/browser_tools.rs index 71d1c61..e147eca 100644 --- a/crates/gyandoot-agent/src/browser_tools.rs +++ b/crates/gyandoot-agent/src/browser_tools.rs @@ -3,16 +3,68 @@ //! These tools are registered with the `ToolExecutor` and provide //! LLM-accessible functions for searching the web, browsing pages, //! clicking elements, and extracting content. +//! +//! Uses the camoufox-browser daemon protocol (Unix socket IPC). use serde_json; use tracing::{info, warn}; -use gyandoot_browser::{BrowserClient, SearchMacro}; +use gyandoot_browser::BrowserClient; use gyandoot_core::ToolDefinition; use crate::tools::Tool; -/// Tool for searching the web using camofox-browser search macros. +/// Search macro name strings sent to the camoufox daemon. +const MACRO_GOOGLE: &str = "@google_search"; +const MACRO_YOUTUBE: &str = "@youtube_search"; +const MACRO_AMAZON: &str = "@amazon_search"; +const MACRO_REDDIT: &str = "@reddit_search"; +const MACRO_WIKIPEDIA: &str = "@wikipedia_search"; +const MACRO_TWITTER: &str = "@twitter_search"; +const MACRO_YELP: &str = "@yelp_search"; +const MACRO_SPOTIFY: &str = "@spotify_search"; +const MACRO_LINKEDIN: &str = "@linkedin_search"; +const MACRO_INSTAGRAM: &str = "@instagram_search"; +const MACRO_TIKTOK: &str = "@tiktok_search"; +const MACRO_TWITCH: &str = "@twitch_search"; + +fn site_to_macro(site: &str) -> &'static str { + match site { + "google" => MACRO_GOOGLE, + "youtube" => MACRO_YOUTUBE, + "amazon" => MACRO_AMAZON, + "reddit" => MACRO_REDDIT, + "wikipedia" => MACRO_WIKIPEDIA, + "twitter" | "x" => MACRO_TWITTER, + "yelp" => MACRO_YELP, + "spotify" => MACRO_SPOTIFY, + "linkedin" => MACRO_LINKEDIN, + "instagram" => MACRO_INSTAGRAM, + "tiktok" => MACRO_TIKTOK, + "twitch" => MACRO_TWITCH, + _ => MACRO_GOOGLE, + } +} + +fn site_name(site: &str) -> &'static str { + match site { + "google" => "Google", + "youtube" => "YouTube", + "amazon" => "Amazon", + "reddit" => "Reddit", + "wikipedia" => "Wikipedia", + "twitter" | "x" => "Twitter/X", + "yelp" => "Yelp", + "spotify" => "Spotify", + "linkedin" => "LinkedIn", + "instagram" => "Instagram", + "tiktok" => "TikTok", + "twitch" => "Twitch", + _ => "Google", + } +} + +/// Tool for searching the web using camoufox daemon macros. pub struct WebSearchTool { client: BrowserClient, } @@ -61,46 +113,23 @@ impl Tool for WebSearchTool { return Err(anyhow::anyhow!("query is required")); } - let macro_name = match site.as_str() { - "google" => SearchMacro::GoogleSearch, - "youtube" => SearchMacro::YoutubeSearch, - "amazon" => SearchMacro::AmazonSearch, - "reddit" => SearchMacro::RedditSearch, - "wikipedia" => SearchMacro::WikipediaSearch, - "twitter" | "x" => SearchMacro::TwitterSearch, - "yelp" => SearchMacro::YelpSearch, - "spotify" => SearchMacro::SpotifySearch, - "linkedin" => SearchMacro::LinkedinSearch, - "instagram" => SearchMacro::InstagramSearch, - "tiktok" => SearchMacro::TiktokSearch, - "twitch" => SearchMacro::TwitchSearch, - _ => SearchMacro::GoogleSearch, - }; + let mname = site_to_macro(&site); + let sname = site_name(&site); - info!("web_search: searching {} for '{}'", macro_name.site_name(), query); + info!("web_search: searching {} for '{}'", sname, query); - let session_key = format!("web_search_{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs()); - - let tab = self.client.create_tab("gyandoot", &session_key, None).await - .map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?; - let tab_id = tab.tab_id; - - let result = self.client.search(&tab_id, "gyandoot", ¯o_name, &query, Some(3000)).await + // Execute search — daemon auto-launches browser on first call + let result = self.client.search(mname, &query).await .map_err(|e| anyhow::anyhow!("Search failed: {}", e))?; - let snap = self.client.snapshot(&tab_id, "gyandoot").await + // Get snapshot of results + let snap = self.client.snapshot(true).await .map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?; - // Close the tab to free resources - let _ = self.client.close_tab(&tab_id, "gyandoot").await; - let refs = snap.extract_refs(); let response = format!( "## {} Search Results\n\n**Query:** {}\n**Page:** {}\n**Title:** {}\n\n**Snapshot content:**\n{}\n\n**Available element refs:** {:?}\n\n*Use `web_browse` to interact with specific results.*", - macro_name.site_name(), + sname, query, result.url, result.title, @@ -112,7 +141,7 @@ impl Tool for WebSearchTool { } } -/// Tool for browsing a specific URL using camofox-browser. +/// Tool for browsing a specific URL using camoufox daemon browser. pub struct WebBrowseTool { client: BrowserClient, } @@ -189,62 +218,52 @@ impl Tool for WebBrowseTool { return Err(anyhow::anyhow!("url is required")); } - let session_key = format!("web_browse_{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs()); - - let tab = self.client.create_tab("gyandoot", &session_key, Some(&url)).await - .map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?; - let tab_id = tab.tab_id; - match action.as_str() { + "navigate" => { + self.client.navigate(&url).await + .map_err(|e| anyhow::anyhow!("Navigation failed: {}", e))?; + } "click" => { - if let Some(ref r#ref) = element_ref { - self.client.click(&tab_id, "gyandoot", r#ref).await - .map_err(|e| anyhow::anyhow!("Click failed: {}", e))?; - } else { - return Err(anyhow::anyhow!("element_ref is required for click action")); - } + let r#ref = element_ref.as_ref() + .ok_or_else(|| anyhow::anyhow!("element_ref is required for click action"))?; + self.client.click(r#ref).await + .map_err(|e| anyhow::anyhow!("Click failed: {}", e))?; } "type" => { - if let Some(ref r#ref) = element_ref { - if let Some(ref t) = text { - self.client.type_text(&tab_id, "gyandoot", r#ref, t, press_enter).await - .map_err(|e| anyhow::anyhow!("Type failed: {}", e))?; - } - } else { - return Err(anyhow::anyhow!("element_ref and text are required for type action")); - } + let r#ref = element_ref.as_ref() + .ok_or_else(|| anyhow::anyhow!("element_ref is required for type action"))?; + let t = text.as_ref() + .ok_or_else(|| anyhow::anyhow!("text is required for type action"))?; + self.client.type_text(r#ref, t, press_enter).await + .map_err(|e| anyhow::anyhow!("Type failed: {}", e))?; } "scroll" => { - self.client.scroll(&tab_id, "gyandoot", &direction, amount).await + self.client.scroll(&direction, amount).await .map_err(|e| anyhow::anyhow!("Scroll failed: {}", e))?; } "back" => { - self.client.go_back(&tab_id, "gyandoot").await + self.client.go_back().await .map_err(|e| anyhow::anyhow!("Go back failed: {}", e))?; } "forward" => { - self.client.go_forward(&tab_id, "gyandoot").await + self.client.go_forward().await .map_err(|e| anyhow::anyhow!("Go forward failed: {}", e))?; } "refresh" => { - self.client.refresh(&tab_id, "gyandoot").await + self.client.refresh().await .map_err(|e| anyhow::anyhow!("Refresh failed: {}", e))?; } - _ => {} // already navigated via create_tab + _ => {} } - let snap = self.client.snapshot(&tab_id, "gyandoot").await + let snap = self.client.snapshot(false).await .map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?; - let _ = self.client.close_tab(&tab_id, "gyandoot").await; - let refs = snap.extract_refs(); + let current_url = if snap.url.is_empty() { &url } else { &snap.url }; let response = format!( "## Page Snapshot\n\n**URL:** {}\n\n**Snapshot:**\n{}\n\n**Element refs:** {:?}\n\n*Use `web_browse` with action=\"click\" and element_ref to interact with elements.*", - url, + current_url, snap.snapshot, refs, ); @@ -281,4 +300,11 @@ mod tests { let params = tool.parameters(); assert!(params["required"].as_array().unwrap().contains(&"url".to_string().into())); } + + #[test] + fn test_site_to_macro() { + assert_eq!(site_to_macro("google"), "@google_search"); + assert_eq!(site_to_macro("youtube"), "@youtube_search"); + assert_eq!(site_to_macro("reddit"), "@reddit_search"); + } } \ No newline at end of file diff --git a/crates/gyandoot-browser/Cargo.toml b/crates/gyandoot-browser/Cargo.toml index 9554e7b..d2a4fb8 100644 --- a/crates/gyandoot-browser/Cargo.toml +++ b/crates/gyandoot-browser/Cargo.toml @@ -3,7 +3,7 @@ name = "gyandoot-browser" version.workspace = true edition.workspace = true license.workspace = true -description = "GyanDoot v2 — Camofox browser automation (anti-detection browsing)" +description = "GyanDoot v2 — Camoufox browser automation via daemon IPC (Unix socket protocol)" [dependencies] gyandoot-core = { path = "../gyandoot-core" } @@ -15,4 +15,4 @@ anyhow.workspace = true tracing.workspace = true uuid.workspace = true chrono.workspace = true -reqwest = { workspace = true, features = ["json"] } \ No newline at end of file +dirs.workspace = true \ No newline at end of file diff --git a/crates/gyandoot-browser/src/client.rs b/crates/gyandoot-browser/src/client.rs index 41ff527..eff7856 100644 --- a/crates/gyandoot-browser/src/client.rs +++ b/crates/gyandoot-browser/src/client.rs @@ -1,467 +1,593 @@ -//! REST client for the camofox-browser API. +//! Daemon IPC client for camoufox-browser. //! -//! Provides async methods for all browser operations: -//! tab management, navigation, snapshots, interaction, and search macros. +//! 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 reqwest::Client as HttpClient; use serde::{Deserialize, Serialize}; -use tracing::{info, debug}; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tracing::{debug, info, warn}; -use crate::macros::SearchMacro; -use crate::snapshot::{ - HealthResponse, InteractionResult, NavigationResult, PageLinks, PageSnapshot, -}; +// ─── Constants ────────────────────────────────────────────────────────── -/// Default camofox-browser API endpoint. -pub const DEFAULT_CAMOFOX_URL: &str = "http://localhost:9377"; +/// Default session name. +pub const DEFAULT_SESSION: &str = "default"; -/// Default HTTP request timeout. -pub const DEFAULT_TIMEOUT_SECS: u64 = 30; +/// 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 ────────────────────────────────────────────────────────────── -/// Error type for browser operations. #[derive(Debug, thiserror::Error)] pub enum BrowserError { - #[error("HTTP request failed: {0}")] - Http(#[from] reqwest::Error), - #[error("API returned error: {status} - {message}")] - Api { status: u16, message: String }, + #[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("Session not found: {0}")] - SessionNotFound(String), + #[error("Navigation failed: {0}")] NavigationFailed(String), + #[error("Snapshot unavailable")] SnapshotUnavailable, - #[error("Invalid macro: {0}")] - InvalidMacro(String), } -/// Result type for browser operations. pub type BrowserResult = Result; -/// Request body for creating a new tab. -#[derive(Debug, Serialize)] -struct CreateTabRequest { - user_id: String, - session_key: String, - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, -} +// ─── IPC Protocol Types ───────────────────────────────────────────────── -/// Request body for navigation. +/// A command sent to the daemon as JSON-line. #[derive(Debug, Serialize)] -struct NavigateRequest { - user_id: String, +struct DaemonCommand { + id: String, + action: String, #[serde(skip_serializing_if = "Option::is_none")] url: Option, #[serde(skip_serializing_if = "Option::is_none")] - macro_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] query: Option, #[serde(skip_serializing_if = "Option::is_none")] - wait: Option, -} - -/// Request body for element interaction. -#[derive(Debug, Serialize)] -struct InteractionRequest { - user_id: String, + macro_name: Option, #[serde(skip_serializing_if = "Option::is_none")] - r#ref: Option, + 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, } -/// Generic API response wrapper (camofox returns flat JSON). -/// Response from creating a new tab. +/// A response from the daemon. #[derive(Debug, Deserialize)] -pub struct TabResponse { - pub tab_id: String, +struct DaemonResponse { + 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, } -/// The camofox-browser client. +/// 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 { - /// Base URL of the camofox-browser API. - base_url: String, - /// HTTP client with sensible defaults. - http: HttpClient, - /// HTTP request timeout. + /// Session name. + session: String, + /// Socket path. + socket_path: PathBuf, + /// Timeout for daemon responses. timeout: Duration, } impl BrowserClient { - /// Create a new client connecting to `localhost:9377`. + /// Create a new client for the default session. pub fn new() -> Self { Self { - base_url: DEFAULT_CAMOFOX_URL.to_string(), - http: HttpClient::builder() - .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) - .build() - .expect("Failed to create HTTP client"), - timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), + session: DEFAULT_SESSION.to_string(), + socket_path: default_socket_path(DEFAULT_SESSION), + timeout: Duration::from_secs(30), } } - /// Create a client with a custom base URL. - pub fn with_base_url(mut self, url: &str) -> Self { - self.base_url = url.trim_end_matches('/').to_string(); + /// 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 timeout. - pub fn with_timeout(mut self, secs: u64) -> Self { - self.timeout = Duration::from_secs(secs); - self.http = HttpClient::builder() - .timeout(self.timeout) - .build() - .expect("Failed to create HTTP client"); + /// Create a client with a custom socket path. + pub fn with_socket(mut self, path: &str) -> Self { + self.socket_path = PathBuf::from(path); self } - // ─── Health ─────────────────────────────────────────────── - - /// Check if the camofox-browser server is healthy. - pub async fn health(&self) -> BrowserResult { - let resp = self.http - .get(format!("{}/health", self.base_url)) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); - } - let health: HealthResponse = resp.json().await?; - Ok(health) + /// Get the socket path. + pub fn socket_path(&self) -> &PathBuf { + &self.socket_path } - // ─── Tab Management ─────────────────────────────────────── - - /// Create a new browser tab. - pub async fn create_tab( - &self, - user_id: &str, - session_key: &str, - url: Option<&str>, - ) -> BrowserResult { - let body = CreateTabRequest { - user_id: user_id.to_string(), - session_key: session_key.to_string(), - url: url.map(|s| s.to_string()), - }; - let resp = self.http - .post(format!("{}/tabs", self.base_url)) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); - } - let tab: TabResponse = resp.json().await?; - Ok(tab) + /// Get the session name. + pub fn session(&self) -> &str { + &self.session } - /// Close a browser tab. - pub async fn close_tab(&self, tab_id: &str, user_id: &str) -> BrowserResult<()> { - let resp = self.http - .delete(format!("{}/tabs/{}", self.base_url, tab_id)) - .query(&[("userId", user_id)]) - .send() - .await?; - if !resp.status().is_success() && resp.status().as_u16() != 404 { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); - } - Ok(()) - } + // ─── Low-level IPC ────────────────────────────────────────────── - // ─── Navigation ─────────────────────────────────────────── - - /// Navigate to a URL. - pub async fn navigate( - &self, - tab_id: &str, - user_id: &str, - url: &str, - wait_ms: Option, - ) -> BrowserResult { - let mut body = NavigateRequest { - user_id: user_id.to_string(), - url: Some(url.to_string()), - macro_name: None, - query: None, - wait: wait_ms, - }; - // Support macro syntax directly in the URL field - if let Some(macro_name) = url.strip_prefix('@') { - let full_macro = format!("@{}", macro_name); - body.url = None; - body.macro_name = Some(full_macro); - } - - let resp = self.http - .post(format!("{}/tabs/{}/navigate", self.base_url, tab_id)) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); - } - let result: NavigationResult = resp.json().await?; - Ok(result) - } - - /// Navigate using a search macro. - pub async fn search( - &self, - tab_id: &str, - user_id: &str, - macro_name: &SearchMacro, - query: &str, - wait_ms: Option, - ) -> BrowserResult { - let body = NavigateRequest { - user_id: user_id.to_string(), + /// 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, - macro_name: Some(macro_name.as_macro_str().to_string()), - query: Some(query.to_string()), - wait: wait_ms, - }; - let resp = self.http - .post(format!("{}/tabs/{}/navigate", self.base_url, tab_id)) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); - } - let result: NavigationResult = resp.json().await?; - Ok(result) - } - - // ─── Snapshots ──────────────────────────────────────────── - - /// Get an accessibility snapshot of the current page. - pub async fn snapshot(&self, tab_id: &str, user_id: &str) -> BrowserResult { - let resp = self.http - .get(format!("{}/tabs/{}/snapshot", self.base_url, tab_id)) - .query(&[("userId", user_id)]) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); - } - let snap: PageSnapshot = resp.json().await?; - Ok(snap) - } - - // ─── Element Interaction ────────────────────────────────── - - /// Click an element by its ref (e.g., "e1"). - pub async fn click(&self, tab_id: &str, user_id: &str, element_ref: &str) -> BrowserResult { - let body = InteractionRequest { - user_id: user_id.to_string(), - r#ref: Some(element_ref.to_string()), + 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, }; - let resp = self.http - .post(format!("{}/tabs/{}/click", self.base_url, tab_id)) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); + 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 result: InteractionResult = resp.json().await?; - Ok(result) + + 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) } - /// Type text into an element and optionally press Enter. - pub async fn type_text( - &self, - tab_id: &str, - user_id: &str, - element_ref: &str, - text: &str, - press_enter: bool, - ) -> BrowserResult { - let body = InteractionRequest { - user_id: user_id.to_string(), - r#ref: Some(element_ref.to_string()), - selector: None, - text: Some(text.to_string()), - press_enter: Some(press_enter), - direction: None, - amount: None, - }; - let resp = self.http - .post(format!("{}/tabs/{}/type", self.base_url, tab_id)) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); + /// 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)); } - let result: InteractionResult = resp.json().await?; - Ok(result) + resp.data.ok_or(BrowserError::MissingData) } - // ─── Navigation Controls ────────────────────────────────── + // ─── Health ───────────────────────────────────────────────────── - /// Go back in browser history. - pub async fn go_back(&self, tab_id: &str, user_id: &str) -> BrowserResult { - let body = serde_json::json!({"userId": user_id}); - let resp = self.http - .post(format!("{}/tabs/{}/back", self.base_url, tab_id)) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), + /// 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, }); } - Ok(resp.json().await?) + + // 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, + }) + } + } } - /// Go forward in browser history. - pub async fn go_forward(&self, tab_id: &str, user_id: &str) -> BrowserResult { - let body = serde_json::json!({"userId": user_id}); - let resp = self.http - .post(format!("{}/tabs/{}/forward", self.base_url, tab_id)) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); - } - Ok(resp.json().await?) + // ─── 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, tab_id: &str, user_id: &str) -> BrowserResult { - let body = serde_json::json!({"userId": user_id}); - let resp = self.http - .post(format!("{}/tabs/{}/refresh", self.base_url, tab_id)) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); - } - Ok(resp.json().await?) + 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(), + }) } - /// Scroll the page. - pub async fn scroll( - &self, - tab_id: &str, - user_id: &str, - direction: &str, - amount: u32, - ) -> BrowserResult<()> { - let body = InteractionRequest { - user_id: user_id.to_string(), - r#ref: None, - selector: None, - text: None, - press_enter: None, - direction: Some(direction.to_string()), - amount: Some(amount), - }; - let resp = self.http - .post(format!("{}/tabs/{}/scroll", self.base_url, tab_id)) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); + // ─── 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(()) } - /// Get links from the current page. - pub async fn links(&self, tab_id: &str, user_id: &str, limit: Option) -> BrowserResult { - let mut req = self.http - .get(format!("{}/tabs/{}/links", self.base_url, tab_id)) - .query(&[("userId", user_id)]); - if let Some(l) = limit { - req = req.query(&[("limit", l)]); - } - let resp = req.send().await?; - if !resp.status().is_success() { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); - } - let links: PageLinks = resp.json().await?; - Ok(links) + /// 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(()) } - /// Clear all session data for a user. - pub async fn clear_session(&self, user_id: &str) -> BrowserResult<()> { - let resp = self.http - .delete(format!("{}/sessions/{}", self.base_url, user_id)) - .send() - .await?; - if !resp.status().is_success() && resp.status().as_u16() != 404 { - return Err(BrowserError::Api { - status: resp.status().as_u16(), - message: resp.text().await.unwrap_or_default(), - }); + // ─── 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(()) } } @@ -472,31 +598,65 @@ impl Default for BrowserClient { } } +// ─── 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::*; - #[tokio::test] - async fn test_health_check_live() { - // Only run if camofox is actually running - let client = BrowserClient::new(); - match client.health().await { - Ok(health) => { - assert!(health.ok); - assert_eq!(health.engine, "camoufox"); - } - Err(_) => { - // Server might not be running — skip test - eprintln!("camofox-browser not running, skipping live test"); - } - } + #[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_macro_detection_in_url() { + 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(); - // Just verify the URL prefix detection works logically - assert!("@google_search".starts_with('@')); - assert!(!"https://google.com".starts_with('@')); + 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"); } } \ No newline at end of file diff --git a/crates/gyandoot-browser/src/lib.rs b/crates/gyandoot-browser/src/lib.rs index 5452f65..e589d5a 100644 --- a/crates/gyandoot-browser/src/lib.rs +++ b/crates/gyandoot-browser/src/lib.rs @@ -1,11 +1,17 @@ -//! # GyanDoot Browser — Anti-Detection Browsing +//! # GyanDoot Browser — Anti-Detection Browsing (Daemon IPC) //! -//! A Rust client for [camofox-browser](https://github.com/jo-inc/camofox-browser), -//! an anti-detection browser server for AI agents powered by Camoufox. +//! A Rust client for [camoufox-browser](https://github.com/msly/camoufox-browser), +//! communicating via Unix socket JSON-line IPC protocol. //! //! Camoufox is a Firefox fork with C++-level fingerprint spoofing — //! no JavaScript shims, no detectable differences from a real browser. -//! This crate wraps its REST API with typed Rust interfaces. +//! +//! ## Architecture +//! +//! The `camoufox-browser@0.1.0` npm package runs as a background daemon that +//! listens on a Unix socket at `~/.camoufox-browser/.sock`. The daemon +//! auto-launches the camoufox browser on-demand. All commands are JSON-line +//! messages sent over the socket. //! //! ## Features //! @@ -13,8 +19,7 @@ //! - Token-efficient accessibility snapshots (~90% smaller than HTML) //! - Stable element refs (`e1`, `e2`) for reliable interaction //! - Session isolation per user -//! - Proxy + GeoIP support -//! - Automatic idle session cleanup +//! - Automatic browser lifecycle (launch on first command) //! //! ## Quick Start //! @@ -27,13 +32,10 @@ //! ``` pub mod client; -pub mod macros; -pub mod session; -pub mod snapshot; -pub use client::{BrowserClient, BrowserError, BrowserResult, DEFAULT_CAMOFOX_URL}; -pub use macros::SearchMacro; -pub use session::{BrowserSession, SessionManager, TabInfo}; -pub use snapshot::{ - HealthResponse, InteractionResult, LinkItem, NavigationResult, PageLinks, PageSnapshot, +pub use client::{ + BrowserClient, BrowserError, BrowserResult, + check_daemon_socket, default_socket_path, + HealthResponse, NavigationResult, PageSnapshot, ElementInfo, + InteractionResult, TabInfo, }; \ No newline at end of file diff --git a/crates/gyandoot-browser/src/macros.rs b/crates/gyandoot-browser/src/macros.rs deleted file mode 100644 index 0563046..0000000 --- a/crates/gyandoot-browser/src/macros.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Search macros for common websites. -//! -//! Camofox-browser supports 14+ built-in search macros that map to -//! site-specific search URLs. Using macros is more reliable than -//! manually constructing URLs because the browser handles all -//! redirects, cookies, and anti-bot challenges. - -use serde::{Deserialize, Serialize}; -use std::fmt; - -/// All supported search macros. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SearchMacro { - /// `@google_search` — Search Google - GoogleSearch, - /// `@youtube_search` — Search YouTube - YoutubeSearch, - /// `@amazon_search` — Search Amazon - AmazonSearch, - /// `@reddit_search` — Search Reddit (returns JSON) - RedditSearch, - /// `@reddit_subreddit` — Browse a subreddit (returns JSON) - RedditSubreddit, - /// `@wikipedia_search` — Search Wikipedia - WikipediaSearch, - /// `@twitter_search` — Search Twitter/X - TwitterSearch, - /// `@yelp_search` — Search Yelp - YelpSearch, - /// `@spotify_search` — Search Spotify - SpotifySearch, - /// `@netflix_search` — Search Netflix - NetflixSearch, - /// `@linkedin_search` — Search LinkedIn - LinkedinSearch, - /// `@instagram_search` — Search Instagram - InstagramSearch, - /// `@tiktok_search` — Search TikTok - TiktokSearch, - /// `@twitch_search` — Search Twitch - TwitchSearch, -} - -impl SearchMacro { - /// All supported macros as a static slice. - pub const ALL: &'static [SearchMacro] = &[ - SearchMacro::GoogleSearch, - SearchMacro::YoutubeSearch, - SearchMacro::AmazonSearch, - SearchMacro::RedditSearch, - SearchMacro::RedditSubreddit, - SearchMacro::WikipediaSearch, - SearchMacro::TwitterSearch, - SearchMacro::YelpSearch, - SearchMacro::SpotifySearch, - SearchMacro::NetflixSearch, - SearchMacro::LinkedinSearch, - SearchMacro::InstagramSearch, - SearchMacro::TiktokSearch, - SearchMacro::TwitchSearch, - ]; - - /// The `@name` string sent to the camofox API. - pub fn as_macro_str(&self) -> &'static str { - match self { - SearchMacro::GoogleSearch => "@google_search", - SearchMacro::YoutubeSearch => "@youtube_search", - SearchMacro::AmazonSearch => "@amazon_search", - SearchMacro::RedditSearch => "@reddit_search", - SearchMacro::RedditSubreddit => "@reddit_subreddit", - SearchMacro::WikipediaSearch => "@wikipedia_search", - SearchMacro::TwitterSearch => "@twitter_search", - SearchMacro::YelpSearch => "@yelp_search", - SearchMacro::SpotifySearch => "@spotify_search", - SearchMacro::NetflixSearch => "@netflix_search", - SearchMacro::LinkedinSearch => "@linkedin_search", - SearchMacro::InstagramSearch => "@instagram_search", - SearchMacro::TiktokSearch => "@tiktok_search", - SearchMacro::TwitchSearch => "@twitch_search", - } - } - - /// Human-readable site name. - pub fn site_name(&self) -> &'static str { - match self { - SearchMacro::GoogleSearch => "Google", - SearchMacro::YoutubeSearch => "YouTube", - SearchMacro::AmazonSearch => "Amazon", - SearchMacro::RedditSearch => "Reddit", - SearchMacro::RedditSubreddit => "Reddit (subreddit)", - SearchMacro::WikipediaSearch => "Wikipedia", - SearchMacro::TwitterSearch => "Twitter/X", - SearchMacro::YelpSearch => "Yelp", - SearchMacro::SpotifySearch => "Spotify", - SearchMacro::NetflixSearch => "Netflix", - SearchMacro::LinkedinSearch => "LinkedIn", - SearchMacro::InstagramSearch => "Instagram", - SearchMacro::TiktokSearch => "TikTok", - SearchMacro::TwitchSearch => "Twitch", - } - } - - /// Parse a `@name` string into a `SearchMacro`. - pub fn from_str(s: &str) -> Option { - match s { - "@google_search" => Some(SearchMacro::GoogleSearch), - "@youtube_search" => Some(SearchMacro::YoutubeSearch), - "@amazon_search" => Some(SearchMacro::AmazonSearch), - "@reddit_search" => Some(SearchMacro::RedditSearch), - "@reddit_subreddit" => Some(SearchMacro::RedditSubreddit), - "@wikipedia_search" => Some(SearchMacro::WikipediaSearch), - "@twitter_search" => Some(SearchMacro::TwitterSearch), - "@yelp_search" => Some(SearchMacro::YelpSearch), - "@spotify_search" => Some(SearchMacro::SpotifySearch), - "@netflix_search" => Some(SearchMacro::NetflixSearch), - "@linkedin_search" => Some(SearchMacro::LinkedinSearch), - "@instagram_search" => Some(SearchMacro::InstagramSearch), - "@tiktok_search" => Some(SearchMacro::TiktokSearch), - "@twitch_search" => Some(SearchMacro::TwitchSearch), - _ => None, - } - } -} - -impl fmt::Display for SearchMacro { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_macro_str()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_all_macros_have_valid_names() { - for m in SearchMacro::ALL { - let name = m.as_macro_str(); - assert!(name.starts_with('@'), "Macro should start with @"); - assert!(name.len() > 2, "Macro should have a name"); - } - } - - #[test] - fn test_roundtrip_parse() { - for m in SearchMacro::ALL { - let name = m.as_macro_str(); - let parsed = SearchMacro::from_str(name); - assert_eq!(parsed, Some(*m), "Roundtrip failed for {name}"); - } - } - - #[test] - fn test_invalid_macro() { - assert_eq!(SearchMacro::from_str("@invalid_search"), None); - assert_eq!(SearchMacro::from_str("google_search"), None); - } - - #[test] - fn test_site_names() { - assert_eq!(SearchMacro::GoogleSearch.site_name(), "Google"); - assert_eq!(SearchMacro::YoutubeSearch.site_name(), "YouTube"); - } -} \ No newline at end of file diff --git a/crates/gyandoot-browser/src/session.rs b/crates/gyandoot-browser/src/session.rs deleted file mode 100644 index 0f228a7..0000000 --- a/crates/gyandoot-browser/src/session.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Browser session management — tracks tabs, sessions, and their lifecycle. - -use uuid::Uuid; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use chrono::Utc; - -/// Represents a browser session (one user's isolated browsing context). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BrowserSession { - /// Session identifier (maps to camofox `sessionKey`). - pub id: String, - /// User identifier. - pub user_id: String, - /// Tabs owned by this session. - pub tabs: HashMap, - /// When the session was created. - pub created_at: chrono::DateTime, - /// When the session was last active. - pub last_active: chrono::DateTime, -} - -impl BrowserSession { - pub fn new(user_id: &str) -> Self { - Self { - id: Uuid::new_v4().to_string(), - user_id: user_id.to_string(), - tabs: HashMap::new(), - created_at: Utc::now(), - last_active: Utc::now(), - } - } - - pub fn touch(&mut self) { - self.last_active = Utc::now(); - } - - pub fn add_tab(&mut self, tab_id: &str, url: &str) { - self.tabs.insert( - tab_id.to_string(), - TabInfo { - id: tab_id.to_string(), - url: url.to_string(), - created_at: Utc::now(), - last_navigated: Utc::now(), - page_title: String::new(), - }, - ); - self.touch(); - } - - pub fn remove_tab(&mut self, tab_id: &str) { - self.tabs.remove(tab_id); - self.touch(); - } -} - -/// Information about a single browser tab. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TabInfo { - /// Camofox tab ID. - pub id: String, - /// Current URL in the tab. - pub url: String, - /// When the tab was created. - pub created_at: chrono::DateTime, - /// When the tab last navigated. - pub last_navigated: chrono::DateTime, - /// Current page title. - #[serde(default)] - pub page_title: String, -} - -impl TabInfo { - pub fn update_title(&mut self, title: &str) { - self.page_title = title.to_string(); - self.last_navigated = Utc::now(); - } -} - -/// Manages all browser sessions. -#[derive(Debug, Clone)] -pub struct SessionManager { - sessions: HashMap, - max_sessions: usize, -} - -impl SessionManager { - pub fn new() -> Self { - Self { - sessions: HashMap::new(), - max_sessions: 50, - } - } - - pub fn with_max(mut self, max: usize) -> Self { - self.max_sessions = max; - self - } - - pub fn get_or_create(&mut self, user_id: &str, session_key: Option<&str>) -> &mut BrowserSession { - let key = session_key.unwrap_or(user_id).to_string(); - if !self.sessions.contains_key(&key) { - let session = BrowserSession::new(user_id); - self.sessions.insert(key.clone(), session); - } - self.sessions.get_mut(&key).unwrap() - } - - pub fn get(&self, session_key: &str) -> Option<&BrowserSession> { - self.sessions.get(session_key) - } - - pub fn get_mut(&mut self, session_key: &str) -> Option<&mut BrowserSession> { - self.sessions.get_mut(session_key) - } - - pub fn remove(&mut self, session_key: &str) -> Option { - self.sessions.remove(session_key) - } - - pub fn all_sessions(&self) -> Vec<&BrowserSession> { - self.sessions.values().collect() - } - - pub fn session_count(&self) -> usize { - self.sessions.len() - } - - /// Remove sessions that have been idle longer than `timeout` seconds. - pub fn cleanup_idle(&mut self, timeout_secs: i64) -> usize { - let now = Utc::now(); - let cutoff = now - chrono::Duration::seconds(timeout_secs); - let keys: Vec = self.sessions - .iter() - .filter(|(_, s)| s.last_active < cutoff) - .map(|(k, _)| k.clone()) - .collect(); - let count = keys.len(); - for k in keys { - self.sessions.remove(&k); - } - count - } -} - -impl Default for SessionManager { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_session_lifecycle() { - let mut mgr = SessionManager::new(); - let session = mgr.get_or_create("user1", Some("session-1")); - session.add_tab("tab-abc", "https://example.com"); - assert_eq!(session.tabs.len(), 1); - } - - #[test] - fn test_session_cleanup() { - let mut mgr = SessionManager::new(); - mgr.get_or_create("user1", Some("old-session")); - // Sessions are created with Utc::now(), so cleanup_idle with 0 should remove nothing - // (they're brand new). Let's use a negative timeout to force cleanup. - let cleaned = mgr.cleanup_idle(-1); - assert_eq!(cleaned, 1); - } - - #[test] - fn test_max_sessions() { - let mut mgr = SessionManager::new().with_max(3); - mgr.get_or_create("u1", Some("s1")); - mgr.get_or_create("u2", Some("s2")); - mgr.get_or_create("u3", Some("s3")); - assert_eq!(mgr.session_count(), 3); - } -} \ No newline at end of file diff --git a/crates/gyandoot-browser/src/snapshot.rs b/crates/gyandoot-browser/src/snapshot.rs deleted file mode 100644 index 0d9ae24..0000000 --- a/crates/gyandoot-browser/src/snapshot.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Types representing accessibility tree snapshots from camofox-browser. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// A page snapshot returned by the camofox-browser API. -/// -/// The snapshot is an accessibility tree with stable element refs -/// (e.g., `e1`, `e2`) that can be used for clicking and typing. -/// This format is ~90% more token-efficient than raw HTML. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PageSnapshot { - /// Accessibility tree text with element refs. - pub snapshot: String, - /// Optional URL of the page. - #[serde(default)] - pub url: String, - /// Optional page title. - #[serde(default)] - pub title: String, - /// Optional screenshot as base64 PNG. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub screenshot: Option, - /// Element reference map: ref -> element info. - #[serde(default)] - pub elements: HashMap, -} - -/// Information about a single element in the snapshot. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ElementInfo { - /// The element's tag name (e.g., "a", "button", "input"). - #[serde(default)] - pub tag: String, - /// Visible text content. - #[serde(default)] - pub text: String, - /// URL for links. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub href: Option, - /// Whether the element is interactive. - #[serde(default)] - pub interactive: bool, -} - -impl PageSnapshot { - /// Create a new snapshot from raw text. - pub fn new(snapshot: String) -> Self { - Self { - snapshot, - url: String::new(), - title: String::new(), - screenshot: None, - elements: HashMap::new(), - } - } - - /// Whether the snapshot is empty (no content). - pub fn is_empty(&self) -> bool { - self.snapshot.is_empty() - } - - /// Number of characters in the snapshot text. - pub fn len(&self) -> usize { - self.snapshot.len() - } - - /// Extract all element refs from the snapshot text. - /// - /// Refs look like `[e1]`, `[e2]` in the accessibility tree. - pub fn extract_refs(&self) -> Vec { - let mut refs = Vec::new(); - for line in self.snapshot.lines() { - // Pattern: [eN] or eN - if let Some(start) = line.find("[e") { - let end = line[start + 2..].find(']').map(|i| start + 2 + i); - if let Some(end_pos) = end { - let ref_str = &line[start + 1..start + 1 + end_pos + 1]; - refs.push(ref_str.to_string()); - } - } - } - refs.sort(); - refs.dedup(); - refs - } -} - -/// Links extracted from a page. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PageLinks { - /// List of links on the page. - pub links: Vec, - /// Total count of links. - pub total: usize, -} - -/// A single link item. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LinkItem { - /// Link text. - pub text: String, - /// Target URL. - pub href: String, - /// Optional title attribute. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, -} - -/// Response from page navigation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NavigationResult { - /// Final URL after navigation. - pub url: String, - /// Page title. - pub title: String, - /// Whether navigation succeeded. - pub ok: bool, -} - -/// Response from element interaction (click/type). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InteractionResult { - /// Whether the interaction succeeded. - pub ok: bool, - /// New snapshot after interaction (if available). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub snapshot: Option, -} - -/// Health check response from camofox-browser. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HealthResponse { - /// Server is OK. - pub ok: bool, - /// Engine type (e.g., "camoufox"). - pub engine: String, - /// Whether browser is connected. - #[serde(default)] - pub browser_connected: bool, - /// Whether browser is running. - #[serde(default)] - pub browser_running: bool, - /// Number of active tabs. - #[serde(default)] - pub active_tabs: u32, - /// Number of active sessions. - #[serde(default)] - pub active_sessions: u32, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_refs_from_snapshot() { - let snap = PageSnapshot::new( - "- heading \"Example\" [level=1]\n- link \"Click\" [e1]:\n - /url: https://example.com\n- paragraph: text [e2]\n".into(), - ); - let refs = snap.extract_refs(); - assert!(refs.contains(&"[e1]".to_string())); - assert!(refs.contains(&"[e2]".to_string())); - } - - #[test] - fn test_empty_snapshot() { - let snap = PageSnapshot::new(String::new()); - assert!(snap.is_empty()); - assert_eq!(snap.len(), 0); - } - - #[test] - fn test_health_response_deserialize() { - let json = r#"{"ok":true,"engine":"camoufox","browserConnected":true,"browserRunning":true,"activeTabs":3,"activeSessions":2}"#; - let health: HealthResponse = serde_json::from_str(json).unwrap(); - assert!(health.ok); - assert_eq!(health.active_tabs, 3); - } -} \ No newline at end of file