//! 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 { let output = Command::new(npx) .args(&["--yes", &format!("camoufox-js@{}", js_version), "fetch"]) .output() .await .map_err(|e| format!("'{} camoufox-js' not found: {}", npx, e))?; let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); 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)); } Ok(stdout.lines().filter(|l| l.contains("complete") || l.contains("Installed") || l.contains("Downloaded")) .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 { 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@ 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 { 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) }