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()
384 lines
13 KiB
JavaScript
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 { }
|
|
}
|
|
}
|
|
}
|