Files
gyandoot-v2/crates/gyandoot-browser/daemon/browser.js
Tara Menon d5cab65099 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()
2026-06-13 23:22:15 +05:30

384 lines
13 KiB
JavaScript

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 { }
}
}
}