diff --git a/Cargo.lock b/Cargo.lock index fb47514..fcd5c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -684,6 +684,7 @@ dependencies = [ "gyandoot-security", "gyandoot-skills", "rand 0.8.6", + "reqwest", "serde", "serde_json", "thiserror", @@ -700,6 +701,7 @@ dependencies = [ "chrono", "dirs", "gyandoot-core", + "reqwest", "serde", "serde_json", "thiserror", diff --git a/crates/gyandoot-agent/Cargo.toml b/crates/gyandoot-agent/Cargo.toml index 809248f..9200d24 100644 --- a/crates/gyandoot-agent/Cargo.toml +++ b/crates/gyandoot-agent/Cargo.toml @@ -23,4 +23,5 @@ chrono.workspace = true async-trait.workspace = true rand.workspace = true dirs.workspace = true -atty.workspace = true \ No newline at end of file +atty.workspace = true +reqwest = { workspace = true, default-features = false, features = ["json", "rustls-tls"] } diff --git a/crates/gyandoot-agent/src/browser_manager.rs b/crates/gyandoot-agent/src/browser_manager.rs index b39dcb0..b5707fc 100644 --- a/crates/gyandoot-agent/src/browser_manager.rs +++ b/crates/gyandoot-agent/src/browser_manager.rs @@ -1,24 +1,10 @@ //! BrowserManager — Start, connect, and manage lifecycle of the camoufox-browser daemon. //! -//! Unlike the old `camoufox-browser` (v0.0.x) which exposed an HTTP REST API -//! on port 9377, the new `camoufox-browser@0.1.0` works via a Unix socket -//! daemon. The CLI wrapper auto-starts the daemon, and commands communicate -//! via JSON-line protocol over `~/.camoufox-browser/.sock`. +//! Two transport backends: +//! 1. **HTTP** (Docker container on port 9377) — the old REST API, stable +//! 2. **Unix socket** (camoufox-browser@0.1.0 daemon) — new JSON-line IPC //! -//! On `gyandoot run`, the BrowserManager: -//! 1. Checks if the daemon socket already exists and is connected -//! 2. If not, runs `camoufox-browser install` to ensure the browser binary -//! is downloaded (~662MB Camoufox browser) -//! 3. Runs `camoufox-browser tab_list --session default` to trigger the -//! daemon auto-start -//! 4. Waits for the Unix socket to appear (up to 30 seconds) -//! 5. Returns a ready-to-use `BrowserClient` -//! -//! Cross-platform npx resolution -//! -//! The daemon is started via Node.js's `npx`. Resolution order: -//! 1. `config.browser.node_path` — explicit path in config -//! 2. `"npx"` on Linux/macOS, `"npx.cmd"` on Windows — PATH lookup +//! The BrowserManager tries HTTP first (Docker), then falls back to npx daemon. use std::time::Duration; @@ -39,9 +25,7 @@ const DAEMON_POLL_MS: u64 = 500; /// Result of browser initialization. #[derive(Debug)] pub enum BrowserInitResult { - /// Browser daemon is running and client is ready. Ready(BrowserClient), - /// Could not start daemon. Unavailable(String), } @@ -54,7 +38,6 @@ enum OsKind { Wsl, } -/// Detect what operating system we're running on. fn detect_os() -> OsKind { if cfg!(target_os = "windows") { return OsKind::Windows; @@ -89,113 +72,84 @@ fn resolve_npx(node_path: Option<&str>) -> &str { } } -/// Get a platform-aware example npx path for error messages. -fn example_npx_path() -> &'static str { - match detect_os() { - OsKind::Mac => "\"/Users/yourname/.nvm/versions/node/v22/bin/npx\"", - _ => "\"/home/user/.nvm/versions/node/v22/bin/npx\"", - } -} +/// Check if the Camoufox Docker container is running on port 9377. +/// Uses a quick HTTP health check. +async fn check_docker_http_api() -> bool { + // Build a reqwest client with a short timeout + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .ok(); -/// Path to the Camoufox browser binary cache directory. -fn camoufox_browser_cache_dir() -> std::path::PathBuf { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp")); - if cfg!(target_os = "macos") { - home.join("Library").join("Caches").join("camoufox") - } else { - home.join(".cache").join("camoufox") + match client { + Some(c) => match c.get("http://localhost:9377/health").send().await { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + }, + None => false, } } /// Check if the Camoufox browser binary is installed and uncorrupted. fn is_browser_installed() -> bool { - let cache_dir = camoufox_browser_cache_dir(); + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + let cache_dir = if cfg!(target_os = "macos") { + home.join("Library").join("Caches").join("camoufox") + } else { + home.join(".cache").join("camoufox") + }; + if !cache_dir.exists() { return false; } if cfg!(target_os = "macos") { - let macos_dir = cache_dir.join("Camoufox.app").join("Contents").join("MacOS"); - let binary = macos_dir.join("camoufox"); - let lib = macos_dir.join("libmozglue.dylib"); - binary.exists() && lib.exists() + let binary = cache_dir.join("Camoufox.app").join("Contents").join("MacOS").join("camoufox"); + binary.exists() } else { cache_dir.join("camoufox").exists() } } -/// Check for known error patterns in the daemon log and return a user-friendly hint. +/// Check for known error patterns in daemon stderr. fn check_known_errors(log: &str) -> Option { let lower = log.to_lowercase(); - if lower.contains("libmozglue.dylib") && lower.contains("no such file") { - return Some("The Camoufox browser download is corrupted (missing libmozglue.dylib). \ - This is a known issue with the camoufox-browser@0.1.0 package on macOS." - .to_string()); + if lower.contains("libmozglue.dylib") { + return Some(String::from( + "camoufox-browser download corrupted (missing libmozglue.dylib).", + )); } if lower.contains("error while loading shared libraries") { - return Some("Missing Linux system libraries needed by the Camoufox browser.".to_string()); + return Some(String::from("Missing Linux system libraries.")); } - if lower.contains("killed") && lower.contains("code signature") { - return Some("macOS code signing issue. The downloaded browser binary may need manual approval." - .to_string()); + if lower.contains("killed") && (lower.contains("sigkill") || lower.contains("exitcode=null")) { + return Some(String::from( + "Browser process killed by OS — corrupted or incompatible binary.", + )); } None } -/// Get platform-specific dependency fix instructions. -fn platform_fix_instructions() -> String { - let os = detect_os(); - let mut msg = String::new(); - - let docker_ok = std::process::Command::new("docker") - .arg("info") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - - match os { - OsKind::Mac => { - msg.push_str("The Camoufox browser download is corrupted on macOS.\n"); - msg.push_str("Fix:\n"); - msg.push_str(" 1. Remove the corrupted cache:\n"); - msg.push_str(" rm -rf ~/Library/Caches/camoufox\n"); - msg.push_str(" 2. Reinstall the browser binary:\n"); - msg.push_str(" npx camoufox-browser@latest install\n"); - msg.push_str(" (this downloads ~662MB, may take a few minutes)\n"); - if docker_ok { - msg.push_str(" 3. Or use Docker (already installed!):\n"); - } else { - msg.push_str(" 3. Or install Docker Desktop and use:\n"); - } - msg.push_str(" docker run -d --name camoufox -p 9377:9377 \\\n"); - msg.push_str(" ghcr.io/nousresearch/camoufox-browser:latest\n"); - } - OsKind::Wsl | OsKind::Linux => { - msg.push_str("Missing Linux system libraries. Install them:\n"); - msg.push_str(" sudo apt-get update && sudo apt-get install -y \\\n"); - msg.push_str(" libgtk-3-0 libnss3 libnspr4 libatk1.0-0t64 \\\n"); - msg.push_str(" libatk-bridge2.0-0t64 libcups2t64 libdrm2 \\\n"); - msg.push_str(" libxkbcommon0 libxcomposite1 libxdamage1 \\\n"); - msg.push_str(" libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \\\n"); - msg.push_str(" libcairo2 libasound2t64 libxcb-shm0\n"); - } - OsKind::Windows => { - msg.push_str("Windows issue. Try reinstalling:\n"); - msg.push_str(" npm install -g camoufox-browser\n"); - } - } - - msg -} - /// Initialize the camoufox-browser daemon and return a ready client. +/// +/// Detection order: +/// 1. Docker HTTP API on port 9377 (most reliable on macOS) +/// 2. Unix socket daemon via npx (native install) pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult { let session = DEFAULT_SESSION; let socket_path = default_socket_path(session); + // ── Try Docker HTTP API first ──────────────────────────────────── + if check_docker_http_api().await { + info!("Camoufox Docker container detected on port 9377 (HTTP API)"); + let client = BrowserClient::new_http("http://localhost:9377"); + return BrowserInitResult::Ready(client); + } + + // ── Try Unix socket daemon ─────────────────────────────────────── if check_daemon_socket(session).await { info!("Camoufox daemon already running on socket: {}", socket_path.display()); let client = BrowserClient::new().with_session(session); @@ -204,29 +158,25 @@ pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult { info!("Camoufox daemon not running, triggering startup..."); + // Check if browser binary is cached if !is_browser_installed() { info!("Camoufox browser not cached. Running install..."); match run_install(node_path).await { Ok(msg) => info!("Camoufox browser install: {}", msg), Err(e) => { let npx_name = resolve_npx(node_path); - let mut hint = String::new(); - let os = detect_os(); - if os == OsKind::Wsl { - hint.push_str( - "\n\nWSL detected: Make sure you can download from GitHub releases.\n", - ); - hint.push_str("Try: curl -L --fail https://github.com/msly/camoufox-browser/releases\n"); - } return BrowserInitResult::Unavailable(format!( "Failed to download Camoufox browser: {}\n\n\ - Run manually:\n {} camoufox-browser@latest install\n{}", - e, npx_name, hint + Run: {} camoufox-browser@latest install\n\n\ + Or use Docker: docker run -d --name camoufox -p 9377:9377 \\\n \ + ghcr.io/nousresearch/camoufox-browser:latest", + e, npx_name, )); } } } + // Trigger daemon start match trigger_daemon_start(session, node_path).await { Ok(()) => { info!("Waiting for daemon socket at {}...", socket_path.display()); @@ -238,51 +188,54 @@ pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult { BrowserInitResult::Ready(client) } Ok(false) => { - let msg = format!( - "Camoufox daemon did not start within {}s.\n\ - Socket not found at {}\n\n{}", + BrowserInitResult::Unavailable(format!( + "Daemon did not start within {}s.\n\n\ + The camoufox-browser npm package has a corrupted binary on macOS.\n\ + Use Docker instead:\n \ + docker run -d --name camoufox -p 9377:9377 \\\n \ + ghcr.io/nousresearch/camoufox-browser:latest\n\n\ + Then run 'gyandoot run' again.", DAEMON_STARTUP_TIMEOUT_SECS, - socket_path.display(), - platform_fix_instructions(), - ); - warn!("{}", msg); - BrowserInitResult::Unavailable(msg) - } - Err(e) => { - let msg = format!("Daemon socket error: {}", e); - warn!("{}", msg); - BrowserInitResult::Unavailable(msg) + )) } + Err(e) => BrowserInitResult::Unavailable(format!("Daemon error: {}", e)), } } Err(e) => { let error_detail = e.to_string(); let known_error = check_known_errors(&error_detail); - let mut msg = format!("Camoufox daemon failed to start.\n\n{}\n\n", error_detail); - if let Some(hint) = known_error { - msg.push_str(&hint); - msg.push('\n'); - msg.push_str(&platform_fix_instructions()); + let mut msg = format!( + "Camoufox daemon failed to start.\n\nError:\n {}\n\n", + error_detail + ); + + if known_error.is_some() { + msg.push_str( + "The camoufox-browser npm package has a corrupted browser binary on macOS.\n\ + Use Docker instead:\n \ + docker run -d --name camoufox -p 9377:9377 \\\n \ + ghcr.io/nousresearch/camoufox-browser:latest\n\n\ + Then run 'gyandoot run' again.", + ); } else { msg.push_str(&format!( - "Try running manually:\n {} camoufox-browser@latest tab_list --session default\n\n{}", + "Try running: {} camoufox-browser@latest tab_list --session default\n\n\ + Or use Docker: docker run -d --name camoufox -p 9377:9377 \\\n \ + ghcr.io/nousresearch/camoufox-browser:latest", resolve_npx(node_path), - platform_fix_instructions(), )); } - msg.push_str("\n\nThen run 'gyandoot run' again."); warn!("{}", msg); BrowserInitResult::Unavailable(msg) } } } -/// Run `camoufox-browser install` to download the browser binary. +/// Run `npx camoufox-browser@latest install`. async fn run_install(node_path: Option<&str>) -> Result { let npx = resolve_npx(node_path); - let output = Command::new(npx) .args(&["camoufox-browser@latest", "install"]) .output() @@ -294,25 +247,16 @@ async fn run_install(node_path: Option<&str>) -> Result { if !output.status.success() { return Err(format!( - "Install failed (exit: {}):\n stderr: {}\n stdout: {}", + "Install failed (exit: {}): stderr: {}", output.status.code().unwrap_or(-1), - stderr.lines().last().unwrap_or("(unknown)"), - stdout.lines().last().unwrap_or("(unknown)"), + stderr.lines().last().unwrap_or("(unknown)") )); } - let last_line = stdout - .lines() - .chain(stderr.lines()) - .filter(|l| l.contains("complete") || l.contains("Downloaded") || l.contains("Updated")) - .last() - .unwrap_or("Install completed") - .to_string(); - - Ok(last_line) + Ok(stdout.lines().last().unwrap_or("Install completed").to_string()) } -/// Trigger daemon startup by running `tab_list`, which auto-starts the daemon. +/// Trigger daemon startup via `npx camoufox-browser@latest tab_list`. async fn trigger_daemon_start(session: &str, node_path: Option<&str>) -> Result<(), String> { let home = dirs::home_dir().ok_or("Cannot determine home directory")?; let log_dir = home.join(".gyandoot"); @@ -321,22 +265,11 @@ async fn trigger_daemon_start(session: &str, node_path: Option<&str>) -> Result< let log_file = log_dir.join("camoufox-daemon-spawn.log"); let npx = resolve_npx(node_path); - let output = Command::new(npx) .args(&["camoufox-browser@latest", "tab_list", "--session", session]) .output() .await - .map_err(|e| { - let hint = if node_path.is_none() { - format!( - "\nSet [browser].node_path in your config:\n [browser]\n node_path = {}", - example_npx_path() - ) - } else { - format!("\nnpx at '{}' not found or not executable", npx) - }; - format!("{} not found: {}. Is Node.js installed?{}", npx, e, hint) - })?; + .map_err(|e| format!("{} not found: {}", npx, e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); @@ -353,26 +286,19 @@ async fn trigger_daemon_start(session: &str, node_path: Option<&str>) -> Result< ), ); - return Err(format!( - "{}\n Full log saved to {}", - details, - log_file.display(), - )); + return Err(format!("{}\n Log: {}", details, log_file.display())); } Ok(()) } -/// 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 { if check_daemon_socket(session).await { return Ok(true); } tokio::time::sleep(Duration::from_millis(DAEMON_POLL_MS)).await; } - Ok(false) } \ No newline at end of file diff --git a/crates/gyandoot-browser/Cargo.toml b/crates/gyandoot-browser/Cargo.toml index d2a4fb8..2db6f52 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 — Camoufox browser automation via daemon IPC (Unix socket protocol)" +description = "GyanDoot v2 — Camoufox browser automation via daemon IPC (Unix socket + HTTP fallback)" [dependencies] gyandoot-core = { path = "../gyandoot-core" } @@ -15,4 +15,5 @@ anyhow.workspace = true tracing.workspace = true uuid.workspace = true chrono.workspace = true -dirs.workspace = true \ No newline at end of file +dirs.workspace = true +reqwest = { workspace = true, default-features = false, features = ["json"] } \ No newline at end of file diff --git a/crates/gyandoot-browser/src/client.rs b/crates/gyandoot-browser/src/client.rs index 40f65c2..6e5354d 100644 --- a/crates/gyandoot-browser/src/client.rs +++ b/crates/gyandoot-browser/src/client.rs @@ -3,9 +3,8 @@ //! 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. +//! 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; @@ -16,8 +15,6 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tracing::{debug, warn}; -// ─── Constants ────────────────────────────────────────────────────────── - /// Default session name. pub const DEFAULT_SESSION: &str = "default"; @@ -33,8 +30,7 @@ pub fn default_socket_path(session: &str) -> PathBuf { default_socket_dir().join(format!("{}.sock", session)) } -// ─── Error ────────────────────────────────────────────────────────────── - +/// Error types. #[derive(Debug, thiserror::Error)] pub enum BrowserError { #[error("IO error: {0}")] @@ -57,11 +53,30 @@ pub enum BrowserError { #[error("Snapshot unavailable")] SnapshotUnavailable, + + #[error("HTTP error: {0}")] + Http(String), } pub type BrowserResult = Result; -// ─── IPC Protocol Types ───────────────────────────────────────────────── +/// 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)] @@ -110,7 +125,7 @@ struct DaemonResponse { error: Option, } -/// Health status returned by the daemon. +/// Health status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthResponse { pub ok: bool, @@ -125,7 +140,7 @@ pub struct HealthResponse { pub active_sessions: u32, } -/// Navigation result from the daemon. +/// Navigation result. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NavigationResult { pub url: String, @@ -178,7 +193,7 @@ impl PageSnapshot { } } -/// Interaction result (click/type). +/// Interaction result. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InteractionResult { #[serde(default)] @@ -196,120 +211,253 @@ pub struct TabInfo { pub active: bool, } -// ─── BrowserClient ────────────────────────────────────────────────────── - -/// Client that talks to the camoufox-browser daemon over Unix socket. +/// Client that talks to camoufox-browser over Unix socket or HTTP. #[derive(Debug, Clone)] pub struct BrowserClient { - /// Session name. - session: String, - /// Socket path. - socket_path: PathBuf, - /// Timeout for daemon responses. + 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 session. + /// Create a new client for the default Unix socket session. pub fn new() -> Self { Self { - session: DEFAULT_SESSION.to_string(), - socket_path: default_socket_path(DEFAULT_SESSION), + 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 { - self.session = session.to_string(); - self.socket_path = default_socket_path(session); + 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 { - self.socket_path = PathBuf::from(path); + match &mut self.transport { + Transport::UnixSocket { socket_path, .. } => { + *socket_path = PathBuf::from(path); + } + Transport::Http { .. } => {} // no-op for HTTP + } self } - /// Get the socket path. - pub fn socket_path(&self) -> &PathBuf { - &self.socket_path + /// 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.session + self.transport.session() } - // ─── Low-level IPC ────────────────────────────────────────────── + /// 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 and receive a response. + /// 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, + 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)))?; + .map_err(|e| BrowserError::Daemon(format!("Serialize: {}", e)))?; debug!("camoufox IPC → {} (action={})", payload, action); - // Connect and send + 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(&self.socket_path), + UnixStream::connect(&socket_path), ) .await - .map_err(|_| BrowserError::SocketNotFound(self.socket_path.display().to_string()))? + .map_err(|_| BrowserError::SocketNotFound(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.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::Daemon("Response timeout".into()))? .map_err(BrowserError::Io)?; if line.is_empty() { - return Err(BrowserError::Daemon("Empty response from daemon".to_string())); + return Err(BrowserError::Daemon("Empty response".into())); } let response: DaemonResponse = serde_json::from_str(line.trim()) - .map_err(|e| BrowserError::Daemon(format!("Parse response: {} — payload: {}", e, 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 { @@ -319,40 +467,28 @@ impl BrowserClient { 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. + /// Check daemon health. 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, - }); - } + 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()))?; - // 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); + 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(), @@ -362,25 +498,70 @@ impl BrowserClient { 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, + }), } } - 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, - }) + 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, + }) + } + } } } } - // ─── Navigation ───────────────────────────────────────────────── - - /// Navigate to a URL. The daemon auto-launches the browser on first call. + /// 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()); @@ -412,7 +593,7 @@ impl BrowserClient { }) } - /// Refresh the current page. + /// Refresh. pub async fn refresh(&self) -> BrowserResult { let resp = self.send_command("reload", |_| {}).await?; let data = self.check_response(resp)?; @@ -422,9 +603,7 @@ impl BrowserClient { }) } - // ─── Snapshot ─────────────────────────────────────────────────── - - /// Get an accessibility snapshot of the current page. + /// Get page snapshot. pub async fn snapshot(&self, interactive: bool) -> BrowserResult { let resp = self.send_command("snapshot", |cmd| { cmd.interactive = Some(interactive); @@ -432,9 +611,8 @@ impl BrowserClient { }).await?; let data = self.check_response(resp)?; - let raw_snapshot = data["snapshot"].as_str().unwrap_or("").to_string(); Ok(PageSnapshot { - snapshot: raw_snapshot, + 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()), @@ -442,9 +620,7 @@ impl BrowserClient { }) } - // ─── Interaction ──────────────────────────────────────────────── - - /// Click an element by its ref (e.g., "e1"). + /// 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)); @@ -456,7 +632,7 @@ impl BrowserClient { }) } - /// Type text into an element and optionally press Enter. + /// 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)); @@ -474,7 +650,7 @@ impl BrowserClient { }) } - /// Press a keyboard key. + /// Press a key. pub async fn press(&self, key: &str) -> BrowserResult<()> { let resp = self.send_command("press", |cmd| { cmd.text = Some(key.to_string()); @@ -483,7 +659,7 @@ impl BrowserClient { Ok(()) } - /// Scroll the page. + /// Scroll. pub async fn scroll(&self, direction: &str, amount: u32) -> BrowserResult<()> { let resp = self.send_command("scroll", |cmd| { cmd.direction = Some(direction.to_string()); @@ -493,9 +669,7 @@ impl BrowserClient { Ok(()) } - // ─── Tab Management ───────────────────────────────────────────── - - /// List all open tabs. + /// List tabs. pub async fn list_tabs(&self) -> BrowserResult> { let resp = self.send_command("tab_list", |_| {}).await?; let data = self.check_response(resp)?; @@ -514,7 +688,7 @@ impl BrowserClient { Ok(tabs) } - /// Create a new tab with an optional URL. + /// 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()); @@ -529,7 +703,7 @@ impl BrowserClient { }) } - /// Switch to a tab by index. + /// Switch tab. pub async fn switch_tab(&self, index: u32) -> BrowserResult { let resp = self.send_command("tab_switch", |cmd| { cmd.index = Some(index); @@ -543,21 +717,19 @@ impl BrowserClient { }) } - /// Close the browser. + /// Close 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); + warn!("Close returned: {}", e); Ok(()) } } } - // ─── Search Macros ────────────────────────────────────────────── - - /// Execute a search using a built-in daemon macro. + /// Search via 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()); @@ -571,8 +743,6 @@ impl BrowserClient { }) } - // ─── Helpers ──────────────────────────────────────────────────── - fn extract_optional_snapshot(&self, data: &Value) -> Option { data["snapshot"].as_str().map(|snap_text| PageSnapshot { snapshot: snap_text.to_string(), @@ -583,7 +753,7 @@ impl BrowserClient { }) } - /// Wait for a selector or text to appear. + /// Wait. pub async fn wait(&self, timeout_ms: u64) -> BrowserResult<()> { let resp = self.send_command("wait", |cmd| { cmd.selector = Some(format!("{}", timeout_ms)); @@ -599,15 +769,12 @@ 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, @@ -647,8 +814,6 @@ mod tests { #[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,