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-security",
|
||||||
"gyandoot-skills",
|
"gyandoot-skills",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -700,6 +701,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"gyandoot-core",
|
"gyandoot-core",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ async-trait.workspace = true
|
|||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
atty.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.
|
//! 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
|
//! Two transport backends:
|
||||||
//! on port 9377, the new `camoufox-browser@0.1.0` works via a Unix socket
|
//! 1. **HTTP** (Docker container on port 9377) — the old REST API, stable
|
||||||
//! daemon. The CLI wrapper auto-starts the daemon, and commands communicate
|
//! 2. **Unix socket** (camoufox-browser@0.1.0 daemon) — new JSON-line IPC
|
||||||
//! via JSON-line protocol over `~/.camoufox-browser/<session>.sock`.
|
|
||||||
//!
|
//!
|
||||||
//! On `gyandoot run`, the BrowserManager:
|
//! The BrowserManager tries HTTP first (Docker), then falls back to npx daemon.
|
||||||
//! 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
|
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -39,9 +25,7 @@ const DAEMON_POLL_MS: u64 = 500;
|
|||||||
/// Result of browser initialization.
|
/// Result of browser initialization.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BrowserInitResult {
|
pub enum BrowserInitResult {
|
||||||
/// Browser daemon is running and client is ready.
|
|
||||||
Ready(BrowserClient),
|
Ready(BrowserClient),
|
||||||
/// Could not start daemon.
|
|
||||||
Unavailable(String),
|
Unavailable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +38,6 @@ enum OsKind {
|
|||||||
Wsl,
|
Wsl,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect what operating system we're running on.
|
|
||||||
fn detect_os() -> OsKind {
|
fn detect_os() -> OsKind {
|
||||||
if cfg!(target_os = "windows") {
|
if cfg!(target_os = "windows") {
|
||||||
return OsKind::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.
|
/// Check if the Camoufox Docker container is running on port 9377.
|
||||||
fn example_npx_path() -> &'static str {
|
/// Uses a quick HTTP health check.
|
||||||
match detect_os() {
|
async fn check_docker_http_api() -> bool {
|
||||||
OsKind::Mac => "\"/Users/yourname/.nvm/versions/node/v22/bin/npx\"",
|
// Build a reqwest client with a short timeout
|
||||||
_ => "\"/home/user/.nvm/versions/node/v22/bin/npx\"",
|
let client = reqwest::Client::builder()
|
||||||
}
|
.timeout(Duration::from_secs(2))
|
||||||
}
|
.build()
|
||||||
|
.ok();
|
||||||
|
|
||||||
/// Path to the Camoufox browser binary cache directory.
|
match client {
|
||||||
fn camoufox_browser_cache_dir() -> std::path::PathBuf {
|
Some(c) => match c.get("http://localhost:9377/health").send().await {
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
Ok(resp) => resp.status().is_success(),
|
||||||
if cfg!(target_os = "macos") {
|
Err(_) => false,
|
||||||
home.join("Library").join("Caches").join("camoufox")
|
},
|
||||||
} else {
|
None => false,
|
||||||
home.join(".cache").join("camoufox")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the Camoufox browser binary is installed and uncorrupted.
|
/// Check if the Camoufox browser binary is installed and uncorrupted.
|
||||||
fn is_browser_installed() -> bool {
|
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() {
|
if !cache_dir.exists() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
let macos_dir = cache_dir.join("Camoufox.app").join("Contents").join("MacOS");
|
let binary = cache_dir.join("Camoufox.app").join("Contents").join("MacOS").join("camoufox");
|
||||||
let binary = macos_dir.join("camoufox");
|
binary.exists()
|
||||||
let lib = macos_dir.join("libmozglue.dylib");
|
|
||||||
binary.exists() && lib.exists()
|
|
||||||
} else {
|
} else {
|
||||||
cache_dir.join("camoufox").exists()
|
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> {
|
fn check_known_errors(log: &str) -> Option<String> {
|
||||||
let lower = log.to_lowercase();
|
let lower = log.to_lowercase();
|
||||||
|
|
||||||
if lower.contains("libmozglue.dylib") && lower.contains("no such file") {
|
if lower.contains("libmozglue.dylib") {
|
||||||
return Some("The Camoufox browser download is corrupted (missing libmozglue.dylib). \
|
return Some(String::from(
|
||||||
This is a known issue with the camoufox-browser@0.1.0 package on macOS."
|
"camoufox-browser download corrupted (missing libmozglue.dylib).",
|
||||||
.to_string());
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if lower.contains("error while loading shared libraries") {
|
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") {
|
if lower.contains("killed") && (lower.contains("sigkill") || lower.contains("exitcode=null")) {
|
||||||
return Some("macOS code signing issue. The downloaded browser binary may need manual approval."
|
return Some(String::from(
|
||||||
.to_string());
|
"Browser process killed by OS — corrupted or incompatible binary.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
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.
|
/// 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 {
|
pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
|
||||||
let session = DEFAULT_SESSION;
|
let session = DEFAULT_SESSION;
|
||||||
let socket_path = default_socket_path(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 {
|
if check_daemon_socket(session).await {
|
||||||
info!("Camoufox daemon already running on socket: {}", socket_path.display());
|
info!("Camoufox daemon already running on socket: {}", socket_path.display());
|
||||||
let client = BrowserClient::new().with_session(session);
|
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...");
|
info!("Camoufox daemon not running, triggering startup...");
|
||||||
|
|
||||||
|
// Check if browser binary is cached
|
||||||
if !is_browser_installed() {
|
if !is_browser_installed() {
|
||||||
info!("Camoufox browser not cached. Running install...");
|
info!("Camoufox browser not cached. Running install...");
|
||||||
match run_install(node_path).await {
|
match run_install(node_path).await {
|
||||||
Ok(msg) => info!("Camoufox browser install: {}", msg),
|
Ok(msg) => info!("Camoufox browser install: {}", msg),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let npx_name = resolve_npx(node_path);
|
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!(
|
return BrowserInitResult::Unavailable(format!(
|
||||||
"Failed to download Camoufox browser: {}\n\n\
|
"Failed to download Camoufox browser: {}\n\n\
|
||||||
Run manually:\n {} camoufox-browser@latest install\n{}",
|
Run: {} camoufox-browser@latest install\n\n\
|
||||||
e, npx_name, hint
|
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 {
|
match trigger_daemon_start(session, node_path).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
info!("Waiting for daemon socket at {}...", socket_path.display());
|
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)
|
BrowserInitResult::Ready(client)
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
let msg = format!(
|
BrowserInitResult::Unavailable(format!(
|
||||||
"Camoufox daemon did not start within {}s.\n\
|
"Daemon did not start within {}s.\n\n\
|
||||||
Socket not found at {}\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,
|
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) => {
|
Err(e) => {
|
||||||
let error_detail = e.to_string();
|
let error_detail = e.to_string();
|
||||||
let known_error = check_known_errors(&error_detail);
|
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 {
|
let mut msg = format!(
|
||||||
msg.push_str(&hint);
|
"Camoufox daemon failed to start.\n\nError:\n {}\n\n",
|
||||||
msg.push('\n');
|
error_detail
|
||||||
msg.push_str(&platform_fix_instructions());
|
);
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
msg.push_str(&format!(
|
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),
|
resolve_npx(node_path),
|
||||||
platform_fix_instructions(),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
msg.push_str("\n\nThen run 'gyandoot run' again.");
|
|
||||||
warn!("{}", msg);
|
warn!("{}", msg);
|
||||||
BrowserInitResult::Unavailable(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> {
|
async fn run_install(node_path: Option<&str>) -> Result<String, String> {
|
||||||
let npx = resolve_npx(node_path);
|
let npx = resolve_npx(node_path);
|
||||||
|
|
||||||
let output = Command::new(npx)
|
let output = Command::new(npx)
|
||||||
.args(&["camoufox-browser@latest", "install"])
|
.args(&["camoufox-browser@latest", "install"])
|
||||||
.output()
|
.output()
|
||||||
@@ -294,25 +247,16 @@ async fn run_install(node_path: Option<&str>) -> Result<String, String> {
|
|||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Install failed (exit: {}):\n stderr: {}\n stdout: {}",
|
"Install failed (exit: {}): stderr: {}",
|
||||||
output.status.code().unwrap_or(-1),
|
output.status.code().unwrap_or(-1),
|
||||||
stderr.lines().last().unwrap_or("(unknown)"),
|
stderr.lines().last().unwrap_or("(unknown)")
|
||||||
stdout.lines().last().unwrap_or("(unknown)"),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let last_line = stdout
|
Ok(stdout.lines().last().unwrap_or("Install completed").to_string())
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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> {
|
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 home = dirs::home_dir().ok_or("Cannot determine home directory")?;
|
||||||
let log_dir = home.join(".gyandoot");
|
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 log_file = log_dir.join("camoufox-daemon-spawn.log");
|
||||||
|
|
||||||
let npx = resolve_npx(node_path);
|
let npx = resolve_npx(node_path);
|
||||||
|
|
||||||
let output = Command::new(npx)
|
let output = Command::new(npx)
|
||||||
.args(&["camoufox-browser@latest", "tab_list", "--session", session])
|
.args(&["camoufox-browser@latest", "tab_list", "--session", session])
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| format!("{} not found: {}", npx, 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)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
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!(
|
return Err(format!("{}\n Log: {}", details, log_file.display()));
|
||||||
"{}\n Full log saved to {}",
|
|
||||||
details,
|
|
||||||
log_file.display(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll for the daemon Unix socket to appear.
|
|
||||||
async fn wait_for_socket(session: &str) -> Result<bool, String> {
|
async fn wait_for_socket(session: &str) -> Result<bool, String> {
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(DAEMON_STARTUP_TIMEOUT_SECS);
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(DAEMON_STARTUP_TIMEOUT_SECS);
|
||||||
|
|
||||||
while tokio::time::Instant::now() < deadline {
|
while tokio::time::Instant::now() < deadline {
|
||||||
if check_daemon_socket(session).await {
|
if check_daemon_socket(session).await {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(DAEMON_POLL_MS)).await;
|
tokio::time::sleep(Duration::from_millis(DAEMON_POLL_MS)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ name = "gyandoot-browser"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.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]
|
[dependencies]
|
||||||
gyandoot-core = { path = "../gyandoot-core" }
|
gyandoot-core = { path = "../gyandoot-core" }
|
||||||
@@ -16,3 +16,4 @@ tracing.workspace = true
|
|||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
dirs.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
|
//! Communicates with the camoufox-browser daemon via JSON-line protocol
|
||||||
//! over a Unix socket at `~/.camoufox-browser/<session>.sock`.
|
//! over a Unix socket at `~/.camoufox-browser/<session>.sock`.
|
||||||
//!
|
//!
|
||||||
//! The daemon is started by `camoufox-browser@latest` (npx) which spawns
|
//! Also supports HTTP JSON transport for the Docker container on port 9377.
|
||||||
//! a background Node.js process that auto-launches the camoufox browser
|
//! Auto-detected — no user configuration needed.
|
||||||
//! on the first browser-dependent command.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -16,8 +15,6 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Default session name.
|
/// Default session name.
|
||||||
pub const DEFAULT_SESSION: &str = "default";
|
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))
|
default_socket_dir().join(format!("{}.sock", session))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Error ──────────────────────────────────────────────────────────────
|
/// Error types.
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum BrowserError {
|
pub enum BrowserError {
|
||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
@@ -57,11 +53,30 @@ pub enum BrowserError {
|
|||||||
|
|
||||||
#[error("Snapshot unavailable")]
|
#[error("Snapshot unavailable")]
|
||||||
SnapshotUnavailable,
|
SnapshotUnavailable,
|
||||||
|
|
||||||
|
#[error("HTTP error: {0}")]
|
||||||
|
Http(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type BrowserResult<T> = Result<T, BrowserError>;
|
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.
|
/// A command sent to the daemon as JSON-line.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -110,7 +125,7 @@ struct DaemonResponse {
|
|||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Health status returned by the daemon.
|
/// Health status.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HealthResponse {
|
pub struct HealthResponse {
|
||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
@@ -125,7 +140,7 @@ pub struct HealthResponse {
|
|||||||
pub active_sessions: u32,
|
pub active_sessions: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigation result from the daemon.
|
/// Navigation result.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct NavigationResult {
|
pub struct NavigationResult {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -178,7 +193,7 @@ impl PageSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interaction result (click/type).
|
/// Interaction result.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct InteractionResult {
|
pub struct InteractionResult {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -196,120 +211,253 @@ pub struct TabInfo {
|
|||||||
pub active: bool,
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── BrowserClient ──────────────────────────────────────────────────────
|
/// Client that talks to camoufox-browser over Unix socket or HTTP.
|
||||||
|
|
||||||
/// Client that talks to the camoufox-browser daemon over Unix socket.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BrowserClient {
|
pub struct BrowserClient {
|
||||||
/// Session name.
|
transport: Transport,
|
||||||
session: String,
|
|
||||||
/// Socket path.
|
|
||||||
socket_path: PathBuf,
|
|
||||||
/// Timeout for daemon responses.
|
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
|
/// Whether this client was created via HTTP (Docker) transport.
|
||||||
|
is_http: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrowserClient {
|
impl BrowserClient {
|
||||||
/// Create a new client for the default session.
|
/// Create a new client for the default Unix socket session.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
session: DEFAULT_SESSION.to_string(),
|
transport: Transport::UnixSocket {
|
||||||
socket_path: default_socket_path(DEFAULT_SESSION),
|
session: DEFAULT_SESSION.to_string(),
|
||||||
|
socket_path: default_socket_path(DEFAULT_SESSION),
|
||||||
|
},
|
||||||
timeout: Duration::from_secs(30),
|
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.
|
/// Create a client with a custom session name.
|
||||||
pub fn with_session(mut self, session: &str) -> Self {
|
pub fn with_session(mut self, session: &str) -> Self {
|
||||||
self.session = session.to_string();
|
match &mut self.transport {
|
||||||
self.socket_path = default_socket_path(session);
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a client with a custom socket path.
|
/// Create a client with a custom socket path.
|
||||||
pub fn with_socket(mut self, path: &str) -> Self {
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the socket path.
|
/// Get the socket path (for Unix socket transport).
|
||||||
pub fn socket_path(&self) -> &PathBuf {
|
/// Returns None if using HTTP transport.
|
||||||
&self.socket_path
|
pub fn socket_path(&self) -> Option<&PathBuf> {
|
||||||
|
match &self.transport {
|
||||||
|
Transport::UnixSocket { socket_path, .. } => Some(socket_path),
|
||||||
|
Transport::Http { .. } => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the session name.
|
/// Get the session name.
|
||||||
pub fn session(&self) -> &str {
|
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> {
|
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 id = format!("g{}", chrono::Utc::now().timestamp_millis());
|
||||||
let mut cmd = DaemonCommand {
|
let mut cmd = DaemonCommand {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
action: action.to_string(),
|
action: action.to_string(),
|
||||||
url: None,
|
url: None, query: None, macro_name: None,
|
||||||
query: None,
|
wait: None, selector: None, r#ref: None,
|
||||||
macro_name: None,
|
text: None, press_enter: None, headless: None,
|
||||||
wait: None,
|
index: None, direction: None, amount: None,
|
||||||
selector: None,
|
interactive: None, compact: None,
|
||||||
r#ref: None,
|
|
||||||
text: None,
|
|
||||||
press_enter: None,
|
|
||||||
headless: None,
|
|
||||||
index: None,
|
|
||||||
direction: None,
|
|
||||||
amount: None,
|
|
||||||
interactive: None,
|
|
||||||
compact: None,
|
|
||||||
};
|
};
|
||||||
build(&mut cmd);
|
build(&mut cmd);
|
||||||
|
|
||||||
let payload = serde_json::to_string(&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);
|
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(
|
let stream = tokio::time::timeout(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
UnixStream::connect(&self.socket_path),
|
UnixStream::connect(&socket_path),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| BrowserError::SocketNotFound(self.socket_path.display().to_string()))?
|
.map_err(|_| BrowserError::SocketNotFound(socket_path.display().to_string()))?
|
||||||
.map_err(BrowserError::Io)?;
|
.map_err(BrowserError::Io)?;
|
||||||
|
|
||||||
let (reader, mut writer) = stream.into_split();
|
let (reader, mut writer) = stream.into_split();
|
||||||
|
writer.write_all(format!("{}\n", payload).as_bytes()).await.map_err(BrowserError::Io)?;
|
||||||
// Write command
|
|
||||||
writer
|
|
||||||
.write_all(format!("{}\n", payload).as_bytes())
|
|
||||||
.await
|
|
||||||
.map_err(BrowserError::Io)?;
|
|
||||||
writer.shutdown().await.map_err(BrowserError::Io)?;
|
writer.shutdown().await.map_err(BrowserError::Io)?;
|
||||||
|
|
||||||
// Read response
|
|
||||||
let mut buf_reader = BufReader::new(reader);
|
let mut buf_reader = BufReader::new(reader);
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
tokio::time::timeout(self.timeout, buf_reader.read_line(&mut line))
|
tokio::time::timeout(self.timeout, buf_reader.read_line(&mut line))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| BrowserError::Daemon("Response timeout".to_string()))?
|
.map_err(|_| BrowserError::Daemon("Response timeout".into()))?
|
||||||
.map_err(BrowserError::Io)?;
|
.map_err(BrowserError::Io)?;
|
||||||
|
|
||||||
if line.is_empty() {
|
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())
|
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);
|
debug!("camoufox IPC ← {:?}", response);
|
||||||
|
|
||||||
Ok(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.
|
/// Extract data payload from a successful response.
|
||||||
fn check_response(&self, resp: DaemonResponse) -> BrowserResult<Value> {
|
fn check_response(&self, resp: DaemonResponse) -> BrowserResult<Value> {
|
||||||
if !resp.success {
|
if !resp.success {
|
||||||
@@ -319,40 +467,28 @@ impl BrowserClient {
|
|||||||
resp.data.ok_or(BrowserError::MissingData)
|
resp.data.ok_or(BrowserError::MissingData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Health ─────────────────────────────────────────────────────
|
/// Check daemon health.
|
||||||
|
|
||||||
/// Check daemon health by sending a quick snapshot command.
|
|
||||||
/// The daemon has no dedicated /health endpoint — we check socket existence + connectivity.
|
|
||||||
pub async fn health(&self) -> BrowserResult<HealthResponse> {
|
pub async fn health(&self) -> BrowserResult<HealthResponse> {
|
||||||
// Check if socket exists
|
match &self.transport {
|
||||||
if !self.socket_path.exists() {
|
Transport::Http { base_url } => {
|
||||||
return Ok(HealthResponse {
|
let http_client = reqwest::Client::builder()
|
||||||
ok: false,
|
.timeout(Duration::from_secs(5))
|
||||||
engine: "camoufox".to_string(),
|
.build()
|
||||||
browser_connected: false,
|
.map_err(|e| BrowserError::Http(e.to_string()))?;
|
||||||
browser_running: false,
|
|
||||||
active_tabs: 0,
|
|
||||||
active_sessions: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try a quick "tab_list" command as health check
|
match http_client.get(format!("{}/health", base_url)).send().await {
|
||||||
match self.send_command("tab_list", |_| {}).await {
|
Ok(resp) => {
|
||||||
Ok(resp) => {
|
let text = resp.text().await.unwrap_or_default();
|
||||||
match self.check_response(resp) {
|
if let Ok(json) = serde_json::from_str::<Value>(&text) {
|
||||||
Ok(data) => {
|
return Ok(HealthResponse {
|
||||||
let tabs = data["tabs"].as_array().map(|a| a.len() as u32).unwrap_or(0);
|
ok: json["ok"].as_bool().unwrap_or(false),
|
||||||
Ok(HealthResponse {
|
engine: "camoufox".to_string(),
|
||||||
ok: true,
|
browser_connected: json["browserRunning"].as_bool().unwrap_or(false),
|
||||||
engine: "camoufox".to_string(),
|
browser_running: json["browserRunning"].as_bool().unwrap_or(false),
|
||||||
browser_connected: true,
|
active_tabs: json["activeTabs"].as_u64().unwrap_or(0) as u32,
|
||||||
browser_running: true,
|
active_sessions: 1,
|
||||||
active_tabs: tabs,
|
});
|
||||||
active_sessions: 1,
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Health check daemon error: {}", e);
|
|
||||||
Ok(HealthResponse {
|
Ok(HealthResponse {
|
||||||
ok: true,
|
ok: true,
|
||||||
engine: "camoufox".to_string(),
|
engine: "camoufox".to_string(),
|
||||||
@@ -362,25 +498,70 @@ impl BrowserClient {
|
|||||||
active_sessions: 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,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Transport::UnixSocket { socket_path, .. } => {
|
||||||
debug!("Health check failed: {}", e);
|
if !socket_path.exists() {
|
||||||
Ok(HealthResponse {
|
return Ok(HealthResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
engine: "camoufox".to_string(),
|
engine: "camoufox".to_string(),
|
||||||
browser_connected: false,
|
browser_connected: false,
|
||||||
browser_running: false,
|
browser_running: false,
|
||||||
active_tabs: 0,
|
active_tabs: 0,
|
||||||
active_sessions: 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.
|
||||||
|
|
||||||
/// Navigate to a URL. The daemon auto-launches the browser on first call.
|
|
||||||
pub async fn navigate(&self, url: &str) -> BrowserResult<NavigationResult> {
|
pub async fn navigate(&self, url: &str) -> BrowserResult<NavigationResult> {
|
||||||
let resp = self.send_command("navigate", |cmd| {
|
let resp = self.send_command("navigate", |cmd| {
|
||||||
cmd.url = Some(url.to_string());
|
cmd.url = Some(url.to_string());
|
||||||
@@ -412,7 +593,7 @@ impl BrowserClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh the current page.
|
/// Refresh.
|
||||||
pub async fn refresh(&self) -> BrowserResult<NavigationResult> {
|
pub async fn refresh(&self) -> BrowserResult<NavigationResult> {
|
||||||
let resp = self.send_command("reload", |_| {}).await?;
|
let resp = self.send_command("reload", |_| {}).await?;
|
||||||
let data = self.check_response(resp)?;
|
let data = self.check_response(resp)?;
|
||||||
@@ -422,9 +603,7 @@ impl BrowserClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Snapshot ───────────────────────────────────────────────────
|
/// Get page snapshot.
|
||||||
|
|
||||||
/// Get an accessibility snapshot of the current page.
|
|
||||||
pub async fn snapshot(&self, interactive: bool) -> BrowserResult<PageSnapshot> {
|
pub async fn snapshot(&self, interactive: bool) -> BrowserResult<PageSnapshot> {
|
||||||
let resp = self.send_command("snapshot", |cmd| {
|
let resp = self.send_command("snapshot", |cmd| {
|
||||||
cmd.interactive = Some(interactive);
|
cmd.interactive = Some(interactive);
|
||||||
@@ -432,9 +611,8 @@ impl BrowserClient {
|
|||||||
}).await?;
|
}).await?;
|
||||||
let data = self.check_response(resp)?;
|
let data = self.check_response(resp)?;
|
||||||
|
|
||||||
let raw_snapshot = data["snapshot"].as_str().unwrap_or("").to_string();
|
|
||||||
Ok(PageSnapshot {
|
Ok(PageSnapshot {
|
||||||
snapshot: raw_snapshot,
|
snapshot: data["snapshot"].as_str().unwrap_or("").to_string(),
|
||||||
url: data["url"].as_str().unwrap_or("").to_string(),
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
||||||
title: data["title"].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()),
|
screenshot: data["screenshot"].as_str().map(|s| s.to_string()),
|
||||||
@@ -442,9 +620,7 @@ impl BrowserClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Interaction ────────────────────────────────────────────────
|
/// Click an element.
|
||||||
|
|
||||||
/// Click an element by its ref (e.g., "e1").
|
|
||||||
pub async fn click(&self, element_ref: &str) -> BrowserResult<InteractionResult> {
|
pub async fn click(&self, element_ref: &str) -> BrowserResult<InteractionResult> {
|
||||||
let resp = self.send_command("click", |cmd| {
|
let resp = self.send_command("click", |cmd| {
|
||||||
cmd.selector = Some(format!("[ref=\"{}\"]", element_ref));
|
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> {
|
pub async fn type_text(&self, element_ref: &str, text: &str, press_enter: bool) -> BrowserResult<InteractionResult> {
|
||||||
let resp = self.send_command("fill", |cmd| {
|
let resp = self.send_command("fill", |cmd| {
|
||||||
cmd.selector = Some(format!("[ref=\"{}\"]", element_ref));
|
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<()> {
|
pub async fn press(&self, key: &str) -> BrowserResult<()> {
|
||||||
let resp = self.send_command("press", |cmd| {
|
let resp = self.send_command("press", |cmd| {
|
||||||
cmd.text = Some(key.to_string());
|
cmd.text = Some(key.to_string());
|
||||||
@@ -483,7 +659,7 @@ impl BrowserClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scroll the page.
|
/// Scroll.
|
||||||
pub async fn scroll(&self, direction: &str, amount: u32) -> BrowserResult<()> {
|
pub async fn scroll(&self, direction: &str, amount: u32) -> BrowserResult<()> {
|
||||||
let resp = self.send_command("scroll", |cmd| {
|
let resp = self.send_command("scroll", |cmd| {
|
||||||
cmd.direction = Some(direction.to_string());
|
cmd.direction = Some(direction.to_string());
|
||||||
@@ -493,9 +669,7 @@ impl BrowserClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tab Management ─────────────────────────────────────────────
|
/// List tabs.
|
||||||
|
|
||||||
/// List all open tabs.
|
|
||||||
pub async fn list_tabs(&self) -> BrowserResult<Vec<TabInfo>> {
|
pub async fn list_tabs(&self) -> BrowserResult<Vec<TabInfo>> {
|
||||||
let resp = self.send_command("tab_list", |_| {}).await?;
|
let resp = self.send_command("tab_list", |_| {}).await?;
|
||||||
let data = self.check_response(resp)?;
|
let data = self.check_response(resp)?;
|
||||||
@@ -514,7 +688,7 @@ impl BrowserClient {
|
|||||||
Ok(tabs)
|
Ok(tabs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new tab with an optional URL.
|
/// New tab.
|
||||||
pub async fn new_tab(&self, url: Option<&str>) -> BrowserResult<TabInfo> {
|
pub async fn new_tab(&self, url: Option<&str>) -> BrowserResult<TabInfo> {
|
||||||
let resp = self.send_command("tab_new", |cmd| {
|
let resp = self.send_command("tab_new", |cmd| {
|
||||||
cmd.url = url.map(|s| s.to_string());
|
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> {
|
pub async fn switch_tab(&self, index: u32) -> BrowserResult<TabInfo> {
|
||||||
let resp = self.send_command("tab_switch", |cmd| {
|
let resp = self.send_command("tab_switch", |cmd| {
|
||||||
cmd.index = Some(index);
|
cmd.index = Some(index);
|
||||||
@@ -543,21 +717,19 @@ impl BrowserClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close the browser.
|
/// Close browser.
|
||||||
pub async fn close_browser(&self) -> BrowserResult<()> {
|
pub async fn close_browser(&self) -> BrowserResult<()> {
|
||||||
let resp = self.send_command("close", |_| {}).await;
|
let resp = self.send_command("close", |_| {}).await;
|
||||||
match resp {
|
match resp {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Close browser returned: {}", e);
|
warn!("Close returned: {}", e);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Search Macros ──────────────────────────────────────────────
|
/// Search via macro.
|
||||||
|
|
||||||
/// Execute a search using a built-in daemon macro.
|
|
||||||
pub async fn search(&self, macro_name: &str, query: &str) -> BrowserResult<NavigationResult> {
|
pub async fn search(&self, macro_name: &str, query: &str) -> BrowserResult<NavigationResult> {
|
||||||
let resp = self.send_command("navigate", |cmd| {
|
let resp = self.send_command("navigate", |cmd| {
|
||||||
cmd.macro_name = Some(macro_name.to_string());
|
cmd.macro_name = Some(macro_name.to_string());
|
||||||
@@ -571,8 +743,6 @@ impl BrowserClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn extract_optional_snapshot(&self, data: &Value) -> Option<PageSnapshot> {
|
fn extract_optional_snapshot(&self, data: &Value) -> Option<PageSnapshot> {
|
||||||
data["snapshot"].as_str().map(|snap_text| PageSnapshot {
|
data["snapshot"].as_str().map(|snap_text| PageSnapshot {
|
||||||
snapshot: snap_text.to_string(),
|
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<()> {
|
pub async fn wait(&self, timeout_ms: u64) -> BrowserResult<()> {
|
||||||
let resp = self.send_command("wait", |cmd| {
|
let resp = self.send_command("wait", |cmd| {
|
||||||
cmd.selector = Some(format!("{}", timeout_ms));
|
cmd.selector = Some(format!("{}", timeout_ms));
|
||||||
@@ -599,15 +769,12 @@ impl Default for BrowserClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Connection / Daemon Management ────────────────────────────────
|
|
||||||
|
|
||||||
/// Check if the daemon socket is reachable.
|
/// Check if the daemon socket is reachable.
|
||||||
pub async fn check_daemon_socket(session: &str) -> bool {
|
pub async fn check_daemon_socket(session: &str) -> bool {
|
||||||
let path = default_socket_path(session);
|
let path = default_socket_path(session);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Quick connect test
|
|
||||||
match tokio::time::timeout(Duration::from_secs(1), UnixStream::connect(&path)).await {
|
match tokio::time::timeout(Duration::from_secs(1), UnixStream::connect(&path)).await {
|
||||||
Ok(Ok(_)) => true,
|
Ok(Ok(_)) => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -647,8 +814,6 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_health_no_daemon_running() {
|
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 client = BrowserClient::new();
|
||||||
let health = client.health().await.unwrap_or(HealthResponse {
|
let health = client.health().await.unwrap_or(HealthResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user