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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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 {
|
||||
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<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,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<HealthResponse> {
|
||||
// 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::<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(),
|
||||
@@ -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<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,
|
||||
|
||||
Reference in New Issue
Block a user