fix(browser): dual transport — Unix socket + HTTP Docker + binary corruption detection

Root cause on macOS: camoufox-browser@0.1.0 downloads an alpha binary
(v150.0.2-alpha.26) from daijro/camoufox that has a broken macOS build
(missing libmozglue.dylib). Binary crashes with SIGABRT/SIGKILL.

Changes:
1. gyandoot-browser client now supports TWO transports:
   - Transport::UnixSocket (existing — daemon IPC)
   - Transport::Http (new — Docker REST API on port 9377)
2. init_browser() tries Docker HTTP first, falls back to npx daemon
3. HTTP transport maps browser actions to Docker REST endpoints
   (/navigate, /tabs, /health, etc.) with correct userId params
4. check_known_errors() detects libmozglue.dylib + SIGKILL patterns
5. Browser cache check works for both macOS and Linux paths
6. Error messages correctly say 'Use Docker' on corrupted binary
7. Added reqwest dep to gyandoot-agent for Docker health check

Clean build: 0 errors
This commit is contained in:
2026-06-12 19:49:16 +05:30
parent 495893c1de
commit 71cf7c8830
5 changed files with 394 additions and 299 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -24,3 +24,4 @@ async-trait.workspace = true
rand.workspace = true
dirs.workspace = true
atty.workspace = true
reqwest = { workspace = true, default-features = false, features = ["json", "rustls-tls"] }

View File

@@ -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/<session>.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<String> {
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<String, String> {
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<String, String> {
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<bool, String> {
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)
}

View File

@@ -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" }
@@ -16,3 +16,4 @@ tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
dirs.workspace = true
reqwest = { workspace = true, default-features = false, features = ["json"] }

View File

@@ -3,9 +3,8 @@
//! Communicates with the camoufox-browser daemon via JSON-line protocol
//! over a Unix socket at `~/.camoufox-browser/<session>.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<T> = Result<T, BrowserError>;
// ─── 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<String>,
}
/// 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 {
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<DaemonResponse> {
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<DaemonResponse> {
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<DaemonResponse> {
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::<Value>(&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<Value> {
if !resp.success {
@@ -319,13 +467,49 @@ 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<HealthResponse> {
// Check if socket exists
if !self.socket_path.exists() {
match &self.transport {
Transport::Http { base_url } => {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| BrowserError::Http(e.to_string()))?;
match http_client.get(format!("{}/health", base_url)).send().await {
Ok(resp) => {
let text = resp.text().await.unwrap_or_default();
if let Ok(json) = serde_json::from_str::<Value>(&text) {
return Ok(HealthResponse {
ok: json["ok"].as_bool().unwrap_or(false),
engine: "camoufox".to_string(),
browser_connected: json["browserRunning"].as_bool().unwrap_or(false),
browser_running: json["browserRunning"].as_bool().unwrap_or(false),
active_tabs: json["activeTabs"].as_u64().unwrap_or(0) as u32,
active_sessions: 1,
});
}
Ok(HealthResponse {
ok: true,
engine: "camoufox".to_string(),
browser_connected: false,
browser_running: false,
active_tabs: 0,
active_sessions: 0,
})
}
Err(_e) => Ok(HealthResponse {
ok: false,
engine: "camoufox".to_string(),
browser_connected: false,
browser_running: false,
active_tabs: 0,
active_sessions: 0,
}),
}
}
Transport::UnixSocket { socket_path, .. } => {
if !socket_path.exists() {
return Ok(HealthResponse {
ok: false,
engine: "camoufox".to_string(),
@@ -336,10 +520,8 @@ impl BrowserClient {
});
}
// Try a quick "tab_list" command as health check
match self.send_command("tab_list", |_| {}).await {
Ok(resp) => {
match self.check_response(resp) {
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 {
@@ -362,8 +544,7 @@ impl BrowserClient {
active_sessions: 0,
})
}
}
}
},
Err(e) => {
debug!("Health check failed: {}", e);
Ok(HealthResponse {
@@ -377,10 +558,10 @@ impl BrowserClient {
}
}
}
}
}
// ─── 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<NavigationResult> {
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<NavigationResult> {
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<PageSnapshot> {
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<InteractionResult> {
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<InteractionResult> {
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<Vec<TabInfo>> {
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<TabInfo> {
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<TabInfo> {
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<NavigationResult> {
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<PageSnapshot> {
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,