feat: embed daemon JS in binary, eliminate npm install of camoufox-browser
The daemon JS files (daemon.js, server.js, browser.js, protocol.js, snapshot.js, ipc.js) are now EMBEDDED in the compiled Rust binary via include_dir! and extracted to ~/.gyandoot/daemon/ on first run. This eliminates the need to npm install -g camoufox-browser@0.1.0, which bundles an incompatible camoufox-js@0.9.3 that caused: - 'Cannot open database' errors - Re-downloading 298MB browser on every navigation - Circular install/remove/install loops Now the daemon imports camoufox-js from the global npm install (v0.11.0) which is installed by install.sh. Clean, no bundled deps, no conflicts. Also removed: is_npm_package_installed(), install_npm_package(), find_daemon_js() — all replaced by gyandoot_browser::daemon::extract_daemon()
This commit is contained in:
383
crates/gyandoot-browser/daemon/browser.js
Normal file
383
crates/gyandoot-browser/daemon/browser.js
Normal file
@@ -0,0 +1,383 @@
|
||||
import * as os from "node:os";
|
||||
import { getEnhancedSnapshot, parseRef } from "./snapshot.js";
|
||||
function expandTilde(p) {
|
||||
if (!p.startsWith("~/"))
|
||||
return p;
|
||||
return `${os.homedir()}/${p.slice(2)}`;
|
||||
}
|
||||
export class BrowserManager {
|
||||
browser = null;
|
||||
context = null;
|
||||
pages = [];
|
||||
activePageIndex = 0;
|
||||
activeFrame = null;
|
||||
isPersistentContext = false;
|
||||
launchConfig = null;
|
||||
dialogHandler = null;
|
||||
dialogHandlerPage = null;
|
||||
consoleMessages = [];
|
||||
pageErrors = [];
|
||||
refMap = {};
|
||||
lastSnapshot = "";
|
||||
isLaunched() {
|
||||
return this.browser !== null || this.isPersistentContext;
|
||||
}
|
||||
hasPages() {
|
||||
return this.pages.length > 0;
|
||||
}
|
||||
async ensurePage() {
|
||||
if (!this.context)
|
||||
throw new Error("Browser not launched. Call open first.");
|
||||
if (this.pages.length > 0)
|
||||
return;
|
||||
const page = this.context.pages()[0] || (await this.context.newPage());
|
||||
this.trackPage(page);
|
||||
this.activePageIndex = this.pages.indexOf(page);
|
||||
if (this.activePageIndex < 0)
|
||||
this.activePageIndex = 0;
|
||||
this.activeFrame = null;
|
||||
await page.bringToFront().catch(() => { });
|
||||
}
|
||||
getPage() {
|
||||
const page = this.pages[this.activePageIndex] || this.pages[0];
|
||||
if (!page) {
|
||||
throw new Error("Browser not launched. Call open first.");
|
||||
}
|
||||
return page;
|
||||
}
|
||||
getFrame() {
|
||||
if (this.activeFrame)
|
||||
return this.activeFrame;
|
||||
return this.getPage().mainFrame();
|
||||
}
|
||||
getContext() {
|
||||
if (!this.context) {
|
||||
throw new Error("Browser not launched. Call open first.");
|
||||
}
|
||||
return this.context;
|
||||
}
|
||||
getActiveIndex() {
|
||||
return this.activePageIndex;
|
||||
}
|
||||
getConsoleMessages() {
|
||||
return this.consoleMessages;
|
||||
}
|
||||
clearConsoleMessages() {
|
||||
this.consoleMessages = [];
|
||||
}
|
||||
getPageErrors() {
|
||||
return this.pageErrors;
|
||||
}
|
||||
clearPageErrors() {
|
||||
this.pageErrors = [];
|
||||
}
|
||||
getLastSnapshot() {
|
||||
return this.lastSnapshot;
|
||||
}
|
||||
getRefMap() {
|
||||
return this.refMap;
|
||||
}
|
||||
getLaunchConfig() {
|
||||
return this.launchConfig;
|
||||
}
|
||||
trackPage(page) {
|
||||
if (this.pages.includes(page))
|
||||
return;
|
||||
this.pages.push(page);
|
||||
page.on("console", (msg) => {
|
||||
this.consoleMessages.push({
|
||||
type: msg.type(),
|
||||
text: msg.text(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
this.pageErrors.push({ message: error.message, timestamp: Date.now() });
|
||||
});
|
||||
page.on("close", () => {
|
||||
const idx = this.pages.indexOf(page);
|
||||
if (idx < 0)
|
||||
return;
|
||||
if (idx === this.activePageIndex) {
|
||||
this.activeFrame = null;
|
||||
}
|
||||
this.pages.splice(idx, 1);
|
||||
if (this.pages.length === 0) {
|
||||
this.activePageIndex = 0;
|
||||
return;
|
||||
}
|
||||
if (this.activePageIndex >= this.pages.length) {
|
||||
this.activePageIndex = this.pages.length - 1;
|
||||
}
|
||||
else if (this.activePageIndex > idx) {
|
||||
this.activePageIndex--;
|
||||
}
|
||||
});
|
||||
}
|
||||
setupContextTracking(context) {
|
||||
context.on("page", (page) => {
|
||||
this.trackPage(page);
|
||||
const idx = this.pages.indexOf(page);
|
||||
if (idx >= 0 && idx !== this.activePageIndex) {
|
||||
this.activePageIndex = idx;
|
||||
this.activeFrame = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
async launch(config) {
|
||||
if (this.isLaunched())
|
||||
return;
|
||||
this.launchConfig = config;
|
||||
let Camoufox;
|
||||
try {
|
||||
({ Camoufox } = await import("camoufox-js"));
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("better-sqlite3") && message.includes("Could not locate the bindings file")) {
|
||||
throw new Error([
|
||||
"better-sqlite3 native bindings are missing (required by camoufox-js).",
|
||||
"If you installed dependencies with pnpm, allow build scripts and rebuild:",
|
||||
" pnpm -C camoufox-browser install",
|
||||
" pnpm -C camoufox-browser rebuild better-sqlite3",
|
||||
"If you're using npm, reinstall should run the build scripts automatically."
|
||||
].join("\n"));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const headless = config.headless;
|
||||
const executable_path = config.executablePath;
|
||||
const args = config.args;
|
||||
const proxy = config.proxy;
|
||||
if (config.profile) {
|
||||
const user_data_dir = expandTilde(config.profile);
|
||||
const ctx = (await Camoufox({
|
||||
headless,
|
||||
user_data_dir,
|
||||
...(executable_path ? { executable_path } : {}),
|
||||
...(args ? { args } : {}),
|
||||
...(proxy ? { proxy } : {}),
|
||||
...(config.userAgent ? { userAgent: config.userAgent } : {}),
|
||||
...(config.headers ? { extraHTTPHeaders: config.headers } : {}),
|
||||
...(config.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {})
|
||||
}));
|
||||
this.context = ctx;
|
||||
this.isPersistentContext = true;
|
||||
this.setupContextTracking(ctx);
|
||||
const initial = ctx.pages();
|
||||
if (initial.length > 0) {
|
||||
for (const p of initial)
|
||||
this.trackPage(p);
|
||||
this.activePageIndex = 0;
|
||||
}
|
||||
else {
|
||||
const p = await ctx.newPage();
|
||||
this.trackPage(p);
|
||||
this.activePageIndex = this.pages.indexOf(p);
|
||||
}
|
||||
await this.getPage().bringToFront().catch(() => { });
|
||||
return;
|
||||
}
|
||||
const b = (await Camoufox({
|
||||
headless,
|
||||
...(executable_path ? { executable_path } : {}),
|
||||
...(args ? { args } : {}),
|
||||
...(proxy ? { proxy } : {})
|
||||
}));
|
||||
this.browser = b;
|
||||
this.context = await b.newContext({
|
||||
...(config.userAgent ? { userAgent: config.userAgent } : {}),
|
||||
...(config.headers ? { extraHTTPHeaders: config.headers } : {}),
|
||||
...(config.storageState ? { storageState: config.storageState } : {}),
|
||||
...(config.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {})
|
||||
});
|
||||
this.setupContextTracking(this.context);
|
||||
const initial = this.context.pages();
|
||||
if (initial.length > 0) {
|
||||
for (const p of initial)
|
||||
this.trackPage(p);
|
||||
this.activePageIndex = 0;
|
||||
}
|
||||
else {
|
||||
const p = await this.context.newPage();
|
||||
this.trackPage(p);
|
||||
this.activePageIndex = this.pages.indexOf(p);
|
||||
}
|
||||
await this.getPage().bringToFront().catch(() => { });
|
||||
}
|
||||
async navigate(url, options) {
|
||||
await this.ensurePage();
|
||||
const page = this.getPage();
|
||||
if (options?.headers && Object.keys(options.headers).length > 0) {
|
||||
await page.setExtraHTTPHeaders(options.headers);
|
||||
}
|
||||
await page.goto(url, { waitUntil: options?.waitUntil ?? "load" });
|
||||
return { url: page.url(), title: await page.title() };
|
||||
}
|
||||
async newTab() {
|
||||
const ctx = this.getContext();
|
||||
const page = await ctx.newPage();
|
||||
this.trackPage(page);
|
||||
this.activePageIndex = this.pages.indexOf(page);
|
||||
if (this.activePageIndex < 0)
|
||||
this.activePageIndex = Math.max(0, this.pages.length - 1);
|
||||
this.activeFrame = null;
|
||||
await page.bringToFront().catch(() => { });
|
||||
return page;
|
||||
}
|
||||
async newTabManaged() {
|
||||
await this.newTab();
|
||||
return { index: this.activePageIndex, total: this.pages.length };
|
||||
}
|
||||
async switchTo(index) {
|
||||
if (index < 0 || index >= this.pages.length) {
|
||||
throw new Error(`Invalid tab index: ${index}. Available: 0-${Math.max(0, this.pages.length - 1)}`);
|
||||
}
|
||||
this.activePageIndex = index;
|
||||
this.activeFrame = null;
|
||||
const page = this.getPage();
|
||||
await page.bringToFront().catch(() => { });
|
||||
return { index: this.activePageIndex, url: page.url(), title: "" };
|
||||
}
|
||||
async closeTab(index) {
|
||||
const targetIndex = index ?? this.activePageIndex;
|
||||
if (targetIndex < 0 || targetIndex >= this.pages.length) {
|
||||
throw new Error(`Invalid tab index: ${targetIndex}`);
|
||||
}
|
||||
if (this.pages.length === 1) {
|
||||
throw new Error('Cannot close the last tab. Use "close" to close the browser.');
|
||||
}
|
||||
const page = this.pages[targetIndex];
|
||||
await page.close();
|
||||
const idx = this.pages.indexOf(page);
|
||||
if (idx >= 0) {
|
||||
this.pages.splice(idx, 1);
|
||||
}
|
||||
if (this.activePageIndex >= this.pages.length) {
|
||||
this.activePageIndex = this.pages.length - 1;
|
||||
}
|
||||
else if (this.activePageIndex > targetIndex) {
|
||||
this.activePageIndex--;
|
||||
}
|
||||
await this.getPage().bringToFront().catch(() => { });
|
||||
return { closed: targetIndex, remaining: this.pages.length };
|
||||
}
|
||||
async listTabs() {
|
||||
return await Promise.all(this.pages.map(async (page, index) => ({
|
||||
index,
|
||||
url: page.url(),
|
||||
title: await page.title().catch(() => ""),
|
||||
active: index === this.activePageIndex
|
||||
})));
|
||||
}
|
||||
async getSnapshot(options) {
|
||||
const frame = this.getFrame();
|
||||
const snapshot = await getEnhancedSnapshot(frame, options);
|
||||
this.refMap = snapshot.refs;
|
||||
this.lastSnapshot = snapshot.tree;
|
||||
return snapshot;
|
||||
}
|
||||
getLocatorFromRef(refArg) {
|
||||
const ref = parseRef(refArg);
|
||||
if (!ref)
|
||||
return null;
|
||||
const refData = this.refMap[ref];
|
||||
if (!refData)
|
||||
return null;
|
||||
const frame = this.getFrame();
|
||||
if (refData.role === "clickable" || refData.role === "focusable") {
|
||||
return frame.locator(refData.selector);
|
||||
}
|
||||
let locator = frame.getByRole(refData.role, {
|
||||
name: refData.name,
|
||||
exact: true
|
||||
});
|
||||
if (refData.nth !== undefined) {
|
||||
locator = locator.nth(refData.nth);
|
||||
}
|
||||
return locator;
|
||||
}
|
||||
getLocator(selectorOrRef) {
|
||||
const refLocator = this.getLocatorFromRef(selectorOrRef);
|
||||
if (refLocator)
|
||||
return refLocator;
|
||||
return this.getFrame().locator(selectorOrRef);
|
||||
}
|
||||
switchToMainFrame() {
|
||||
this.activeFrame = null;
|
||||
}
|
||||
async switchToFrame(options) {
|
||||
const page = this.getPage();
|
||||
if (options.selector) {
|
||||
const frameElement = await page.$(options.selector);
|
||||
if (!frameElement) {
|
||||
throw new Error(`Frame not found: ${options.selector}`);
|
||||
}
|
||||
const frame = await frameElement.contentFrame();
|
||||
if (!frame) {
|
||||
throw new Error(`Element is not a frame: ${options.selector}`);
|
||||
}
|
||||
this.activeFrame = frame;
|
||||
return;
|
||||
}
|
||||
if (options.name) {
|
||||
const frame = page.frame({ name: options.name });
|
||||
if (!frame)
|
||||
throw new Error(`Frame not found with name: ${options.name}`);
|
||||
this.activeFrame = frame;
|
||||
return;
|
||||
}
|
||||
if (options.url) {
|
||||
const frame = page.frame({ url: options.url });
|
||||
if (!frame)
|
||||
throw new Error(`Frame not found with URL: ${options.url}`);
|
||||
this.activeFrame = frame;
|
||||
return;
|
||||
}
|
||||
throw new Error("Missing frame selector");
|
||||
}
|
||||
setDialogHandler(response, promptText) {
|
||||
const page = this.getPage();
|
||||
if (this.dialogHandler && this.dialogHandlerPage) {
|
||||
this.dialogHandlerPage.removeListener("dialog", this.dialogHandler);
|
||||
}
|
||||
this.dialogHandler = async (dialog) => {
|
||||
if (response === "accept") {
|
||||
await dialog.accept(promptText);
|
||||
}
|
||||
else {
|
||||
await dialog.dismiss();
|
||||
}
|
||||
};
|
||||
this.dialogHandlerPage = page;
|
||||
page.on("dialog", this.dialogHandler);
|
||||
}
|
||||
async close() {
|
||||
const browser = this.browser;
|
||||
const context = this.context;
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.pages = [];
|
||||
this.activePageIndex = 0;
|
||||
this.activeFrame = null;
|
||||
this.isPersistentContext = false;
|
||||
this.launchConfig = null;
|
||||
this.dialogHandler = null;
|
||||
this.dialogHandlerPage = null;
|
||||
this.refMap = {};
|
||||
this.lastSnapshot = "";
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
}
|
||||
catch { }
|
||||
return;
|
||||
}
|
||||
if (context) {
|
||||
try {
|
||||
await context.close();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user