Root cause: camoufox-js@0.11.0 fetch can exit 0 but silently fail to download the browser binary when: 1. A partial/incomplete cache from a previous run exists 2. GitHub API rate-limits the unauthenticated release query 3. Network drops mid-download Fix: 1. run_fetch() now DELETE incomplete cache (dir exists but no version.json) before fetching — prevents 'already cached' no-op 2. After fetch, VERIFY version.json exists — if missing, RETRY once 3. After retry, verify Camoufox.app/Contents/MacOS/camoufox exists 4. Log stdout and stderr in INFO traces for debugging
404 lines
16 KiB
Rust
404 lines
16 KiB
Rust
//! BrowserManager — Start, connect, and manage lifecycle of the camoufox-browser daemon.
|
|
//!
|
|
//! Uses `camoufox-js@0.11.0` (pinned, not @latest) for fetching the browser binary,
|
|
//! and `camoufox-browser@0.1.0` (pinned) for the daemon Unix socket protocol.
|
|
//!
|
|
//! Pinning rationale:
|
|
//! - `camoufox-browser@0.1.0` bundles `camoufox-js@0.9.3` which downloads BROKEN
|
|
//! alpha macOS builds (v150.0.2-alpha.26, missing libmozglue.dylib)
|
|
//! - `camoufox-js@0.11.0` (latest, Jun 4 2026) downloads STABLE browser binaries
|
|
//! - Both packages share the same cache directory (~/Library/Caches/camoufox/).
|
|
//! We use the pinned `camoufox-js` for fetch, `camoufox-browser` for daemon.
|
|
|
|
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
|
|
use tokio::process::Command;
|
|
use tracing::{info, warn};
|
|
|
|
use gyandoot_browser::{check_daemon_socket, default_socket_path, BrowserClient};
|
|
|
|
// ─── Pinned Versions ─────────────────────────────────────────────────────
|
|
// These are pinned to specific versions to avoid upstream breakage.
|
|
// Update ONLY after testing the new version on both macOS and Linux.
|
|
|
|
/// Version of camoufox-js for downloading the browser binary.
|
|
/// v0.11.0 (Jun 4 2026) — latest stable, downloads correct macOS arm64/x64 builds.
|
|
const CAMOUFOX_JS_VERSION: &str = "0.11.0";
|
|
|
|
/// Version of camoufox-browser for daemon IPC protocol.
|
|
/// v0.1.0 (Mar 15 2026) — the only published version, daemon protocol is stable.
|
|
const CAMOUFOX_BROWSER_VERSION: &str = "0.1.0";
|
|
|
|
/// Default session name.
|
|
const DEFAULT_SESSION: &str = "default";
|
|
|
|
/// Maximum time to wait for the daemon socket to appear.
|
|
const DAEMON_STARTUP_TIMEOUT_SECS: u64 = 30;
|
|
|
|
/// Poll interval while waiting for daemon to start.
|
|
const DAEMON_POLL_MS: u64 = 500;
|
|
|
|
/// Result of browser initialization.
|
|
#[derive(Debug)]
|
|
pub enum BrowserInitResult {
|
|
Ready(BrowserClient),
|
|
Unavailable(String),
|
|
}
|
|
|
|
/// Resolve the npx executable name, respecting config and platform.
|
|
fn resolve_npx(node_path: Option<&str>) -> &str {
|
|
if let Some(p) = node_path {
|
|
if !p.is_empty() {
|
|
return p;
|
|
}
|
|
}
|
|
if cfg!(target_os = "windows") {
|
|
"npx.cmd"
|
|
} else {
|
|
"npx"
|
|
}
|
|
}
|
|
|
|
/// Cache directory for the Camoufox browser binary.
|
|
fn 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")
|
|
}
|
|
}
|
|
|
|
/// Check if the Camoufox browser binary is installed and appears intact.
|
|
fn is_browser_installed() -> bool {
|
|
let cache = browser_cache_dir();
|
|
if !cache.exists() {
|
|
return false;
|
|
}
|
|
// Check for the version marker file (written by camoufox-js fetch)
|
|
if !cache.join("version.json").exists() {
|
|
return false;
|
|
}
|
|
if cfg!(target_os = "macos") {
|
|
cache.join("Camoufox.app").join("Contents").join("MacOS").join("camoufox").exists()
|
|
} else {
|
|
cache.join("camoufox").exists()
|
|
}
|
|
}
|
|
|
|
/// Check for known error patterns in daemon stderr.
|
|
fn check_known_errors(log: &str) -> Option<&'static str> {
|
|
let lower = log.to_lowercase();
|
|
if lower.contains("libmozglue.dylib") {
|
|
return Some("Browser binary corrupted (missing libmozglue.dylib). Re-run 'gyandoot run' to re-download with the pinned version.");
|
|
}
|
|
if lower.contains("error while loading shared libraries") {
|
|
return Some("Missing Linux system libraries.");
|
|
}
|
|
if lower.contains("sigkill") {
|
|
return Some("macOS Gatekeeper killed the browser (SIGKILL). Run: xattr -dr com.apple.quarantine ~/Library/Caches/camoufox");
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Remove macOS quarantine attribute from the Camoufox cache directory.
|
|
/// Gatekeeper kills unsigned headless browsers with SIGKILL on Apple Silicon.
|
|
async fn remove_macos_quarantine() {
|
|
if !cfg!(target_os = "macos") {
|
|
return;
|
|
}
|
|
let cache = browser_cache_dir();
|
|
if !cache.exists() {
|
|
return;
|
|
}
|
|
let output = Command::new("xattr")
|
|
.args(&["-dr", "com.apple.quarantine", &cache.to_string_lossy()])
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.output()
|
|
.await;
|
|
match output {
|
|
Ok(o) if o.status.success() => {
|
|
info!("Removed macOS quarantine from {}", cache.display());
|
|
}
|
|
_ => {
|
|
warn!("Could not remove quarantine (xattr not available or failed)");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Initialize the browser daemon and return a ready client.
|
|
///
|
|
/// Steps:
|
|
/// 1. Ensure browser binary is downloaded using pinned `camoufox-js@0.11.0`
|
|
/// 2. Ensure npm package is available for daemon
|
|
/// 3. Start daemon via pinned `camoufox-browser@0.1.0`
|
|
/// 4. Connect to Unix socket
|
|
pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
|
|
let session = DEFAULT_SESSION;
|
|
let socket_path = default_socket_path(session);
|
|
let npx = resolve_npx(node_path);
|
|
|
|
// 1. Already running?
|
|
if check_daemon_socket(session).await {
|
|
info!("Camoufox daemon already running on socket: {}", socket_path.display());
|
|
return BrowserInitResult::Ready(BrowserClient::new().with_session(session));
|
|
}
|
|
|
|
info!("Camoufox daemon not running, starting up...");
|
|
|
|
// 2. Download browser binary if missing (using PINNED camoufox-js)
|
|
if !is_browser_installed() {
|
|
info!("Downloading Camoufox browser using camoufox-js@{}...", CAMOUFOX_JS_VERSION);
|
|
match run_fetch(npx, CAMOUFOX_JS_VERSION).await {
|
|
Ok(msg) => {
|
|
info!("Camoufox browser fetch: {}", msg);
|
|
// On macOS, remove Gatekeeper quarantine immediately after download
|
|
remove_macos_quarantine().await;
|
|
}
|
|
Err(e) => {
|
|
return BrowserInitResult::Unavailable(format!(
|
|
"Failed to download Camoufox browser: {}\n\n\
|
|
Try manually: {} camoufox-js@{} fetch",
|
|
e, npx, CAMOUFOX_JS_VERSION
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// On macOS, always try to remove Gatekeeper quarantine from cached binary
|
|
// (even if it was already installed — prior npx downloads may have set it)
|
|
remove_macos_quarantine().await;
|
|
|
|
// 3. Ensure the `camoufox-browser` npm package is globally installed for daemon
|
|
if !is_npm_package_installed(npx, "camoufox-browser", CAMOUFOX_BROWSER_VERSION).await {
|
|
info!("Installing camoufox-browser@{} (daemon)...", CAMOUFOX_BROWSER_VERSION);
|
|
match install_npm_package(npx, "camoufox-browser", CAMOUFOX_BROWSER_VERSION).await {
|
|
Ok(_) => info!("camoufox-browser@{} installed", CAMOUFOX_BROWSER_VERSION),
|
|
Err(e) => {
|
|
return BrowserInitResult::Unavailable(format!(
|
|
"Failed to install camoufox-browser: {}\n\n\
|
|
Try manually: npm install -g camoufox-browser@{}",
|
|
e, CAMOUFOX_BROWSER_VERSION
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Trigger daemon start
|
|
match trigger_daemon_start(npx, session).await {
|
|
Ok(()) => {
|
|
info!("Waiting for daemon socket...");
|
|
match wait_for_socket(session).await {
|
|
Ok(true) => {
|
|
info!("Camoufox daemon ready");
|
|
tokio::time::sleep(Duration::from_millis(1000)).await;
|
|
BrowserInitResult::Ready(BrowserClient::new().with_session(session))
|
|
}
|
|
Ok(false) => {
|
|
let msg = format!(
|
|
"Daemon did not start within {}s.\n\n\
|
|
Possible causes:\n \
|
|
1. Missing Linux system libraries (WSL/Ubuntu):\n \
|
|
sudo apt-get update && sudo apt-get install -y \\\n \
|
|
libgtk-3-0 libnss3 libnspr4 libatk1.0-0t64 \\\n \
|
|
libatk-bridge2.0-0t64 libcups2t64 libdrm2 \\\n \
|
|
libxkbcommon0 libxcomposite1 libxdamage1 \\\n \
|
|
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \\\n \
|
|
libcairo2 libasound2t64 libxcb-shm0\n \
|
|
2. Try debugging: {} camoufox-browser@{} tab_list --session default\n \
|
|
Log: {}",
|
|
DAEMON_STARTUP_TIMEOUT_SECS,
|
|
npx, CAMOUFOX_BROWSER_VERSION, socket_path.display()
|
|
);
|
|
warn!("{}", msg);
|
|
BrowserInitResult::Unavailable(msg)
|
|
}
|
|
Err(e) => BrowserInitResult::Unavailable(format!("Daemon error: {}", e)),
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let mut msg = format!(
|
|
"Camoufox daemon failed to start.\n\n{}\n\n",
|
|
e
|
|
);
|
|
if let Some(hint) = check_known_errors(&msg) {
|
|
msg.push_str(hint);
|
|
msg.push_str(&format!(
|
|
"\n\nTry: rm -rf ~/Library/Caches/camoufox && {} gyandoot run",
|
|
npx
|
|
));
|
|
} else {
|
|
msg.push_str(&format!(
|
|
"Debug: {} camoufox-browser@{} tab_list --session default",
|
|
npx, CAMOUFOX_BROWSER_VERSION
|
|
));
|
|
}
|
|
warn!("{}", msg);
|
|
BrowserInitResult::Unavailable(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch the browser binary using pinned camoufox-js.
|
|
async fn run_fetch(npx: &str, js_version: &str) -> Result<String, String> {
|
|
let cache = browser_cache_dir();
|
|
|
|
// If cache dir exists but is incomplete, nuke it first
|
|
// (partial downloads from prior aborted runs can confuse camoufox-js)
|
|
if cache.exists() && !cache.join("version.json").exists() {
|
|
info!("Removing incomplete cache at {}", cache.display());
|
|
let _ = std::fs::remove_dir_all(&cache);
|
|
info!("Will re-download fresh");
|
|
}
|
|
|
|
let output = Command::new(npx)
|
|
.args(&["--yes", &format!("camoufox-js@{js_version}"), "fetch"])
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("'{npx} camoufox-js' not found: {e}"))?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
|
|
info!("camoufox-js fetch stdout: {}", stdout.lines().last().unwrap_or("(empty)"));
|
|
if !stderr.is_empty() {
|
|
info!("camoufox-js fetch stderr: {}", stderr.lines().last().unwrap_or("(empty)"));
|
|
}
|
|
|
|
if !output.status.success() {
|
|
let detail = if !stderr.is_empty() {
|
|
stderr.lines().filter(|l| l.contains("Error") || l.contains("error")).last().unwrap_or("(check output)")
|
|
} else {
|
|
stdout.lines().last().unwrap_or("(unknown)")
|
|
};
|
|
return Err(format!("exit {}: {}", output.status.code().unwrap_or(-1), detail));
|
|
}
|
|
|
|
// Verify the download actually produced version.json
|
|
if !cache.join("version.json").exists() {
|
|
warn!("camoufox-js exited 0 but version.json not found at {}", cache.display());
|
|
warn!("This usually means the GitHub release download failed. Retrying once...");
|
|
|
|
// Retry once with full output visible
|
|
let retry_output = Command::new(npx)
|
|
.args(&["--yes", &format!("camoufox-js@{js_version}"), "fetch"])
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Retry failed: {e}"))?;
|
|
|
|
let retry_stdout = String::from_utf8_lossy(&retry_output.stdout).trim().to_string();
|
|
let retry_stderr = String::from_utf8_lossy(&retry_output.stderr).trim().to_string();
|
|
|
|
info!("Retry stdout: {}", retry_stdout.lines().last().unwrap_or("(empty)"));
|
|
if !retry_stderr.is_empty() {
|
|
info!("Retry stderr: {}", retry_stderr.lines().last().unwrap_or("(empty)"));
|
|
}
|
|
|
|
if !retry_output.status.success() || !cache.join("version.json").exists() {
|
|
return Err(format!(
|
|
"Download failed after retry.\n stdout: {}\n stderr: {}\n cache: {}\n\nThis is likely a network/GitHub rate-limit issue.\nTry manually:\n export GITHUB_TOKEN=your_token\n {} --yes camoufox-js@{js_version} fetch",
|
|
retry_stdout.lines().last().unwrap_or("(empty)"),
|
|
retry_stderr.lines().last().unwrap_or("(empty)"),
|
|
cache.display(),
|
|
npx,
|
|
));
|
|
}
|
|
}
|
|
|
|
// Verify the binary exists too
|
|
let binary_ok = if cfg!(target_os = "macos") {
|
|
cache.join("Camoufox.app").join("Contents").join("MacOS").join("camoufox").exists()
|
|
} else {
|
|
cache.join("camoufox").exists()
|
|
};
|
|
|
|
if !binary_ok {
|
|
warn!("version.json exists but browser binary missing — possibly corrupted download");
|
|
}
|
|
|
|
Ok(stdout.lines()
|
|
.chain(stderr.lines())
|
|
.filter(|l| l.contains("complete") || l.contains("Installed") || l.contains("Downloaded") || l.contains("Camoufox"))
|
|
.last()
|
|
.unwrap_or("Fetch completed")
|
|
.to_string())
|
|
}
|
|
|
|
/// Check if an npm package is already installed globally.
|
|
async fn is_npm_package_installed(npx: &str, package: &str, version: &str) -> bool {
|
|
Command::new(npx)
|
|
.args(&["--yes", &format!("{package}@{version}"), "--version"])
|
|
.output()
|
|
.await
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Install an npm package globally.
|
|
async fn install_npm_package(_npx: &str, package: &str, version: &str) -> Result<String, String> {
|
|
let output = Command::new("npm")
|
|
.args(&["install", "-g", &format!("{package}@{version}")])
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("npm not found: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
return Err(stderr.lines().last().unwrap_or("(unknown)").to_string());
|
|
}
|
|
|
|
Ok(format!("{package}@{version} installed"))
|
|
}
|
|
|
|
/// Trigger daemon startup via `npx camoufox-browser@<pinned> tab_list`.
|
|
async fn trigger_daemon_start(npx: &str, session: &str) -> Result<(), String> {
|
|
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
|
|
let log_dir = home.join(".gyandoot");
|
|
std::fs::create_dir_all(&log_dir)
|
|
.map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?;
|
|
let log_file = log_dir.join("camoufox-daemon-spawn.log");
|
|
|
|
let output = Command::new(npx)
|
|
.args(&[
|
|
"--yes",
|
|
&format!("camoufox-browser@{}", CAMOUFOX_BROWSER_VERSION),
|
|
"tab_list",
|
|
"--session", session,
|
|
])
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("{} not found: {}", npx, e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
let details = if !stderr.is_empty() { stderr } else { stdout };
|
|
|
|
let _ = std::fs::write(
|
|
&log_file,
|
|
format!(
|
|
"exit_code: {}\nstderr:\n{}\nstdout:\n{}\n",
|
|
output.status.code().unwrap_or(-1),
|
|
String::from_utf8_lossy(&output.stderr),
|
|
String::from_utf8_lossy(&output.stdout),
|
|
),
|
|
);
|
|
|
|
return Err(format!("{}\n Log: {}", details, log_file.display()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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)
|
|
} |