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:
20
Cargo.lock
generated
20
Cargo.lock
generated
@@ -701,6 +701,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"gyandoot-core",
|
"gyandoot-core",
|
||||||
|
"include_dir",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1207,6 +1208,25 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "include_dir"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
||||||
|
dependencies = [
|
||||||
|
"include_dir_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "include_dir_macros"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
//! BrowserManager — Start, connect, and manage lifecycle of the camoufox-browser daemon.
|
//! 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,
|
//! The daemon JS files (daemon.js, server.js, browser.js, protocol.js, snapshot.js, ipc.js)
|
||||||
//! and `camoufox-browser@0.1.0` (pinned) for the daemon Unix socket protocol.
|
//! are EMBEDDED in the compiled binary at compile time via `include_dir!`.
|
||||||
|
//! They are extracted to `~/.gyandoot/daemon/` on first run.
|
||||||
//!
|
//!
|
||||||
//! Pinning rationale:
|
//! This eliminates the need to `npm install -g camoufox-browser@0.1.0`, which bundles
|
||||||
//! - `camoufox-browser@0.1.0` bundles `camoufox-js@0.9.3` which downloads BROKEN
|
//! an incompatible `camoufox-js@0.9.3`. The daemon imports `camoufox-js` from the
|
||||||
//! alpha macOS builds (v150.0.2-alpha.26, missing libmozglue.dylib)
|
//! global npm install (v0.11.0, installed by install.sh).
|
||||||
//! - `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::process::Stdio;
|
||||||
use std::time::Duration;
|
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.
|
/// v0.11.0 (Jun 4 2026) — latest stable, downloads correct macOS arm64/x64 builds.
|
||||||
const CAMOUFOX_JS_VERSION: &str = "0.11.0";
|
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.
|
/// Default session name.
|
||||||
const DEFAULT_SESSION: &str = "default";
|
const DEFAULT_SESSION: &str = "default";
|
||||||
|
|
||||||
@@ -132,8 +126,8 @@ async fn remove_macos_quarantine() {
|
|||||||
///
|
///
|
||||||
/// Steps:
|
/// Steps:
|
||||||
/// 1. Ensure browser binary is downloaded using pinned `camoufox-js@0.11.0`
|
/// 1. Ensure browser binary is downloaded using pinned `camoufox-js@0.11.0`
|
||||||
/// 2. Ensure npm package is available for daemon
|
/// 2. Extract embedded daemon JS files to `~/.gyandoot/daemon/`
|
||||||
/// 3. Start daemon via pinned `camoufox-browser@0.1.0`
|
/// 3. Start daemon via `node ~/.gyandoot/daemon/daemon.js`
|
||||||
/// 4. Connect to Unix socket
|
/// 4. Connect to Unix socket
|
||||||
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;
|
||||||
@@ -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)
|
// (even if it was already installed — prior npx downloads may have set it)
|
||||||
remove_macos_quarantine().await;
|
remove_macos_quarantine().await;
|
||||||
|
|
||||||
// 3. Ensure the `camoufox-browser` npm package is globally installed for daemon
|
// 3. Extract embedded daemon files to ~/.gyandoot/daemon/
|
||||||
if !is_npm_package_installed(npx, "camoufox-browser", CAMOUFOX_BROWSER_VERSION).await {
|
let daemon_path = match gyandoot_browser::daemon::extract_daemon() {
|
||||||
info!("Installing camoufox-browser@{} (daemon)...", CAMOUFOX_BROWSER_VERSION);
|
Ok(path) => path,
|
||||||
match install_npm_package(npx, "camoufox-browser", CAMOUFOX_BROWSER_VERSION).await {
|
Err(e) => {
|
||||||
Ok(_) => info!("camoufox-browser@{} installed", CAMOUFOX_BROWSER_VERSION),
|
return BrowserInitResult::Unavailable(format!(
|
||||||
Err(e) => {
|
"Failed to extract daemon files: {}\n\n\
|
||||||
return BrowserInitResult::Unavailable(format!(
|
Make sure Node.js is installed and ~/.gyandoot/ is writable.",
|
||||||
"Failed to install camoufox-browser: {}\n\n\
|
e
|
||||||
Try manually: npm install -g camoufox-browser@{}",
|
));
|
||||||
e, CAMOUFOX_BROWSER_VERSION
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// 4. Trigger daemon start
|
// 4. Trigger daemon start
|
||||||
match trigger_daemon_start(npx, session).await {
|
match trigger_daemon_start(&daemon_path, session).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
info!("Waiting for daemon socket...");
|
info!("Waiting for daemon socket...");
|
||||||
match wait_for_socket(session).await {
|
match wait_for_socket(session).await {
|
||||||
@@ -207,10 +198,10 @@ pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
|
|||||||
libxkbcommon0 libxcomposite1 libxdamage1 \\\n \
|
libxkbcommon0 libxcomposite1 libxdamage1 \\\n \
|
||||||
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \\\n \
|
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \\\n \
|
||||||
libcairo2 libasound2t64 libxcb-shm0\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: {}",
|
Log: {}",
|
||||||
DAEMON_STARTUP_TIMEOUT_SECS,
|
DAEMON_STARTUP_TIMEOUT_SECS,
|
||||||
npx, CAMOUFOX_BROWSER_VERSION, socket_path.display()
|
socket_path.display()
|
||||||
);
|
);
|
||||||
warn!("{}", msg);
|
warn!("{}", msg);
|
||||||
BrowserInitResult::Unavailable(msg)
|
BrowserInitResult::Unavailable(msg)
|
||||||
@@ -230,10 +221,7 @@ pub async fn init_browser(node_path: Option<&str>) -> BrowserInitResult {
|
|||||||
npx
|
npx
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
msg.push_str(&format!(
|
msg.push_str("Check daemon stderr log: ~/.gyandoot/camoufox-daemon-stderr.log");
|
||||||
"Debug: {} camoufox-browser@{} tab_list --session default",
|
|
||||||
npx, CAMOUFOX_BROWSER_VERSION
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
warn!("{}", msg);
|
warn!("{}", msg);
|
||||||
BrowserInitResult::Unavailable(msg)
|
BrowserInitResult::Unavailable(msg)
|
||||||
@@ -326,95 +314,15 @@ async fn run_fetch(npx: &str, js_version: &str) -> Result<String, String> {
|
|||||||
.to_string())
|
.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an npm package is already installed globally by checking for its daemon.js on disk.
|
/// Trigger daemon startup by spawning the embedded daemon.js with node.
|
||||||
/// We do NOT run `npx --version` because that actually loads the package code,
|
/// The daemon files are extracted to `~/.gyandoot/daemon/` at compile time.
|
||||||
/// which fails if we've removed the bundled camoufox-js dependency.
|
async fn trigger_daemon_start(daemon_path: &std::path::Path, session: &str) -> Result<(), String> {
|
||||||
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> {
|
|
||||||
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");
|
||||||
std::fs::create_dir_all(&log_dir)
|
std::fs::create_dir_all(&log_dir)
|
||||||
.map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?;
|
.map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?;
|
||||||
let log_file = log_dir.join("camoufox-daemon-spawn.log");
|
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);
|
info!("Starting daemon: node {} (session={})", daemon_path.display(), session);
|
||||||
|
|
||||||
// Spawn the daemon in the background — it's a long-running server
|
// 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(())
|
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> {
|
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 {
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ uuid.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
reqwest = { workspace = true, default-features = false, features = ["json"] }
|
reqwest = { workspace = true, default-features = false, features = ["json"] }
|
||||||
|
include_dir = "0.7"
|
||||||
383
crates/gyandoot-browser/daemon/browser.js
Normal file
383
crates/gyandoot-browser/daemon/browser.js
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import * as os from "node:os";
|
||||||
|
import { getEnhancedSnapshot, parseRef } from "./snapshot.js";
|
||||||
|
function expandTilde(p) {
|
||||||
|
if (!p.startsWith("~/"))
|
||||||
|
return p;
|
||||||
|
return `${os.homedir()}/${p.slice(2)}`;
|
||||||
|
}
|
||||||
|
export class BrowserManager {
|
||||||
|
browser = null;
|
||||||
|
context = null;
|
||||||
|
pages = [];
|
||||||
|
activePageIndex = 0;
|
||||||
|
activeFrame = null;
|
||||||
|
isPersistentContext = false;
|
||||||
|
launchConfig = null;
|
||||||
|
dialogHandler = null;
|
||||||
|
dialogHandlerPage = null;
|
||||||
|
consoleMessages = [];
|
||||||
|
pageErrors = [];
|
||||||
|
refMap = {};
|
||||||
|
lastSnapshot = "";
|
||||||
|
isLaunched() {
|
||||||
|
return this.browser !== null || this.isPersistentContext;
|
||||||
|
}
|
||||||
|
hasPages() {
|
||||||
|
return this.pages.length > 0;
|
||||||
|
}
|
||||||
|
async ensurePage() {
|
||||||
|
if (!this.context)
|
||||||
|
throw new Error("Browser not launched. Call open first.");
|
||||||
|
if (this.pages.length > 0)
|
||||||
|
return;
|
||||||
|
const page = this.context.pages()[0] || (await this.context.newPage());
|
||||||
|
this.trackPage(page);
|
||||||
|
this.activePageIndex = this.pages.indexOf(page);
|
||||||
|
if (this.activePageIndex < 0)
|
||||||
|
this.activePageIndex = 0;
|
||||||
|
this.activeFrame = null;
|
||||||
|
await page.bringToFront().catch(() => { });
|
||||||
|
}
|
||||||
|
getPage() {
|
||||||
|
const page = this.pages[this.activePageIndex] || this.pages[0];
|
||||||
|
if (!page) {
|
||||||
|
throw new Error("Browser not launched. Call open first.");
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
getFrame() {
|
||||||
|
if (this.activeFrame)
|
||||||
|
return this.activeFrame;
|
||||||
|
return this.getPage().mainFrame();
|
||||||
|
}
|
||||||
|
getContext() {
|
||||||
|
if (!this.context) {
|
||||||
|
throw new Error("Browser not launched. Call open first.");
|
||||||
|
}
|
||||||
|
return this.context;
|
||||||
|
}
|
||||||
|
getActiveIndex() {
|
||||||
|
return this.activePageIndex;
|
||||||
|
}
|
||||||
|
getConsoleMessages() {
|
||||||
|
return this.consoleMessages;
|
||||||
|
}
|
||||||
|
clearConsoleMessages() {
|
||||||
|
this.consoleMessages = [];
|
||||||
|
}
|
||||||
|
getPageErrors() {
|
||||||
|
return this.pageErrors;
|
||||||
|
}
|
||||||
|
clearPageErrors() {
|
||||||
|
this.pageErrors = [];
|
||||||
|
}
|
||||||
|
getLastSnapshot() {
|
||||||
|
return this.lastSnapshot;
|
||||||
|
}
|
||||||
|
getRefMap() {
|
||||||
|
return this.refMap;
|
||||||
|
}
|
||||||
|
getLaunchConfig() {
|
||||||
|
return this.launchConfig;
|
||||||
|
}
|
||||||
|
trackPage(page) {
|
||||||
|
if (this.pages.includes(page))
|
||||||
|
return;
|
||||||
|
this.pages.push(page);
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
this.consoleMessages.push({
|
||||||
|
type: msg.type(),
|
||||||
|
text: msg.text(),
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
page.on("pageerror", (error) => {
|
||||||
|
this.pageErrors.push({ message: error.message, timestamp: Date.now() });
|
||||||
|
});
|
||||||
|
page.on("close", () => {
|
||||||
|
const idx = this.pages.indexOf(page);
|
||||||
|
if (idx < 0)
|
||||||
|
return;
|
||||||
|
if (idx === this.activePageIndex) {
|
||||||
|
this.activeFrame = null;
|
||||||
|
}
|
||||||
|
this.pages.splice(idx, 1);
|
||||||
|
if (this.pages.length === 0) {
|
||||||
|
this.activePageIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.activePageIndex >= this.pages.length) {
|
||||||
|
this.activePageIndex = this.pages.length - 1;
|
||||||
|
}
|
||||||
|
else if (this.activePageIndex > idx) {
|
||||||
|
this.activePageIndex--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupContextTracking(context) {
|
||||||
|
context.on("page", (page) => {
|
||||||
|
this.trackPage(page);
|
||||||
|
const idx = this.pages.indexOf(page);
|
||||||
|
if (idx >= 0 && idx !== this.activePageIndex) {
|
||||||
|
this.activePageIndex = idx;
|
||||||
|
this.activeFrame = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async launch(config) {
|
||||||
|
if (this.isLaunched())
|
||||||
|
return;
|
||||||
|
this.launchConfig = config;
|
||||||
|
let Camoufox;
|
||||||
|
try {
|
||||||
|
({ Camoufox } = await import("camoufox-js"));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("better-sqlite3") && message.includes("Could not locate the bindings file")) {
|
||||||
|
throw new Error([
|
||||||
|
"better-sqlite3 native bindings are missing (required by camoufox-js).",
|
||||||
|
"If you installed dependencies with pnpm, allow build scripts and rebuild:",
|
||||||
|
" pnpm -C camoufox-browser install",
|
||||||
|
" pnpm -C camoufox-browser rebuild better-sqlite3",
|
||||||
|
"If you're using npm, reinstall should run the build scripts automatically."
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const headless = config.headless;
|
||||||
|
const executable_path = config.executablePath;
|
||||||
|
const args = config.args;
|
||||||
|
const proxy = config.proxy;
|
||||||
|
if (config.profile) {
|
||||||
|
const user_data_dir = expandTilde(config.profile);
|
||||||
|
const ctx = (await Camoufox({
|
||||||
|
headless,
|
||||||
|
user_data_dir,
|
||||||
|
...(executable_path ? { executable_path } : {}),
|
||||||
|
...(args ? { args } : {}),
|
||||||
|
...(proxy ? { proxy } : {}),
|
||||||
|
...(config.userAgent ? { userAgent: config.userAgent } : {}),
|
||||||
|
...(config.headers ? { extraHTTPHeaders: config.headers } : {}),
|
||||||
|
...(config.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {})
|
||||||
|
}));
|
||||||
|
this.context = ctx;
|
||||||
|
this.isPersistentContext = true;
|
||||||
|
this.setupContextTracking(ctx);
|
||||||
|
const initial = ctx.pages();
|
||||||
|
if (initial.length > 0) {
|
||||||
|
for (const p of initial)
|
||||||
|
this.trackPage(p);
|
||||||
|
this.activePageIndex = 0;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
this.trackPage(p);
|
||||||
|
this.activePageIndex = this.pages.indexOf(p);
|
||||||
|
}
|
||||||
|
await this.getPage().bringToFront().catch(() => { });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = (await Camoufox({
|
||||||
|
headless,
|
||||||
|
...(executable_path ? { executable_path } : {}),
|
||||||
|
...(args ? { args } : {}),
|
||||||
|
...(proxy ? { proxy } : {})
|
||||||
|
}));
|
||||||
|
this.browser = b;
|
||||||
|
this.context = await b.newContext({
|
||||||
|
...(config.userAgent ? { userAgent: config.userAgent } : {}),
|
||||||
|
...(config.headers ? { extraHTTPHeaders: config.headers } : {}),
|
||||||
|
...(config.storageState ? { storageState: config.storageState } : {}),
|
||||||
|
...(config.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {})
|
||||||
|
});
|
||||||
|
this.setupContextTracking(this.context);
|
||||||
|
const initial = this.context.pages();
|
||||||
|
if (initial.length > 0) {
|
||||||
|
for (const p of initial)
|
||||||
|
this.trackPage(p);
|
||||||
|
this.activePageIndex = 0;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const p = await this.context.newPage();
|
||||||
|
this.trackPage(p);
|
||||||
|
this.activePageIndex = this.pages.indexOf(p);
|
||||||
|
}
|
||||||
|
await this.getPage().bringToFront().catch(() => { });
|
||||||
|
}
|
||||||
|
async navigate(url, options) {
|
||||||
|
await this.ensurePage();
|
||||||
|
const page = this.getPage();
|
||||||
|
if (options?.headers && Object.keys(options.headers).length > 0) {
|
||||||
|
await page.setExtraHTTPHeaders(options.headers);
|
||||||
|
}
|
||||||
|
await page.goto(url, { waitUntil: options?.waitUntil ?? "load" });
|
||||||
|
return { url: page.url(), title: await page.title() };
|
||||||
|
}
|
||||||
|
async newTab() {
|
||||||
|
const ctx = this.getContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
this.trackPage(page);
|
||||||
|
this.activePageIndex = this.pages.indexOf(page);
|
||||||
|
if (this.activePageIndex < 0)
|
||||||
|
this.activePageIndex = Math.max(0, this.pages.length - 1);
|
||||||
|
this.activeFrame = null;
|
||||||
|
await page.bringToFront().catch(() => { });
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
async newTabManaged() {
|
||||||
|
await this.newTab();
|
||||||
|
return { index: this.activePageIndex, total: this.pages.length };
|
||||||
|
}
|
||||||
|
async switchTo(index) {
|
||||||
|
if (index < 0 || index >= this.pages.length) {
|
||||||
|
throw new Error(`Invalid tab index: ${index}. Available: 0-${Math.max(0, this.pages.length - 1)}`);
|
||||||
|
}
|
||||||
|
this.activePageIndex = index;
|
||||||
|
this.activeFrame = null;
|
||||||
|
const page = this.getPage();
|
||||||
|
await page.bringToFront().catch(() => { });
|
||||||
|
return { index: this.activePageIndex, url: page.url(), title: "" };
|
||||||
|
}
|
||||||
|
async closeTab(index) {
|
||||||
|
const targetIndex = index ?? this.activePageIndex;
|
||||||
|
if (targetIndex < 0 || targetIndex >= this.pages.length) {
|
||||||
|
throw new Error(`Invalid tab index: ${targetIndex}`);
|
||||||
|
}
|
||||||
|
if (this.pages.length === 1) {
|
||||||
|
throw new Error('Cannot close the last tab. Use "close" to close the browser.');
|
||||||
|
}
|
||||||
|
const page = this.pages[targetIndex];
|
||||||
|
await page.close();
|
||||||
|
const idx = this.pages.indexOf(page);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.pages.splice(idx, 1);
|
||||||
|
}
|
||||||
|
if (this.activePageIndex >= this.pages.length) {
|
||||||
|
this.activePageIndex = this.pages.length - 1;
|
||||||
|
}
|
||||||
|
else if (this.activePageIndex > targetIndex) {
|
||||||
|
this.activePageIndex--;
|
||||||
|
}
|
||||||
|
await this.getPage().bringToFront().catch(() => { });
|
||||||
|
return { closed: targetIndex, remaining: this.pages.length };
|
||||||
|
}
|
||||||
|
async listTabs() {
|
||||||
|
return await Promise.all(this.pages.map(async (page, index) => ({
|
||||||
|
index,
|
||||||
|
url: page.url(),
|
||||||
|
title: await page.title().catch(() => ""),
|
||||||
|
active: index === this.activePageIndex
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
async getSnapshot(options) {
|
||||||
|
const frame = this.getFrame();
|
||||||
|
const snapshot = await getEnhancedSnapshot(frame, options);
|
||||||
|
this.refMap = snapshot.refs;
|
||||||
|
this.lastSnapshot = snapshot.tree;
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
getLocatorFromRef(refArg) {
|
||||||
|
const ref = parseRef(refArg);
|
||||||
|
if (!ref)
|
||||||
|
return null;
|
||||||
|
const refData = this.refMap[ref];
|
||||||
|
if (!refData)
|
||||||
|
return null;
|
||||||
|
const frame = this.getFrame();
|
||||||
|
if (refData.role === "clickable" || refData.role === "focusable") {
|
||||||
|
return frame.locator(refData.selector);
|
||||||
|
}
|
||||||
|
let locator = frame.getByRole(refData.role, {
|
||||||
|
name: refData.name,
|
||||||
|
exact: true
|
||||||
|
});
|
||||||
|
if (refData.nth !== undefined) {
|
||||||
|
locator = locator.nth(refData.nth);
|
||||||
|
}
|
||||||
|
return locator;
|
||||||
|
}
|
||||||
|
getLocator(selectorOrRef) {
|
||||||
|
const refLocator = this.getLocatorFromRef(selectorOrRef);
|
||||||
|
if (refLocator)
|
||||||
|
return refLocator;
|
||||||
|
return this.getFrame().locator(selectorOrRef);
|
||||||
|
}
|
||||||
|
switchToMainFrame() {
|
||||||
|
this.activeFrame = null;
|
||||||
|
}
|
||||||
|
async switchToFrame(options) {
|
||||||
|
const page = this.getPage();
|
||||||
|
if (options.selector) {
|
||||||
|
const frameElement = await page.$(options.selector);
|
||||||
|
if (!frameElement) {
|
||||||
|
throw new Error(`Frame not found: ${options.selector}`);
|
||||||
|
}
|
||||||
|
const frame = await frameElement.contentFrame();
|
||||||
|
if (!frame) {
|
||||||
|
throw new Error(`Element is not a frame: ${options.selector}`);
|
||||||
|
}
|
||||||
|
this.activeFrame = frame;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.name) {
|
||||||
|
const frame = page.frame({ name: options.name });
|
||||||
|
if (!frame)
|
||||||
|
throw new Error(`Frame not found with name: ${options.name}`);
|
||||||
|
this.activeFrame = frame;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.url) {
|
||||||
|
const frame = page.frame({ url: options.url });
|
||||||
|
if (!frame)
|
||||||
|
throw new Error(`Frame not found with URL: ${options.url}`);
|
||||||
|
this.activeFrame = frame;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Missing frame selector");
|
||||||
|
}
|
||||||
|
setDialogHandler(response, promptText) {
|
||||||
|
const page = this.getPage();
|
||||||
|
if (this.dialogHandler && this.dialogHandlerPage) {
|
||||||
|
this.dialogHandlerPage.removeListener("dialog", this.dialogHandler);
|
||||||
|
}
|
||||||
|
this.dialogHandler = async (dialog) => {
|
||||||
|
if (response === "accept") {
|
||||||
|
await dialog.accept(promptText);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await dialog.dismiss();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.dialogHandlerPage = page;
|
||||||
|
page.on("dialog", this.dialogHandler);
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
const browser = this.browser;
|
||||||
|
const context = this.context;
|
||||||
|
this.browser = null;
|
||||||
|
this.context = null;
|
||||||
|
this.pages = [];
|
||||||
|
this.activePageIndex = 0;
|
||||||
|
this.activeFrame = null;
|
||||||
|
this.isPersistentContext = false;
|
||||||
|
this.launchConfig = null;
|
||||||
|
this.dialogHandler = null;
|
||||||
|
this.dialogHandlerPage = null;
|
||||||
|
this.refMap = {};
|
||||||
|
this.lastSnapshot = "";
|
||||||
|
if (browser) {
|
||||||
|
try {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context) {
|
||||||
|
try {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/gyandoot-browser/daemon/daemon.js
Normal file
16
crates/gyandoot-browser/daemon/daemon.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Daemon entrypoint (spawned by CLI).
|
||||||
|
*
|
||||||
|
* Listens on a per-session IPC endpoint (Unix socket or Windows localhost TCP).
|
||||||
|
* The CLI communicates via JSON-line protocol.
|
||||||
|
*/
|
||||||
|
import { DaemonServer } from "./server.js";
|
||||||
|
async function main() {
|
||||||
|
const server = new DaemonServer();
|
||||||
|
await server.start();
|
||||||
|
}
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`[camoufox-browser] Fatal: ${err?.message ?? String(err)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
76
crates/gyandoot-browser/daemon/ipc.js
Normal file
76
crates/gyandoot-browser/daemon/ipc.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
export const isWindows = process.platform === "win32";
|
||||||
|
export function isValidSessionName(name) {
|
||||||
|
if (!name)
|
||||||
|
return false;
|
||||||
|
if (name.length > 64)
|
||||||
|
return false;
|
||||||
|
if (name.includes(".."))
|
||||||
|
return false;
|
||||||
|
return /^[a-zA-Z0-9._-]+$/.test(name);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get the base directory for socket/pid files.
|
||||||
|
* Priority: CAMOUFOX_BROWSER_SOCKET_DIR > AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.camoufox-browser > tmpdir
|
||||||
|
*/
|
||||||
|
export function getSocketDir() {
|
||||||
|
const explicit = process.env.CAMOUFOX_BROWSER_SOCKET_DIR || process.env.AGENT_BROWSER_SOCKET_DIR;
|
||||||
|
if (explicit && explicit.length > 0)
|
||||||
|
return explicit;
|
||||||
|
if (process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length > 0) {
|
||||||
|
return path.join(process.env.XDG_RUNTIME_DIR, "camoufox-browser");
|
||||||
|
}
|
||||||
|
const home = os.homedir();
|
||||||
|
if (home)
|
||||||
|
return path.join(home, ".camoufox-browser");
|
||||||
|
return path.join(os.tmpdir(), "camoufox-browser");
|
||||||
|
}
|
||||||
|
export function ensureSocketDir() {
|
||||||
|
const dir = getSocketDir();
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function getPidFile(session) {
|
||||||
|
return path.join(getSocketDir(), `${session}.pid`);
|
||||||
|
}
|
||||||
|
export function getUnixSocketPath(session) {
|
||||||
|
return path.join(getSocketDir(), `${session}.sock`);
|
||||||
|
}
|
||||||
|
export function getPortFile(session) {
|
||||||
|
return path.join(getSocketDir(), `${session}.port`);
|
||||||
|
}
|
||||||
|
export function cleanupIpcFiles(session) {
|
||||||
|
const pidFile = getPidFile(session);
|
||||||
|
const portFile = getPortFile(session);
|
||||||
|
const sockPath = getUnixSocketPath(session);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(pidFile))
|
||||||
|
fs.unlinkSync(pidFile);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(portFile))
|
||||||
|
fs.unlinkSync(portFile);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(sockPath))
|
||||||
|
fs.unlinkSync(sockPath);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get port number for TCP mode (Windows).
|
||||||
|
* Uses a hash of the session name to get a consistent port.
|
||||||
|
*/
|
||||||
|
export function getPortForSession(session) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < session.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + session.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return 49152 + (Math.abs(hash) % 16383);
|
||||||
|
}
|
||||||
46
crates/gyandoot-browser/daemon/protocol.js
Normal file
46
crates/gyandoot-browser/daemon/protocol.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
const commandSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
action: z.string()
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
export function parseCommand(input) {
|
||||||
|
let json;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(input);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return { success: false, error: "Invalid JSON" };
|
||||||
|
}
|
||||||
|
const id = typeof json === "object" && json !== null && "id" in json
|
||||||
|
? String(json.id)
|
||||||
|
: undefined;
|
||||||
|
const result = commandSchema.safeParse(json);
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
|
||||||
|
return { success: false, error: `Validation error: ${errors}`, id };
|
||||||
|
}
|
||||||
|
return { success: true, command: result.data };
|
||||||
|
}
|
||||||
|
export function successResponse(id, data) {
|
||||||
|
return { id, success: true, data };
|
||||||
|
}
|
||||||
|
export function errorResponse(id, error) {
|
||||||
|
return { id, success: false, error };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Serialize a response to JSON string.
|
||||||
|
* Replaces lone Unicode surrogates with U+FFFD to avoid serde_json parse errors.
|
||||||
|
*/
|
||||||
|
function sanitizeUtf16(input) {
|
||||||
|
// Fast path: most strings contain no surrogate code units.
|
||||||
|
if (!/[\uD800-\uDFFF]/.test(input))
|
||||||
|
return input;
|
||||||
|
// Replace lone high/low surrogates. Valid surrogate pairs are preserved.
|
||||||
|
// Ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toWellFormed
|
||||||
|
return input.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
|
||||||
|
}
|
||||||
|
export function serializeResponse(response) {
|
||||||
|
return JSON.stringify(response, (_key, value) => typeof value === "string" ? sanitizeUtf16(value) : value);
|
||||||
|
}
|
||||||
1027
crates/gyandoot-browser/daemon/server.js
Normal file
1027
crates/gyandoot-browser/daemon/server.js
Normal file
File diff suppressed because it is too large
Load Diff
385
crates/gyandoot-browser/daemon/snapshot.js
Normal file
385
crates/gyandoot-browser/daemon/snapshot.js
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* Enhanced snapshot with element refs for deterministic element selection.
|
||||||
|
*
|
||||||
|
* This is largely compatible with agent-browser's snapshot/ref workflow:
|
||||||
|
* - `snapshot -i` returns a tree with `[ref=e1]` markers.
|
||||||
|
* - Subsequent actions can target `@e1` deterministically.
|
||||||
|
*/
|
||||||
|
let refCounter = 0;
|
||||||
|
export function resetRefs() {
|
||||||
|
refCounter = 0;
|
||||||
|
}
|
||||||
|
function nextRef() {
|
||||||
|
return `e${++refCounter}`;
|
||||||
|
}
|
||||||
|
const INTERACTIVE_ROLES = new Set([
|
||||||
|
"button",
|
||||||
|
"link",
|
||||||
|
"textbox",
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
"combobox",
|
||||||
|
"listbox",
|
||||||
|
"menuitem",
|
||||||
|
"menuitemcheckbox",
|
||||||
|
"menuitemradio",
|
||||||
|
"option",
|
||||||
|
"searchbox",
|
||||||
|
"slider",
|
||||||
|
"spinbutton",
|
||||||
|
"switch",
|
||||||
|
"tab",
|
||||||
|
"treeitem"
|
||||||
|
]);
|
||||||
|
const CONTENT_ROLES = new Set([
|
||||||
|
"heading",
|
||||||
|
"cell",
|
||||||
|
"gridcell",
|
||||||
|
"columnheader",
|
||||||
|
"rowheader",
|
||||||
|
"listitem",
|
||||||
|
"article",
|
||||||
|
"region",
|
||||||
|
"main",
|
||||||
|
"navigation"
|
||||||
|
]);
|
||||||
|
const STRUCTURAL_ROLES = new Set([
|
||||||
|
"generic",
|
||||||
|
"group",
|
||||||
|
"list",
|
||||||
|
"table",
|
||||||
|
"row",
|
||||||
|
"rowgroup",
|
||||||
|
"grid",
|
||||||
|
"treegrid",
|
||||||
|
"menu",
|
||||||
|
"menubar",
|
||||||
|
"toolbar",
|
||||||
|
"tablist",
|
||||||
|
"tree",
|
||||||
|
"directory",
|
||||||
|
"document",
|
||||||
|
"application",
|
||||||
|
"presentation",
|
||||||
|
"none"
|
||||||
|
]);
|
||||||
|
function buildSelector(role, name) {
|
||||||
|
const escapedName = JSON.stringify(name);
|
||||||
|
return `getByRole('${role}', { name: ${escapedName}, exact: true })`;
|
||||||
|
}
|
||||||
|
async function findCursorInteractiveElements(page, selector) {
|
||||||
|
const rootSelector = selector || "body";
|
||||||
|
const scriptBody = `(rootSel) => {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
const interactiveRoles = new Set([
|
||||||
|
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox',
|
||||||
|
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'searchbox',
|
||||||
|
'slider', 'spinbutton', 'switch', 'tab', 'treeitem'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const interactiveTags = new Set([
|
||||||
|
'a', 'button', 'input', 'select', 'textarea', 'details', 'summary'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const root = document.querySelector(rootSel) || document.body;
|
||||||
|
const allElements = root.querySelectorAll('*');
|
||||||
|
|
||||||
|
const buildSelector = (el) => {
|
||||||
|
const testId = el.getAttribute('data-testid');
|
||||||
|
if (testId) return '[data-testid=\"' + testId + '\"]';
|
||||||
|
if (el.id) return '#' + CSS.escape(el.id);
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
let current = el;
|
||||||
|
while (current && current !== document.body) {
|
||||||
|
let sel = current.tagName.toLowerCase();
|
||||||
|
const classes = Array.from(current.classList).filter(c => c.trim());
|
||||||
|
if (classes.length > 0) sel += '.' + CSS.escape(classes[0]);
|
||||||
|
|
||||||
|
const parent = current.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
const siblings = Array.from(parent.children);
|
||||||
|
const matching = siblings.filter(s => {
|
||||||
|
if (s.tagName !== current.tagName) return false;
|
||||||
|
if (classes.length > 0 && !s.classList.contains(classes[0])) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (matching.length > 1) {
|
||||||
|
const idx = matching.indexOf(current) + 1;
|
||||||
|
sel += ':nth-of-type(' + idx + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.unshift(sel);
|
||||||
|
current = current.parentElement;
|
||||||
|
if (path.length >= 1) {
|
||||||
|
try {
|
||||||
|
const candidate = path.join(' > ');
|
||||||
|
if (document.querySelectorAll(candidate).length === 1) break;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (path.length >= 10) break;
|
||||||
|
}
|
||||||
|
return path.join(' > ');
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const el of allElements) {
|
||||||
|
const tagName = el.tagName.toLowerCase();
|
||||||
|
if (interactiveTags.has(tagName)) continue;
|
||||||
|
|
||||||
|
const role = el.getAttribute('role');
|
||||||
|
if (role && interactiveRoles.has(role.toLowerCase())) continue;
|
||||||
|
|
||||||
|
const computedStyle = getComputedStyle(el);
|
||||||
|
const hasCursorPointer = computedStyle.cursor === 'pointer';
|
||||||
|
const hasOnClick = el.hasAttribute('onclick') || el.onclick !== null;
|
||||||
|
const tabIndex = el.getAttribute('tabindex');
|
||||||
|
const hasTabIndex = tabIndex !== null && tabIndex !== '-1';
|
||||||
|
|
||||||
|
if (!hasCursorPointer && !hasOnClick && !hasTabIndex) continue;
|
||||||
|
|
||||||
|
if (hasCursorPointer && !hasOnClick && !hasTabIndex) {
|
||||||
|
const parent = el.parentElement;
|
||||||
|
if (parent && getComputedStyle(parent).cursor === 'pointer') continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (el.textContent || '').trim().slice(0, 100);
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 || rect.height === 0) continue;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
selector: buildSelector(el),
|
||||||
|
text,
|
||||||
|
tagName,
|
||||||
|
hasOnClick,
|
||||||
|
hasCursorPointer,
|
||||||
|
hasTabIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}`;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||||
|
const fn = new Function("return " + scriptBody)();
|
||||||
|
return page.evaluate(fn, rootSelector);
|
||||||
|
}
|
||||||
|
export async function getEnhancedSnapshot(page, options = {}) {
|
||||||
|
resetRefs();
|
||||||
|
const refs = {};
|
||||||
|
const locator = options.selector ? page.locator(options.selector) : page.locator(":root");
|
||||||
|
const ariaTree = await locator.ariaSnapshot();
|
||||||
|
if (!ariaTree) {
|
||||||
|
return { tree: "(empty)", refs: {} };
|
||||||
|
}
|
||||||
|
const enhancedTree = processAriaTree(ariaTree, refs, options);
|
||||||
|
if (options.cursor) {
|
||||||
|
const cursorElements = await findCursorInteractiveElements(page, options.selector);
|
||||||
|
const existingTexts = new Set(Object.values(refs).map((r) => r.name.toLowerCase()));
|
||||||
|
for (const m of enhancedTree.matchAll(/"([^"]+)"/g)) {
|
||||||
|
existingTexts.add(m[1].toLowerCase());
|
||||||
|
}
|
||||||
|
const additionalLines = [];
|
||||||
|
for (const el of cursorElements) {
|
||||||
|
const elTextLower = el.text.toLowerCase();
|
||||||
|
if (existingTexts.has(elTextLower))
|
||||||
|
continue;
|
||||||
|
existingTexts.add(elTextLower);
|
||||||
|
const ref = nextRef();
|
||||||
|
const role = el.hasCursorPointer || el.hasOnClick ? "clickable" : "focusable";
|
||||||
|
refs[ref] = { selector: el.selector, role, name: el.text };
|
||||||
|
const hints = [];
|
||||||
|
if (el.hasCursorPointer)
|
||||||
|
hints.push("cursor:pointer");
|
||||||
|
if (el.hasOnClick)
|
||||||
|
hints.push("onclick");
|
||||||
|
if (el.hasTabIndex)
|
||||||
|
hints.push("tabindex");
|
||||||
|
additionalLines.push(`- ${role} "${el.text}" [ref=${ref}] [${hints.join(", ")}]`);
|
||||||
|
}
|
||||||
|
if (additionalLines.length > 0) {
|
||||||
|
const separator = enhancedTree === "(no interactive elements)"
|
||||||
|
? ""
|
||||||
|
: "\n# Cursor-interactive elements:\n";
|
||||||
|
const base = enhancedTree === "(no interactive elements)" ? "" : enhancedTree;
|
||||||
|
return { tree: base + separator + additionalLines.join("\n"), refs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tree: enhancedTree, refs };
|
||||||
|
}
|
||||||
|
function createRoleNameTracker() {
|
||||||
|
const counts = new Map();
|
||||||
|
const refsByKey = new Map();
|
||||||
|
return {
|
||||||
|
counts,
|
||||||
|
refsByKey,
|
||||||
|
getKey(role, name) {
|
||||||
|
return `${role}:${name ?? ""}`;
|
||||||
|
},
|
||||||
|
getNextIndex(role, name) {
|
||||||
|
const key = this.getKey(role, name);
|
||||||
|
const current = counts.get(key) ?? 0;
|
||||||
|
counts.set(key, current + 1);
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
trackRef(role, name, ref) {
|
||||||
|
const key = this.getKey(role, name);
|
||||||
|
const list = refsByKey.get(key) ?? [];
|
||||||
|
list.push(ref);
|
||||||
|
refsByKey.set(key, list);
|
||||||
|
},
|
||||||
|
getDuplicateKeys() {
|
||||||
|
const duplicates = new Set();
|
||||||
|
for (const [key, list] of refsByKey) {
|
||||||
|
if (list.length > 1)
|
||||||
|
duplicates.add(key);
|
||||||
|
}
|
||||||
|
return duplicates;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function processAriaTree(ariaTree, refs, options) {
|
||||||
|
const lines = ariaTree.split("\n");
|
||||||
|
const result = [];
|
||||||
|
const tracker = createRoleNameTracker();
|
||||||
|
if (options.interactive) {
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
|
||||||
|
if (!match)
|
||||||
|
continue;
|
||||||
|
const [, , role, name, suffix] = match;
|
||||||
|
const roleLower = role.toLowerCase();
|
||||||
|
if (INTERACTIVE_ROLES.has(roleLower)) {
|
||||||
|
const ref = nextRef();
|
||||||
|
const resolvedName = name ?? "";
|
||||||
|
const nth = tracker.getNextIndex(roleLower, resolvedName);
|
||||||
|
tracker.trackRef(roleLower, resolvedName, ref);
|
||||||
|
refs[ref] = {
|
||||||
|
selector: buildSelector(roleLower, resolvedName),
|
||||||
|
role: roleLower,
|
||||||
|
name: resolvedName,
|
||||||
|
nth
|
||||||
|
};
|
||||||
|
let enhanced = `- ${role}`;
|
||||||
|
if (name)
|
||||||
|
enhanced += ` "${name}"`;
|
||||||
|
enhanced += ` [ref=${ref}]`;
|
||||||
|
if (nth > 0)
|
||||||
|
enhanced += ` [nth=${nth}]`;
|
||||||
|
if (suffix && suffix.includes("["))
|
||||||
|
enhanced += suffix;
|
||||||
|
result.push(enhanced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removeNthFromNonDuplicates(refs, tracker);
|
||||||
|
return result.join("\n") || "(no interactive elements)";
|
||||||
|
}
|
||||||
|
for (const line of lines) {
|
||||||
|
const processed = processLine(line, refs, options, tracker);
|
||||||
|
if (processed !== null)
|
||||||
|
result.push(processed);
|
||||||
|
}
|
||||||
|
removeNthFromNonDuplicates(refs, tracker);
|
||||||
|
if (options.compact) {
|
||||||
|
return compactTree(result.join("\n"));
|
||||||
|
}
|
||||||
|
return result.join("\n");
|
||||||
|
}
|
||||||
|
function removeNthFromNonDuplicates(refs, tracker) {
|
||||||
|
const duplicateKeys = tracker.getDuplicateKeys();
|
||||||
|
for (const [ref, data] of Object.entries(refs)) {
|
||||||
|
const key = tracker.getKey(data.role, data.name);
|
||||||
|
if (!duplicateKeys.has(key)) {
|
||||||
|
delete refs[ref].nth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getIndentLevel(line) {
|
||||||
|
const match = line.match(/^(\s*)/);
|
||||||
|
return match ? Math.floor(match[1].length / 2) : 0;
|
||||||
|
}
|
||||||
|
function processLine(line, refs, options, tracker) {
|
||||||
|
const depth = getIndentLevel(line);
|
||||||
|
if (options.maxDepth !== undefined && depth > options.maxDepth) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
|
||||||
|
if (!match) {
|
||||||
|
if (options.interactive)
|
||||||
|
return null;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
const [, prefix, role, name, suffix] = match;
|
||||||
|
const roleLower = role.toLowerCase();
|
||||||
|
if (role.startsWith("/"))
|
||||||
|
return line;
|
||||||
|
const isInteractive = INTERACTIVE_ROLES.has(roleLower);
|
||||||
|
const isContent = CONTENT_ROLES.has(roleLower);
|
||||||
|
const isStructural = STRUCTURAL_ROLES.has(roleLower);
|
||||||
|
if (options.interactive && !isInteractive)
|
||||||
|
return null;
|
||||||
|
if (options.compact && isStructural && !name)
|
||||||
|
return null;
|
||||||
|
const shouldHaveRef = isInteractive || (isContent && name);
|
||||||
|
if (shouldHaveRef) {
|
||||||
|
const ref = nextRef();
|
||||||
|
const resolvedName = isInteractive ? (name ?? "") : name;
|
||||||
|
const nth = tracker.getNextIndex(roleLower, resolvedName);
|
||||||
|
tracker.trackRef(roleLower, resolvedName, ref);
|
||||||
|
refs[ref] = {
|
||||||
|
selector: buildSelector(roleLower, resolvedName),
|
||||||
|
role: roleLower,
|
||||||
|
name: resolvedName,
|
||||||
|
nth
|
||||||
|
};
|
||||||
|
let enhanced = `${prefix}${role}`;
|
||||||
|
if (name)
|
||||||
|
enhanced += ` "${name}"`;
|
||||||
|
enhanced += ` [ref=${ref}]`;
|
||||||
|
if (nth > 0)
|
||||||
|
enhanced += ` [nth=${nth}]`;
|
||||||
|
if (suffix)
|
||||||
|
enhanced += suffix;
|
||||||
|
return enhanced;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
function compactTree(tree) {
|
||||||
|
const lines = tree.split("\n");
|
||||||
|
const result = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes("[ref=")) {
|
||||||
|
result.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.includes(":") && !line.endsWith(":")) {
|
||||||
|
result.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const currentIndent = getIndentLevel(line);
|
||||||
|
let hasRelevantChildren = false;
|
||||||
|
for (let j = i + 1; j < lines.length; j++) {
|
||||||
|
const childIndent = getIndentLevel(lines[j]);
|
||||||
|
if (childIndent <= currentIndent)
|
||||||
|
break;
|
||||||
|
if (lines[j].includes("[ref=")) {
|
||||||
|
hasRelevantChildren = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasRelevantChildren) {
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.join("\n");
|
||||||
|
}
|
||||||
|
export function parseRef(arg) {
|
||||||
|
if (arg.startsWith("@"))
|
||||||
|
return arg.slice(1);
|
||||||
|
if (arg.startsWith("ref="))
|
||||||
|
return arg.slice(4);
|
||||||
|
if (/^e\\d+$/.test(arg))
|
||||||
|
return arg;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
75
crates/gyandoot-browser/src/daemon.rs
Normal file
75
crates/gyandoot-browser/src/daemon.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
//! Embedded daemon — extracts the camoufox-browser daemon JS files
|
||||||
|
//! from the compiled binary to disk at runtime.
|
||||||
|
//!
|
||||||
|
//! This eliminates the need to `npm install -g camoufox-browser@0.1.0`,
|
||||||
|
//! which bundles an incompatible camoufox-js@0.9.3. Instead, the daemon
|
||||||
|
//! files are embedded at compile time and extracted to `~/.gyandoot/daemon/`.
|
||||||
|
//! The daemon then imports `camoufox-js` from the global npm install (v0.11.0).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Directory where daemon files are extracted, relative to `~/.gyandoot/`.
|
||||||
|
const DAEMON_DIR: &str = "daemon";
|
||||||
|
|
||||||
|
/// Embedded daemon files (compiled into the binary via `include_dir!`).
|
||||||
|
static DAEMON_FILES: include_dir::Dir = include_dir::include_dir!("$CARGO_MANIFEST_DIR/daemon");
|
||||||
|
|
||||||
|
/// Get the path to the extracted daemon directory.
|
||||||
|
pub fn daemon_dir() -> PathBuf {
|
||||||
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||||
|
home.join(".gyandoot").join(DAEMON_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the path to the daemon entrypoint (daemon.js).
|
||||||
|
pub fn daemon_js_path() -> PathBuf {
|
||||||
|
daemon_dir().join("daemon.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract all embedded daemon files to `~/.gyandoot/daemon/`.
|
||||||
|
/// Returns the path to daemon.js on success.
|
||||||
|
pub fn extract_daemon() -> Result<PathBuf, String> {
|
||||||
|
let target_dir = daemon_dir();
|
||||||
|
std::fs::create_dir_all(&target_dir)
|
||||||
|
.map_err(|e| format!("Cannot create daemon dir {}: {}", target_dir.display(), e))?;
|
||||||
|
|
||||||
|
extract_dir(&DAEMON_FILES, &target_dir)?;
|
||||||
|
|
||||||
|
let entry = daemon_js_path();
|
||||||
|
if entry.exists() {
|
||||||
|
info!("Daemon extracted to {} ({} files)", target_dir.display(), count_files(&target_dir));
|
||||||
|
Ok(entry)
|
||||||
|
} else {
|
||||||
|
Err(format!("Daemon entrypoint not found after extraction: {}", entry.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively extract an embedded directory to a target path on disk.
|
||||||
|
fn extract_dir(dir: &include_dir::Dir, target: &std::path::Path) -> Result<(), String> {
|
||||||
|
for entry in dir.files() {
|
||||||
|
let file_path = target.join(entry.path());
|
||||||
|
if let Some(parent) = file_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Cannot create dir {}: {}", parent.display(), e))?;
|
||||||
|
}
|
||||||
|
std::fs::write(&file_path, entry.contents())
|
||||||
|
.map_err(|e| format!("Cannot write {}: {}", file_path.display(), e))?;
|
||||||
|
}
|
||||||
|
for subdir in dir.dirs() {
|
||||||
|
extract_dir(subdir, target)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count files in a directory (non-recursive for display).
|
||||||
|
fn count_files(dir: &std::path::Path) -> usize {
|
||||||
|
let mut count = 0;
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod daemon;
|
||||||
|
|
||||||
pub use client::{
|
pub use client::{
|
||||||
BrowserClient, BrowserError, BrowserResult,
|
BrowserClient, BrowserError, BrowserResult,
|
||||||
|
|||||||
28
install.sh
28
install.sh
@@ -218,34 +218,18 @@ install_camoufox_browser() {
|
|||||||
|
|
||||||
if ! command -v npx &>/dev/null && ! command -v npm &>/dev/null; then
|
if ! command -v npx &>/dev/null && ! command -v npm &>/dev/null; then
|
||||||
warn "Node.js not found. Skipping Camoufox install."
|
warn "Node.js not found. Skipping Camoufox install."
|
||||||
warn "Install Node.js and re-run: npm install -g camoufox-browser@0.1.0"
|
warn "Install Node.js and re-run: npm install -g camoufox-js@0.11.0 && npx --yes camoufox-js@0.11.0 fetch"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 1: Install npm package (daemon)
|
# Step 1: Install camoufox-js globally (daemon imports this at runtime)
|
||||||
info "Installing npm package (camoufox-browser@0.1.0)..."
|
info "Installing camoufox-js@0.11.0 (browser engine)..."
|
||||||
npm install -g camoufox-browser@0.1.0 2>&1 | tail -3 || {
|
|
||||||
warn "npm install failed. Try manually: npm install -g camoufox-browser@0.1.0"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Step 2: Override bundled camoufox-js@0.9.3 with v0.11.0
|
|
||||||
# The daemon does: import("camoufox-js") — Node resolves this from
|
|
||||||
# the daemon's own node_modules/ FIRST, before checking global.
|
|
||||||
# So we must REMOVE the bundled copy so Node falls through to global.
|
|
||||||
info "Upgrading bundled camoufox-js to v0.11.0 (daemon dependency)..."
|
|
||||||
local cb_pkg
|
|
||||||
cb_pkg="$(npm root -g 2>/dev/null)/camoufox-browser"
|
|
||||||
if [ -d "$cb_pkg/node_modules/camoufox-js" ]; then
|
|
||||||
rm -rf "$cb_pkg/node_modules/camoufox-js"
|
|
||||||
ok "Removed bundled camoufox-js@0.9.3 from daemon package"
|
|
||||||
fi
|
|
||||||
# Ensure global v0.11.0 exists
|
|
||||||
npm install -g camoufox-js@0.11.0 2>&1 | tail -3 || {
|
npm install -g camoufox-js@0.11.0 2>&1 | tail -3 || {
|
||||||
warn "Failed to install camoufox-js@0.11.0 globally"
|
warn "npm install failed. Try manually: npm install -g camoufox-js@0.11.0"
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 3: Download browser binary
|
# Step 2: Download browser binary
|
||||||
info "Downloading Camoufox browser binary (~311MB)..."
|
info "Downloading Camoufox browser binary (~311MB)..."
|
||||||
npx --yes camoufox-js@0.11.0 fetch 2>&1 || {
|
npx --yes camoufox-js@0.11.0 fetch 2>&1 || {
|
||||||
warn "Download failed. Try manually: npx --yes camoufox-js@0.11.0 fetch"
|
warn "Download failed. Try manually: npx --yes camoufox-js@0.11.0 fetch"
|
||||||
|
|||||||
Reference in New Issue
Block a user