From d5cab6509986ea261d3d24bd937cdf8829ca4c74 Mon Sep 17 00:00:00 2001 From: Tara Menon Date: Sat, 13 Jun 2026 23:22:15 +0530 Subject: [PATCH] feat: embed daemon JS in binary, eliminate npm install of camoufox-browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() --- Cargo.lock | 20 + crates/gyandoot-agent/src/browser_manager.rs | 200 +--- crates/gyandoot-browser/Cargo.toml | 3 +- crates/gyandoot-browser/daemon/browser.js | 383 +++++++ crates/gyandoot-browser/daemon/daemon.js | 16 + crates/gyandoot-browser/daemon/ipc.js | 76 ++ crates/gyandoot-browser/daemon/protocol.js | 46 + crates/gyandoot-browser/daemon/server.js | 1027 ++++++++++++++++++ crates/gyandoot-browser/daemon/snapshot.js | 385 +++++++ crates/gyandoot-browser/src/daemon.rs | 75 ++ crates/gyandoot-browser/src/lib.rs | 1 + install.sh | 28 +- 12 files changed, 2063 insertions(+), 197 deletions(-) create mode 100644 crates/gyandoot-browser/daemon/browser.js create mode 100644 crates/gyandoot-browser/daemon/daemon.js create mode 100644 crates/gyandoot-browser/daemon/ipc.js create mode 100644 crates/gyandoot-browser/daemon/protocol.js create mode 100644 crates/gyandoot-browser/daemon/server.js create mode 100644 crates/gyandoot-browser/daemon/snapshot.js create mode 100644 crates/gyandoot-browser/src/daemon.rs diff --git a/Cargo.lock b/Cargo.lock index fcd5c8d..0dc9149 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -701,6 +701,7 @@ dependencies = [ "chrono", "dirs", "gyandoot-core", + "include_dir", "reqwest", "serde", "serde_json", @@ -1207,6 +1208,25 @@ dependencies = [ "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]] name = "indexmap" version = "2.14.0" diff --git a/crates/gyandoot-agent/src/browser_manager.rs b/crates/gyandoot-agent/src/browser_manager.rs index bb193b7..f8ba01c 100644 --- a/crates/gyandoot-agent/src/browser_manager.rs +++ b/crates/gyandoot-agent/src/browser_manager.rs @@ -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 { .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 { - 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 { - // 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 { 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 { tokio::time::sleep(Duration::from_millis(DAEMON_POLL_MS)).await; } Ok(false) -} \ No newline at end of file +} diff --git a/crates/gyandoot-browser/Cargo.toml b/crates/gyandoot-browser/Cargo.toml index 2db6f52..be6bd28 100644 --- a/crates/gyandoot-browser/Cargo.toml +++ b/crates/gyandoot-browser/Cargo.toml @@ -16,4 +16,5 @@ tracing.workspace = true uuid.workspace = true chrono.workspace = true dirs.workspace = true -reqwest = { workspace = true, default-features = false, features = ["json"] } \ No newline at end of file +reqwest = { workspace = true, default-features = false, features = ["json"] } +include_dir = "0.7" \ No newline at end of file diff --git a/crates/gyandoot-browser/daemon/browser.js b/crates/gyandoot-browser/daemon/browser.js new file mode 100644 index 0000000..9f8c10e --- /dev/null +++ b/crates/gyandoot-browser/daemon/browser.js @@ -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 { } + } + } +} diff --git a/crates/gyandoot-browser/daemon/daemon.js b/crates/gyandoot-browser/daemon/daemon.js new file mode 100644 index 0000000..21da5c8 --- /dev/null +++ b/crates/gyandoot-browser/daemon/daemon.js @@ -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); +}); diff --git a/crates/gyandoot-browser/daemon/ipc.js b/crates/gyandoot-browser/daemon/ipc.js new file mode 100644 index 0000000..1797731 --- /dev/null +++ b/crates/gyandoot-browser/daemon/ipc.js @@ -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); +} diff --git a/crates/gyandoot-browser/daemon/protocol.js b/crates/gyandoot-browser/daemon/protocol.js new file mode 100644 index 0000000..f1e5ad6 --- /dev/null +++ b/crates/gyandoot-browser/daemon/protocol.js @@ -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])|(? typeof value === "string" ? sanitizeUtf16(value) : value); +} diff --git a/crates/gyandoot-browser/daemon/server.js b/crates/gyandoot-browser/daemon/server.js new file mode 100644 index 0000000..3c576ab --- /dev/null +++ b/crates/gyandoot-browser/daemon/server.js @@ -0,0 +1,1027 @@ +import * as net from "node:net"; +import * as fs from "node:fs"; +import { cleanupIpcFiles, ensureSocketDir, getSocketDir, getPidFile, getPortFile, getPortForSession, getUnixSocketPath, isValidSessionName, isWindows } from "./ipc.js"; +import { errorResponse, parseCommand, serializeResponse, successResponse } from "./protocol.js"; +import { BrowserManager } from "./browser.js"; +import * as path from "node:path"; +import * as fsPromises from "node:fs/promises"; +/** + * Backpressure-aware socket write. + * If the kernel buffer is full, waits for 'drain' before resolving. + */ +async function safeWrite(socket, payload) { + await new Promise((resolve, reject) => { + if (socket.destroyed) + return resolve(); + const ok = socket.write(payload); + if (ok) + return resolve(); + const cleanup = () => { + socket.removeListener("drain", onDrain); + socket.removeListener("error", onError); + socket.removeListener("close", onClose); + }; + const onDrain = () => { + cleanup(); + resolve(); + }; + const onError = (err) => { + cleanup(); + reject(err); + }; + const onClose = () => { + cleanup(); + resolve(); + }; + socket.once("drain", onDrain); + socket.once("error", onError); + socket.once("close", onClose); + }); +} +function looksLikeHttp(line) { + return /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i.test(line); +} +function getSession() { + const raw = process.env.CAMOUFOX_BROWSER_SESSION || process.env.AGENT_BROWSER_SESSION || "default"; + if (!isValidSessionName(raw)) { + throw new Error(`Invalid session name: ${raw}`); + } + return raw; +} +const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000; +function getIdleTimeoutMs() { + const raw = process.env.CAMOUFOX_BROWSER_IDLE_TIMEOUT_MS || process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + if (raw === undefined) + return DEFAULT_IDLE_TIMEOUT_MS; + const val = parseInt(raw, 10); + if (Number.isNaN(val)) + return DEFAULT_IDLE_TIMEOUT_MS; + return val; +} +export class DaemonServer { + session; + idleTimeoutMs; + exitOnShutdown; + server = null; + shuttingDown = false; + idleTimer = null; + browser = new BrowserManager(); + readyResolve; + readyPromise; + constructor(opts) { + this.session = getSession(); + this.idleTimeoutMs = getIdleTimeoutMs(); + this.exitOnShutdown = opts?.exitOnShutdown ?? true; + this.readyPromise = new Promise((resolve) => { + this.readyResolve = resolve; + }); + } + async waitUntilReady(timeoutMs = 5000) { + await Promise.race([ + this.readyPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error("Daemon did not become ready")), timeoutMs)) + ]); + } + async start() { + ensureSocketDir(); + cleanupIpcFiles(this.session); + fs.writeFileSync(getPidFile(this.session), String(process.pid)); + this.server = net.createServer((socket) => this.handleConnection(socket)); + this.server.on("error", (err) => { + process.stderr.write(`[camoufox-browser] Server error: ${String(err)}\n`); + this.shutdown(1).catch(() => { }); + }); + if (isWindows) { + const port = getPortForSession(this.session); + fs.writeFileSync(getPortFile(this.session), String(port)); + this.server.listen(port, "127.0.0.1", () => this.readyResolve()); + } + else { + const sockPath = getUnixSocketPath(this.session); + this.server.listen(sockPath, () => this.readyResolve()); + } + this.resetIdleTimer(); + if (this.exitOnShutdown) { + const shutdown = async () => { + await this.shutdown(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + process.on("SIGHUP", shutdown); + process.on("exit", () => { + cleanupIpcFiles(this.session); + }); + } + await new Promise((resolve) => { + this.server.on("close", resolve); + }); + } + resetIdleTimer() { + if (this.idleTimeoutMs <= 0) + return; + if (this.idleTimer) + clearTimeout(this.idleTimer); + this.idleTimer = setTimeout(() => { + this.shutdown(0).catch(() => { }); + }, this.idleTimeoutMs); + if (this.idleTimer && typeof this.idleTimer === "object" && "unref" in this.idleTimer) { + this.idleTimer.unref(); + } + } + async shutdown(exitCode) { + if (this.shuttingDown) + return; + this.shuttingDown = true; + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + if (this.server) { + await new Promise((resolve) => { + try { + this.server.close(() => resolve()); + } + catch { + resolve(); + } + }); + this.server = null; + } + cleanupIpcFiles(this.session); + if (this.exitOnShutdown) { + process.exit(exitCode); + } + } + handleConnection(socket) { + let buffer = ""; + let httpChecked = false; + const commandQueue = []; + let processing = false; + const processQueue = async () => { + if (processing) + return; + processing = true; + while (commandQueue.length > 0) { + const line = commandQueue.shift(); + this.resetIdleTimer(); + const parseResult = parseCommand(line); + if (!parseResult.success) { + const id = parseResult.id ?? "unknown"; + await safeWrite(socket, serializeResponse(errorResponse(id, parseResult.error)) + "\n"); + continue; + } + const cmd = parseResult.command; + const response = await this.execute(cmd); + await safeWrite(socket, serializeResponse(response) + "\n"); + if (cmd.action === "close") { + setTimeout(() => { + this.shutdown(0).catch(() => { }); + }, 100); + commandQueue.length = 0; + processing = false; + return; + } + } + processing = false; + }; + socket.on("data", (data) => { + buffer += data.toString(); + if (!httpChecked) { + httpChecked = true; + const trimmed = buffer.trimStart(); + if (looksLikeHttp(trimmed)) { + socket.destroy(); + return; + } + } + while (buffer.includes("\n")) { + const newlineIdx = buffer.indexOf("\n"); + const line = buffer.slice(0, newlineIdx).trim(); + buffer = buffer.slice(newlineIdx + 1); + if (!line) + continue; + commandQueue.push(line); + } + processQueue().catch((err) => { + process.stderr.write(`[camoufox-browser] processQueue error: ${err?.message ?? String(err)}\n`); + }); + }); + } + async execute(cmd) { + try { + if (cmd.action === "close") { + await this.browser.close(); + return successResponse(cmd.id, { closed: true }); + } + const needsBrowser = cmd.action === "launch" || + cmd.action === "navigate" || + cmd.action === "back" || + cmd.action === "forward" || + cmd.action === "reload" || + cmd.action === "tab_new" || + cmd.action === "tab_list" || + cmd.action === "tab_switch" || + cmd.action === "tab_close" || + cmd.action === "url" || + cmd.action === "title" || + cmd.action === "snapshot" || + cmd.action === "evaluate" || + cmd.action === "scroll" || + cmd.action === "scrollintoview" || + cmd.action === "click" || + cmd.action === "dblclick" || + cmd.action === "focus" || + cmd.action === "drag" || + cmd.action === "upload" || + cmd.action === "download" || + cmd.action === "frame" || + cmd.action === "mainframe" || + cmd.action === "dialog" || + cmd.action === "console" || + cmd.action === "errors" || + cmd.action === "highlight" || + cmd.action === "cookies_get" || + cmd.action === "cookies_set" || + cmd.action === "cookies_clear" || + cmd.action === "storage_get" || + cmd.action === "storage_set" || + cmd.action === "storage_clear" || + cmd.action === "fill" || + cmd.action === "type" || + cmd.action === "press" || + cmd.action === "keydown" || + cmd.action === "keyup" || + cmd.action === "keyboard" || + cmd.action === "hover" || + cmd.action === "check" || + cmd.action === "uncheck" || + cmd.action === "select" || + cmd.action === "gettext" || + cmd.action === "innerhtml" || + cmd.action === "inputvalue" || + cmd.action === "getattribute" || + cmd.action === "count" || + cmd.action === "boundingbox" || + cmd.action === "styles" || + cmd.action === "isvisible" || + cmd.action === "isenabled" || + cmd.action === "ischecked" || + cmd.action === "wait" || + cmd.action === "waitforurl" || + cmd.action === "waitforloadstate" || + cmd.action === "screenshot"; + const desiredHeadless = typeof cmd.headless === "boolean" ? cmd.headless : undefined; + // Auto-launch on the first browser-dependent command for drop-in ergonomics. + if (needsBrowser && !this.browser.isLaunched() && cmd.action !== "launch") { + const cfgFromEnv = getLaunchConfigFromEnv(); + await this.browser.launch(desiredHeadless === undefined ? cfgFromEnv : { ...cfgFromEnv, headless: desiredHeadless }); + } + // If caller explicitly requests headless/headed, enforce it. + // When a browser is already running with a different mode, we either: + // - relaunch automatically for navigate/launch (since navigation will happen anyway), or + // - return an actionable error for other commands to avoid surprising state loss. + if (needsBrowser && desiredHeadless !== undefined && this.browser.isLaunched()) { + const current = this.browser.getLaunchConfig(); + if (current && current.headless !== desiredHeadless) { + if (cmd.action === "navigate" || cmd.action === "launch") { + const relaunchCfg = { ...current, headless: desiredHeadless }; + await this.browser.close(); + await this.browser.launch(relaunchCfg); + } + else { + if (envIsTruthy("CAMOUFOX_BROWSER_DEBUG") || envIsTruthy("AGENT_BROWSER_DEBUG")) { + const currentMode = current.headless ? "headless" : "headed"; + const desiredMode = desiredHeadless ? "headless" : "headed"; + process.stderr.write(`[camoufox-browser] requested ${desiredMode} but session is already ${currentMode}; keeping existing browser. (Hint: run 'camoufox-browser close --session ${this.session}' to restart.)\n`); + } + } + } + } + // Recover from stale state (launched but no pages). + if (needsBrowser && this.browser.isLaunched() && !this.browser.hasPages() && cmd.action !== "launch") { + await this.browser.ensurePage(); + } + switch (cmd.action) { + case "launch": { + const cfgFromEnv = getLaunchConfigFromEnv(); + const override = coerceLaunchConfig(cmd); + const cfg = override ? { ...cfgFromEnv, headless: override.headless } : cfgFromEnv; + await this.browser.launch(cfg); + return successResponse(cmd.id, { launched: true }); + } + case "navigate": { + const url = typeof cmd.url === "string" ? cmd.url : ""; + if (!url) + return errorResponse(cmd.id, "Missing url"); + const waitUntil = cmd.waitUntil === "load" || cmd.waitUntil === "domcontentloaded" || cmd.waitUntil === "networkidle" + ? cmd.waitUntil + : undefined; + const headers = cmd.headers && typeof cmd.headers === "object" && cmd.headers !== null ? cmd.headers : undefined; + const result = await this.browser.navigate(url, { waitUntil, headers }); + return successResponse(cmd.id, result); + } + case "back": { + const page = this.browser.getPage(); + await page.goBack(); + return successResponse(cmd.id, { url: page.url() }); + } + case "forward": { + const page = this.browser.getPage(); + await page.goForward(); + return successResponse(cmd.id, { url: page.url() }); + } + case "reload": { + const page = this.browser.getPage(); + await page.reload(); + return successResponse(cmd.id, { url: page.url() }); + } + case "tab_new": { + const result = await this.browser.newTabManaged(); + if (typeof cmd.url === "string" && cmd.url.length > 0) { + const page = this.browser.getPage(); + await page.goto(cmd.url, { waitUntil: "domcontentloaded" }); + } + return successResponse(cmd.id, result); + } + case "tab_list": { + const tabs = await this.browser.listTabs(); + return successResponse(cmd.id, { tabs, active: this.browser.getActiveIndex() }); + } + case "tab_switch": { + const index = typeof cmd.index === "number" ? cmd.index : NaN; + if (!Number.isFinite(index)) + return errorResponse(cmd.id, "Missing index"); + const result = await this.browser.switchTo(index); + const page = this.browser.getPage(); + return successResponse(cmd.id, { ...result, title: await page.title().catch(() => "") }); + } + case "tab_close": { + const rawIndex = cmd.index; + const index = typeof rawIndex === "number" ? rawIndex : undefined; + const result = await this.browser.closeTab(index); + return successResponse(cmd.id, result); + } + case "frame": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + await this.browser.switchToFrame({ selector }); + return successResponse(cmd.id, { switched: true }); + } + case "mainframe": { + this.browser.switchToMainFrame(); + return successResponse(cmd.id, { switched: true }); + } + case "dialog": { + const response = cmd.response === "accept" || cmd.response === "dismiss" + ? cmd.response + : null; + if (!response) + return errorResponse(cmd.id, "Missing response"); + const promptText = typeof cmd.promptText === "string" ? cmd.promptText : undefined; + this.browser.setDialogHandler(response, promptText); + return successResponse(cmd.id, { handler: "set", response }); + } + case "url": { + const page = this.browser.getPage(); + return successResponse(cmd.id, { url: page.url() }); + } + case "title": { + const page = this.browser.getPage(); + return successResponse(cmd.id, { title: await page.title() }); + } + case "snapshot": { + const interactive = typeof cmd.interactive === "boolean" ? cmd.interactive : undefined; + const cursor = typeof cmd.cursor === "boolean" ? cmd.cursor : undefined; + const compact = typeof cmd.compact === "boolean" ? cmd.compact : undefined; + const selector = typeof cmd.selector === "string" ? cmd.selector : undefined; + const maxDepth = typeof cmd.maxDepth === "number" ? cmd.maxDepth : undefined; + const { tree, refs } = await this.browser.getSnapshot({ + interactive, + cursor, + compact, + selector, + maxDepth + }); + const simpleRefs = {}; + for (const [ref, data] of Object.entries(refs)) { + simpleRefs[ref] = { role: data.role, name: data.name }; + } + const page = this.browser.getPage(); + return successResponse(cmd.id, { + snapshot: tree || "Empty page", + refs: Object.keys(simpleRefs).length > 0 ? simpleRefs : undefined, + origin: page.url() + }); + } + case "console": { + const clear = cmd.clear === true; + if (clear) { + this.browser.clearConsoleMessages(); + return successResponse(cmd.id, { cleared: true }); + } + const page = this.browser.getPage(); + const messages = this.browser.getConsoleMessages(); + return successResponse(cmd.id, { messages, origin: page.url() }); + } + case "errors": { + const clear = cmd.clear === true; + if (clear) { + this.browser.clearPageErrors(); + return successResponse(cmd.id, { cleared: true }); + } + const errors = this.browser.getPageErrors(); + return successResponse(cmd.id, { errors }); + } + case "highlight": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + await this.browser.getLocator(selector).highlight(); + return successResponse(cmd.id, { highlighted: true }); + } + case "cookies_get": { + const page = this.browser.getPage(); + const context = page.context(); + const urlsRaw = cmd.urls; + const urls = Array.isArray(urlsRaw) && urlsRaw.every((u) => typeof u === "string") ? urlsRaw : undefined; + const cookies = urls ? await context.cookies(urls) : await context.cookies(); + return successResponse(cmd.id, { cookies }); + } + case "cookies_set": { + const page = this.browser.getPage(); + const context = page.context(); + const cookiesRaw = cmd.cookies; + if (!Array.isArray(cookiesRaw) || cookiesRaw.length === 0) + return errorResponse(cmd.id, "Missing cookies"); + const pageUrl = page.url(); + const cookies = cookiesRaw + .filter((c) => typeof c === "object" && + c !== null && + typeof c.name === "string" && + typeof c.value === "string") + .map((cookie) => { + if (!cookie.url && !cookie.domain && !cookie.path) { + return { ...cookie, url: pageUrl }; + } + return cookie; + }); + if (cookies.length === 0) + return errorResponse(cmd.id, "Missing cookies"); + await context.addCookies(cookies); + return successResponse(cmd.id, { set: true }); + } + case "cookies_clear": { + const page = this.browser.getPage(); + const context = page.context(); + await context.clearCookies(); + return successResponse(cmd.id, { cleared: true }); + } + case "storage_get": { + const page = this.browser.getPage(); + const type = typeof cmd.type === "string" ? cmd.type : ""; + if (type !== "local" && type !== "session") + return errorResponse(cmd.id, "Missing storage type"); + const storageType = type === "local" ? "localStorage" : "sessionStorage"; + const key = typeof cmd.key === "string" ? cmd.key : ""; + if (key) { + const value = await page.evaluate(`${storageType}.getItem(${JSON.stringify(key)})`); + return successResponse(cmd.id, { key, value }); + } + const data = await page.evaluate(` + (() => { + const storage = ${storageType}; + const result = {}; + for (let i = 0; i < storage.length; i++) { + const k = storage.key(i); + if (k) result[k] = storage.getItem(k); + } + return result; + })() + `); + return successResponse(cmd.id, { data }); + } + case "storage_set": { + const page = this.browser.getPage(); + const type = typeof cmd.type === "string" ? cmd.type : ""; + if (type !== "local" && type !== "session") + return errorResponse(cmd.id, "Missing storage type"); + const storageType = type === "local" ? "localStorage" : "sessionStorage"; + const key = typeof cmd.key === "string" ? cmd.key : ""; + const value = typeof cmd.value === "string" ? cmd.value : ""; + if (!key) + return errorResponse(cmd.id, "Missing key"); + await page.evaluate(`${storageType}.setItem(${JSON.stringify(key)}, ${JSON.stringify(value)})`); + return successResponse(cmd.id, { set: true }); + } + case "storage_clear": { + const page = this.browser.getPage(); + const type = typeof cmd.type === "string" ? cmd.type : ""; + if (type !== "local" && type !== "session") + return errorResponse(cmd.id, "Missing storage type"); + const storageType = type === "local" ? "localStorage" : "sessionStorage"; + await page.evaluate(`${storageType}.clear()`); + return successResponse(cmd.id, { cleared: true }); + } + case "evaluate": { + const page = this.browser.getPage(); + const script = typeof cmd.script === "string" ? cmd.script : ""; + if (!script) + return errorResponse(cmd.id, "Missing script"); + const result = await page.evaluate(script); + return successResponse(cmd.id, { result, origin: page.url() }); + } + case "scroll": { + const page = this.browser.getPage(); + const direction = typeof cmd.direction === "string" ? cmd.direction : "down"; + const amount = typeof cmd.amount === "number" ? cmd.amount : 300; + const selector = typeof cmd.selector === "string" ? cmd.selector : undefined; + let deltaX = 0; + let deltaY = 0; + switch (direction) { + case "up": + deltaY = -amount; + break; + case "down": + deltaY = amount; + break; + case "left": + deltaX = -amount; + break; + case "right": + deltaX = amount; + break; + default: + deltaY = amount; + break; + } + if (selector) { + const element = this.browser.getLocator(selector); + await element.scrollIntoViewIfNeeded(); + await element.evaluate((el, { x, y }) => { + el.scrollBy(x, y); + }, { x: deltaX, y: deltaY }); + } + else { + await page.evaluate(`window.scrollBy(${deltaX}, ${deltaY})`); + } + return successResponse(cmd.id, { scrolled: true }); + } + case "scrollintoview": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + await this.browser.getLocator(selector).scrollIntoViewIfNeeded(); + return successResponse(cmd.id, { scrolled: true }); + } + case "click": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const locator = this.browser.getLocator(selector); + if (cmd.newTab === true) { + const fullUrl = await locator.evaluate((el) => { + const href = el.getAttribute("href"); + return href ? new globalThis.URL(href, globalThis.document.baseURI).toString() : ""; + }); + if (!fullUrl) { + return errorResponse(cmd.id, `Element '${selector}' does not have an href attribute. --new-tab only works on links.`); + } + await this.browser.newTab(); + const newPage = this.browser.getPage(); + await newPage.goto(fullUrl); + return successResponse(cmd.id, { clicked: true, newTab: true, url: fullUrl }); + } + await locator.click(); + return successResponse(cmd.id, { clicked: true }); + } + case "dblclick": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + await this.browser.getLocator(selector).dblclick(); + return successResponse(cmd.id, { clicked: true }); + } + case "focus": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + await this.browser.getLocator(selector).focus(); + return successResponse(cmd.id, { focused: true }); + } + case "drag": { + const source = typeof cmd.source === "string" ? cmd.source : ""; + const target = typeof cmd.target === "string" ? cmd.target : ""; + if (!source || !target) + return errorResponse(cmd.id, "Missing source/target"); + await this.browser.getLocator(source).dragTo(this.browser.getLocator(target)); + return successResponse(cmd.id, { dragged: true }); + } + case "upload": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const filesRaw = cmd.files; + const files = typeof filesRaw === "string" + ? [filesRaw] + : Array.isArray(filesRaw) && filesRaw.every((f) => typeof f === "string") + ? filesRaw + : []; + if (files.length === 0) + return errorResponse(cmd.id, "Missing files"); + await this.browser.getLocator(selector).setInputFiles(files); + return successResponse(cmd.id, { uploaded: files }); + } + case "download": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const outPath = typeof cmd.path === "string" ? cmd.path : ""; + if (!outPath) + return errorResponse(cmd.id, "Missing path"); + const page = this.browser.getPage(); + const locator = this.browser.getLocator(selector); + await fsPromises.mkdir(path.dirname(outPath), { recursive: true }); + const [download] = await Promise.all([page.waitForEvent("download"), locator.click()]); + await download.saveAs(outPath); + return successResponse(cmd.id, { + path: outPath, + suggestedFilename: download.suggestedFilename() + }); + } + case "fill": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const value = typeof cmd.value === "string" ? cmd.value : ""; + await this.browser.getLocator(selector).fill(value); + return successResponse(cmd.id, { filled: true }); + } + case "type": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const text = typeof cmd.text === "string" ? cmd.text : ""; + await this.browser.getLocator(selector).pressSequentially(text); + return successResponse(cmd.id, { typed: true }); + } + case "press": { + const key = typeof cmd.key === "string" ? cmd.key : ""; + if (!key) + return errorResponse(cmd.id, "Missing key"); + if (typeof cmd.selector === "string" && cmd.selector.length > 0) { + await this.browser.getLocator(cmd.selector).press(key); + } + else { + await this.browser.getPage().keyboard.press(key); + } + return successResponse(cmd.id, { pressed: true }); + } + case "keydown": { + const key = typeof cmd.key === "string" ? cmd.key : ""; + if (!key) + return errorResponse(cmd.id, "Missing key"); + const page = this.browser.getPage(); + await page.keyboard.down(key); + return successResponse(cmd.id, { down: true, key }); + } + case "keyup": { + const key = typeof cmd.key === "string" ? cmd.key : ""; + if (!key) + return errorResponse(cmd.id, "Missing key"); + const page = this.browser.getPage(); + await page.keyboard.up(key); + return successResponse(cmd.id, { up: true, key }); + } + case "keyboard": { + const page = this.browser.getPage(); + const subaction = typeof cmd.subaction === "string" ? (cmd.subaction || "type") : "type"; + const text = typeof cmd.text === "string" ? cmd.text : ""; + if (subaction === "type") { + await page.keyboard.type(text); + return successResponse(cmd.id, { typed: true, text }); + } + if (subaction === "insertText") { + await page.keyboard.insertText(text); + return successResponse(cmd.id, { inserted: true, text }); + } + return errorResponse(cmd.id, `Unknown keyboard subaction: ${subaction}`); + } + case "hover": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + await this.browser.getLocator(selector).hover(); + return successResponse(cmd.id, { hovered: true }); + } + case "check": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + await this.browser.getLocator(selector).check(); + return successResponse(cmd.id, { checked: true }); + } + case "uncheck": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + await this.browser.getLocator(selector).uncheck(); + return successResponse(cmd.id, { checked: false }); + } + case "select": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const valuesRaw = cmd.values; + if (typeof valuesRaw === "string") { + await this.browser.getLocator(selector).selectOption(valuesRaw); + } + else if (Array.isArray(valuesRaw) && valuesRaw.every((v) => typeof v === "string")) { + await this.browser.getLocator(selector).selectOption(valuesRaw); + } + else { + return errorResponse(cmd.id, "Missing values"); + } + return successResponse(cmd.id, { selected: true }); + } + case "gettext": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const page = this.browser.getPage(); + const locator = this.browser.getLocator(selector); + const inner = await locator.innerText(); + const text = inner || (await locator.textContent()) || ""; + return successResponse(cmd.id, { text, origin: page.url() }); + } + case "innerhtml": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const page = this.browser.getPage(); + const html = await this.browser.getLocator(selector).innerHTML(); + return successResponse(cmd.id, { html, origin: page.url() }); + } + case "inputvalue": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const page = this.browser.getPage(); + const value = await this.browser.getLocator(selector).inputValue(); + return successResponse(cmd.id, { value, origin: page.url() }); + } + case "getattribute": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const attribute = typeof cmd.attribute === "string" ? cmd.attribute : ""; + if (!attribute) + return errorResponse(cmd.id, "Missing attribute"); + const page = this.browser.getPage(); + const value = await this.browser.getLocator(selector).getAttribute(attribute); + return successResponse(cmd.id, { attribute, value, origin: page.url() }); + } + case "count": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const count = await this.browser.getLocator(selector).count(); + return successResponse(cmd.id, { count }); + } + case "boundingbox": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const box = await this.browser.getLocator(selector).boundingBox(); + return successResponse(cmd.id, { box }); + } + case "styles": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const page = this.browser.getPage(); + const extractStylesScript = `(function(el) { + const s = getComputedStyle(el); + const r = el.getBoundingClientRect(); + return { + tag: el.tagName.toLowerCase(), + text: (el.innerText || '').trim().slice(0, 80) || null, + box: { + x: Math.round(r.x), + y: Math.round(r.y), + width: Math.round(r.width), + height: Math.round(r.height), + }, + styles: { + fontSize: s.fontSize, + fontWeight: s.fontWeight, + fontFamily: (s.fontFamily || '').split(',')[0].trim().replace(/\"/g, ''), + color: s.color, + backgroundColor: s.backgroundColor, + borderRadius: s.borderRadius, + border: s.border !== 'none' && s.borderWidth !== '0px' ? s.border : null, + boxShadow: s.boxShadow !== 'none' ? s.boxShadow : null, + padding: s.padding, + }, + }; + })`; + const refLocator = this.browser.getLocatorFromRef(selector); + if (refLocator) { + const element = await refLocator.evaluate((el, script) => { + const fn = eval(script); + return fn(el); + }, extractStylesScript); + return successResponse(cmd.id, { elements: [element] }); + } + const elements = await page.$$eval(selector, (els, script) => { + const fn = eval(script); + return els.map((el) => fn(el)); + }, extractStylesScript); + return successResponse(cmd.id, { elements }); + } + case "isvisible": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const visible = await this.browser.getLocator(selector).isVisible(); + return successResponse(cmd.id, { visible }); + } + case "isenabled": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const enabled = await this.browser.getLocator(selector).isEnabled(); + return successResponse(cmd.id, { enabled }); + } + case "ischecked": { + const selector = typeof cmd.selector === "string" ? cmd.selector : ""; + if (!selector) + return errorResponse(cmd.id, "Missing selector"); + const checked = await this.browser.getLocator(selector).isChecked(); + return successResponse(cmd.id, { checked }); + } + case "wait": { + const page = this.browser.getPage(); + const frame = this.browser.getFrame(); + const timeout = typeof cmd.timeout === "number" ? cmd.timeout : undefined; + if (typeof cmd.text === "string" && cmd.text.length > 0) { + await page.waitForFunction(`(document.body && (document.body.innerText || '')).includes(${JSON.stringify(cmd.text)})`, { timeout }); + return successResponse(cmd.id, { waited: true }); + } + if (typeof cmd.selector === "string" && cmd.selector.length > 0) { + const state = cmd.state === "attached" || cmd.state === "detached" || cmd.state === "visible" || cmd.state === "hidden" + ? cmd.state + : "visible"; + const locator = this.browser.getLocatorFromRef(cmd.selector); + if (locator) { + await locator.waitFor({ state, timeout }); + } + else { + await frame.waitForSelector(cmd.selector, { state, timeout }); + } + return successResponse(cmd.id, { waited: true }); + } + if (timeout !== undefined) { + await page.waitForTimeout(timeout); + return successResponse(cmd.id, { waited: true }); + } + await page.waitForLoadState("load"); + return successResponse(cmd.id, { waited: true }); + } + case "waitforurl": { + const page = this.browser.getPage(); + const url = typeof cmd.url === "string" ? cmd.url : ""; + if (!url) + return errorResponse(cmd.id, "Missing url"); + const timeout = typeof cmd.timeout === "number" ? cmd.timeout : undefined; + await page.waitForURL(url, { timeout }); + return successResponse(cmd.id, { url: page.url() }); + } + case "waitforloadstate": { + const page = this.browser.getPage(); + const state = typeof cmd.state === "string" ? cmd.state : ""; + if (!state) + return errorResponse(cmd.id, "Missing state"); + const timeout = typeof cmd.timeout === "number" ? cmd.timeout : undefined; + await page.waitForLoadState(state, { timeout }); + return successResponse(cmd.id, { state }); + } + case "screenshot": { + const page = this.browser.getPage(); + const selector = typeof cmd.selector === "string" ? cmd.selector : undefined; + const fullPage = cmd.fullPage === true; + const requestedFormat = typeof cmd.format === "string" ? cmd.format : undefined; + if (requestedFormat === "webp") { + return errorResponse(cmd.id, "webp screenshots are not supported by this Playwright build"); + } + const format = requestedFormat === "jpeg" ? "jpeg" : "png"; + const quality = typeof cmd.quality === "number" ? cmd.quality : undefined; + const pathArg = typeof cmd.path === "string" ? cmd.path : undefined; + const screenshotDirRaw = cmd.screenshotDir; + const screenshotDir = typeof screenshotDirRaw === "string" + ? screenshotDirRaw + : path.join(getSocketDir(), "tmp", "screenshots"); + let savePath = pathArg; + if (!savePath) { + const ext = format === "jpeg" ? "jpg" : format; + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const random = Math.random().toString(36).slice(2, 8); + savePath = path.join(screenshotDir, `screenshot-${timestamp}-${random}.${ext}`); + } + await fsPromises.mkdir(path.dirname(savePath), { recursive: true }); + const options = { + path: savePath, + type: format, + ...(format === "jpeg" && quality !== undefined ? { quality } : {}), + ...(selector ? {} : { fullPage }) + }; + if (selector) { + await this.browser.getLocator(selector).screenshot(options); + } + else { + await page.screenshot(options); + } + return successResponse(cmd.id, { path: savePath }); + } + default: + return errorResponse(cmd.id, `Not yet implemented: ${cmd.action}`); + } + } + catch (err) { + const message = err instanceof Error ? err.message : String(err); + return errorResponse(cmd.id, message); + } + } +} +function envIsTruthy(name) { + const raw = process.env[name]; + if (!raw) + return false; + const v = raw.trim().toLowerCase(); + return v !== "" && v !== "0" && v !== "false" && v !== "no"; +} +function getLaunchConfigFromEnv() { + const headed = envIsTruthy("CAMOUFOX_BROWSER_HEADED") || envIsTruthy("AGENT_BROWSER_HEADED"); + const debug = envIsTruthy("CAMOUFOX_BROWSER_DEBUG") || envIsTruthy("AGENT_BROWSER_DEBUG"); + const argsEnv = process.env.CAMOUFOX_BROWSER_ARGS || process.env.AGENT_BROWSER_ARGS; + const args = argsEnv && argsEnv.trim().length > 0 + ? argsEnv + .split(/[,\n]/) + .map((a) => a.trim()) + .filter(Boolean) + : undefined; + const proxyServer = process.env.CAMOUFOX_BROWSER_PROXY || process.env.AGENT_BROWSER_PROXY; + const proxyBypass = process.env.CAMOUFOX_BROWSER_PROXY_BYPASS || process.env.AGENT_BROWSER_PROXY_BYPASS; + const proxy = proxyServer + ? { + server: proxyServer, + ...(proxyBypass ? { bypass: proxyBypass } : {}) + } + : undefined; + const ignoreHTTPSErrors = envIsTruthy("CAMOUFOX_BROWSER_IGNORE_HTTPS_ERRORS") || + envIsTruthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS"); + const config = { + headless: !headed, + executablePath: process.env.CAMOUFOX_BROWSER_EXECUTABLE_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH, + profile: process.env.CAMOUFOX_BROWSER_PROFILE || process.env.AGENT_BROWSER_PROFILE, + storageState: process.env.CAMOUFOX_BROWSER_STATE || process.env.AGENT_BROWSER_STATE, + args, + userAgent: process.env.CAMOUFOX_BROWSER_USER_AGENT || process.env.AGENT_BROWSER_USER_AGENT, + proxy, + ignoreHTTPSErrors + }; + if (debug) { + process.stderr.write(`[camoufox-browser] launch config: ${JSON.stringify(config)}\n`); + } + return config; +} +function coerceLaunchConfig(cmd) { + const anyCmd = cmd; + const headless = typeof anyCmd.headless === "boolean" ? anyCmd.headless : undefined; + if (headless === undefined) + return null; + const proxy = (() => { + const p = anyCmd.proxy; + if (!p || typeof p !== "object") + return undefined; + const server = typeof p.server === "string" ? p.server : undefined; + if (!server) + return undefined; + const bypass = typeof p.bypass === "string" ? p.bypass : undefined; + const username = typeof p.username === "string" ? p.username : undefined; + const password = typeof p.password === "string" ? p.password : undefined; + return { server, ...(bypass ? { bypass } : {}), ...(username ? { username } : {}), ...(password ? { password } : {}) }; + })(); + const args = Array.isArray(anyCmd.args) && anyCmd.args.every((a) => typeof a === "string") ? anyCmd.args : undefined; + return { + headless, + executablePath: typeof anyCmd.executablePath === "string" ? anyCmd.executablePath : undefined, + profile: typeof anyCmd.profile === "string" ? anyCmd.profile : undefined, + storageState: typeof anyCmd.storageState === "string" ? anyCmd.storageState : undefined, + proxy, + args, + userAgent: typeof anyCmd.userAgent === "string" ? anyCmd.userAgent : undefined, + headers: typeof anyCmd.headers === "object" && anyCmd.headers !== null ? anyCmd.headers : undefined, + ignoreHTTPSErrors: typeof anyCmd.ignoreHTTPSErrors === "boolean" ? anyCmd.ignoreHTTPSErrors : undefined + }; +} diff --git a/crates/gyandoot-browser/daemon/snapshot.js b/crates/gyandoot-browser/daemon/snapshot.js new file mode 100644 index 0000000..f2a594f --- /dev/null +++ b/crates/gyandoot-browser/daemon/snapshot.js @@ -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; +} diff --git a/crates/gyandoot-browser/src/daemon.rs b/crates/gyandoot-browser/src/daemon.rs new file mode 100644 index 0000000..f4d28a0 --- /dev/null +++ b/crates/gyandoot-browser/src/daemon.rs @@ -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 { + 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 +} diff --git a/crates/gyandoot-browser/src/lib.rs b/crates/gyandoot-browser/src/lib.rs index e589d5a..d05ab57 100644 --- a/crates/gyandoot-browser/src/lib.rs +++ b/crates/gyandoot-browser/src/lib.rs @@ -32,6 +32,7 @@ //! ``` pub mod client; +pub mod daemon; pub use client::{ BrowserClient, BrowserError, BrowserResult, diff --git a/install.sh b/install.sh index be6d8c1..ed61706 100755 --- a/install.sh +++ b/install.sh @@ -218,34 +218,18 @@ install_camoufox_browser() { if ! command -v npx &>/dev/null && ! command -v npm &>/dev/null; then 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 fi - # Step 1: Install npm package (daemon) - info "Installing npm package (camoufox-browser@0.1.0)..." - 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 + # Step 1: Install camoufox-js globally (daemon imports this at runtime) + info "Installing camoufox-js@0.11.0 (browser engine)..." 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)..." npx --yes camoufox-js@0.11.0 fetch 2>&1 || { warn "Download failed. Try manually: npx --yes camoufox-js@0.11.0 fetch"