mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-06 08:07:53 +02:00
feat: Phase 3.5 — cookie import, QA testing, team retro (v0.3.1) (#29)
* Phase 2: Enhanced browser — dialog handling, upload, state checks, snapshots - CircularBuffer O(1) ring buffer for console/network/dialog (was O(n) array+shift) - Async buffer flush with Bun.write() (was appendFileSync) - Dialog auto-accept/dismiss with buffer + prompt text support - File upload command (upload <sel> <file...>) - Element state checks (is visible/hidden/enabled/disabled/checked/editable/focused) - Annotated screenshots with ref labels overlaid (-a flag) - Snapshot diffing against previous snapshot (-D flag) - Cursor-interactive element scan for non-ARIA clickables (-C flag) - Snapshot scoping depth limit (-d N flag) - Health check with page.evaluate + 2s timeout - Playwright error wrapping — actionable messages for AI agents - Fix useragent — context recreation preserves cookies/storage/URLs - wait --networkidle / --load / --domcontentloaded flags - console --errors filter (error + warning only) - cookie-import <json-file> with auto-fill domain from page URL - 166 integration tests (was ~63) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Phase 2: Rewrite SKILL.md as QA playbook + command reference Reorient SKILL.md files from raw command reference to QA-first playbook with 10 workflow patterns (test user flows, verify deployments, dogfood features, responsive layouts, file upload, forms, dialogs, compare pages). Compact command reference tables at the bottom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Phase 3: /qa skill — systematic QA testing with health scores New /qa skill for systematic web app QA testing. Three modes: - full: 5-10 documented issues with screenshots and repro steps - quick: 30-second smoke test with health score - regression: compare against saved baseline Includes issue taxonomy (7 categories, 4 severity levels), structured report template, health score rubric (weighted across 7 categories), framework detection guidance (Next.js, Rails, WordPress, SPA). Also adds browse/bin/find-browse (DRY binary discovery using git rev-parse), .gstack/ to .gitignore, and updated TODO roadmap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Bump to v0.3.0 — Phase 2 + Phase 3 changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: cookie-import-browser — Chromium cookie decryption module + tests Pure logic module for reading and decrypting cookies from macOS Chromium browsers (Comet, Chrome, Arc, Brave, Edge). Supports v10 AES-128-CBC encryption with macOS Keychain access, PBKDF2 key derivation, and per-browser key caching. 18 unit tests with encrypted cookie fixtures. * feat: cookie picker web UI + route handler Two-panel dark-theme picker served from the browse server. Left panel shows source browser domains with search and import buttons. Right panel shows imported domains with trash buttons. No cookie values exposed. 6 API endpoints, importedDomains Set tracking, inline clearCookies. * feat: wire cookie-import-browser into browse server Add cookie-picker route dispatch (no auth, localhost-only), add cookie-import-browser to WRITE_COMMANDS and CHAIN_WRITE, add serverPort property to BrowserManager, add write command with two modes (picker UI vs --domain direct import), update CLI help text. * chore: /setup-browser-cookies skill + docs (Phase 3.5) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version and changelog (v0.3.1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: redact sensitive values from command output (PR #21) type no longer echoes text (reports character count), cookie redacts value with ****, header redacts Authorization/Cookie/X-API-Key/X-Auth-Token, storage set drops value, forms redacts password fields. Prevents secrets from persisting in LLM transcripts. 7 new tests. Credit: fredluz (PR #21) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: path traversal prevention for screenshot/pdf/eval (PR #26) Add validateOutputPath() for screenshot/pdf/responsive (restricts to /tmp and cwd) and validateReadPath() for eval (blocks .. sequences and absolute paths outside safe dirs). 7 new tests. Credit: Jah-yee (PR #26) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: auto-install Playwright Chromium in setup (PR #22) Setup now verifies Playwright can launch Chromium, and auto-installs it via `bunx playwright install chromium` if missing. Exits non-zero if build or Chromium launch fails. Credit: AkbarDevop (PR #22) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: fix path validation bypass, CORS restriction, cookie-import path check - startsWith('/tmp') matched '/tmpevil' — now requires trailing slash - CORS Access-Control-Allow-Origin changed from * to http://127.0.0.1:<port> - cookie-import now validates file paths (was missing validateReadPath) - 3 new tests for prefix collision and cookie-import path traversal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review informational issues + add regression tests - Add cookie-import to CHAIN_WRITE set for chain command routing - Add path validation to snapshot -a -o output path - Fix package.json version to match 0.3.1 - Use crypto.randomUUID() for temp DB paths (unpredictable filenames) - Add regression tests for chain cookie-import and snapshot path validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add /qa, /setup-browser-cookies to README + update BROWSER.md - Add /qa and /setup-browser-cookies to skills table, install/update/uninstall blurbs - Add dedicated README sections for both new skills with usage examples - Update demo workflow to show cookie import → QA → browse flow - Update BROWSER.md: cookie import commands, new source files, test count (203) - Update skill count from 6 to 8 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: team-aware /retro v2.0 — per-person praise and growth opportunities - Identify current user via git config, orient narrative as "you" vs teammates - Add per-author metrics: commits, LOC, focus areas, commit type mix, sessions - New "Your Week" section with personal deep-dive for whoever runs the command - New "Team Breakdown" with per-person praise and growth opportunities - Track AI-assisted commits via Co-Authored-By trailers - Personal + team shipping streaks - Tone: praise like a 1:1, growth like investment advice, never compare negatively Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add Conductor parallel sessions section to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+233
-33
@@ -5,10 +5,18 @@
|
||||
* browser.on('disconnected') → log error → process.exit(1)
|
||||
* CLI detects dead server → auto-restarts on next command
|
||||
* We do NOT try to self-heal — don't hide failure.
|
||||
*
|
||||
* Dialog handling:
|
||||
* page.on('dialog') → auto-accept by default → store in dialog buffer
|
||||
* Prevents browser lockup from alert/confirm/prompt
|
||||
*
|
||||
* Context recreation (useragent):
|
||||
* recreateContext() saves cookies/storage/URLs, creates new context,
|
||||
* restores state. Falls back to clean slate on any failure.
|
||||
*/
|
||||
|
||||
import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright';
|
||||
import { addConsoleEntry, addNetworkEntry, networkBuffer, type LogEntry, type NetworkEntry } from './buffers';
|
||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||
|
||||
export class BrowserManager {
|
||||
private browser: Browser | null = null;
|
||||
@@ -19,9 +27,20 @@ export class BrowserManager {
|
||||
private extraHeaders: Record<string, string> = {};
|
||||
private customUserAgent: string | null = null;
|
||||
|
||||
// ─── Ref Map (snapshot → @e1, @e2, ...) ────────────────────
|
||||
/** Server port — set after server starts, used by cookie-import-browser command */
|
||||
public serverPort: number = 0;
|
||||
|
||||
// ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
|
||||
private refMap: Map<string, Locator> = new Map();
|
||||
|
||||
// ─── Snapshot Diffing ─────────────────────────────────────
|
||||
// NOT cleared on navigation — it's a text baseline for diffing
|
||||
private lastSnapshot: string | null = null;
|
||||
|
||||
// ─── Dialog Handling ──────────────────────────────────────
|
||||
private dialogAutoAccept: boolean = true;
|
||||
private dialogPromptText: string | null = null;
|
||||
|
||||
async launch() {
|
||||
this.browser = await chromium.launch({ headless: true });
|
||||
|
||||
@@ -32,9 +51,17 @@ export class BrowserManager {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
this.context = await this.browser.newContext({
|
||||
const contextOptions: any = {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
};
|
||||
if (this.customUserAgent) {
|
||||
contextOptions.userAgent = this.customUserAgent;
|
||||
}
|
||||
this.context = await this.browser.newContext(contextOptions);
|
||||
|
||||
if (Object.keys(this.extraHeaders).length > 0) {
|
||||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||||
}
|
||||
|
||||
// Create first tab
|
||||
await this.newTab();
|
||||
@@ -49,8 +76,20 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.browser !== null && this.browser.isConnected();
|
||||
/** Health check — verifies Chromium is connected AND responsive */
|
||||
async isHealthy(): Promise<boolean> {
|
||||
if (!this.browser || !this.browser.isConnected()) return false;
|
||||
try {
|
||||
const page = this.pages.get(this.activeTabId);
|
||||
if (!page) return true; // connected but no pages — still healthy
|
||||
await Promise.race([
|
||||
page.evaluate('1'),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tab Management ────────────────────────────────────────
|
||||
@@ -62,7 +101,7 @@ export class BrowserManager {
|
||||
this.pages.set(id, page);
|
||||
this.activeTabId = id;
|
||||
|
||||
// Wire up console/network capture
|
||||
// Wire up console/network/dialog capture
|
||||
this.wirePageEvents(page);
|
||||
|
||||
if (url) {
|
||||
@@ -101,19 +140,6 @@ export class BrowserManager {
|
||||
return this.pages.size;
|
||||
}
|
||||
|
||||
getTabList(): Array<{ id: number; url: string; title: string; active: boolean }> {
|
||||
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
|
||||
for (const [id, page] of this.pages) {
|
||||
tabs.push({
|
||||
id,
|
||||
url: page.url(),
|
||||
title: '', // title requires await, populated by caller
|
||||
active: id === this.activeTabId,
|
||||
});
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
|
||||
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
|
||||
for (const [id, page] of this.pages) {
|
||||
@@ -152,12 +178,12 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector.
|
||||
* Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector.
|
||||
* Returns { locator } for refs or { selector } for CSS selectors.
|
||||
*/
|
||||
resolveRef(selector: string): { locator: Locator } | { selector: string } {
|
||||
if (selector.startsWith('@e')) {
|
||||
const ref = selector.slice(1); // "e3"
|
||||
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
||||
const ref = selector.slice(1); // "e3" or "c1"
|
||||
const locator = this.refMap.get(ref);
|
||||
if (!locator) {
|
||||
throw new Error(
|
||||
@@ -173,6 +199,32 @@ export class BrowserManager {
|
||||
return this.refMap.size;
|
||||
}
|
||||
|
||||
// ─── Snapshot Diffing ─────────────────────────────────────
|
||||
setLastSnapshot(text: string | null) {
|
||||
this.lastSnapshot = text;
|
||||
}
|
||||
|
||||
getLastSnapshot(): string | null {
|
||||
return this.lastSnapshot;
|
||||
}
|
||||
|
||||
// ─── Dialog Control ───────────────────────────────────────
|
||||
setDialogAutoAccept(accept: boolean) {
|
||||
this.dialogAutoAccept = accept;
|
||||
}
|
||||
|
||||
getDialogAutoAccept(): boolean {
|
||||
return this.dialogAutoAccept;
|
||||
}
|
||||
|
||||
setDialogPromptText(text: string | null) {
|
||||
this.dialogPromptText = text;
|
||||
}
|
||||
|
||||
getDialogPromptText(): string | null {
|
||||
return this.dialogPromptText;
|
||||
}
|
||||
|
||||
// ─── Viewport ──────────────────────────────────────────────
|
||||
async setViewport(width: number, height: number) {
|
||||
await this.getPage().setViewportSize({ width, height });
|
||||
@@ -187,21 +239,169 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
// ─── User Agent ────────────────────────────────────────────
|
||||
// Note: user agent changes require a new context in Playwright
|
||||
// For simplicity, we just store it and apply on next "restart"
|
||||
setUserAgent(ua: string) {
|
||||
this.customUserAgent = ua;
|
||||
}
|
||||
|
||||
// ─── Console/Network/Ref Wiring ────────────────────────────
|
||||
getUserAgent(): string | null {
|
||||
return this.customUserAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate the browser context to apply user agent changes.
|
||||
* Saves and restores cookies, localStorage, sessionStorage, and open pages.
|
||||
* Falls back to a clean slate on any failure.
|
||||
*/
|
||||
async recreateContext(): Promise<string | null> {
|
||||
if (!this.browser || !this.context) {
|
||||
throw new Error('Browser not launched');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Save state from current context
|
||||
const savedCookies = await this.context.cookies();
|
||||
const savedPages: Array<{ url: string; isActive: boolean; storage: any }> = [];
|
||||
|
||||
for (const [id, page] of this.pages) {
|
||||
const url = page.url();
|
||||
let storage = null;
|
||||
try {
|
||||
storage = await page.evaluate(() => ({
|
||||
localStorage: { ...localStorage },
|
||||
sessionStorage: { ...sessionStorage },
|
||||
}));
|
||||
} catch {}
|
||||
savedPages.push({
|
||||
url: url === 'about:blank' ? '' : url,
|
||||
isActive: id === this.activeTabId,
|
||||
storage,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Close old pages and context
|
||||
for (const page of this.pages.values()) {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
this.pages.clear();
|
||||
await this.context.close().catch(() => {});
|
||||
|
||||
// 3. Create new context with updated settings
|
||||
const contextOptions: any = {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
};
|
||||
if (this.customUserAgent) {
|
||||
contextOptions.userAgent = this.customUserAgent;
|
||||
}
|
||||
this.context = await this.browser.newContext(contextOptions);
|
||||
|
||||
if (Object.keys(this.extraHeaders).length > 0) {
|
||||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||||
}
|
||||
|
||||
// 4. Restore cookies
|
||||
if (savedCookies.length > 0) {
|
||||
await this.context.addCookies(savedCookies);
|
||||
}
|
||||
|
||||
// 5. Re-create pages
|
||||
let activeId: number | null = null;
|
||||
for (const saved of savedPages) {
|
||||
const page = await this.context.newPage();
|
||||
const id = this.nextTabId++;
|
||||
this.pages.set(id, page);
|
||||
this.wirePageEvents(page);
|
||||
|
||||
if (saved.url) {
|
||||
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
||||
}
|
||||
|
||||
// 6. Restore storage
|
||||
if (saved.storage) {
|
||||
try {
|
||||
await page.evaluate((s: any) => {
|
||||
if (s.localStorage) {
|
||||
for (const [k, v] of Object.entries(s.localStorage)) {
|
||||
localStorage.setItem(k, v as string);
|
||||
}
|
||||
}
|
||||
if (s.sessionStorage) {
|
||||
for (const [k, v] of Object.entries(s.sessionStorage)) {
|
||||
sessionStorage.setItem(k, v as string);
|
||||
}
|
||||
}
|
||||
}, saved.storage);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (saved.isActive) activeId = id;
|
||||
}
|
||||
|
||||
// If no pages were saved, create a blank one
|
||||
if (this.pages.size === 0) {
|
||||
await this.newTab();
|
||||
} else {
|
||||
this.activeTabId = activeId ?? [...this.pages.keys()][0];
|
||||
}
|
||||
|
||||
// Clear refs — pages are new, locators are stale
|
||||
this.clearRefs();
|
||||
|
||||
return null; // success
|
||||
} catch (err: any) {
|
||||
// Fallback: create a clean context + blank tab
|
||||
try {
|
||||
this.pages.clear();
|
||||
if (this.context) await this.context.close().catch(() => {});
|
||||
|
||||
const contextOptions: any = {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
};
|
||||
if (this.customUserAgent) {
|
||||
contextOptions.userAgent = this.customUserAgent;
|
||||
}
|
||||
this.context = await this.browser!.newContext(contextOptions);
|
||||
await this.newTab();
|
||||
this.clearRefs();
|
||||
} catch {
|
||||
// If even the fallback fails, we're in trouble — but browser is still alive
|
||||
}
|
||||
return `Context recreation failed: ${err.message}. Browser reset to blank tab.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Console/Network/Dialog/Ref Wiring ────────────────────
|
||||
private wirePageEvents(page: Page) {
|
||||
// Clear ref map on navigation — refs point to stale elements after page change
|
||||
// (lastSnapshot is NOT cleared — it's a text baseline for diffing)
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
this.clearRefs();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Dialog auto-handling (prevents browser lockup) ─────
|
||||
page.on('dialog', async (dialog) => {
|
||||
const entry: DialogEntry = {
|
||||
timestamp: Date.now(),
|
||||
type: dialog.type(),
|
||||
message: dialog.message(),
|
||||
defaultValue: dialog.defaultValue() || undefined,
|
||||
action: this.dialogAutoAccept ? 'accepted' : 'dismissed',
|
||||
response: this.dialogAutoAccept ? (this.dialogPromptText ?? undefined) : undefined,
|
||||
};
|
||||
addDialogEntry(entry);
|
||||
|
||||
try {
|
||||
if (this.dialogAutoAccept) {
|
||||
await dialog.accept(this.dialogPromptText ?? undefined);
|
||||
} else {
|
||||
await dialog.dismiss();
|
||||
}
|
||||
} catch {
|
||||
// Dialog may have been dismissed by navigation — ignore
|
||||
}
|
||||
});
|
||||
|
||||
page.on('console', (msg) => {
|
||||
addConsoleEntry({
|
||||
timestamp: Date.now(),
|
||||
@@ -219,13 +419,13 @@ export class BrowserManager {
|
||||
});
|
||||
|
||||
page.on('response', (res) => {
|
||||
// Find matching request entry and update it
|
||||
// Find matching request entry and update it (backward scan)
|
||||
const url = res.url();
|
||||
const status = res.status();
|
||||
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
||||
if (networkBuffer[i].url === url && !networkBuffer[i].status) {
|
||||
networkBuffer[i].status = status;
|
||||
networkBuffer[i].duration = Date.now() - networkBuffer[i].timestamp;
|
||||
const entry = networkBuffer.get(i);
|
||||
if (entry && entry.url === url && !entry.status) {
|
||||
networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -240,8 +440,9 @@ export class BrowserManager {
|
||||
const body = await res.body().catch(() => null);
|
||||
const size = body ? body.length : 0;
|
||||
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
||||
if (networkBuffer[i].url === url && !networkBuffer[i].size) {
|
||||
networkBuffer[i].size = size;
|
||||
const entry = networkBuffer.get(i);
|
||||
if (entry && entry.url === url && !entry.size) {
|
||||
networkBuffer.set(i, { ...entry, size });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -250,4 +451,3 @@ export class BrowserManager {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-14
@@ -1,8 +1,95 @@
|
||||
/**
|
||||
* Shared buffers and types — extracted to break circular dependency
|
||||
* between server.ts and browser-manager.ts
|
||||
*
|
||||
* CircularBuffer<T>: O(1) insert ring buffer with fixed capacity.
|
||||
*
|
||||
* ┌───┬───┬───┬───┬───┬───┐
|
||||
* │ 3 │ 4 │ 5 │ │ 1 │ 2 │ capacity=6, head=4, size=5
|
||||
* └───┴───┴───┴───┴─▲─┴───┘
|
||||
* │
|
||||
* head (oldest entry)
|
||||
*
|
||||
* push() writes at (head+size) % capacity, O(1)
|
||||
* toArray() returns entries in insertion order, O(n)
|
||||
* totalAdded keeps incrementing past capacity (flush cursor)
|
||||
*/
|
||||
|
||||
// ─── CircularBuffer ─────────────────────────────────────────
|
||||
|
||||
export class CircularBuffer<T> {
|
||||
private buffer: (T | undefined)[];
|
||||
private head: number = 0;
|
||||
private _size: number = 0;
|
||||
private _totalAdded: number = 0;
|
||||
readonly capacity: number;
|
||||
|
||||
constructor(capacity: number) {
|
||||
this.capacity = capacity;
|
||||
this.buffer = new Array(capacity);
|
||||
}
|
||||
|
||||
push(entry: T): void {
|
||||
const index = (this.head + this._size) % this.capacity;
|
||||
this.buffer[index] = entry;
|
||||
if (this._size < this.capacity) {
|
||||
this._size++;
|
||||
} else {
|
||||
// Buffer full — advance head (overwrites oldest)
|
||||
this.head = (this.head + 1) % this.capacity;
|
||||
}
|
||||
this._totalAdded++;
|
||||
}
|
||||
|
||||
/** Return entries in insertion order (oldest first) */
|
||||
toArray(): T[] {
|
||||
const result: T[] = [];
|
||||
for (let i = 0; i < this._size; i++) {
|
||||
result.push(this.buffer[(this.head + i) % this.capacity] as T);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Return the last N entries (most recent first → reversed to oldest first) */
|
||||
last(n: number): T[] {
|
||||
const count = Math.min(n, this._size);
|
||||
const result: T[] = [];
|
||||
const start = (this.head + this._size - count) % this.capacity;
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(this.buffer[(start + i) % this.capacity] as T);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this._size;
|
||||
}
|
||||
|
||||
get totalAdded(): number {
|
||||
return this._totalAdded;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.head = 0;
|
||||
this._size = 0;
|
||||
// Don't reset totalAdded — flush cursor depends on it
|
||||
}
|
||||
|
||||
/** Get entry by index (0 = oldest) — used by network response matching */
|
||||
get(index: number): T | undefined {
|
||||
if (index < 0 || index >= this._size) return undefined;
|
||||
return this.buffer[(this.head + index) % this.capacity];
|
||||
}
|
||||
|
||||
/** Set entry by index (0 = oldest) — used by network response matching */
|
||||
set(index: number, entry: T): void {
|
||||
if (index < 0 || index >= this._size) return;
|
||||
this.buffer[(this.head + index) % this.capacity] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Entry Types ────────────────────────────────────────────
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
level: string;
|
||||
@@ -18,27 +105,33 @@ export interface NetworkEntry {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const consoleBuffer: LogEntry[] = [];
|
||||
export const networkBuffer: NetworkEntry[] = [];
|
||||
export interface DialogEntry {
|
||||
timestamp: number;
|
||||
type: string; // 'alert' | 'confirm' | 'prompt' | 'beforeunload'
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
action: string; // 'accepted' | 'dismissed'
|
||||
response?: string; // text provided for prompt
|
||||
}
|
||||
|
||||
// ─── Buffer Instances ───────────────────────────────────────
|
||||
|
||||
const HIGH_WATER_MARK = 50_000;
|
||||
|
||||
// Total entries ever added — used by server.ts flush logic as a cursor
|
||||
// that keeps advancing even after the ring buffer wraps.
|
||||
export let consoleTotalAdded = 0;
|
||||
export let networkTotalAdded = 0;
|
||||
export const consoleBuffer = new CircularBuffer<LogEntry>(HIGH_WATER_MARK);
|
||||
export const networkBuffer = new CircularBuffer<NetworkEntry>(HIGH_WATER_MARK);
|
||||
export const dialogBuffer = new CircularBuffer<DialogEntry>(HIGH_WATER_MARK);
|
||||
|
||||
// ─── Convenience add functions ──────────────────────────────
|
||||
|
||||
export function addConsoleEntry(entry: LogEntry) {
|
||||
if (consoleBuffer.length >= HIGH_WATER_MARK) {
|
||||
consoleBuffer.shift();
|
||||
}
|
||||
consoleBuffer.push(entry);
|
||||
consoleTotalAdded++;
|
||||
}
|
||||
|
||||
export function addNetworkEntry(entry: NetworkEntry) {
|
||||
if (networkBuffer.length >= HIGH_WATER_MARK) {
|
||||
networkBuffer.shift();
|
||||
}
|
||||
networkBuffer.push(entry);
|
||||
networkTotalAdded++;
|
||||
}
|
||||
|
||||
export function addDialogEntry(entry: DialogEntry) {
|
||||
dialogBuffer.push(entry);
|
||||
}
|
||||
|
||||
+13
-4
@@ -211,20 +211,29 @@ Navigation: goto <url> | back | forward | reload | url
|
||||
Content: text | html [sel] | links | forms | accessibility
|
||||
Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
|
||||
hover <sel> | type <text> | press <key>
|
||||
scroll [sel] | wait <sel> | viewport <WxH>
|
||||
scroll [sel] | wait <sel|--networkidle|--load> | viewport <WxH>
|
||||
upload <sel> <file1> [file2...]
|
||||
cookie-import <json-file>
|
||||
cookie-import-browser [browser] [--domain <d>]
|
||||
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
||||
console [--clear] | network [--clear]
|
||||
console [--clear|--errors] | network [--clear] | dialog [--clear]
|
||||
cookies | storage [set <k> <v>] | perf
|
||||
is <prop> <sel> (visible|hidden|enabled|disabled|checked|editable|focused)
|
||||
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
||||
Snapshot: snapshot [-i] [-c] [-d N] [-s sel]
|
||||
Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]
|
||||
-D/--diff: diff against previous snapshot
|
||||
-a/--annotate: annotated screenshot with ref labels
|
||||
-C/--cursor-interactive: find non-ARIA clickable elements
|
||||
Compare: diff <url1> <url2>
|
||||
Multi-step: chain (reads JSON from stdin)
|
||||
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
||||
Server: status | cookie <n>=<v> | header <n>:<v>
|
||||
useragent <str> | stop | restart
|
||||
Dialogs: dialog-accept [text] | dialog-dismiss
|
||||
|
||||
Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
click @e3 | fill @e4 "value" | hover @e1`);
|
||||
click @e3 | fill @e4 "value" | hover @e1
|
||||
@c refs from -C: click @c1`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Chromium browser cookie import — read and decrypt cookies from real browsers
|
||||
*
|
||||
* Supports macOS Chromium-based browsers: Comet, Chrome, Arc, Brave, Edge.
|
||||
* Pure logic module — no Playwright dependency, no HTTP concerns.
|
||||
*
|
||||
* Decryption pipeline (Chromium macOS "v10" format):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────────┐
|
||||
* │ 1. Keychain: `security find-generic-password -s "<svc>" -w` │
|
||||
* │ → base64 password string │
|
||||
* │ │
|
||||
* │ 2. Key derivation: │
|
||||
* │ PBKDF2(password, salt="saltysalt", iter=1003, len=16, sha1) │
|
||||
* │ → 16-byte AES key │
|
||||
* │ │
|
||||
* │ 3. For each cookie with encrypted_value starting with "v10": │
|
||||
* │ - Ciphertext = encrypted_value[3:] │
|
||||
* │ - IV = 16 bytes of 0x20 (space character) │
|
||||
* │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │
|
||||
* │ - Remove PKCS7 padding │
|
||||
* │ - Skip first 32 bytes (HMAC-SHA256 authentication tag) │
|
||||
* │ - Remaining bytes = cookie value (UTF-8) │
|
||||
* │ │
|
||||
* │ 4. If encrypted_value is empty but `value` field is set, │
|
||||
* │ use value directly (unencrypted cookie) │
|
||||
* │ │
|
||||
* │ 5. Chromium epoch: microseconds since 1601-01-01 │
|
||||
* │ Unix seconds = (epoch - 11644473600000000) / 1000000 │
|
||||
* │ │
|
||||
* │ 6. sameSite: 0→"None", 1→"Lax", 2→"Strict", else→"Lax" │
|
||||
* └──────────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface BrowserInfo {
|
||||
name: string;
|
||||
dataDir: string; // relative to ~/Library/Application Support/
|
||||
keychainService: string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
export interface DomainEntry {
|
||||
domain: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
cookies: PlaywrightCookie[];
|
||||
count: number;
|
||||
failed: number;
|
||||
domainCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PlaywrightCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
}
|
||||
|
||||
export class CookieImportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public action?: 'retry',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CookieImportError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Browser Registry ───────────────────────────────────────────
|
||||
// Hardcoded — NEVER interpolate user input into shell commands.
|
||||
|
||||
const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome'] },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'] },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'] },
|
||||
];
|
||||
|
||||
// ─── Key Cache ──────────────────────────────────────────────────
|
||||
// Cache derived AES keys per browser. First import per browser does
|
||||
// Keychain + PBKDF2. Subsequent imports reuse the cached key.
|
||||
|
||||
const keyCache = new Map<string, Buffer>();
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find which browsers are installed (have a cookie DB on disk).
|
||||
*/
|
||||
export function findInstalledBrowsers(): BrowserInfo[] {
|
||||
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
|
||||
return BROWSER_REGISTRY.filter(b => {
|
||||
const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies');
|
||||
try { return fs.existsSync(dbPath); } catch { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List unique cookie domains + counts from a browser's DB. No decryption.
|
||||
*/
|
||||
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
|
||||
const browser = resolveBrowser(browserName);
|
||||
const dbPath = getCookieDbPath(browser, profile);
|
||||
const db = openDb(dbPath, browser.name);
|
||||
try {
|
||||
const now = chromiumNow();
|
||||
const rows = db.query(
|
||||
`SELECT host_key AS domain, COUNT(*) AS count
|
||||
FROM cookies
|
||||
WHERE has_expires = 0 OR expires_utc > ?
|
||||
GROUP BY host_key
|
||||
ORDER BY count DESC`
|
||||
).all(now) as DomainEntry[];
|
||||
return { domains: rows, browser: browser.name };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and return Playwright-compatible cookies for specific domains.
|
||||
*/
|
||||
export async function importCookies(
|
||||
browserName: string,
|
||||
domains: string[],
|
||||
profile = 'Default',
|
||||
): Promise<ImportResult> {
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
|
||||
const browser = resolveBrowser(browserName);
|
||||
const derivedKey = await getDerivedKey(browser);
|
||||
const dbPath = getCookieDbPath(browser, profile);
|
||||
const db = openDb(dbPath, browser.name);
|
||||
|
||||
try {
|
||||
const now = chromiumNow();
|
||||
// Parameterized query — no SQL injection
|
||||
const placeholders = domains.map(() => '?').join(',');
|
||||
const rows = db.query(
|
||||
`SELECT host_key, name, value, encrypted_value, path, expires_utc,
|
||||
is_secure, is_httponly, has_expires, samesite
|
||||
FROM cookies
|
||||
WHERE host_key IN (${placeholders})
|
||||
AND (has_expires = 0 OR expires_utc > ?)
|
||||
ORDER BY host_key, name`
|
||||
).all(...domains, now) as RawCookie[];
|
||||
|
||||
const cookies: PlaywrightCookie[] = [];
|
||||
let failed = 0;
|
||||
const domainCounts: Record<string, number> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const value = decryptCookieValue(row, derivedKey);
|
||||
const cookie = toPlaywrightCookie(row, value);
|
||||
cookies.push(cookie);
|
||||
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { cookies, count: cookies.length, failed, domainCounts };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal: Browser Resolution ───────────────────────────────
|
||||
|
||||
function resolveBrowser(nameOrAlias: string): BrowserInfo {
|
||||
const needle = nameOrAlias.toLowerCase().trim();
|
||||
const found = BROWSER_REGISTRY.find(b =>
|
||||
b.aliases.includes(needle) || b.name.toLowerCase() === needle
|
||||
);
|
||||
if (!found) {
|
||||
const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', ');
|
||||
throw new CookieImportError(
|
||||
`Unknown browser '${nameOrAlias}'. Supported: ${supported}`,
|
||||
'unknown_browser',
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function validateProfile(profile: string): void {
|
||||
if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) {
|
||||
throw new CookieImportError(
|
||||
`Invalid profile name: '${profile}'`,
|
||||
'bad_request',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getCookieDbPath(browser: BrowserInfo, profile: string): string {
|
||||
validateProfile(profile);
|
||||
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
|
||||
const dbPath = path.join(appSupport, browser.dataDir, profile, 'Cookies');
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new CookieImportError(
|
||||
`${browser.name} is not installed (no cookie database at ${dbPath})`,
|
||||
'not_installed',
|
||||
);
|
||||
}
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
// ─── Internal: SQLite Access ────────────────────────────────────
|
||||
|
||||
function openDb(dbPath: string, browserName: string): Database {
|
||||
try {
|
||||
return new Database(dbPath, { readonly: true });
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) {
|
||||
return openDbFromCopy(dbPath, browserName);
|
||||
}
|
||||
if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) {
|
||||
throw new CookieImportError(
|
||||
`Cookie database for ${browserName} is corrupt`,
|
||||
'db_corrupt',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function openDbFromCopy(dbPath: string, browserName: string): Database {
|
||||
const tmpPath = `/tmp/browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`;
|
||||
try {
|
||||
fs.copyFileSync(dbPath, tmpPath);
|
||||
// Also copy WAL and SHM if they exist (for consistent reads)
|
||||
const walPath = dbPath + '-wal';
|
||||
const shmPath = dbPath + '-shm';
|
||||
if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal');
|
||||
if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm');
|
||||
|
||||
const db = new Database(tmpPath, { readonly: true });
|
||||
// Schedule cleanup after the DB is closed
|
||||
const origClose = db.close.bind(db);
|
||||
db.close = () => {
|
||||
origClose();
|
||||
try { fs.unlinkSync(tmpPath); } catch {}
|
||||
try { fs.unlinkSync(tmpPath + '-wal'); } catch {}
|
||||
try { fs.unlinkSync(tmpPath + '-shm'); } catch {}
|
||||
};
|
||||
return db;
|
||||
} catch {
|
||||
// Clean up on failure
|
||||
try { fs.unlinkSync(tmpPath); } catch {}
|
||||
throw new CookieImportError(
|
||||
`Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`,
|
||||
'db_locked',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal: Keychain Access (async, 10s timeout) ─────────────
|
||||
|
||||
async function getDerivedKey(browser: BrowserInfo): Promise<Buffer> {
|
||||
const cached = keyCache.get(browser.keychainService);
|
||||
if (cached) return cached;
|
||||
|
||||
const password = await getKeychainPassword(browser.keychainService);
|
||||
const derived = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
|
||||
keyCache.set(browser.keychainService, derived);
|
||||
return derived;
|
||||
}
|
||||
|
||||
async function getKeychainPassword(service: string): Promise<string> {
|
||||
// Use async Bun.spawn with timeout to avoid blocking the event loop.
|
||||
// macOS may show an Allow/Deny dialog that blocks until the user responds.
|
||||
const proc = Bun.spawn(
|
||||
['security', 'find-generic-password', '-s', service, '-w'],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
);
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new CookieImportError(
|
||||
`macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`,
|
||||
'keychain_timeout',
|
||||
'retry',
|
||||
));
|
||||
}, 10_000),
|
||||
);
|
||||
|
||||
try {
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
if (exitCode !== 0) {
|
||||
// Distinguish denied vs not found vs other
|
||||
const errText = stderr.trim().toLowerCase();
|
||||
if (errText.includes('user canceled') || errText.includes('denied') || errText.includes('interaction not allowed')) {
|
||||
throw new CookieImportError(
|
||||
`Keychain access denied. Click "Allow" in the macOS dialog for "${service}".`,
|
||||
'keychain_denied',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
if (errText.includes('could not be found') || errText.includes('not found')) {
|
||||
throw new CookieImportError(
|
||||
`No Keychain entry for "${service}". Is this a Chromium-based browser?`,
|
||||
'keychain_not_found',
|
||||
);
|
||||
}
|
||||
throw new CookieImportError(
|
||||
`Could not read Keychain: ${stderr.trim()}`,
|
||||
'keychain_error',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
|
||||
return stdout.trim();
|
||||
} catch (err) {
|
||||
if (err instanceof CookieImportError) throw err;
|
||||
throw new CookieImportError(
|
||||
`Could not read Keychain: ${(err as Error).message}`,
|
||||
'keychain_error',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal: Cookie Decryption ────────────────────────────────
|
||||
|
||||
interface RawCookie {
|
||||
host_key: string;
|
||||
name: string;
|
||||
value: string;
|
||||
encrypted_value: Buffer | Uint8Array;
|
||||
path: string;
|
||||
expires_utc: number | bigint;
|
||||
is_secure: number;
|
||||
is_httponly: number;
|
||||
has_expires: number;
|
||||
samesite: number;
|
||||
}
|
||||
|
||||
function decryptCookieValue(row: RawCookie, key: Buffer): string {
|
||||
// Prefer unencrypted value if present
|
||||
if (row.value && row.value.length > 0) return row.value;
|
||||
|
||||
const ev = Buffer.from(row.encrypted_value);
|
||||
if (ev.length === 0) return '';
|
||||
|
||||
const prefix = ev.slice(0, 3).toString('utf-8');
|
||||
if (prefix !== 'v10') {
|
||||
throw new Error(`Unknown encryption prefix: ${prefix}`);
|
||||
}
|
||||
|
||||
const ciphertext = ev.slice(3);
|
||||
const iv = Buffer.alloc(16, 0x20); // 16 space characters
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
|
||||
// First 32 bytes are HMAC-SHA256 authentication tag; actual value follows
|
||||
if (plaintext.length <= 32) return '';
|
||||
return plaintext.slice(32).toString('utf-8');
|
||||
}
|
||||
|
||||
function toPlaywrightCookie(row: RawCookie, value: string): PlaywrightCookie {
|
||||
return {
|
||||
name: row.name,
|
||||
value,
|
||||
domain: row.host_key,
|
||||
path: row.path || '/',
|
||||
expires: chromiumEpochToUnix(row.expires_utc, row.has_expires),
|
||||
secure: row.is_secure === 1,
|
||||
httpOnly: row.is_httponly === 1,
|
||||
sameSite: mapSameSite(row.samesite),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal: Chromium Epoch Conversion ────────────────────────
|
||||
|
||||
const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
|
||||
|
||||
function chromiumNow(): bigint {
|
||||
// Current time in Chromium epoch (microseconds since 1601-01-01)
|
||||
return BigInt(Date.now()) * 1000n + CHROMIUM_EPOCH_OFFSET;
|
||||
}
|
||||
|
||||
function chromiumEpochToUnix(epoch: number | bigint, hasExpires: number): number {
|
||||
if (hasExpires === 0 || epoch === 0 || epoch === 0n) return -1; // session cookie
|
||||
const epochBig = BigInt(epoch);
|
||||
const unixMicro = epochBig - CHROMIUM_EPOCH_OFFSET;
|
||||
return Number(unixMicro / 1000000n);
|
||||
}
|
||||
|
||||
function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
|
||||
switch (value) {
|
||||
case 0: return 'None';
|
||||
case 1: return 'Lax';
|
||||
case 2: return 'Strict';
|
||||
default: return 'Lax';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Cookie picker route handler — HTTP + Playwright glue
|
||||
*
|
||||
* Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts
|
||||
* (decryption) and cookie-picker-ui.ts (HTML generation).
|
||||
*
|
||||
* Routes (no auth — localhost-only, accepted risk):
|
||||
* GET /cookie-picker → serves the picker HTML page
|
||||
* GET /cookie-picker/browsers → list installed browsers
|
||||
* GET /cookie-picker/domains → list domains + counts for a browser
|
||||
* POST /cookie-picker/import → decrypt + import cookies to Playwright
|
||||
* POST /cookie-picker/remove → clear cookies for domains
|
||||
* GET /cookie-picker/imported → currently imported domains + counts
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
|
||||
import { getCookiePickerHTML } from './cookie-picker-ui';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────
|
||||
// Tracks which domains were imported via the picker.
|
||||
// /imported only returns cookies for domains in this Set.
|
||||
// /remove clears from this Set.
|
||||
const importedDomains = new Set<string>();
|
||||
const importedCounts = new Map<string, number>();
|
||||
|
||||
// ─── JSON Helpers ───────────────────────────────────────────────
|
||||
|
||||
function jsonResponse(data: any, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': `http://127.0.0.1:${parseInt(url.port, 10) || 9400}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(message: string, code: string, status = 400, action?: string): Response {
|
||||
return jsonResponse({ error: message, code, ...(action ? { action } : {}) }, status);
|
||||
}
|
||||
|
||||
// ─── Route Handler ──────────────────────────────────────────────
|
||||
|
||||
export async function handleCookiePickerRoute(
|
||||
url: URL,
|
||||
req: Request,
|
||||
bm: BrowserManager,
|
||||
): Promise<Response> {
|
||||
const pathname = url.pathname;
|
||||
|
||||
// CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': `http://127.0.0.1:${parseInt(url.port, 10) || 9400}`,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// GET /cookie-picker — serve the picker UI
|
||||
if (pathname === '/cookie-picker' && req.method === 'GET') {
|
||||
const port = parseInt(url.port, 10) || 9400;
|
||||
const html = getCookiePickerHTML(port);
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cookie-picker/browsers — list installed browsers
|
||||
if (pathname === '/cookie-picker/browsers' && req.method === 'GET') {
|
||||
const browsers = findInstalledBrowsers();
|
||||
return jsonResponse({
|
||||
browsers: browsers.map(b => ({
|
||||
name: b.name,
|
||||
aliases: b.aliases,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cookie-picker/domains?browser=<name> — list domains + counts
|
||||
if (pathname === '/cookie-picker/domains' && req.method === 'GET') {
|
||||
const browserName = url.searchParams.get('browser');
|
||||
if (!browserName) {
|
||||
return errorResponse("Missing 'browser' parameter", 'missing_param');
|
||||
}
|
||||
const result = listDomains(browserName);
|
||||
return jsonResponse({
|
||||
browser: result.browser,
|
||||
domains: result.domains,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /cookie-picker/import — decrypt + import to Playwright session
|
||||
if (pathname === '/cookie-picker/import' && req.method === 'POST') {
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON body', 'bad_request');
|
||||
}
|
||||
|
||||
const { browser, domains } = body;
|
||||
if (!browser) return errorResponse("Missing 'browser' field", 'missing_param');
|
||||
if (!domains || !Array.isArray(domains) || domains.length === 0) {
|
||||
return errorResponse("Missing or empty 'domains' array", 'missing_param');
|
||||
}
|
||||
|
||||
// Decrypt cookies from the browser DB
|
||||
const result = await importCookies(browser, domains);
|
||||
|
||||
if (result.cookies.length === 0) {
|
||||
return jsonResponse({
|
||||
imported: 0,
|
||||
failed: result.failed,
|
||||
domainCounts: {},
|
||||
message: result.failed > 0
|
||||
? `All ${result.failed} cookies failed to decrypt`
|
||||
: 'No cookies found for the specified domains',
|
||||
});
|
||||
}
|
||||
|
||||
// Add to Playwright context
|
||||
const page = bm.getPage();
|
||||
await page.context().addCookies(result.cookies);
|
||||
|
||||
// Track what was imported
|
||||
for (const domain of Object.keys(result.domainCounts)) {
|
||||
importedDomains.add(domain);
|
||||
importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]);
|
||||
}
|
||||
|
||||
console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`);
|
||||
|
||||
return jsonResponse({
|
||||
imported: result.count,
|
||||
failed: result.failed,
|
||||
domainCounts: result.domainCounts,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /cookie-picker/remove — clear cookies for domains
|
||||
if (pathname === '/cookie-picker/remove' && req.method === 'POST') {
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON body', 'bad_request');
|
||||
}
|
||||
|
||||
const { domains } = body;
|
||||
if (!domains || !Array.isArray(domains) || domains.length === 0) {
|
||||
return errorResponse("Missing or empty 'domains' array", 'missing_param');
|
||||
}
|
||||
|
||||
const page = bm.getPage();
|
||||
const context = page.context();
|
||||
for (const domain of domains) {
|
||||
await context.clearCookies({ domain });
|
||||
importedDomains.delete(domain);
|
||||
importedCounts.delete(domain);
|
||||
}
|
||||
|
||||
console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`);
|
||||
|
||||
return jsonResponse({
|
||||
removed: domains.length,
|
||||
domains,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cookie-picker/imported — currently imported domains + counts
|
||||
if (pathname === '/cookie-picker/imported' && req.method === 'GET') {
|
||||
const entries: Array<{ domain: string; count: number }> = [];
|
||||
for (const domain of importedDomains) {
|
||||
entries.push({ domain, count: importedCounts.get(domain) || 0 });
|
||||
}
|
||||
entries.sort((a, b) => b.count - a.count);
|
||||
|
||||
return jsonResponse({
|
||||
domains: entries,
|
||||
totalDomains: entries.length,
|
||||
totalCookies: entries.reduce((sum, e) => sum + e.count, 0),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
} catch (err: any) {
|
||||
if (err instanceof CookieImportError) {
|
||||
return errorResponse(err.message, err.code, 400, err.action);
|
||||
}
|
||||
console.error(`[cookie-picker] Error: ${err.message}`);
|
||||
return errorResponse(err.message || 'Internal error', 'internal_error', 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* Cookie picker UI — self-contained HTML page
|
||||
*
|
||||
* Dark theme, two-panel layout, vanilla HTML/CSS/JS.
|
||||
* Left: source browser domains with search + import buttons.
|
||||
* Right: imported domains with trash buttons.
|
||||
* No cookie values exposed anywhere.
|
||||
*/
|
||||
|
||||
export function getCookiePickerHTML(serverPort: number): string {
|
||||
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cookie Import — gstack browse</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Header ──────────────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #222;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.header .port {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* ─── Layout ──────────────────────────── */
|
||||
.container {
|
||||
display: flex;
|
||||
height: calc(100vh - 53px);
|
||||
}
|
||||
.panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-left {
|
||||
border-right: 1px solid #222;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 16px 20px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* ─── Browser Pills ───────────────────── */
|
||||
.browser-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 20px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pill {
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.pill:hover { border-color: #555; color: #ddd; }
|
||||
.pill.active {
|
||||
border-color: #4ade80;
|
||||
background: #0a2a14;
|
||||
color: #4ade80;
|
||||
}
|
||||
.pill .dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
}
|
||||
|
||||
/* ─── Search ──────────────────────────── */
|
||||
.search-wrap {
|
||||
padding: 0 20px 12px;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #333;
|
||||
background: #141414;
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input::placeholder { color: #555; }
|
||||
.search-input:focus { border-color: #555; }
|
||||
|
||||
/* ─── Domain List ─────────────────────── */
|
||||
.domain-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.domain-list::-webkit-scrollbar { width: 6px; }
|
||||
.domain-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.domain-list::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
||||
|
||||
.domain-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.1s;
|
||||
gap: 8px;
|
||||
}
|
||||
.domain-row:hover { background: #1a1a1a; }
|
||||
.domain-name {
|
||||
flex: 1;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.domain-count {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
.btn-add, .btn-trash {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-add:hover { border-color: #4ade80; color: #4ade80; background: #0a2a14; }
|
||||
.btn-trash:hover { border-color: #f87171; color: #f87171; background: #2a0a0a; }
|
||||
.btn-add:disabled, .btn-trash:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-add.imported {
|
||||
border-color: #333;
|
||||
color: #4ade80;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Footer ──────────────────────────── */
|
||||
.panel-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #222;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ─── Imported Panel ──────────────────── */
|
||||
.imported-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #444;
|
||||
font-size: 13px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Banner ──────────────────────────── */
|
||||
.banner {
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.banner.error {
|
||||
background: #1a0a0a;
|
||||
border-bottom: 1px solid #3a1111;
|
||||
color: #f87171;
|
||||
}
|
||||
.banner.info {
|
||||
background: #0a1a2a;
|
||||
border-bottom: 1px solid #112233;
|
||||
color: #60a5fa;
|
||||
}
|
||||
.banner .banner-text { flex: 1; }
|
||||
.banner .banner-close, .banner .banner-retry {
|
||||
background: none;
|
||||
border: 1px solid currentColor;
|
||||
color: inherit;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ─── Spinner ─────────────────────────── */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px; height: 14px;
|
||||
border: 2px solid #333;
|
||||
border-top-color: #4ade80;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.loading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
gap: 10px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Cookie Import</h1>
|
||||
<span class="port">localhost:${serverPort}</span>
|
||||
</div>
|
||||
|
||||
<div id="banner" class="banner"></div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Left Panel: Source Browser -->
|
||||
<div class="panel panel-left">
|
||||
<div class="panel-header">Source Browser</div>
|
||||
<div id="browser-pills" class="browser-pills"></div>
|
||||
<div class="search-wrap">
|
||||
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
|
||||
</div>
|
||||
<div class="domain-list" id="source-domains">
|
||||
<div class="loading-row"><span class="spinner"></span> Detecting browsers...</div>
|
||||
</div>
|
||||
<div class="panel-footer" id="source-footer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Imported -->
|
||||
<div class="panel panel-right">
|
||||
<div class="panel-header">Imported to Session</div>
|
||||
<div class="domain-list" id="imported-domains">
|
||||
<div class="imported-empty">No cookies imported yet</div>
|
||||
</div>
|
||||
<div class="panel-footer" id="imported-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const BASE = '${baseUrl}';
|
||||
let activeBrowser = null;
|
||||
let allDomains = [];
|
||||
let importedSet = {}; // domain → count
|
||||
let inflight = {}; // domain → true (prevents double-click)
|
||||
|
||||
const $pills = document.getElementById('browser-pills');
|
||||
const $search = document.getElementById('search');
|
||||
const $sourceDomains = document.getElementById('source-domains');
|
||||
const $importedDomains = document.getElementById('imported-domains');
|
||||
const $sourceFooter = document.getElementById('source-footer');
|
||||
const $importedFooter = document.getElementById('imported-footer');
|
||||
const $banner = document.getElementById('banner');
|
||||
|
||||
// ─── Banner ────────────────────────────
|
||||
function showBanner(msg, type, retryFn) {
|
||||
$banner.className = 'banner ' + type;
|
||||
$banner.style.display = 'flex';
|
||||
let html = '<span class="banner-text">' + escHtml(msg) + '</span>';
|
||||
if (retryFn) {
|
||||
html += '<button class="banner-retry" id="banner-retry">Retry</button>';
|
||||
}
|
||||
html += '<button class="banner-close" id="banner-close">×</button>';
|
||||
$banner.innerHTML = html;
|
||||
document.getElementById('banner-close').onclick = () => { $banner.style.display = 'none'; };
|
||||
if (retryFn) {
|
||||
document.getElementById('banner-retry').onclick = () => {
|
||||
$banner.style.display = 'none';
|
||||
retryFn();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────
|
||||
async function api(path, opts) {
|
||||
const res = await fetch(BASE + '/cookie-picker' + path, opts);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || 'Request failed');
|
||||
err.code = data.code;
|
||||
err.action = data.action;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Init ───────────────────────────────
|
||||
async function init() {
|
||||
try {
|
||||
const [browserData, importedData] = await Promise.all([
|
||||
api('/browsers'),
|
||||
api('/imported'),
|
||||
]);
|
||||
|
||||
// Populate imported state
|
||||
for (const entry of importedData.domains) {
|
||||
importedSet[entry.domain] = entry.count;
|
||||
}
|
||||
renderImported();
|
||||
|
||||
// Render browser pills
|
||||
const browsers = browserData.browsers;
|
||||
if (browsers.length === 0) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">No Chromium browsers detected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$pills.innerHTML = '';
|
||||
browsers.forEach(b => {
|
||||
const pill = document.createElement('button');
|
||||
pill.className = 'pill';
|
||||
pill.innerHTML = '<span class="dot"></span>' + escHtml(b.name);
|
||||
pill.onclick = () => selectBrowser(b.name);
|
||||
$pills.appendChild(pill);
|
||||
});
|
||||
|
||||
// Auto-select first browser
|
||||
selectBrowser(browsers[0].name);
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', init);
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Select Browser ────────────────────
|
||||
async function selectBrowser(name) {
|
||||
activeBrowser = name;
|
||||
|
||||
// Update pills
|
||||
$pills.querySelectorAll('.pill').forEach(p => {
|
||||
p.classList.toggle('active', p.textContent === name);
|
||||
});
|
||||
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
$search.value = '';
|
||||
|
||||
try {
|
||||
const data = await api('/domains?browser=' + encodeURIComponent(name));
|
||||
allDomains = data.domains;
|
||||
renderSourceDomains();
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load domains</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render Source Domains ─────────────
|
||||
function renderSourceDomains() {
|
||||
const query = $search.value.toLowerCase();
|
||||
const filtered = query
|
||||
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
|
||||
: allDomains;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' +
|
||||
(query ? 'No matching domains' : 'No cookie domains found') + '</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const d of filtered) {
|
||||
const isImported = d.domain in importedSet;
|
||||
const isInflight = inflight[d.domain];
|
||||
html += '<div class="domain-row">';
|
||||
html += '<span class="domain-name">' + escHtml(d.domain) + '</span>';
|
||||
html += '<span class="domain-count">' + d.count + '</span>';
|
||||
if (isInflight) {
|
||||
html += '<span class="btn-add" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;"></span></span>';
|
||||
} else if (isImported) {
|
||||
html += '<span class="btn-add imported">✓</span>';
|
||||
} else {
|
||||
html += '<button class="btn-add" data-domain="' + escHtml(d.domain) + '" title="Import">+</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$sourceDomains.innerHTML = html;
|
||||
|
||||
// Total counts
|
||||
const totalDomains = allDomains.length;
|
||||
const totalCookies = allDomains.reduce((s, d) => s + d.count, 0);
|
||||
$sourceFooter.textContent = totalDomains + ' domains · ' + totalCookies.toLocaleString() + ' cookies';
|
||||
|
||||
// Click handlers
|
||||
$sourceDomains.querySelectorAll('.btn-add[data-domain]').forEach(btn => {
|
||||
btn.addEventListener('click', () => importDomain(btn.dataset.domain));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Import Domain ─────────────────────
|
||||
async function importDomain(domain) {
|
||||
if (inflight[domain] || domain in importedSet) return;
|
||||
inflight[domain] = true;
|
||||
renderSourceDomains();
|
||||
|
||||
try {
|
||||
const data = await api('/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ browser: activeBrowser, domains: [domain] }),
|
||||
});
|
||||
|
||||
if (data.domainCounts) {
|
||||
for (const [d, count] of Object.entries(data.domainCounts)) {
|
||||
importedSet[d] = (importedSet[d] || 0) + count;
|
||||
}
|
||||
}
|
||||
renderImported();
|
||||
} catch (err) {
|
||||
showBanner('Import failed for ' + domain + ': ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => importDomain(domain) : null);
|
||||
} finally {
|
||||
delete inflight[domain];
|
||||
renderSourceDomains();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render Imported ───────────────────
|
||||
function renderImported() {
|
||||
const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
$importedDomains.innerHTML = '<div class="imported-empty">No cookies imported yet</div>';
|
||||
$importedFooter.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const [domain, count] of entries) {
|
||||
const isInflight = inflight['remove:' + domain];
|
||||
html += '<div class="domain-row">';
|
||||
html += '<span class="domain-name">' + escHtml(domain) + '</span>';
|
||||
html += '<span class="domain-count">' + count + '</span>';
|
||||
if (isInflight) {
|
||||
html += '<span class="btn-trash" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;border-top-color:#f87171;"></span></span>';
|
||||
} else {
|
||||
html += '<button class="btn-trash" data-domain="' + escHtml(domain) + '" title="Remove">🗑</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$importedDomains.innerHTML = html;
|
||||
|
||||
const totalCookies = entries.reduce((s, e) => s + e[1], 0);
|
||||
$importedFooter.textContent = entries.length + ' domains · ' + totalCookies.toLocaleString() + ' cookies imported';
|
||||
|
||||
// Click handlers
|
||||
$importedDomains.querySelectorAll('.btn-trash[data-domain]').forEach(btn => {
|
||||
btn.addEventListener('click', () => removeDomain(btn.dataset.domain));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Remove Domain ─────────────────────
|
||||
async function removeDomain(domain) {
|
||||
if (inflight['remove:' + domain]) return;
|
||||
inflight['remove:' + domain] = true;
|
||||
renderImported();
|
||||
|
||||
try {
|
||||
await api('/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domains: [domain] }),
|
||||
});
|
||||
delete importedSet[domain];
|
||||
renderImported();
|
||||
renderSourceDomains(); // update checkmarks
|
||||
} catch (err) {
|
||||
showBanner('Remove failed for ' + domain + ': ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => removeDomain(domain) : null);
|
||||
} finally {
|
||||
delete inflight['remove:' + domain];
|
||||
renderImported();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Search ────────────────────────────
|
||||
$search.addEventListener('input', renderSourceDomains);
|
||||
|
||||
// ─── Start ─────────────────────────────
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
+44
-22
@@ -4,8 +4,43 @@
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { handleSnapshot } from './snapshot';
|
||||
import { getCleanText } from './read-commands';
|
||||
import * as Diff from 'diff';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Security: Path validation to prevent path traversal attacks
|
||||
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
|
||||
|
||||
function validateOutputPath(filePath: string): void {
|
||||
const resolved = path.resolve(filePath);
|
||||
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
|
||||
if (!isSafe) {
|
||||
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Command sets for chain routing (mirrors server.ts — kept local to avoid circular import)
|
||||
const CHAIN_READ = new Set([
|
||||
'text', 'html', 'links', 'forms', 'accessibility',
|
||||
'js', 'eval', 'css', 'attrs',
|
||||
'console', 'network', 'cookies', 'storage', 'perf',
|
||||
'dialog', 'is',
|
||||
]);
|
||||
const CHAIN_WRITE = new Set([
|
||||
'goto', 'back', 'forward', 'reload',
|
||||
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
|
||||
'viewport', 'cookie', 'cookie-import', 'header', 'useragent',
|
||||
'upload', 'dialog-accept', 'dialog-dismiss',
|
||||
'cookie-import-browser',
|
||||
]);
|
||||
const CHAIN_META = new Set([
|
||||
'tabs', 'tab', 'newtab', 'closetab',
|
||||
'status', 'stop', 'restart',
|
||||
'screenshot', 'pdf', 'responsive',
|
||||
'chain', 'diff',
|
||||
'url', 'snapshot',
|
||||
]);
|
||||
|
||||
export async function handleMetaCommand(
|
||||
command: string,
|
||||
@@ -73,6 +108,7 @@ export async function handleMetaCommand(
|
||||
case 'screenshot': {
|
||||
const page = bm.getPage();
|
||||
const screenshotPath = args[0] || '/tmp/browse-screenshot.png';
|
||||
validateOutputPath(screenshotPath);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
return `Screenshot saved: ${screenshotPath}`;
|
||||
}
|
||||
@@ -80,6 +116,7 @@ export async function handleMetaCommand(
|
||||
case 'pdf': {
|
||||
const page = bm.getPage();
|
||||
const pdfPath = args[0] || '/tmp/browse-page.pdf';
|
||||
validateOutputPath(pdfPath);
|
||||
await page.pdf({ path: pdfPath, format: 'A4' });
|
||||
return `PDF saved: ${pdfPath}`;
|
||||
}
|
||||
@@ -87,6 +124,7 @@ export async function handleMetaCommand(
|
||||
case 'responsive': {
|
||||
const page = bm.getPage();
|
||||
const prefix = args[0] || '/tmp/browse-responsive';
|
||||
validateOutputPath(prefix);
|
||||
const viewports = [
|
||||
{ name: 'mobile', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
@@ -129,16 +167,14 @@ export async function handleMetaCommand(
|
||||
const { handleReadCommand } = await import('./read-commands');
|
||||
const { handleWriteCommand } = await import('./write-commands');
|
||||
|
||||
const WRITE_SET = new Set(['goto','back','forward','reload','click','fill','select','hover','type','press','scroll','wait','viewport','cookie','header','useragent']);
|
||||
const READ_SET = new Set(['text','html','links','forms','accessibility','js','eval','css','attrs','console','network','cookies','storage','perf']);
|
||||
|
||||
for (const cmd of commands) {
|
||||
const [name, ...cmdArgs] = cmd;
|
||||
try {
|
||||
let result: string;
|
||||
if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
else if (READ_SET.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
|
||||
else result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
||||
if (CHAIN_WRITE.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
else if (CHAIN_READ.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
|
||||
else if (CHAIN_META.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
||||
else throw new Error(`Unknown command: ${name}`);
|
||||
results.push(`[${name}] ${result}`);
|
||||
} catch (err: any) {
|
||||
results.push(`[${name}] ERROR: ${err.message}`);
|
||||
@@ -153,26 +189,12 @@ export async function handleMetaCommand(
|
||||
const [url1, url2] = args;
|
||||
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
|
||||
|
||||
// Get text from URL1
|
||||
const page = bm.getPage();
|
||||
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const text1 = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
const clone = body.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
|
||||
return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n');
|
||||
});
|
||||
const text1 = await getCleanText(page);
|
||||
|
||||
// Get text from URL2
|
||||
await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const text2 = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
const clone = body.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
|
||||
return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n');
|
||||
});
|
||||
const text2 = await getCleanText(page);
|
||||
|
||||
const changes = Diff.diffLines(text1, text2);
|
||||
const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
|
||||
|
||||
+92
-19
@@ -6,8 +6,45 @@
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { consoleBuffer, networkBuffer } from './buffers';
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
||||
import type { Page } from 'playwright';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Security: Path validation to prevent path traversal attacks
|
||||
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
|
||||
|
||||
function validateReadPath(filePath: string): void {
|
||||
if (path.isAbsolute(filePath)) {
|
||||
const resolved = path.resolve(filePath);
|
||||
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
|
||||
if (!isSafe) {
|
||||
throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
const normalized = path.normalize(filePath);
|
||||
if (normalized.includes('..')) {
|
||||
throw new Error('Path traversal sequences (..) are not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract clean text from a page (strips script/style/noscript/svg).
|
||||
* Exported for DRY reuse in meta-commands (diff).
|
||||
*/
|
||||
export async function getCleanText(page: Page): Promise<string> {
|
||||
return await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
const clone = body.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
|
||||
return clone.innerText
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('\n');
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleReadCommand(
|
||||
command: string,
|
||||
@@ -18,17 +55,7 @@ export async function handleReadCommand(
|
||||
|
||||
switch (command) {
|
||||
case 'text': {
|
||||
return await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
const clone = body.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
|
||||
return clone.innerText
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('\n');
|
||||
});
|
||||
return await getCleanText(page);
|
||||
}
|
||||
|
||||
case 'html': {
|
||||
@@ -65,7 +92,7 @@ export async function handleReadCommand(
|
||||
id: input.id || undefined,
|
||||
placeholder: input.placeholder || undefined,
|
||||
required: input.required || undefined,
|
||||
value: input.value || undefined,
|
||||
value: input.type === 'password' ? '[redacted]' : (input.value || undefined),
|
||||
options: el.tagName === 'SELECT'
|
||||
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
|
||||
: undefined,
|
||||
@@ -98,6 +125,7 @@ export async function handleReadCommand(
|
||||
case 'eval': {
|
||||
const filePath = args[0];
|
||||
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
||||
validateReadPath(filePath);
|
||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||
const code = fs.readFileSync(filePath, 'utf-8');
|
||||
const result = await page.evaluate(code);
|
||||
@@ -154,26 +182,71 @@ export async function handleReadCommand(
|
||||
|
||||
case 'console': {
|
||||
if (args[0] === '--clear') {
|
||||
consoleBuffer.length = 0;
|
||||
consoleBuffer.clear();
|
||||
return 'Console buffer cleared.';
|
||||
}
|
||||
if (consoleBuffer.length === 0) return '(no console messages)';
|
||||
return consoleBuffer.map(e =>
|
||||
const entries = args[0] === '--errors'
|
||||
? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning')
|
||||
: consoleBuffer.toArray();
|
||||
if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)';
|
||||
return entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'network': {
|
||||
if (args[0] === '--clear') {
|
||||
networkBuffer.length = 0;
|
||||
networkBuffer.clear();
|
||||
return 'Network buffer cleared.';
|
||||
}
|
||||
if (networkBuffer.length === 0) return '(no network requests)';
|
||||
return networkBuffer.map(e =>
|
||||
return networkBuffer.toArray().map(e =>
|
||||
`${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'dialog': {
|
||||
if (args[0] === '--clear') {
|
||||
dialogBuffer.clear();
|
||||
return 'Dialog buffer cleared.';
|
||||
}
|
||||
if (dialogBuffer.length === 0) return '(no dialogs captured)';
|
||||
return dialogBuffer.toArray().map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'is': {
|
||||
const property = args[0];
|
||||
const selector = args[1];
|
||||
if (!property || !selector) throw new Error('Usage: browse is <property> <selector>\nProperties: visible, hidden, enabled, disabled, checked, editable, focused');
|
||||
|
||||
const resolved = bm.resolveRef(selector);
|
||||
let locator;
|
||||
if ('locator' in resolved) {
|
||||
locator = resolved.locator;
|
||||
} else {
|
||||
locator = page.locator(resolved.selector);
|
||||
}
|
||||
|
||||
switch (property) {
|
||||
case 'visible': return String(await locator.isVisible());
|
||||
case 'hidden': return String(await locator.isHidden());
|
||||
case 'enabled': return String(await locator.isEnabled());
|
||||
case 'disabled': return String(await locator.isDisabled());
|
||||
case 'checked': return String(await locator.isChecked());
|
||||
case 'editable': return String(await locator.isEditable());
|
||||
case 'focused': {
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
);
|
||||
return String(isFocused);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`);
|
||||
}
|
||||
}
|
||||
|
||||
case 'cookies': {
|
||||
const cookies = await page.context().cookies();
|
||||
return JSON.stringify(cookies, null, 2);
|
||||
@@ -184,7 +257,7 @@ export async function handleReadCommand(
|
||||
const key = args[1];
|
||||
const value = args[2] || '';
|
||||
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
|
||||
return `Set localStorage["${key}"] = "${value}"`;
|
||||
return `Set localStorage["${key}"]`;
|
||||
}
|
||||
const storage = await page.evaluate(() => ({
|
||||
localStorage: { ...localStorage },
|
||||
|
||||
+93
-38
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Architecture:
|
||||
* Bun.serve HTTP on localhost → routes commands to Playwright
|
||||
* Console/network buffers: in-memory (all entries) + disk flush every 1s
|
||||
* Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
|
||||
* Chromium crash → server EXITS with clear error (CLI auto-restarts)
|
||||
* Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min)
|
||||
*/
|
||||
@@ -12,6 +12,7 @@ import { BrowserManager } from './browser-manager';
|
||||
import { handleReadCommand } from './read-commands';
|
||||
import { handleWriteCommand } from './write-commands';
|
||||
import { handleMetaCommand } from './meta-commands';
|
||||
import { handleCookiePickerRoute } from './cookie-picker-routes';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
@@ -32,36 +33,58 @@ function validateAuth(req: Request): boolean {
|
||||
}
|
||||
|
||||
// ─── Buffer (from buffers.ts) ────────────────────────────────────
|
||||
import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, consoleTotalAdded, networkTotalAdded, type LogEntry, type NetworkEntry } from './buffers';
|
||||
export { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry };
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
|
||||
export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry };
|
||||
|
||||
const CONSOLE_LOG_PATH = `/tmp/browse-console${INSTANCE_SUFFIX}.log`;
|
||||
const NETWORK_LOG_PATH = `/tmp/browse-network${INSTANCE_SUFFIX}.log`;
|
||||
const DIALOG_LOG_PATH = `/tmp/browse-dialog${INSTANCE_SUFFIX}.log`;
|
||||
let lastConsoleFlushed = 0;
|
||||
let lastNetworkFlushed = 0;
|
||||
let lastDialogFlushed = 0;
|
||||
let flushInProgress = false;
|
||||
|
||||
function flushBuffers() {
|
||||
// Use totalAdded cursor (not buffer.length) because the ring buffer
|
||||
// stays pinned at HIGH_WATER_MARK after wrapping.
|
||||
const newConsoleCount = consoleTotalAdded - lastConsoleFlushed;
|
||||
if (newConsoleCount > 0) {
|
||||
const count = Math.min(newConsoleCount, consoleBuffer.length);
|
||||
const newEntries = consoleBuffer.slice(-count);
|
||||
const lines = newEntries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n') + '\n';
|
||||
fs.appendFileSync(CONSOLE_LOG_PATH, lines);
|
||||
lastConsoleFlushed = consoleTotalAdded;
|
||||
}
|
||||
async function flushBuffers() {
|
||||
if (flushInProgress) return; // Guard against concurrent flush
|
||||
flushInProgress = true;
|
||||
|
||||
const newNetworkCount = networkTotalAdded - lastNetworkFlushed;
|
||||
if (newNetworkCount > 0) {
|
||||
const count = Math.min(newNetworkCount, networkBuffer.length);
|
||||
const newEntries = networkBuffer.slice(-count);
|
||||
const lines = newEntries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||||
).join('\n') + '\n';
|
||||
fs.appendFileSync(NETWORK_LOG_PATH, lines);
|
||||
lastNetworkFlushed = networkTotalAdded;
|
||||
try {
|
||||
// Console buffer
|
||||
const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed;
|
||||
if (newConsoleCount > 0) {
|
||||
const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n') + '\n';
|
||||
await Bun.write(CONSOLE_LOG_PATH, (await Bun.file(CONSOLE_LOG_PATH).text().catch(() => '')) + lines);
|
||||
lastConsoleFlushed = consoleBuffer.totalAdded;
|
||||
}
|
||||
|
||||
// Network buffer
|
||||
const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed;
|
||||
if (newNetworkCount > 0) {
|
||||
const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||||
).join('\n') + '\n';
|
||||
await Bun.write(NETWORK_LOG_PATH, (await Bun.file(NETWORK_LOG_PATH).text().catch(() => '')) + lines);
|
||||
lastNetworkFlushed = networkBuffer.totalAdded;
|
||||
}
|
||||
|
||||
// Dialog buffer
|
||||
const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed;
|
||||
if (newDialogCount > 0) {
|
||||
const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
||||
).join('\n') + '\n';
|
||||
await Bun.write(DIALOG_LOG_PATH, (await Bun.file(DIALOG_LOG_PATH).text().catch(() => '')) + lines);
|
||||
lastDialogFlushed = dialogBuffer.totalAdded;
|
||||
}
|
||||
} catch {
|
||||
// Flush failures are non-fatal — buffers are in memory
|
||||
} finally {
|
||||
flushInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,24 +105,22 @@ const idleCheckInterval = setInterval(() => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Read/write/meta command sets for routing
|
||||
const READ_COMMANDS = new Set([
|
||||
// ─── Command Sets (exported for chain command) ──────────────────
|
||||
export const READ_COMMANDS = new Set([
|
||||
'text', 'html', 'links', 'forms', 'accessibility',
|
||||
'js', 'eval', 'css', 'attrs',
|
||||
'console', 'network', 'cookies', 'storage', 'perf',
|
||||
'dialog', 'is',
|
||||
]);
|
||||
|
||||
const WRITE_COMMANDS = new Set([
|
||||
export const WRITE_COMMANDS = new Set([
|
||||
'goto', 'back', 'forward', 'reload',
|
||||
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
|
||||
'viewport', 'cookie', 'header', 'useragent',
|
||||
'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent',
|
||||
'upload', 'dialog-accept', 'dialog-dismiss',
|
||||
]);
|
||||
|
||||
const META_COMMANDS = new Set([
|
||||
export const META_COMMANDS = new Set([
|
||||
'tabs', 'tab', 'newtab', 'closetab',
|
||||
'status', 'stop', 'restart',
|
||||
'screenshot', 'pdf', 'responsive',
|
||||
@@ -107,6 +128,10 @@ const META_COMMANDS = new Set([
|
||||
'url', 'snapshot',
|
||||
]);
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Find port: deterministic from CONDUCTOR_PORT, or scan range
|
||||
async function findPort(): Promise<number> {
|
||||
// Deterministic port from CONDUCTOR_PORT (e.g., 55040 - 45600 = 9440)
|
||||
@@ -134,6 +159,29 @@ async function findPort(): Promise<number> {
|
||||
throw new Error(`[browse] No available port in range ${start}-${start + 9}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate Playwright errors into actionable messages for AI agents.
|
||||
*/
|
||||
function wrapError(err: any): string {
|
||||
const msg = err.message || String(err);
|
||||
// Timeout errors
|
||||
if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) {
|
||||
if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) {
|
||||
return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`;
|
||||
}
|
||||
if (msg.includes('page.goto') || msg.includes('Navigation')) {
|
||||
return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`;
|
||||
}
|
||||
return `Operation timed out: ${msg.split('\n')[0]}`;
|
||||
}
|
||||
// Multiple elements matched
|
||||
if (msg.includes('resolved to') && msg.includes('elements')) {
|
||||
return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`;
|
||||
}
|
||||
// Pass through other errors
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function handleCommand(body: any): Promise<Response> {
|
||||
const { command, args = [] } = body;
|
||||
|
||||
@@ -168,7 +216,7 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
return new Response(JSON.stringify({ error: wrapError(err) }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
@@ -182,7 +230,7 @@ async function shutdown() {
|
||||
console.log('[browse] Shutting down...');
|
||||
clearInterval(flushInterval);
|
||||
clearInterval(idleCheckInterval);
|
||||
flushBuffers(); // Final flush
|
||||
await flushBuffers(); // Final flush (async now)
|
||||
|
||||
await browserManager.close();
|
||||
|
||||
@@ -201,6 +249,7 @@ async function start() {
|
||||
// Clear old log files
|
||||
try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {}
|
||||
try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {}
|
||||
try { fs.unlinkSync(DIALOG_LOG_PATH); } catch {}
|
||||
|
||||
const port = await findPort();
|
||||
|
||||
@@ -216,9 +265,14 @@ async function start() {
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Health check — no auth required
|
||||
// Cookie picker routes — no auth required (localhost-only)
|
||||
if (url.pathname.startsWith('/cookie-picker')) {
|
||||
return handleCookiePickerRoute(url, req, browserManager);
|
||||
}
|
||||
|
||||
// Health check — no auth required (now async)
|
||||
if (url.pathname === '/health') {
|
||||
const healthy = browserManager.isHealthy();
|
||||
const healthy = await browserManager.isHealthy();
|
||||
return new Response(JSON.stringify({
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
@@ -257,6 +311,7 @@ async function start() {
|
||||
};
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
|
||||
browserManager.serverPort = port;
|
||||
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
|
||||
console.log(`[browse] State file: ${STATE_FILE}`);
|
||||
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
|
||||
|
||||
+189
-4
@@ -8,11 +8,18 @@
|
||||
* 4. Store Map<string, Locator> on BrowserManager
|
||||
* 5. Return compact text output with refs prepended
|
||||
*
|
||||
* Extended features:
|
||||
* --diff / -D: Compare against last snapshot, return unified diff
|
||||
* --annotate / -a: Screenshot with overlay boxes at each @ref
|
||||
* --output / -o: Output path for annotated screenshot
|
||||
* -C / --cursor-interactive: Scan for cursor:pointer/onclick/tabindex elements
|
||||
*
|
||||
* Later: "click @e3" → look up Locator → locator.click()
|
||||
*/
|
||||
|
||||
import type { Page, Locator } from 'playwright';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import * as Diff from 'diff';
|
||||
|
||||
// Roles considered "interactive" for the -i flag
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
@@ -23,10 +30,14 @@ const INTERACTIVE_ROLES = new Set([
|
||||
]);
|
||||
|
||||
interface SnapshotOptions {
|
||||
interactive?: boolean; // -i: only interactive elements
|
||||
compact?: boolean; // -c: remove empty structural elements
|
||||
depth?: number; // -d N: limit tree depth
|
||||
selector?: string; // -s SEL: scope to CSS selector
|
||||
interactive?: boolean; // -i: only interactive elements
|
||||
compact?: boolean; // -c: remove empty structural elements
|
||||
depth?: number; // -d N: limit tree depth
|
||||
selector?: string; // -s SEL: scope to CSS selector
|
||||
diff?: boolean; // -D / --diff: diff against last snapshot
|
||||
annotate?: boolean; // -a / --annotate: annotated screenshot
|
||||
outputPath?: string; // -o / --output: path for annotated screenshot
|
||||
cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc.
|
||||
}
|
||||
|
||||
interface ParsedNode {
|
||||
@@ -63,6 +74,23 @@ export function parseSnapshotArgs(args: string[]): SnapshotOptions {
|
||||
opts.selector = args[++i];
|
||||
if (!opts.selector) throw new Error('Usage: snapshot -s <selector>');
|
||||
break;
|
||||
case '-D':
|
||||
case '--diff':
|
||||
opts.diff = true;
|
||||
break;
|
||||
case '-a':
|
||||
case '--annotate':
|
||||
opts.annotate = true;
|
||||
break;
|
||||
case '-o':
|
||||
case '--output':
|
||||
opts.outputPath = args[++i];
|
||||
if (!opts.outputPath) throw new Error('Usage: snapshot -o <path>');
|
||||
break;
|
||||
case '-C':
|
||||
case '--cursor-interactive':
|
||||
opts.cursorInteractive = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown snapshot flag: ${args[i]}`);
|
||||
}
|
||||
@@ -201,6 +229,74 @@ export async function handleSnapshot(
|
||||
output.push(outputLine);
|
||||
}
|
||||
|
||||
// ─── Cursor-interactive scan (-C) ─────────────────────────
|
||||
if (opts.cursorInteractive) {
|
||||
try {
|
||||
const cursorElements = await page.evaluate(() => {
|
||||
const STANDARD_INTERACTIVE = new Set([
|
||||
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS',
|
||||
]);
|
||||
|
||||
const results: Array<{ selector: string; text: string; reason: string }> = [];
|
||||
const allElements = document.querySelectorAll('*');
|
||||
|
||||
for (const el of allElements) {
|
||||
// Skip standard interactive elements (already in ARIA tree)
|
||||
if (STANDARD_INTERACTIVE.has(el.tagName)) continue;
|
||||
// Skip hidden elements
|
||||
if (!(el as HTMLElement).offsetParent && el.tagName !== 'BODY') continue;
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const hasCursorPointer = style.cursor === 'pointer';
|
||||
const hasOnclick = el.hasAttribute('onclick');
|
||||
const hasTabindex = el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex')!, 10) >= 0;
|
||||
const hasRole = el.hasAttribute('role');
|
||||
|
||||
if (!hasCursorPointer && !hasOnclick && !hasTabindex) continue;
|
||||
// Skip if it has an ARIA role (likely already captured)
|
||||
if (hasRole) continue;
|
||||
|
||||
// Build deterministic nth-child CSS path
|
||||
const parts: string[] = [];
|
||||
let current: Element | null = el;
|
||||
while (current && current !== document.documentElement) {
|
||||
const parent = current.parentElement;
|
||||
if (!parent) break;
|
||||
const siblings = [...parent.children];
|
||||
const index = siblings.indexOf(current) + 1;
|
||||
parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`);
|
||||
current = parent;
|
||||
}
|
||||
const selector = parts.join(' > ');
|
||||
|
||||
const text = (el as HTMLElement).innerText?.trim().slice(0, 80) || el.tagName.toLowerCase();
|
||||
const reasons: string[] = [];
|
||||
if (hasCursorPointer) reasons.push('cursor:pointer');
|
||||
if (hasOnclick) reasons.push('onclick');
|
||||
if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`);
|
||||
|
||||
results.push({ selector, text, reason: reasons.join(', ') });
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
if (cursorElements.length > 0) {
|
||||
output.push('');
|
||||
output.push('── cursor-interactive (not in ARIA tree) ──');
|
||||
let cRefCounter = 1;
|
||||
for (const elem of cursorElements) {
|
||||
const ref = `c${cRefCounter++}`;
|
||||
const locator = page.locator(elem.selector);
|
||||
refMap.set(ref, locator);
|
||||
output.push(`@${ref} [${elem.reason}] "${elem.text}"`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
output.push('');
|
||||
output.push('(cursor scan failed — CSP restriction)');
|
||||
}
|
||||
}
|
||||
|
||||
// Store ref map on BrowserManager
|
||||
bm.setRefMap(refMap);
|
||||
|
||||
@@ -208,5 +304,94 @@ export async function handleSnapshot(
|
||||
return '(no interactive elements found)';
|
||||
}
|
||||
|
||||
const snapshotText = output.join('\n');
|
||||
|
||||
// ─── Annotated screenshot (-a) ────────────────────────────
|
||||
if (opts.annotate) {
|
||||
const screenshotPath = opts.outputPath || '/tmp/browse-annotated.png';
|
||||
// Validate output path (consistent with screenshot/pdf/responsive)
|
||||
const resolvedPath = require('path').resolve(screenshotPath);
|
||||
const safeDirs = ['/tmp', process.cwd()];
|
||||
if (!safeDirs.some((dir: string) => resolvedPath === dir || resolvedPath.startsWith(dir + '/'))) {
|
||||
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
||||
}
|
||||
try {
|
||||
// Inject overlay divs at each ref's bounding box
|
||||
const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = [];
|
||||
for (const [ref, locator] of refMap) {
|
||||
try {
|
||||
const box = await locator.boundingBox({ timeout: 1000 });
|
||||
if (box) {
|
||||
boxes.push({ ref: `@${ref}`, box });
|
||||
}
|
||||
} catch {
|
||||
// Element may be offscreen or hidden — skip
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate((boxes) => {
|
||||
for (const { ref, box } of boxes) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = '__browse_annotation__';
|
||||
overlay.style.cssText = `
|
||||
position: absolute; top: ${box.y}px; left: ${box.x}px;
|
||||
width: ${box.width}px; height: ${box.height}px;
|
||||
border: 2px solid red; background: rgba(255,0,0,0.1);
|
||||
pointer-events: none; z-index: 99999;
|
||||
font-size: 10px; color: red; font-weight: bold;
|
||||
`;
|
||||
const label = document.createElement('span');
|
||||
label.textContent = ref;
|
||||
label.style.cssText = 'position: absolute; top: -14px; left: 0; background: red; color: white; padding: 0 3px; font-size: 10px;';
|
||||
overlay.appendChild(label);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
}, boxes);
|
||||
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
|
||||
// Always remove overlays
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove());
|
||||
});
|
||||
|
||||
output.push('');
|
||||
output.push(`[annotated screenshot: ${screenshotPath}]`);
|
||||
} catch {
|
||||
// Remove overlays even on screenshot failure
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove());
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Diff mode (-D) ───────────────────────────────────────
|
||||
if (opts.diff) {
|
||||
const lastSnapshot = bm.getLastSnapshot();
|
||||
if (!lastSnapshot) {
|
||||
bm.setLastSnapshot(snapshotText);
|
||||
return snapshotText + '\n\n(no previous snapshot to diff against — this snapshot stored as baseline)';
|
||||
}
|
||||
|
||||
const changes = Diff.diffLines(lastSnapshot, snapshotText);
|
||||
const diffOutput: string[] = ['--- previous snapshot', '+++ current snapshot', ''];
|
||||
|
||||
for (const part of changes) {
|
||||
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
|
||||
const diffLines = part.value.split('\n').filter(l => l.length > 0);
|
||||
for (const line of diffLines) {
|
||||
diffOutput.push(`${prefix} ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
bm.setLastSnapshot(snapshotText);
|
||||
return diffOutput.join('\n');
|
||||
}
|
||||
|
||||
// Store for future diffs
|
||||
bm.setLastSnapshot(snapshotText);
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, importCookies } from './cookie-import-browser';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export async function handleWriteCommand(
|
||||
command: string,
|
||||
@@ -94,7 +97,7 @@ export async function handleWriteCommand(
|
||||
const text = args.join(' ');
|
||||
if (!text) throw new Error('Usage: browse type <text>');
|
||||
await page.keyboard.type(text);
|
||||
return `Typed "${text}"`;
|
||||
return `Typed ${text.length} characters`;
|
||||
}
|
||||
|
||||
case 'press': {
|
||||
@@ -121,7 +124,20 @@ export async function handleWriteCommand(
|
||||
|
||||
case 'wait': {
|
||||
const selector = args[0];
|
||||
if (!selector) throw new Error('Usage: browse wait <selector>');
|
||||
if (!selector) throw new Error('Usage: browse wait <selector|--networkidle|--load|--domcontentloaded>');
|
||||
if (selector === '--networkidle') {
|
||||
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
||||
await page.waitForLoadState('networkidle', { timeout });
|
||||
return 'Network idle';
|
||||
}
|
||||
if (selector === '--load') {
|
||||
await page.waitForLoadState('load');
|
||||
return 'Page loaded';
|
||||
}
|
||||
if (selector === '--domcontentloaded') {
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
return 'DOM content loaded';
|
||||
}
|
||||
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
@@ -153,7 +169,7 @@ export async function handleWriteCommand(
|
||||
domain: url.hostname,
|
||||
path: '/',
|
||||
}]);
|
||||
return `Cookie set: ${name}=${value}`;
|
||||
return `Cookie set: ${name}=****`;
|
||||
}
|
||||
|
||||
case 'header': {
|
||||
@@ -163,14 +179,131 @@ export async function handleWriteCommand(
|
||||
const name = headerStr.slice(0, sep).trim();
|
||||
const value = headerStr.slice(sep + 1).trim();
|
||||
await bm.setExtraHeader(name, value);
|
||||
return `Header set: ${name}: ${value}`;
|
||||
const sensitiveHeaders = ['authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token'];
|
||||
const redactedValue = sensitiveHeaders.includes(name.toLowerCase()) ? '****' : value;
|
||||
return `Header set: ${name}: ${redactedValue}`;
|
||||
}
|
||||
|
||||
case 'useragent': {
|
||||
const ua = args.join(' ');
|
||||
if (!ua) throw new Error('Usage: browse useragent <string>');
|
||||
bm.setUserAgent(ua);
|
||||
return `User agent set (applies on next restart): ${ua}`;
|
||||
const error = await bm.recreateContext();
|
||||
if (error) {
|
||||
return `User agent set to "${ua}" but: ${error}`;
|
||||
}
|
||||
return `User agent set: ${ua}`;
|
||||
}
|
||||
|
||||
case 'upload': {
|
||||
const [selector, ...filePaths] = args;
|
||||
if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2...]');
|
||||
|
||||
// Validate all files exist before upload
|
||||
for (const fp of filePaths) {
|
||||
if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
|
||||
}
|
||||
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.setInputFiles(filePaths);
|
||||
} else {
|
||||
await page.locator(resolved.selector).setInputFiles(filePaths);
|
||||
}
|
||||
|
||||
const fileInfo = filePaths.map(fp => {
|
||||
const stat = fs.statSync(fp);
|
||||
return `${path.basename(fp)} (${stat.size}B)`;
|
||||
}).join(', ');
|
||||
return `Uploaded: ${fileInfo}`;
|
||||
}
|
||||
|
||||
case 'dialog-accept': {
|
||||
const text = args.length > 0 ? args.join(' ') : null;
|
||||
bm.setDialogAutoAccept(true);
|
||||
bm.setDialogPromptText(text);
|
||||
return text
|
||||
? `Dialogs will be accepted with text: "${text}"`
|
||||
: 'Dialogs will be accepted';
|
||||
}
|
||||
|
||||
case 'dialog-dismiss': {
|
||||
bm.setDialogAutoAccept(false);
|
||||
bm.setDialogPromptText(null);
|
||||
return 'Dialogs will be dismissed';
|
||||
}
|
||||
|
||||
case 'cookie-import': {
|
||||
const filePath = args[0];
|
||||
if (!filePath) throw new Error('Usage: browse cookie-import <json-file>');
|
||||
// Path validation — prevent reading arbitrary files
|
||||
if (path.isAbsolute(filePath)) {
|
||||
const safeDirs = ['/tmp', process.cwd()];
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!safeDirs.some(dir => resolved === dir || resolved.startsWith(dir + '/'))) {
|
||||
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
||||
}
|
||||
}
|
||||
if (path.normalize(filePath).includes('..')) {
|
||||
throw new Error('Path traversal sequences (..) are not allowed');
|
||||
}
|
||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
let cookies: any[];
|
||||
try { cookies = JSON.parse(raw); } catch { throw new Error(`Invalid JSON in ${filePath}`); }
|
||||
if (!Array.isArray(cookies)) throw new Error('Cookie file must contain a JSON array');
|
||||
|
||||
// Auto-fill domain from current page URL when missing (consistent with cookie command)
|
||||
const pageUrl = new URL(page.url());
|
||||
const defaultDomain = pageUrl.hostname;
|
||||
|
||||
for (const c of cookies) {
|
||||
if (!c.name || c.value === undefined) throw new Error('Each cookie must have "name" and "value" fields');
|
||||
if (!c.domain) c.domain = defaultDomain;
|
||||
if (!c.path) c.path = '/';
|
||||
}
|
||||
|
||||
await page.context().addCookies(cookies);
|
||||
return `Loaded ${cookies.length} cookies from ${filePath}`;
|
||||
}
|
||||
|
||||
case 'cookie-import-browser': {
|
||||
// Two modes:
|
||||
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain>
|
||||
// 2. Open picker UI: cookie-import-browser [browser]
|
||||
const browserArg = args[0];
|
||||
const domainIdx = args.indexOf('--domain');
|
||||
|
||||
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
||||
// Direct import mode — no UI
|
||||
const domain = args[domainIdx + 1];
|
||||
const browser = browserArg || 'comet';
|
||||
const result = await importCookies(browser, [domain]);
|
||||
if (result.cookies.length > 0) {
|
||||
await page.context().addCookies(result.cookies);
|
||||
}
|
||||
const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`];
|
||||
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
||||
return msg.join(' ');
|
||||
}
|
||||
|
||||
// Picker UI mode — open in user's browser
|
||||
const port = bm.serverPort;
|
||||
if (!port) throw new Error('Server port not available');
|
||||
|
||||
const browsers = findInstalledBrowsers();
|
||||
if (browsers.length === 0) {
|
||||
throw new Error('No Chromium browsers found. Supported: Comet, Chrome, Arc, Brave, Edge');
|
||||
}
|
||||
|
||||
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`;
|
||||
try {
|
||||
Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' });
|
||||
} catch {
|
||||
// open may fail silently — URL is in the message below
|
||||
}
|
||||
|
||||
return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`;
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user