feat: embed daemon JS in binary, eliminate npm install of camoufox-browser

The daemon JS files (daemon.js, server.js, browser.js, protocol.js,
snapshot.js, ipc.js) are now EMBEDDED in the compiled Rust binary via
include_dir! and extracted to ~/.gyandoot/daemon/ on first run.

This eliminates the need to npm install -g camoufox-browser@0.1.0,
which bundles an incompatible camoufox-js@0.9.3 that caused:
- 'Cannot open database' errors
- Re-downloading 298MB browser on every navigation
- Circular install/remove/install loops

Now the daemon imports camoufox-js from the global npm install (v0.11.0)
which is installed by install.sh. Clean, no bundled deps, no conflicts.

Also removed: is_npm_package_installed(), install_npm_package(),
find_daemon_js() — all replaced by gyandoot_browser::daemon::extract_daemon()
This commit is contained in:
2026-06-13 23:22:15 +05:30
parent 57308e7805
commit d5cab65099
12 changed files with 2063 additions and 197 deletions

View File

@@ -1,14 +1,12 @@
//! 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.
//! The daemon JS files (daemon.js, server.js, browser.js, protocol.js, snapshot.js, ipc.js)
//! are EMBEDDED in the compiled binary at compile time via `include_dir!`.
//! They are extracted to `~/.gyandoot/daemon/` on first run.
//!
//! 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.
//! This eliminates the need to `npm install -g camoufox-browser@0.1.0`, which bundles
//! an incompatible `camoufox-js@0.9.3`. The daemon imports `camoufox-js` from the
//! global npm install (v0.11.0, installed by install.sh).
use std::process::Stdio;
use std::time::Duration;
@@ -26,10 +24,6 @@ use gyandoot_browser::{check_daemon_socket, default_socket_path, BrowserClient};
/// 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";
@@ -132,8 +126,8 @@ async fn remove_macos_quarantine() {
///
/// 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`
/// 2. Extract embedded daemon JS files to `~/.gyandoot/daemon/`
/// 3. Start daemon via `node ~/.gyandoot/daemon/daemon.js`
/// 4. Connect to Unix socket
pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
let session = DEFAULT_SESSION;
@@ -171,23 +165,20 @@ pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
// (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
));
}
// 3. Extract embedded daemon files to ~/.gyandoot/daemon/
let daemon_path = match gyandoot_browser::daemon::extract_daemon() {
Ok(path) => path,
Err(e) => {
return BrowserInitResult::Unavailable(format!(
"Failed to extract daemon files: {}\n\n\
Make sure Node.js is installed and ~/.gyandoot/ is writable.",
e
));
}
}
};
// 4. Trigger daemon start
match trigger_daemon_start(npx, session).await {
match trigger_daemon_start(&daemon_path, session).await {
Ok(()) => {
info!("Waiting for daemon socket...");
match wait_for_socket(session).await {
@@ -207,10 +198,10 @@ pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
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 \
2. Check daemon stderr log: ~/.gyandoot/camoufox-daemon-stderr.log\n \
Log: {}",
DAEMON_STARTUP_TIMEOUT_SECS,
npx, CAMOUFOX_BROWSER_VERSION, socket_path.display()
socket_path.display()
);
warn!("{}", msg);
BrowserInitResult::Unavailable(msg)
@@ -230,10 +221,7 @@ pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
npx
));
} else {
msg.push_str(&format!(
"Debug: {} camoufox-browser@{} tab_list --session default",
npx, CAMOUFOX_BROWSER_VERSION
));
msg.push_str("Check daemon stderr log: ~/.gyandoot/camoufox-daemon-stderr.log");
}
warn!("{}", msg);
BrowserInitResult::Unavailable(msg)
@@ -326,95 +314,15 @@ async fn run_fetch(npx: &str, js_version: &str) -> Result<String, String> {
.to_string())
}
/// Check if an npm package is already installed globally by checking for its daemon.js on disk.
/// We do NOT run `npx --version` because that actually loads the package code,
/// which fails if we've removed the bundled camoufox-js dependency.
async fn is_npm_package_installed(_npx: &str, package: &str, _version: &str) -> bool {
// Try `npm root -g` to find global node_modules
let root_output = Command::new("npm")
.args(&["root", "-g"])
.output()
.await;
let global_root = match root_output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => return false,
};
let daemon_path = std::path::Path::new(&global_root)
.join(package)
.join("dist")
.join("daemon.js");
if daemon_path.exists() {
return true;
}
// Fallback: check common locations
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let fallbacks = vec![
home.join(".nvm").join("versions").join("node").join("v22").join("lib").join("node_modules")
.join(package).join("dist").join("daemon.js"),
home.join(".nvm").join("versions").join("node").join("v24").join("lib").join("node_modules")
.join(package).join("dist").join("daemon.js"),
std::path::PathBuf::from("/usr/local/lib/node_modules").join(package).join("dist").join("daemon.js"),
std::path::PathBuf::from("/opt/homebrew/lib/node_modules").join(package).join("dist").join("daemon.js"),
];
for fb in &fallbacks {
if fb.exists() {
return true;
}
}
false
}
/// Install an npm package globally, then remove the bundled camoufox-js
/// so the daemon uses the globally installed version instead.
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());
}
// After install, remove the bundled camoufox-js so Node resolves to the global version
let root_output = Command::new("npm")
.args(&["root", "-g"])
.output()
.await;
if let Ok(o) = root_output {
if o.status.success() {
let global_root = String::from_utf8_lossy(&o.stdout).trim().to_string();
let bundled = std::path::Path::new(&global_root)
.join(package)
.join("node_modules")
.join("camoufox-js");
if bundled.exists() {
let _ = std::fs::remove_dir_all(&bundled);
info!("Removed bundled camoufox-js from {} (daemon will use global version)", package);
}
}
}
Ok(format!("{package}@{version} installed"))
}
/// Trigger daemon startup by spawning the daemon.js directly with node.
/// This is better than going through `npx camoufox-browser tab_list` because:
/// 1. The npm CLI wrapper spawns the daemon with stdio: "ignore" (hides errors)
/// 2. We capture stderr directly to diagnose crashes
/// 3. We can wait longer than the 5s default in daemon-client.js
async fn trigger_daemon_start(npx: &str, session: &str) -> Result<(), String> {
/// Trigger daemon startup by spawning the embedded daemon.js with node.
/// The daemon files are extracted to `~/.gyandoot/daemon/` at compile time.
async fn trigger_daemon_start(daemon_path: &std::path::Path, 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");
// Find the daemon.js path in the globally installed camoufox-browser package
let daemon_path = find_daemon_js(npx).await?;
info!("Starting daemon: node {} (session={})", daemon_path.display(), session);
// Spawn the daemon in the background — it's a long-running server
@@ -454,62 +362,6 @@ async fn trigger_daemon_start(npx: &str, session: &str) -> Result<(), String> {
Ok(())
}
/// Find the daemon.js entrypoint in the globally installed camoufox-browser package.
/// Tries: npm root -g → find dist/daemon.js
async fn find_daemon_js(_npx: &str) -> Result<std::path::PathBuf, String> {
// Try `npm root -g` to find global node_modules
let root_output = Command::new("npm")
.args(&["root", "-g"])
.output()
.await
.map_err(|e| format!("Cannot find npm global root: {}", e))?;
if !root_output.status.success() {
return Err("npm root -g failed".to_string());
}
let global_root = String::from_utf8_lossy(&root_output.stdout).trim().to_string();
let daemon_path = std::path::Path::new(&global_root)
.join("camoufox-browser")
.join("dist")
.join("daemon.js");
if daemon_path.exists() {
info!("Found daemon at {}", daemon_path.display());
return Ok(daemon_path);
}
// Fallback: try common locations
let fallbacks = vec![
home_dir().join(".nvm").join("versions").join("node").join("v22").join("lib").join("node_modules")
.join("camoufox-browser").join("dist").join("daemon.js"),
home_dir().join(".nvm").join("versions").join("node").join("v24").join("lib").join("node_modules")
.join("camoufox-browser").join("dist").join("daemon.js"),
std::path::PathBuf::from("/usr/local/lib/node_modules/camoufox-browser/dist/daemon.js"),
std::path::PathBuf::from("/opt/homebrew/lib/node_modules/camoufox-browser/dist/daemon.js"),
];
for fb in &fallbacks {
if fb.exists() {
info!("Found daemon at {} (fallback)", fb.display());
return Ok(fb.clone());
}
}
Err(format!(
"camoufox-browser daemon.js not found.\n\
Tried: {}\n\
And fallback paths.\n\
Run: npm install -g camoufox-browser@{}",
daemon_path.display(),
CAMOUFOX_BROWSER_VERSION
))
}
fn home_dir() -> std::path::PathBuf {
dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
}
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 {
@@ -519,4 +371,4 @@ async fn wait_for_socket(session: &str) -> Result<bool, String> {
tokio::time::sleep(Duration::from_millis(DAEMON_POLL_MS)).await;
}
Ok(false)
}
}