diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c965593..d952916 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,22 +21,31 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Build Chrome extension + run: | + VERSION="${GITHUB_REF_NAME#v}" + bun run build:ext + # ZIP the extension dist (platform-independent) + cd packages/extension/dist + zip -r "../../../browseruse-extension-${VERSION}.zip" . + cd ../../.. + - name: Compile cli.ts (arm64 native + x64 cross-compile) run: | VERSION="${GITHUB_REF_NAME#v}" # arm64 (native) - bun build --compile src/cli.ts --outfile browseruse + bun build --compile packages/cli/src/cli.ts --outfile browseruse tar czf "browseruse-${VERSION}-darwin-arm64.tar.gz" browseruse rm browseruse # x64 (cross-compile) - bun build --compile --target=bun-darwin-x64 src/cli.ts --outfile browseruse + bun build --compile --target=bun-darwin-x64 packages/cli/src/cli.ts --outfile browseruse tar czf "browseruse-${VERSION}-darwin-x64.tar.gz" browseruse rm browseruse - name: Generate checksums - run: shasum -a 256 browseruse-*.tar.gz > checksums.txt + run: shasum -a 256 browseruse-*.tar.gz browseruse-extension-*.zip > checksums.txt - name: Create GitHub Release uses: softprops/action-gh-release@v2 @@ -44,4 +53,5 @@ jobs: generate_release_notes: true files: | browseruse-*.tar.gz + browseruse-extension-*.zip checksums.txt diff --git a/.gitignore b/.gitignore index e2eaf50..6c27f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ bun.lock /tmp/browseruse.log .DS_Store +packages/extension/dist/ diff --git a/README.md b/README.md index 8cb65e7..b8c0fb1 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ src/ generated.ts Auto-generated CDP types (56 domains, 652 methods) browser_protocol.json js_protocol.json -interaction-skills/ CDP pattern recipes (screenshots, cookies, tabs, etc.) + interaction-skills/ CDP pattern recipes (screenshots, cookies, tabs, etc.) SKILL.md Agent-facing documentation ``` diff --git a/package.json b/package.json index 5b2f73d..8d9344a 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,29 @@ { "name": "browseruse", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "private": true, + "workspaces": ["packages/*"], "bin": { - "browseruse": "./src/cli.ts" + "browseruse": "./packages/cli/src/cli.ts" }, "scripts": { - "gen": "bun src/gen.ts", - "repl": "bun src/repl.ts", - "start": "bun src/cli.ts --start", - "test": "bun test" + "gen": "bun packages/cli/src/gen.ts", + "repl": "bun packages/cli/src/repl.ts", + "start": "bun packages/cli/src/cli.ts --start", + "test": "bun test", + "build:ext": "bun packages/extension/build.ts", + "install:ext": "bun packages/extension/install.ts" + }, + "dependencies": { + "@browseruse/cli": "workspace:*", + "@browseruse/protocol": "workspace:*", + "puppeteer-core": "^24.43.1" }, "devDependencies": { "@types/bun": "latest", + "@types/chrome": "^0.0.287", + "esbuild": "^0.24.0", "typescript": "^5.5.0" } } diff --git a/interaction-skills/connection.md b/packages/cli/interaction-skills/connection.md similarity index 100% rename from interaction-skills/connection.md rename to packages/cli/interaction-skills/connection.md diff --git a/interaction-skills/cookies.md b/packages/cli/interaction-skills/cookies.md similarity index 100% rename from interaction-skills/cookies.md rename to packages/cli/interaction-skills/cookies.md diff --git a/interaction-skills/cross-origin-iframes.md b/packages/cli/interaction-skills/cross-origin-iframes.md similarity index 100% rename from interaction-skills/cross-origin-iframes.md rename to packages/cli/interaction-skills/cross-origin-iframes.md diff --git a/interaction-skills/dialogs.md b/packages/cli/interaction-skills/dialogs.md similarity index 100% rename from interaction-skills/dialogs.md rename to packages/cli/interaction-skills/dialogs.md diff --git a/interaction-skills/downloads.md b/packages/cli/interaction-skills/downloads.md similarity index 100% rename from interaction-skills/downloads.md rename to packages/cli/interaction-skills/downloads.md diff --git a/interaction-skills/drag-and-drop.md b/packages/cli/interaction-skills/drag-and-drop.md similarity index 100% rename from interaction-skills/drag-and-drop.md rename to packages/cli/interaction-skills/drag-and-drop.md diff --git a/interaction-skills/dropdowns.md b/packages/cli/interaction-skills/dropdowns.md similarity index 100% rename from interaction-skills/dropdowns.md rename to packages/cli/interaction-skills/dropdowns.md diff --git a/interaction-skills/iframes.md b/packages/cli/interaction-skills/iframes.md similarity index 100% rename from interaction-skills/iframes.md rename to packages/cli/interaction-skills/iframes.md diff --git a/interaction-skills/network-requests.md b/packages/cli/interaction-skills/network-requests.md similarity index 100% rename from interaction-skills/network-requests.md rename to packages/cli/interaction-skills/network-requests.md diff --git a/interaction-skills/print-as-pdf.md b/packages/cli/interaction-skills/print-as-pdf.md similarity index 100% rename from interaction-skills/print-as-pdf.md rename to packages/cli/interaction-skills/print-as-pdf.md diff --git a/interaction-skills/screenshots.md b/packages/cli/interaction-skills/screenshots.md similarity index 100% rename from interaction-skills/screenshots.md rename to packages/cli/interaction-skills/screenshots.md diff --git a/interaction-skills/scrolling.md b/packages/cli/interaction-skills/scrolling.md similarity index 100% rename from interaction-skills/scrolling.md rename to packages/cli/interaction-skills/scrolling.md diff --git a/interaction-skills/shadow-dom.md b/packages/cli/interaction-skills/shadow-dom.md similarity index 100% rename from interaction-skills/shadow-dom.md rename to packages/cli/interaction-skills/shadow-dom.md diff --git a/interaction-skills/tabs.md b/packages/cli/interaction-skills/tabs.md similarity index 100% rename from interaction-skills/tabs.md rename to packages/cli/interaction-skills/tabs.md diff --git a/interaction-skills/uploads.md b/packages/cli/interaction-skills/uploads.md similarity index 100% rename from interaction-skills/uploads.md rename to packages/cli/interaction-skills/uploads.md diff --git a/interaction-skills/viewport.md b/packages/cli/interaction-skills/viewport.md similarity index 100% rename from interaction-skills/viewport.md rename to packages/cli/interaction-skills/viewport.md diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..9bb5c95 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,12 @@ +{ + "name": "@browseruse/cli", + "version": "0.4.0", + "type": "module", + "private": true, + "bin": { + "browseruse": "./src/cli.ts" + }, + "dependencies": { + "@browseruse/protocol": "workspace:*" + } +} diff --git a/src/__tests__/e2e.test.ts b/packages/cli/src/__tests__/e2e.test.ts similarity index 99% rename from src/__tests__/e2e.test.ts rename to packages/cli/src/__tests__/e2e.test.ts index 23cff5a..ca977b1 100644 --- a/src/__tests__/e2e.test.ts +++ b/packages/cli/src/__tests__/e2e.test.ts @@ -40,7 +40,7 @@ beforeAll(async () => { port = randomPort(); baseUrl = `http://127.0.0.1:${port}`; - proc = Bun.spawn(['bun', 'src/repl.ts'], { + proc = Bun.spawn(['bun', 'packages/cli/src/repl.ts'], { env: { ...process.env, BROWSERUSE_PORT: String(port) }, stdout: 'pipe', stderr: 'pipe', diff --git a/src/__tests__/repl.test.ts b/packages/cli/src/__tests__/repl.test.ts similarity index 99% rename from src/__tests__/repl.test.ts rename to packages/cli/src/__tests__/repl.test.ts index 7f9d05f..8eddfa5 100644 --- a/src/__tests__/repl.test.ts +++ b/packages/cli/src/__tests__/repl.test.ts @@ -32,7 +32,7 @@ beforeAll(async () => { port = randomPort(); baseUrl = `http://127.0.0.1:${port}`; - proc = Bun.spawn(['bun', 'src/repl.ts'], { + proc = Bun.spawn(['bun', 'packages/cli/src/repl.ts'], { env: { ...process.env, BROWSERUSE_PORT: String(port) }, stdout: 'pipe', stderr: 'pipe', diff --git a/src/__tests__/unit.test.ts b/packages/cli/src/__tests__/unit.test.ts similarity index 100% rename from src/__tests__/unit.test.ts rename to packages/cli/src/__tests__/unit.test.ts diff --git a/src/browser.ts b/packages/cli/src/browser.ts similarity index 89% rename from src/browser.ts rename to packages/cli/src/browser.ts index 95c748c..8be1155 100644 --- a/src/browser.ts +++ b/packages/cli/src/browser.ts @@ -10,7 +10,7 @@ */ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs'; -import { join } from 'path'; +import { join, dirname } from 'path'; import { homedir } from 'os'; // --------------------------------------------------------------------------- @@ -49,6 +49,27 @@ const PROFILES_DIR = join(BROWSERUSE_DIR, 'profiles'); const PORT_FILE = join(BROWSERUSE_DIR, 'cdp-port'); const PID_FILE = join(BROWSERUSE_DIR, 'cdp-pid'); +/** + * Resolve the Chrome extension directory. Checks in order: + * 1. Sarea app bundle: .app/Contents/Resources/browseruse-extension/ + * 2. Development: packages/extension/dist/ (relative to source) + */ +function extensionDistDir(): string { + const execDir = dirname(process.execPath); + + // Bundled in Sarea.app: .app/Contents/Resources/bin/browseruse + // Extension at: .app/Contents/Resources/browseruse-extension/ + if (execDir.includes('.app/Contents/')) { + const resourcesDir = join(execDir, '..'); + const bundled = join(resourcesDir, 'browseruse-extension'); + if (existsSync(join(bundled, 'manifest.json'))) return bundled; + } + + // Development: packages/cli/src/browser.ts → packages/extension/dist + const cliSrc = dirname(new URL(import.meta.url).pathname); + return join(cliSrc, '..', '..', 'extension', 'dist'); +} + function profileDir(name: string): string { return join(PROFILES_DIR, name); } @@ -152,7 +173,6 @@ const DEFAULT_FLAGS = [ '--disable-background-networking', '--disable-client-side-phishing-detection', '--disable-default-apps', - '--disable-extensions-except=', '--disable-hang-monitor', '--disable-popup-blocking', '--disable-prompt-on-repost', @@ -183,10 +203,22 @@ export async function launchBrowser(opts: LaunchOptions = {}): Promise { // Fast-path: help / version (no server needed, no dynamic imports) if (cmd === '--help' || cmd === '-h') { printUsage(); return; } - if (cmd === '--version' || cmd === '-v') { console.log('browseruse 0.3.0'); return; } + if (cmd === '--version' || cmd === '-v') { console.log('browseruse 0.4.0'); return; } // Command registry lookup const command = commandMap.get(cmd); diff --git a/packages/cli/src/control-socket.ts b/packages/cli/src/control-socket.ts new file mode 100644 index 0000000..b19a594 --- /dev/null +++ b/packages/cli/src/control-socket.ts @@ -0,0 +1,210 @@ +/** + * Unix socket server for NDJSON control protocol. + * + * Listens on ~/.browseruse/browseruse.sock and accepts one-shot NDJSON + * requests from local clients (e.g. Sarea, scripts, nc -U). + * + * Protocol: + * Client connects → sends one line of JSON (terminated by \n) → + * server responds with one line of JSON → connection closes. + * + * Request format: + * { "protocolVersion": 1, "operation": { "kind": "browseruse.", "payload": { ... } } } + * + * Response format: + * { "ok": true, "result": { "kind": "", "data": { ... } } } + * { "ok": false, "error": { "code": "", "message": "" } } + */ + +import type { Session } from './session.ts'; +import { listPageTargets } from './session.ts'; +import { getExtensionConnected } from './ws-handler.ts'; +import { homedir } from 'os'; +import { join } from 'path'; +import { unlinkSync, chmodSync, mkdirSync, existsSync } from 'fs'; + +const BROWSERUSE_DIR = join(homedir(), '.browseruse'); +const SOCKET_PATH = join(BROWSERUSE_DIR, 'browseruse.sock'); +const PROTOCOL_VERSION = 1; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Request { + protocolVersion: number; + operation: { + kind: string; + payload?: Record; + tabId?: number; + }; +} + +interface Response { + ok: boolean; + error?: { code: string; message: string }; + result?: unknown; +} + +// --------------------------------------------------------------------------- +// Helpers (duplicated from repl.ts to avoid circular deps) +// --------------------------------------------------------------------------- + +function isExpression(code: string): boolean { + const trimmed = code.trim(); + if (!trimmed) return false; + if (/[;\n]/.test(trimmed)) return false; + if (/^(let|const|var|if|for|while|do|switch|class|function|throw|try|return|import|export)\b/.test(trimmed)) return false; + return true; +} + +function serialize(v: unknown): unknown { + if (v === undefined) return undefined; + try { + return JSON.parse(JSON.stringify(v, (_k, val) => typeof val === 'bigint' ? val.toString() : val)); + } catch { + return String(v); + } +} + +async function runSnippet(code: string): Promise { + const body = isExpression(code) ? `return (${code});` : code; + const wrapped = `(async () => { ${body} })()`; + return await (0, eval)(wrapped); +} + +// --------------------------------------------------------------------------- +// Request handler +// --------------------------------------------------------------------------- + +async function handleRequest(raw: string, session: Session, startedAt: number): Promise { + let req: Request; + try { + req = JSON.parse(raw.trim()); + } catch { + return { ok: false, error: { code: 'parse_error', message: 'Invalid JSON' } }; + } + + if (req.protocolVersion !== PROTOCOL_VERSION) { + return { ok: false, error: { code: 'version_mismatch', message: `Expected protocol version ${PROTOCOL_VERSION}, got ${req.protocolVersion}` } }; + } + + if (!req.operation?.kind) { + return { ok: false, error: { code: 'invalid_request', message: 'Missing operation.kind' } }; + } + + try { + switch (req.operation.kind) { + case 'browseruse.ping': + return { ok: true, result: { kind: 'pong' } }; + + case 'browseruse.status': + return { ok: true, result: { kind: 'status', data: { + connected: session.isConnected(), + sessionId: session.getActiveSession() ?? null, + uptime: Math.floor((Date.now() - startedAt) / 1000), + extensionConnected: getExtensionConnected(), + }}}; + + case 'browseruse.connect': + await session.connect(req.operation.payload ?? {}); + return { ok: true, result: { kind: 'connected' } }; + + case 'browseruse.eval': { + const code = (req.operation.payload as any)?.code; + if (!code || typeof code !== 'string') { + return { ok: false, error: { code: 'invalid_params', message: 'Missing or invalid "code" in payload' } }; + } + const result = await runSnippet(code); + return { ok: true, result: { kind: 'eval', data: { value: serialize(result) } } }; + } + + case 'browseruse.tabs': { + if (!session.isConnected()) { + return { ok: false, error: { code: 'not_connected', message: 'Not connected to a browser' } }; + } + const tabs = await listPageTargets(session); + return { ok: true, result: { kind: 'tabs', data: tabs } }; + } + + case 'browseruse.cdpRaw': { + const method = (req.operation.payload as any)?.method; + const params = (req.operation.payload as any)?.params ?? {}; + if (!method || typeof method !== 'string') { + return { ok: false, error: { code: 'invalid_params', message: 'Missing or invalid "method" in payload' } }; + } + if (!session.isConnected()) { + return { ok: false, error: { code: 'not_connected', message: 'Not connected to a browser' } }; + } + const cdpResult = await session._call(method, params); + return { ok: true, result: { kind: 'cdp', data: cdpResult } }; + } + + default: + return { ok: false, error: { code: 'unknown_operation', message: `Unknown operation: ${req.operation.kind}` } }; + } + } catch (e: any) { + return { ok: false, error: { code: 'internal_error', message: e.message ?? String(e) } }; + } +} + +// --------------------------------------------------------------------------- +// Socket server +// --------------------------------------------------------------------------- + +/** Per-connection buffer to accumulate data until we see a newline. */ +const buffers = new WeakMap(); + +export function startControlSocket(session: Session, startedAt: number): void { + // Ensure ~/.browseruse/ exists + if (!existsSync(BROWSERUSE_DIR)) { + mkdirSync(BROWSERUSE_DIR, { recursive: true }); + } + + // Remove stale socket file + try { unlinkSync(SOCKET_PATH); } catch {} + + Bun.listen({ + unix: SOCKET_PATH, + socket: { + open(socket) { + buffers.set(socket, ''); + }, + data(socket, data) { + let buf = (buffers.get(socket) ?? '') + data.toString(); + const newlineIdx = buf.indexOf('\n'); + if (newlineIdx === -1) { + // Haven't received a complete line yet, buffer and wait + buffers.set(socket, buf); + return; + } + + const line = buf.slice(0, newlineIdx); + buffers.delete(socket); + + handleRequest(line, session, startedAt) + .then(response => { + socket.write(JSON.stringify(response) + '\n'); + socket.end(); + }) + .catch(() => { + socket.end(); + }); + }, + close() {}, + error(_socket, err) { + // Log but don't crash + if (process.env.DEBUG) { + console.error('[control-socket] error:', err); + } + }, + }, + }); + + // Restrict permissions to owner only + chmodSync(SOCKET_PATH, 0o600); +} + +export function getSocketPath(): string { + return SOCKET_PATH; +} diff --git a/src/gen.ts b/packages/cli/src/gen.ts similarity index 100% rename from src/gen.ts rename to packages/cli/src/gen.ts diff --git a/src/generated.ts b/packages/cli/src/generated.ts similarity index 100% rename from src/generated.ts rename to packages/cli/src/generated.ts diff --git a/src/js_protocol.json b/packages/cli/src/js_protocol.json similarity index 100% rename from src/js_protocol.json rename to packages/cli/src/js_protocol.json diff --git a/src/repl.ts b/packages/cli/src/repl.ts similarity index 67% rename from src/repl.ts rename to packages/cli/src/repl.ts index 0711449..bc5b085 100644 --- a/src/repl.ts +++ b/packages/cli/src/repl.ts @@ -11,6 +11,10 @@ * POST /connect Connect session. Body: JSON ConnectOptions or empty for auto. * POST /launch Launch managed browser. Body: JSON LaunchOptions or empty. * POST /quit Graceful shutdown. Returns {"ok":true} then exits. + * WS /ws JSON-RPC 2.0 WebSocket for agents and Chrome extension. + * + * Additionally listens on a Unix socket at ~/.browseruse/browseruse.sock + * for NDJSON control protocol (used by Sarea, scripts, etc.). * * State: `session`, the active sessionId, event subscribers, and any * `globalThis.` you set persist across requests for the lifetime of @@ -20,19 +24,11 @@ import { Session, listPageTargets, resolveWsUrl, detectBrowsers } from './session.ts'; import { launchBrowser, getManagedBrowser, closeManagedBrowser } from './browser.ts'; import * as Generated from './generated.ts'; - -const session = new Session(); -(globalThis as any).session = session; -(globalThis as any).listPageTargets = () => listPageTargets(session); -(globalThis as any).resolveWsUrl = resolveWsUrl; -(globalThis as any).detectBrowsers = detectBrowsers; -(globalThis as any).launchBrowser = launchBrowser; -(globalThis as any).getManagedBrowser = getManagedBrowser; -(globalThis as any).closeManagedBrowser = closeManagedBrowser; -(globalThis as any).CDP = Generated; +import { handleWsOpen, handleWsClose, handleWsMessage, type WsData } from './ws-handler.ts'; +import { startControlSocket, getSocketPath } from './control-socket.ts'; +import { unlinkSync } from 'fs'; const PORT = Number(process.env.BROWSERUSE_PORT ?? process.env.CDP_REPL_PORT ?? 9876); -const startedAt = Date.now(); function isExpression(code: string): boolean { const trimmed = code.trim(); @@ -69,13 +65,63 @@ function renderResult(v: unknown): string { return JSON.stringify(s); } -export function runServer(): void { - const server = Bun.serve({ +// --------------------------------------------------------------------------- +// Server options and context +// --------------------------------------------------------------------------- + +export interface ServerOptions { + /** Suppress stdout output (e.g. for embedded / native-host mode). */ + silent?: boolean; + /** Start Unix socket for NDJSON control protocol. Default: true. */ + controlSocket?: boolean; +} + +export interface ServerContext { + session: Session; + server: ReturnType; + startedAt: number; +} + +// --------------------------------------------------------------------------- +// Socket cleanup helper +// --------------------------------------------------------------------------- + +function cleanupSocket(): void { + try { unlinkSync(getSocketPath()); } catch {} +} + +// --------------------------------------------------------------------------- +// Main server +// --------------------------------------------------------------------------- + +export function runServer(opts?: ServerOptions): ServerContext { + const session = new Session(); + const startedAt = Date.now(); + + (globalThis as any).session = session; + (globalThis as any).listPageTargets = () => listPageTargets(session); + (globalThis as any).resolveWsUrl = resolveWsUrl; + (globalThis as any).detectBrowsers = detectBrowsers; + (globalThis as any).launchBrowser = launchBrowser; + (globalThis as any).getManagedBrowser = getManagedBrowser; + (globalThis as any).closeManagedBrowser = closeManagedBrowser; + (globalThis as any).CDP = Generated; + + const server = Bun.serve({ port: PORT, hostname: '127.0.0.1', - async fetch(req) { + async fetch(req, server) { const url = new URL(req.url); + // WS /ws — upgrade to WebSocket + if (url.pathname === '/ws') { + const upgraded = server.upgrade(req, { data: { clientId: '' } }); + if (!upgraded) { + return new Response('WebSocket upgrade failed', { status: 400 }); + } + return undefined; + } + // GET /health if (req.method === 'GET' && url.pathname === '/health') { return Response.json({ @@ -158,6 +204,7 @@ export function runServer(): void { // POST /quit if (req.method === 'POST' && url.pathname === '/quit') { setTimeout(async () => { + cleanupSocket(); await closeManagedBrowser(); server.stop(true); session.close(); @@ -168,26 +215,48 @@ export function runServer(): void { return new Response('not found', { status: 404 }); }, + websocket: { + open(ws) { + handleWsOpen(ws); + }, + message(ws, message) { + handleWsMessage(ws, message as string | Buffer, session, startedAt); + }, + close(ws) { + handleWsClose(ws, session, startedAt); + }, + }, }); - // Clean up managed browser on unexpected exit + // Start Unix socket for NDJSON control protocol + if (opts?.controlSocket !== false) { + startControlSocket(session, startedAt); + } + + // Clean up managed browser and socket on unexpected exit process.on('SIGINT', async () => { + cleanupSocket(); await closeManagedBrowser(); session.close(); process.exit(0); }); process.on('SIGTERM', async () => { + cleanupSocket(); await closeManagedBrowser(); session.close(); process.exit(0); }); - console.log(JSON.stringify({ - ok: true, - ready: true, - port: server.port, - message: `browseruse REPL listening on http://127.0.0.1:${server.port}`, - })); + if (!opts?.silent) { + console.log(JSON.stringify({ + ok: true, + ready: true, + port: server.port, + message: `browseruse REPL listening on http://127.0.0.1:${server.port}`, + })); + } + + return { session, server, startedAt }; } if (import.meta.main) { diff --git a/src/session.ts b/packages/cli/src/session.ts similarity index 100% rename from src/session.ts rename to packages/cli/src/session.ts diff --git a/packages/cli/src/ws-handler.ts b/packages/cli/src/ws-handler.ts new file mode 100644 index 0000000..3b84dd5 --- /dev/null +++ b/packages/cli/src/ws-handler.ts @@ -0,0 +1,312 @@ +/** + * WebSocket connection manager for JSON-RPC 2.0 communication. + * + * Manages connected clients (agents and extension), routes JSON-RPC messages, + * and forwards extension-bound methods to the Chrome extension. + */ + +import type { ServerWebSocket } from 'bun'; +import type { Session } from './session.ts'; +import { getManagedBrowser } from './browser.ts'; +import { + type JsonRpcRequest, + type JsonRpcId, + isRequest, + makeSuccess, + makeError, + makeNotification, + Methods, + SERVER_METHODS, + EXTENSION_METHODS, + Events, + ErrorCodes, +} from '@browseruse/protocol'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ClientType = 'extension' | 'agent' | 'cli'; + +export interface ClientInfo { + id: string; + type: ClientType; + version?: string; + ws: ServerWebSocket; +} + +export interface WsData { + clientId: string; +} + +type PendingForward = { + resolve: (result: unknown) => void; + reject: (error: { code: number; message: string; data?: unknown }) => void; + timer: ReturnType; +}; + +// --------------------------------------------------------------------------- +// Client Registry +// --------------------------------------------------------------------------- + +const clients = new Map(); +let extensionClient: ClientInfo | undefined; +let nextClientNum = 1; +const pendingForwards = new Map(); +let forwardIdCounter = 1; + +const FORWARD_TIMEOUT_MS = 30_000; + +export function getExtensionConnected(): boolean { + return extensionClient !== undefined; +} + +// --------------------------------------------------------------------------- +// WebSocket lifecycle handlers +// --------------------------------------------------------------------------- + +export function handleWsOpen(ws: ServerWebSocket): void { + const clientId = `client-${nextClientNum++}`; + ws.data = { clientId }; + // Client registered on handshake, not on open. +} + +export function handleWsClose(ws: ServerWebSocket, session: Session, startedAt: number): void { + const clientId = ws.data?.clientId; + if (!clientId) return; + + const client = clients.get(clientId); + if (!client) return; + + if (extensionClient?.id === clientId) { + extensionClient = undefined; + // Notify remaining clients that extension disconnected + broadcast(makeNotification(Events.EXTENSION_DISCONNECTED, { clientId })); + // Reject all pending forwards + for (const [fwdId, pending] of pendingForwards) { + clearTimeout(pending.timer); + pending.reject({ code: ErrorCodes.EXTENSION_NOT_CONNECTED, message: 'Extension disconnected' }); + pendingForwards.delete(fwdId); + } + } + + clients.delete(clientId); +} + +export function handleWsMessage( + ws: ServerWebSocket, + message: string | Buffer, + session: Session, + startedAt: number, +): void { + const raw = typeof message === 'string' ? message : message.toString(); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + ws.send(JSON.stringify(makeError(null, ErrorCodes.PARSE_ERROR, 'Parse error'))); + return; + } + + // Handle responses from the extension (forwarded results) + if (isForwardResponse(parsed)) { + handleForwardResponse(parsed as any); + return; + } + + if (!isRequest(parsed)) { + ws.send(JSON.stringify(makeError(null, ErrorCodes.INVALID_REQUEST, 'Invalid request'))); + return; + } + + handleRequest(ws, parsed, session, startedAt); +} + +// --------------------------------------------------------------------------- +// Request routing +// --------------------------------------------------------------------------- + +async function handleRequest( + ws: ServerWebSocket, + req: JsonRpcRequest, + session: Session, + startedAt: number, +): Promise { + const { id, method, params } = req; + + try { + if (SERVER_METHODS.has(method)) { + const result = await handleServerMethod(ws, method, params ?? {}, session, startedAt); + ws.send(JSON.stringify(makeSuccess(id, result))); + } else if (EXTENSION_METHODS.has(method)) { + const result = await forwardToExtension(id, method, params ?? {}); + ws.send(JSON.stringify(makeSuccess(id, result))); + } else { + ws.send(JSON.stringify(makeError(id, ErrorCodes.METHOD_NOT_FOUND, `Unknown method: ${method}`))); + } + } catch (err: any) { + const code = err.code ?? ErrorCodes.INTERNAL_ERROR; + const message = err.message ?? 'Internal error'; + ws.send(JSON.stringify(makeError(id, code, message, err.data))); + } +} + +// --------------------------------------------------------------------------- +// Server-handled methods +// --------------------------------------------------------------------------- + +async function handleServerMethod( + ws: ServerWebSocket, + method: string, + params: Record, + session: Session, + startedAt: number, +): Promise { + switch (method) { + case Methods.SESSION_HANDSHAKE: { + const clientType = (params.clientType as ClientType) ?? 'agent'; + const version = params.version as string | undefined; + const clientId = ws.data.clientId; + + const client: ClientInfo = { id: clientId, type: clientType, version, ws }; + clients.set(clientId, client); + + if (clientType === 'extension') { + extensionClient = client; + // Notify others that extension connected + broadcastExcept(clientId, makeNotification(Events.EXTENSION_CONNECTED, { clientId, version })); + } + + return { + serverVersion: '0.4.0', + sessionConnected: session.isConnected(), + clientId, + }; + } + + case Methods.SESSION_PING: + return { pong: true, timestamp: Date.now() }; + + case Methods.SESSION_STATUS: { + const managed = getManagedBrowser(); + return { + connected: session.isConnected(), + sessionId: session.getActiveSession() ?? null, + managedBrowser: managed ? { pid: managed.pid, port: managed.port, profile: managed.profile } : null, + uptime: Math.floor((Date.now() - startedAt) / 1000), + extensionConnected: extensionClient !== undefined, + }; + } + + case Methods.SESSION_CDP_RAW: { + const cdpMethod = params.method as string; + const cdpParams = (params.params as Record) ?? {}; + const tabId = params.tabId as number | undefined; + if (!cdpMethod) { + throw { code: ErrorCodes.INVALID_PARAMS, message: 'Missing "method" param' }; + } + + // If a direct CDP session is connected, use it + if (session.isConnected()) { + try { + const result = await session._call(cdpMethod, cdpParams); + return result; + } catch (err: any) { + throw { code: ErrorCodes.CDP_ERROR, message: err.message ?? String(err) }; + } + } + + // Otherwise, forward to extension's debugger if available + if (extensionClient && tabId !== undefined) { + return forwardToExtension( + 'cdp-raw', + Methods.DEBUGGER_SEND_COMMAND, + { tabId, method: cdpMethod, params: cdpParams }, + ); + } + + throw { code: ErrorCodes.NOT_CONNECTED, message: 'No CDP session or extension debugger available' }; + } + + default: + throw { code: ErrorCodes.METHOD_NOT_FOUND, message: `Unknown server method: ${method}` }; + } +} + +// --------------------------------------------------------------------------- +// Extension forwarding +// --------------------------------------------------------------------------- + +function forwardToExtension( + _originalId: JsonRpcId, + method: string, + params: Record, +): Promise { + if (!extensionClient) { + return Promise.reject({ code: ErrorCodes.EXTENSION_NOT_CONNECTED, message: 'No extension connected' }); + } + + const fwdId = `fwd-${forwardIdCounter++}`; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingForwards.delete(fwdId); + reject({ code: ErrorCodes.EXTENSION_TIMEOUT, message: `Extension did not respond within ${FORWARD_TIMEOUT_MS}ms` }); + }, FORWARD_TIMEOUT_MS); + + pendingForwards.set(fwdId, { resolve, reject, timer }); + + // Send to extension with our internal forward ID + const msg = { + jsonrpc: '2.0' as const, + id: fwdId, + method, + params, + }; + extensionClient!.ws.send(JSON.stringify(msg)); + }); +} + +function isForwardResponse(msg: unknown): boolean { + const m = msg as any; + return ( + m?.jsonrpc === '2.0' && + typeof m.id === 'string' && + (m.id as string).startsWith('fwd-') && + (m.result !== undefined || m.error !== undefined) + ); +} + +function handleForwardResponse(msg: { id: string; result?: unknown; error?: { code: number; message: string; data?: unknown } }): void { + const pending = pendingForwards.get(msg.id); + if (!pending) return; + + clearTimeout(pending.timer); + pendingForwards.delete(msg.id); + + if (msg.error) { + pending.reject(msg.error); + } else { + pending.resolve(msg.result); + } +} + +// --------------------------------------------------------------------------- +// Broadcasting +// --------------------------------------------------------------------------- + +function broadcast(msg: object): void { + const raw = JSON.stringify(msg); + for (const client of clients.values()) { + try { client.ws.send(raw); } catch { /* ignore dead sockets */ } + } +} + +function broadcastExcept(excludeClientId: string, msg: object): void { + const raw = JSON.stringify(msg); + for (const client of clients.values()) { + if (client.id === excludeClientId) continue; + try { client.ws.send(raw); } catch { /* ignore dead sockets */ } + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..c1482cd --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "lib": ["ESNext", "DOM"], + "types": ["bun"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/extension/build.ts b/packages/extension/build.ts new file mode 100644 index 0000000..8400a0f --- /dev/null +++ b/packages/extension/build.ts @@ -0,0 +1,50 @@ +/** + * Build script for the browseruse Chrome extension. + * + * Uses esbuild to bundle TypeScript source files into extension/dist/. + * Copies static assets (manifest.json, HTML, icons) to dist. + * + * Usage: bun extension/build.ts + */ + +import { build } from 'esbuild'; +import { cpSync, mkdirSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; + +const EXT_DIR = import.meta.dir; +const ROOT = join(EXT_DIR, '../..'); +const DIST = join(EXT_DIR, 'dist'); +const SRC = join(EXT_DIR, 'src'); +const PROTOCOL_DIR = join(ROOT, 'packages', 'protocol'); + +// Clean dist +if (existsSync(DIST)) rmSync(DIST, { recursive: true }); +mkdirSync(DIST, { recursive: true }); + +// Bundle TypeScript entry points +await build({ + entryPoints: [ + join(SRC, 'background/service-worker.ts'), + join(SRC, 'content/content-script.ts'), + join(SRC, 'popup/popup.ts'), + ], + outdir: DIST, + bundle: true, + format: 'esm', + target: 'chrome120', + minify: false, + sourcemap: false, + // Preserve directory structure in output + outbase: SRC, + // Resolve @browseruse/protocol to the workspace package + alias: { + '@browseruse/protocol': join(PROTOCOL_DIR, 'index.ts'), + }, +}); + +// Copy static assets +cpSync(join(EXT_DIR, 'manifest.json'), join(DIST, 'manifest.json')); +cpSync(join(EXT_DIR, 'icons'), join(DIST, 'icons'), { recursive: true }); +cpSync(join(SRC, 'popup/popup.html'), join(DIST, 'popup/popup.html')); + +console.log('Extension built → packages/extension/dist/'); diff --git a/packages/extension/icons/icon128.png b/packages/extension/icons/icon128.png new file mode 100644 index 0000000..2e52b00 Binary files /dev/null and b/packages/extension/icons/icon128.png differ diff --git a/packages/extension/icons/icon16.png b/packages/extension/icons/icon16.png new file mode 100644 index 0000000..7792de3 Binary files /dev/null and b/packages/extension/icons/icon16.png differ diff --git a/packages/extension/icons/icon48.png b/packages/extension/icons/icon48.png new file mode 100644 index 0000000..9f3c962 Binary files /dev/null and b/packages/extension/icons/icon48.png differ diff --git a/packages/extension/install.ts b/packages/extension/install.ts new file mode 100644 index 0000000..5bb5333 --- /dev/null +++ b/packages/extension/install.ts @@ -0,0 +1,70 @@ +/** + * Install script for the browseruse Chrome extension. + * + * The extension now uses a direct WebSocket connection to the REPL server, + * so native messaging host setup is no longer required. This script handles + * any remaining install tasks (currently: cleanup of legacy native host). + * + * Usage: + * bun packages/extension/install.ts [--cleanup] + */ + +import { unlinkSync, existsSync } from 'fs'; +import { join } from 'path'; +import { platform, homedir } from 'os'; + +const HOST_NAME = 'com.browseruse.host'; + +function getNativeHostDir(): string | null { + const os = platform(); + const home = homedir(); + + switch (os) { + case 'darwin': + return join(home, 'Library', 'Application Support', 'Google', 'Chrome', 'NativeMessagingHosts'); + case 'linux': + return join(home, '.config', 'google-chrome', 'NativeMessagingHosts'); + default: + return null; + } +} + +function cleanupLegacyNativeHost(): void { + const hostDir = getNativeHostDir(); + if (!hostDir) return; + + const manifestPath = join(hostDir, `${HOST_NAME}.json`); + if (existsSync(manifestPath)) { + try { + unlinkSync(manifestPath); + console.log(`Removed legacy native messaging host manifest: ${manifestPath}`); + } catch (e: any) { + console.warn(`Warning: could not remove ${manifestPath}: ${e.message}`); + } + } +} + +function main(): void { + const args = process.argv.slice(2); + + if (args.includes('--cleanup')) { + cleanupLegacyNativeHost(); + console.log('Cleanup complete.'); + return; + } + + // Clean up any legacy native messaging host + cleanupLegacyNativeHost(); + + console.log('browseruse extension install complete.'); + console.log(''); + console.log('The extension now connects directly via WebSocket to the REPL server.'); + console.log('No native messaging host registration is needed.'); + console.log(''); + console.log('To use:'); + console.log(' 1. Start the REPL server: browseruse --start'); + console.log(' 2. Load the extension in Chrome (chrome://extensions, developer mode)'); + console.log(' 3. The extension will auto-connect to ws://127.0.0.1:9876/ws'); +} + +main(); diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json new file mode 100644 index 0000000..d8ae2fb --- /dev/null +++ b/packages/extension/manifest.json @@ -0,0 +1,36 @@ +{ + "manifest_version": 3, + "name": "browseruse", + "version": "0.4.0", + "description": "Connect AI agents to your browser via browseruse.", + "permissions": [ + "debugger", + "tabs", + "storage" + ], + "host_permissions": [""], + "background": { + "service_worker": "background/service-worker.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content/content-script.js"], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/packages/extension/src/background/debugger-handler.ts b/packages/extension/src/background/debugger-handler.ts new file mode 100644 index 0000000..11263dc --- /dev/null +++ b/packages/extension/src/background/debugger-handler.ts @@ -0,0 +1,144 @@ +/** + * Debugger handler — manages chrome.debugger lifecycle. + * + * Provides attach/detach/sendCommand for CDP access through the extension. + * Tracks attached tabs and forwards CDP events and detach notifications. + */ + +import { ErrorCodes } from '@browseruse/protocol'; + +const CDP_VERSION = '1.3'; + +/** Set of currently attached tab IDs. */ +const attachedTabs = new Set(); + +/** Callback for debugger events (CDP domain events). */ +let onEventCallback: ((tabId: number, method: string, params?: Record) => void) | null = null; + +/** Callback for debugger detach (user cancelled or tab closed). */ +let onDetachCallback: ((tabId: number, reason: string) => void) | null = null; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Register a callback for CDP events from chrome.debugger.onEvent. + */ +export function setEventCallback(cb: (tabId: number, method: string, params?: Record) => void): void { + onEventCallback = cb; +} + +/** + * Register a callback for debugger detach events. + */ +export function setDetachCallback(cb: (tabId: number, reason: string) => void): void { + onDetachCallback = cb; +} + +/** + * Attach the debugger to a tab. + */ +export async function attach(tabId: number): Promise<{ ok: true; tabId: number }> { + if (attachedTabs.has(tabId)) { + return { ok: true, tabId }; + } + + try { + await chrome.debugger.attach({ tabId }, CDP_VERSION); + attachedTabs.add(tabId); + return { ok: true, tabId }; + } catch (err: any) { + throw { + code: ErrorCodes.DEBUGGER_ATTACH_FAILED, + message: `Failed to attach debugger to tab ${tabId}: ${err.message ?? String(err)}`, + }; + } +} + +/** + * Detach the debugger from a tab. + */ +export async function detach(tabId: number): Promise<{ ok: true; tabId: number }> { + if (!attachedTabs.has(tabId)) { + return { ok: true, tabId }; + } + + try { + await chrome.debugger.detach({ tabId }); + } catch { + // Already detached — ignore + } + attachedTabs.delete(tabId); + return { ok: true, tabId }; +} + +/** + * Send a CDP command via chrome.debugger.sendCommand. + * Auto-attaches to the tab if not already attached. + */ +export async function sendCommand( + tabId: number, + method: string, + params?: Record, +): Promise { + // Auto-attach if not already attached + if (!attachedTabs.has(tabId)) { + await attach(tabId); + } + + try { + const result = await chrome.debugger.sendCommand({ tabId }, method, params ?? {}); + return result; + } catch (err: any) { + // If the debugger was detached (e.g. user cancelled), reflect that + if (!attachedTabs.has(tabId)) { + throw { + code: ErrorCodes.DEBUGGER_DETACHED, + message: `Debugger detached from tab ${tabId}`, + }; + } + throw { + code: ErrorCodes.CDP_ERROR, + message: `CDP error (${method}): ${err.message ?? String(err)}`, + }; + } +} + +/** + * Check if a tab has the debugger attached. + */ +export function isAttached(tabId: number): boolean { + return attachedTabs.has(tabId); +} + +/** + * Get all currently attached tab IDs. + */ +export function getAttachedTabs(): number[] { + return Array.from(attachedTabs); +} + +// --------------------------------------------------------------------------- +// Chrome debugger event listeners +// --------------------------------------------------------------------------- + +chrome.debugger.onEvent.addListener((source, method, params) => { + const tabId = source.tabId; + if (tabId === undefined) return; + + if (onEventCallback) { + onEventCallback(tabId, method, params as Record | undefined); + } +}); + +chrome.debugger.onDetach.addListener((source, reason) => { + const tabId = source.tabId; + if (tabId === undefined) return; + + attachedTabs.delete(tabId); + + if (onDetachCallback) { + onDetachCallback(tabId, reason); + } +}); diff --git a/packages/extension/src/background/service-worker.ts b/packages/extension/src/background/service-worker.ts new file mode 100644 index 0000000..c61780c --- /dev/null +++ b/packages/extension/src/background/service-worker.ts @@ -0,0 +1,196 @@ +/** + * Service worker — main background script for the Chrome extension. + * + * Uses a direct WebSocket connection to the browseruse REPL server + * (ws://127.0.0.1:9876/ws). Routes JSON-RPC requests to tab handlers + * (chrome.tabs) or debugger handlers (chrome.debugger). + */ + +import { handleTabsList, handleTabCreate, handleTabClose, handleTabNavigate, handleTabActivate, handleTabReload } from './tab-handlers'; +import { + attach as debuggerAttach, + detach as debuggerDetach, + sendCommand as debuggerSendCommand, + getAttachedTabs, + setEventCallback, + setDetachCallback, +} from './debugger-handler'; +import { Methods, ErrorCodes, Events, makeSuccess, makeError, makeNotification } from '@browseruse/protocol'; + +const PORT = 9876; +const HOST = '127.0.0.1'; +const WS_URL = `ws://${HOST}:${PORT}/ws`; +const HEALTH_URL = `http://${HOST}:${PORT}/health`; + +// --------------------------------------------------------------------------- +// WebSocket connection +// --------------------------------------------------------------------------- + +let ws: WebSocket | null = null; +let nativeConnected = false; + +/** + * Pre-flight health check before opening a WebSocket. + * Verifies that the server on PORT is actually a browseruse REPL that + * supports WebSocket, avoiding noisy 404 errors when a different service + * (or an older REPL without /ws) occupies the port. + */ +async function checkHealth(): Promise { + try { + const res = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(2000) }); + if (!res.ok) return false; + const data = await res.json(); + return data?.ok === true; + } catch { + return false; + } +} + +async function connectWebSocket(): Promise { + // Verify the server is a browseruse REPL before attempting WebSocket + const healthy = await checkHealth(); + if (!healthy) { + nativeConnected = false; + scheduleReconnect(); + return; + } + + try { + ws = new WebSocket(WS_URL); + } catch { + nativeConnected = false; + scheduleReconnect(); + return; + } + + ws.onopen = () => { + ws!.send(JSON.stringify({ + jsonrpc: '2.0', + id: 'ext-handshake', + method: 'session.handshake', + params: { clientType: 'extension', version: '0.4.0' }, + })); + nativeConnected = true; + // Notify popup + chrome.runtime.sendMessage({ type: 'connection-state', connected: true }).catch(() => {}); + }; + + ws.onmessage = (event) => { + try { + const data = typeof event.data === 'string' ? event.data : String(event.data); + handleServerMessage(JSON.parse(data)); + } catch { + // Ignore parse errors + } + }; + + ws.onclose = () => { + nativeConnected = false; + ws = null; + // Notify popup + chrome.runtime.sendMessage({ type: 'connection-state', connected: false }).catch(() => {}); + scheduleReconnect(); + }; + + ws.onerror = () => { + // onclose will fire after this and handle reconnection + }; +} + +let reconnectTimer: ReturnType | null = null; + +function scheduleReconnect(): void { + if (reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connectWebSocket(); + }, 3000); +} + +// Connect on startup +chrome.runtime.onInstalled.addListener(() => { connectWebSocket(); }); +chrome.runtime.onStartup.addListener(() => { connectWebSocket(); }); +connectWebSocket(); + +// --------------------------------------------------------------------------- +// Debugger event forwarding +// --------------------------------------------------------------------------- + +setEventCallback((tabId, method, params) => { + // Forward CDP events from chrome.debugger back to the server + sendToServer(makeNotification(Events.DEBUGGER_EVENT, { tabId, method, params })); +}); + +setDetachCallback((tabId, reason) => { + sendToServer(makeNotification(Events.DEBUGGER_DETACHED, { tabId, reason })); +}); + +// --------------------------------------------------------------------------- +// Message routing from popup +// --------------------------------------------------------------------------- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (sender.id !== chrome.runtime.id) return; + + if (message.type === 'get-status') { + sendResponse({ + connected: nativeConnected, + attachedTabs: getAttachedTabs(), + }); + return true; + } +}); + +// --------------------------------------------------------------------------- +// JSON-RPC request handling +// --------------------------------------------------------------------------- + +type MethodHandler = (params: any) => Promise; + +const handlers: Record = { + [Methods.TABS_LIST]: handleTabsList, + [Methods.TABS_CREATE]: handleTabCreate, + [Methods.TABS_CLOSE]: handleTabClose, + [Methods.TABS_NAVIGATE]: handleTabNavigate, + [Methods.TABS_ACTIVATE]: handleTabActivate, + [Methods.TABS_RELOAD]: handleTabReload, + [Methods.DEBUGGER_ATTACH]: (params) => debuggerAttach(params.tabId), + [Methods.DEBUGGER_DETACH]: (params) => debuggerDetach(params.tabId), + [Methods.DEBUGGER_SEND_COMMAND]: (params) => debuggerSendCommand(params.tabId, params.method, params.params), +}; + +async function handleServerMessage(msg: any): Promise { + // Only handle requests (have id + method) + if (!msg || !msg.id || !msg.method) return; + + const { id, method, params } = msg; + + try { + let result: unknown; + + if (handlers[method]) { + result = await handlers[method](params ?? {}); + } else { + sendToServer(makeError(id, ErrorCodes.METHOD_NOT_FOUND, `Unknown method: ${method}`)); + return; + } + + sendToServer(makeSuccess(id, result)); + } catch (err: any) { + sendToServer(makeError(id, err.code ?? ErrorCodes.INTERNAL_ERROR, err.message ?? String(err))); + } +} + +// --------------------------------------------------------------------------- +// Send response back to server via WebSocket +// --------------------------------------------------------------------------- + +function sendToServer(msg: object): void { + if (ws?.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify(msg)); + } catch { + // WebSocket may be closing + } + } +} diff --git a/packages/extension/src/background/tab-handlers.ts b/packages/extension/src/background/tab-handlers.ts new file mode 100644 index 0000000..833bec0 --- /dev/null +++ b/packages/extension/src/background/tab-handlers.ts @@ -0,0 +1,74 @@ +/** + * Tab management handlers using Chrome Extensions API. + */ + +export interface TabInfo { + tabId: number; + url: string; + title: string; + active: boolean; + windowId: number; + index: number; +} + +function toTabInfo(tab: chrome.tabs.Tab): TabInfo { + return { + tabId: tab.id!, + url: tab.url ?? '', + title: tab.title ?? '', + active: tab.active, + windowId: tab.windowId, + index: tab.index, + }; +} + +export async function handleTabsList(): Promise<{ tabs: TabInfo[] }> { + const tabs = await chrome.tabs.query({}); + return { tabs: tabs.filter(t => t.id !== undefined).map(toTabInfo) }; +} + +export async function handleTabCreate(params: { url?: string; active?: boolean }): Promise<{ tab: TabInfo }> { + const tab = await chrome.tabs.create({ + url: params.url, + active: params.active ?? true, + }); + return { tab: toTabInfo(tab) }; +} + +export async function handleTabClose(params: { tabId: number }): Promise<{ ok: true }> { + await chrome.tabs.remove(params.tabId); + return { ok: true }; +} + +export async function handleTabNavigate(params: { tabId: number; url: string }): Promise<{ tab: TabInfo }> { + const tab = await chrome.tabs.update(params.tabId, { url: params.url }); + + // Wait for the tab to finish loading + await new Promise((resolve) => { + const listener = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => { + if (tabId === params.tabId && changeInfo.status === 'complete') { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }; + chrome.tabs.onUpdated.addListener(listener); + // Timeout after 30s + setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + }, 30000); + }); + + const updated = await chrome.tabs.get(params.tabId); + return { tab: toTabInfo(updated) }; +} + +export async function handleTabActivate(params: { tabId: number }): Promise<{ ok: true }> { + await chrome.tabs.update(params.tabId, { active: true }); + return { ok: true }; +} + +export async function handleTabReload(params: { tabId: number }): Promise<{ ok: true }> { + await chrome.tabs.reload(params.tabId); + return { ok: true }; +} diff --git a/packages/extension/src/content/content-script.ts b/packages/extension/src/content/content-script.ts new file mode 100644 index 0000000..3ad7613 --- /dev/null +++ b/packages/extension/src/content/content-script.ts @@ -0,0 +1,261 @@ +/** + * Content script — runs in every page, provides visual feedback only. + * + * DOM manipulation is now handled via CDP (chrome.debugger) instead of + * content script messaging. This script only handles visual overlays: + * - Ghost cursor: SVG arrow pointer with smooth CSS transition movement and + * press-down scale animation (two-element: outer=translate, inner=scale). + * - Element highlight: four 1px edge divs with translucent fill overlay, + * positioned via getBoundingClientRect. + * - Click ripple effect. + * + * Listens for visual.showCursor, visual.highlight, visual.click messages + * from the service worker. + */ + +// --------------------------------------------------------------------------- +// Ghost cursor (Playwriter-style two-element cursor) +// --------------------------------------------------------------------------- + +const CURSOR_COLOR = '#3B82F6'; +const CURSOR_SVG = ``; +const CURSOR_DATA_URL = `url("data:image/svg+xml,${encodeURIComponent(CURSOR_SVG)}")`; + +let cursorOuter: HTMLDivElement | null = null; +let cursorInner: HTMLDivElement | null = null; +let cursorX = 0; +let cursorY = 0; +let cursorVisible = false; +let cursorHideTimer: ReturnType | null = null; + +function ensureCursor(): { outer: HTMLDivElement; inner: HTMLDivElement } { + if (cursorOuter && cursorInner && document.documentElement.contains(cursorOuter)) { + return { outer: cursorOuter, inner: cursorInner }; + } + + const outer = document.createElement('div'); + outer.id = '__browseruse_ghost_cursor__'; + Object.assign(outer.style, { + position: 'fixed', + left: '0', + top: '0', + pointerEvents: 'none', + zIndex: '2147483647', + transitionProperty: 'transform', + transitionTimingFunction: 'cubic-bezier(0.65, 0, 0.35, 1)', // easeInOutCubic + willChange: 'transform', + opacity: '0', + transform: 'translate3d(0px, 0px, 0)', + }); + + const inner = document.createElement('div'); + Object.assign(inner.style, { + width: '24px', + height: '24px', + backgroundImage: CURSOR_DATA_URL, + backgroundSize: 'contain', + backgroundRepeat: 'no-repeat', + filter: 'drop-shadow(0 1px 3px rgba(0, 0, 0, 0.35))', + transitionProperty: 'transform, opacity', + transitionTimingFunction: 'cubic-bezier(0.23, 1, 0.32, 1)', + transitionDuration: '140ms', + transform: 'scale(1)', + }); + + outer.appendChild(inner); + document.documentElement.appendChild(outer); + + cursorOuter = outer; + cursorInner = inner; + return { outer, inner }; +} + +function moveCursorTo(x: number, y: number, animate: boolean): void { + const { outer } = ensureCursor(); + + if (!cursorVisible) { + // Teleport (no transition) on first appearance + outer.style.transitionDuration = '0ms'; + outer.style.transform = `translate3d(${x}px, ${y}px, 0)`; + outer.style.opacity = '1'; + cursorVisible = true; + cursorX = x; + cursorY = y; + // Force reflow then allow transitions + outer.offsetHeight; + return; + } + + if (animate) { + const dist = Math.hypot(x - cursorX, y - cursorY); + const duration = Math.min(Math.max(dist / 1.2, 200), 1200); + outer.style.transitionDuration = `${Math.round(duration)}ms`; + } else { + outer.style.transitionDuration = '0ms'; + } + + outer.style.transform = `translate3d(${x}px, ${y}px, 0)`; + outer.style.opacity = '1'; + cursorX = x; + cursorY = y; + + // Auto-hide after 5s of inactivity + if (cursorHideTimer) clearTimeout(cursorHideTimer); + cursorHideTimer = setTimeout(() => { + if (cursorOuter) { + cursorOuter.style.transitionDuration = '600ms'; + cursorOuter.style.opacity = '0'; + cursorVisible = false; + } + }, 5000); +} + +function pressDown(): void { + const { inner } = ensureCursor(); + inner.style.transform = 'scale(0.92)'; +} + +function pressUp(): void { + const { inner } = ensureCursor(); + inner.style.transform = 'scale(1)'; +} + +function showClickAt(x: number, y: number): void { + moveCursorTo(x, y, true); + // Wait for cursor to arrive, then animate press + const dist = Math.hypot(x - cursorX, y - cursorY); + const moveDuration = Math.min(Math.max(dist / 1.2, 200), 1200); + setTimeout(() => { + pressDown(); + setTimeout(() => pressUp(), 140); + }, moveDuration + 20); + + // Also show a ripple at the click point + showRipple(x, y); +} + +function showRipple(x: number, y: number): void { + const ripple = document.createElement('div'); + Object.assign(ripple.style, { + position: 'fixed', + left: `${x - 10}px`, + top: `${y - 10}px`, + width: '20px', + height: '20px', + borderRadius: '50%', + border: `2px solid ${CURSOR_COLOR}`, + background: 'transparent', + zIndex: '2147483646', + pointerEvents: 'none', + transition: 'transform 0.4s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.4s ease-out', + transform: 'scale(1)', + opacity: '0.8', + }); + document.documentElement.appendChild(ripple); + + requestAnimationFrame(() => { + ripple.style.transform = 'scale(3)'; + ripple.style.opacity = '0'; + }); + + setTimeout(() => ripple.remove(), 500); +} + +// --------------------------------------------------------------------------- +// Element highlight overlay (Playwriter-style edge divs) +// --------------------------------------------------------------------------- + +const EDGE_COLOR = 'rgba(59, 130, 246, 0.75)'; +const FILL_COLOR = 'rgba(59, 130, 246, 0.06)'; +const HIGHLIGHT_DURATION_MS = 1000; + +let overlayContainer: HTMLDivElement | null = null; +let overlayHideTimer: ReturnType | null = null; + +function ensureOverlay(): HTMLDivElement { + if (overlayContainer && document.documentElement.contains(overlayContainer)) { + return overlayContainer; + } + + const container = document.createElement('div'); + container.id = '__browseruse_overlay__'; + Object.assign(container.style, { + position: 'fixed', + pointerEvents: 'none', + zIndex: '2147483646', + background: FILL_COLOR, + display: 'none', + transition: 'opacity 0.2s ease-out', + opacity: '1', + }); + + // Four 1px edge divs for crisp highlighting + const edges = [ + { top: '0', left: '0', width: '100%', height: '1px' }, // top + { top: '0', right: '0', width: '1px', height: '100%' }, // right + { bottom: '0', left: '0', width: '100%', height: '1px' }, // bottom + { top: '0', left: '0', width: '1px', height: '100%' }, // left + ]; + + for (const pos of edges) { + const edge = document.createElement('div'); + Object.assign(edge.style, { + position: 'absolute', + background: EDGE_COLOR, + ...pos, + }); + container.appendChild(edge); + } + + document.documentElement.appendChild(container); + overlayContainer = container; + return container; +} + +function highlightRect(x: number, y: number, width: number, height: number): void { + const container = ensureOverlay(); + + Object.assign(container.style, { + top: `${y}px`, + left: `${x}px`, + width: `${width}px`, + height: `${height}px`, + display: 'block', + opacity: '1', + }); + + if (overlayHideTimer) clearTimeout(overlayHideTimer); + overlayHideTimer = setTimeout(() => { + container.style.opacity = '0'; + setTimeout(() => { container.style.display = 'none'; }, 200); + }, HIGHLIGHT_DURATION_MS); +} + +// --------------------------------------------------------------------------- +// Message listener — visual commands only +// --------------------------------------------------------------------------- + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + const { method, params } = message; + if (!method) return false; + + switch (method) { + case 'visual.showCursor': + moveCursorTo(params.x, params.y, params.animate ?? true); + sendResponse({ ok: true }); + return true; + + case 'visual.highlight': + highlightRect(params.x, params.y, params.width, params.height); + sendResponse({ ok: true }); + return true; + + case 'visual.click': + showClickAt(params.x, params.y); + sendResponse({ ok: true }); + return true; + + default: + return false; + } +}); diff --git a/packages/extension/src/popup/popup.html b/packages/extension/src/popup/popup.html new file mode 100644 index 0000000..24bdafa --- /dev/null +++ b/packages/extension/src/popup/popup.html @@ -0,0 +1,112 @@ + + + + + + + +
+

browseruse

+
+ +
+ + Disconnected +
+ +
+
+ Debugger tabs + 0 +
+
+ +
+ The debugger shows a yellow warning bar on attached tabs. This is expected and required for full CDP access. +
+ +
+ + + + diff --git a/packages/extension/src/popup/popup.ts b/packages/extension/src/popup/popup.ts new file mode 100644 index 0000000..cc6ba7a --- /dev/null +++ b/packages/extension/src/popup/popup.ts @@ -0,0 +1,48 @@ +/** + * Popup script — connection status display for native messaging mode. + */ + +const statusEl = document.getElementById('status')!; +const dotEl = document.getElementById('dot')!; +const statusTextEl = document.getElementById('statusText')!; +const attachedCountEl = document.getElementById('attachedCount')!; +const versionEl = document.getElementById('version')!; + +versionEl.textContent = `v${chrome.runtime.getManifest().version}`; + +function setConnected(connected: boolean): void { + if (connected) { + statusEl.className = 'status connected'; + dotEl.className = 'dot green'; + statusTextEl.textContent = 'Connected'; + } else { + statusEl.className = 'status disconnected'; + dotEl.className = 'dot red'; + statusTextEl.textContent = 'Disconnected'; + } +} + +function updateStatus(response: { connected: boolean; attachedTabs?: number[] }): void { + setConnected(response.connected); + if (response.attachedTabs) { + attachedCountEl.textContent = String(response.attachedTabs.length); + } +} + +// Get current connection status +chrome.runtime.sendMessage({ type: 'get-status' }, (response) => { + if (chrome.runtime.lastError) { + setConnected(false); + return; + } + if (response) { + updateStatus(response); + } +}); + +// Listen for state changes +chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'connection-state') { + setConnected(message.connected); + } +}); diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json new file mode 100644 index 0000000..9c60b19 --- /dev/null +++ b/packages/extension/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ESNext", "DOM"], + "types": ["chrome"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/protocol/errors.ts b/packages/protocol/errors.ts new file mode 100644 index 0000000..e8643ac --- /dev/null +++ b/packages/protocol/errors.ts @@ -0,0 +1,30 @@ +/** + * JSON-RPC 2.0 error codes. + * + * Standard codes: -32700 to -32600. + * Application codes: -32000 to -32099 (server error range). + */ + +export const ErrorCodes = { + // Standard JSON-RPC 2.0 errors + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + + // Application-specific errors + NOT_CONNECTED: -32000, + EXTENSION_NOT_CONNECTED: -32001, + EXTENSION_TIMEOUT: -32002, + TAB_NOT_FOUND: -32003, + ELEMENT_NOT_FOUND: -32004, + EVAL_ERROR: -32005, + CDP_ERROR: -32006, + DEBUGGER_ATTACH_FAILED: -32007, + DEBUGGER_DETACHED: -32008, + NATIVE_HOST_ERROR: -32009, + AUTH_FAILED: -32010, +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; diff --git a/packages/protocol/events.ts b/packages/protocol/events.ts new file mode 100644 index 0000000..b5ef5eb --- /dev/null +++ b/packages/protocol/events.ts @@ -0,0 +1,67 @@ +/** + * Notification event types sent from server to clients. + */ + +export const Events = { + /** Extension connected to the server. */ + EXTENSION_CONNECTED: 'event.extensionConnected', + /** Extension disconnected from the server. */ + EXTENSION_DISCONNECTED: 'event.extensionDisconnected', + /** CDP session connection state changed. */ + SESSION_STATE_CHANGED: 'event.sessionStateChanged', + /** Tab was created (forwarded from extension). */ + TAB_CREATED: 'event.tabCreated', + /** Tab was removed (forwarded from extension). */ + TAB_REMOVED: 'event.tabRemoved', + /** Tab was updated (forwarded from extension). */ + TAB_UPDATED: 'event.tabUpdated', + /** Debugger was detached from a tab (user cancelled or tab closed). */ + DEBUGGER_DETACHED: 'event.debuggerDetached', + /** CDP event forwarded from chrome.debugger. */ + DEBUGGER_EVENT: 'event.debuggerEvent', +} as const; + +export type EventName = (typeof Events)[keyof typeof Events]; + +export interface ExtensionConnectedEvent { + clientId: string; + version?: string; +} + +export interface ExtensionDisconnectedEvent { + clientId: string; +} + +export interface SessionStateChangedEvent { + connected: boolean; + sessionId: string | null; +} + +export interface TabCreatedEvent { + tabId: number; + url: string; + title: string; +} + +export interface TabRemovedEvent { + tabId: number; + windowId: number; +} + +export interface TabUpdatedEvent { + tabId: number; + url?: string; + title?: string; + status?: string; +} + +export interface DebuggerDetachedEvent { + tabId: number; + reason: string; +} + +export interface DebuggerEventData { + tabId: number; + method: string; + params?: Record; +} diff --git a/packages/protocol/index.ts b/packages/protocol/index.ts new file mode 100644 index 0000000..068b470 --- /dev/null +++ b/packages/protocol/index.ts @@ -0,0 +1,4 @@ +export * from './jsonrpc'; +export * from './methods'; +export * from './events'; +export * from './errors'; diff --git a/packages/protocol/jsonrpc.ts b/packages/protocol/jsonrpc.ts new file mode 100644 index 0000000..e2c0097 --- /dev/null +++ b/packages/protocol/jsonrpc.ts @@ -0,0 +1,68 @@ +/** + * JSON-RPC 2.0 envelope types. + */ + +export type JsonRpcId = string | number; + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: JsonRpcId; + method: string; + params?: Record; +} + +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: Record; +} + +export interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: JsonRpcId; + result: unknown; +} + +export interface JsonRpcError { + jsonrpc: '2.0'; + id: JsonRpcId | null; + error: { + code: number; + message: string; + data?: unknown; + }; +} + +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcError; +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; + +export function isRequest(msg: unknown): msg is JsonRpcRequest { + const m = msg as any; + return m?.jsonrpc === '2.0' && typeof m.method === 'string' && m.id !== undefined; +} + +export function isNotification(msg: unknown): msg is JsonRpcNotification { + const m = msg as any; + return m?.jsonrpc === '2.0' && typeof m.method === 'string' && m.id === undefined; +} + +export function isResponse(msg: unknown): msg is JsonRpcResponse { + const m = msg as any; + return m?.jsonrpc === '2.0' && (m.result !== undefined || m.error !== undefined); +} + +export function makeSuccess(id: JsonRpcId, result: unknown): JsonRpcSuccess { + return { jsonrpc: '2.0', id, result }; +} + +export function makeError(id: JsonRpcId | null, code: number, message: string, data?: unknown): JsonRpcError { + return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }; +} + +export function makeRequest(id: JsonRpcId, method: string, params?: Record): JsonRpcRequest { + return { jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) }; +} + +export function makeNotification(method: string, params?: Record): JsonRpcNotification { + return { jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) }; +} diff --git a/packages/protocol/methods.ts b/packages/protocol/methods.ts new file mode 100644 index 0000000..c279028 --- /dev/null +++ b/packages/protocol/methods.ts @@ -0,0 +1,160 @@ +/** + * Method name constants and typed params/returns for each RPC method. + */ + +// --------------------------------------------------------------------------- +// Session methods +// --------------------------------------------------------------------------- + +export interface HandshakeParams { + clientType: 'extension' | 'agent' | 'cli'; + version?: string; +} + +export interface HandshakeResult { + serverVersion: string; + sessionConnected: boolean; + clientId: string; +} + +export interface PingResult { + pong: true; + timestamp: number; +} + +export interface StatusResult { + connected: boolean; + sessionId: string | null; + managedBrowser: { pid: number; port: number; profile: string } | null; + uptime: number; + extensionConnected: boolean; +} + +export interface CdpRawParams { + method: string; + params?: Record; + tabId?: number; +} + +// --------------------------------------------------------------------------- +// Tab methods +// --------------------------------------------------------------------------- + +export interface TabInfo { + tabId: number; + url: string; + title: string; + active: boolean; + windowId: number; + index: number; +} + +export interface TabsListResult { + tabs: TabInfo[]; +} + +export interface TabCreateParams { + url?: string; + active?: boolean; +} + +export interface TabCreateResult { + tab: TabInfo; +} + +export interface TabCloseParams { + tabId: number; +} + +export interface TabNavigateParams { + tabId: number; + url: string; +} + +export interface TabNavigateResult { + tab: TabInfo; +} + +export interface TabActivateParams { + tabId: number; +} + +export interface TabReloadParams { + tabId: number; +} + +// --------------------------------------------------------------------------- +// Debugger methods +// --------------------------------------------------------------------------- + +export interface DebuggerAttachParams { + tabId: number; +} + +export interface DebuggerAttachResult { + ok: true; + tabId: number; +} + +export interface DebuggerDetachParams { + tabId: number; +} + +export interface DebuggerDetachResult { + ok: true; + tabId: number; +} + +export interface DebuggerSendCommandParams { + tabId: number; + method: string; + params?: Record; +} + +// --------------------------------------------------------------------------- +// Method name constants +// --------------------------------------------------------------------------- + +export const Methods = { + // Session + SESSION_HANDSHAKE: 'session.handshake', + SESSION_PING: 'session.ping', + SESSION_STATUS: 'session.status', + SESSION_CDP_RAW: 'session.cdpRaw', + + // Tabs + TABS_LIST: 'tabs.list', + TABS_CREATE: 'tabs.create', + TABS_CLOSE: 'tabs.close', + TABS_NAVIGATE: 'tabs.navigate', + TABS_ACTIVATE: 'tabs.activate', + TABS_RELOAD: 'tabs.reload', + + // Debugger + DEBUGGER_ATTACH: 'debugger.attach', + DEBUGGER_DETACH: 'debugger.detach', + DEBUGGER_SEND_COMMAND: 'debugger.sendCommand', +} as const; + +export type MethodName = (typeof Methods)[keyof typeof Methods]; + +/** Methods that are handled directly by the server (not forwarded to the extension). */ +export const SERVER_METHODS = new Set([ + Methods.SESSION_HANDSHAKE, + Methods.SESSION_PING, + Methods.SESSION_STATUS, + Methods.SESSION_CDP_RAW, +]); + +/** Methods that must be forwarded to the Chrome extension. */ +export const EXTENSION_METHODS = new Set([ + Methods.TABS_LIST, + Methods.TABS_CREATE, + Methods.TABS_CLOSE, + Methods.TABS_NAVIGATE, + Methods.TABS_ACTIVATE, + Methods.TABS_RELOAD, + Methods.DEBUGGER_ATTACH, + Methods.DEBUGGER_DETACH, + Methods.DEBUGGER_SEND_COMMAND, +]); diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000..fea97d2 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,8 @@ +{ + "name": "@browseruse/protocol", + "version": "0.4.0", + "type": "module", + "private": true, + "main": "index.ts", + "types": "index.ts" +} diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 0000000..e0244d3 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "composite": true, + "lib": ["ESNext", "DOM"] + }, + "include": ["*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index c1482cd..2b13c5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,10 +6,13 @@ "strict": true, "noUncheckedIndexedAccess": true, "skipLibCheck": true, - "allowImportingTsExtensions": true, "noEmit": true, - "lib": ["ESNext", "DOM"], - "types": ["bun"] + "lib": ["ESNext", "DOM"] }, - "include": ["src/**/*.ts"] + "include": [], + "references": [ + { "path": "packages/protocol" }, + { "path": "packages/cli" }, + { "path": "packages/extension" } + ] }