From 8337ffc486735c359dbb6e2d19186a6436922cea Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Mon, 11 May 2026 17:35:36 +0100 Subject: [PATCH 01/14] feat: add Chrome extension with WebSocket JSON-RPC bridge, restructure as monorepo Add a Manifest V3 Chrome extension that connects to the REPL server via WebSocket (/ws endpoint) using JSON-RPC 2.0. The extension provides tabs, DOM, page, and cookie control through Chrome Extensions APIs, with Playwriter-inspired visual feedback (ghost cursor, element highlight). Restructure the repo into a workspace monorepo with three symmetric packages: - packages/protocol: shared @browseruse/protocol JSON-RPC types - packages/cli: server, CLI, and CDP session (@browseruse/cli) - extension/: Chrome extension (built with esbuild) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + extension/build.ts | 52 +++ extension/icons/icon128.png | Bin 0 -> 306 bytes extension/icons/icon16.png | Bin 0 -> 79 bytes extension/icons/icon48.png | Bin 0 -> 123 bytes extension/manifest.json | 39 ++ extension/src/background/network-handlers.ts | 75 ++++ extension/src/background/page-handlers.ts | 63 ++++ extension/src/background/service-worker.ts | 167 +++++++++ extension/src/background/tab-handlers.ts | 74 ++++ extension/src/content/content-script.ts | 344 ++++++++++++++++++ extension/src/offscreen/offscreen.html | 7 + extension/src/offscreen/offscreen.ts | 110 ++++++ extension/src/popup/popup.html | 130 +++++++ extension/src/popup/popup.ts | 67 ++++ extension/tsconfig.json | 14 + package.json | 18 +- packages/cli/package.json | 12 + .../cli/src}/__tests__/e2e.test.ts | 2 +- .../cli/src}/__tests__/repl.test.ts | 2 +- .../cli/src}/__tests__/unit.test.ts | 0 {src => packages/cli/src}/browser.ts | 0 .../cli/src}/browser_protocol.json | 0 {src => packages/cli/src}/cli.ts | 0 {src => packages/cli/src}/gen.ts | 0 {src => packages/cli/src}/generated.ts | 0 {src => packages/cli/src}/js_protocol.json | 0 {src => packages/cli/src}/repl.ts | 26 +- {src => packages/cli/src}/session.ts | 0 packages/cli/src/ws-handler.ts | 299 +++++++++++++++ packages/cli/tsconfig.json | 15 + packages/protocol/errors.ts | 26 ++ packages/protocol/events.ts | 52 +++ packages/protocol/index.ts | 4 + packages/protocol/jsonrpc.ts | 68 ++++ packages/protocol/methods.ts | 298 +++++++++++++++ packages/protocol/package.json | 8 + tsconfig.json | 11 +- 38 files changed, 1971 insertions(+), 13 deletions(-) create mode 100644 extension/build.ts create mode 100644 extension/icons/icon128.png create mode 100644 extension/icons/icon16.png create mode 100644 extension/icons/icon48.png create mode 100644 extension/manifest.json create mode 100644 extension/src/background/network-handlers.ts create mode 100644 extension/src/background/page-handlers.ts create mode 100644 extension/src/background/service-worker.ts create mode 100644 extension/src/background/tab-handlers.ts create mode 100644 extension/src/content/content-script.ts create mode 100644 extension/src/offscreen/offscreen.html create mode 100644 extension/src/offscreen/offscreen.ts create mode 100644 extension/src/popup/popup.html create mode 100644 extension/src/popup/popup.ts create mode 100644 extension/tsconfig.json create mode 100644 packages/cli/package.json rename {src => packages/cli/src}/__tests__/e2e.test.ts (99%) rename {src => packages/cli/src}/__tests__/repl.test.ts (99%) rename {src => packages/cli/src}/__tests__/unit.test.ts (100%) rename {src => packages/cli/src}/browser.ts (100%) rename {src => packages/cli/src}/browser_protocol.json (100%) rename {src => packages/cli/src}/cli.ts (100%) rename {src => packages/cli/src}/gen.ts (100%) rename {src => packages/cli/src}/generated.ts (100%) rename {src => packages/cli/src}/js_protocol.json (100%) rename {src => packages/cli/src}/repl.ts (89%) rename {src => packages/cli/src}/session.ts (100%) create mode 100644 packages/cli/src/ws-handler.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/protocol/errors.ts create mode 100644 packages/protocol/events.ts create mode 100644 packages/protocol/index.ts create mode 100644 packages/protocol/jsonrpc.ts create mode 100644 packages/protocol/methods.ts create mode 100644 packages/protocol/package.json diff --git a/.gitignore b/.gitignore index e2eaf50..9244e6e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ bun.lock /tmp/browseruse.log .DS_Store +extension/dist/ diff --git a/extension/build.ts b/extension/build.ts new file mode 100644 index 0000000..587c114 --- /dev/null +++ b/extension/build.ts @@ -0,0 +1,52 @@ +/** + * 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, dirname } 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, 'offscreen/offscreen.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, 'offscreen/offscreen.html'), join(DIST, 'offscreen/offscreen.html')); +cpSync(join(SRC, 'popup/popup.html'), join(DIST, 'popup/popup.html')); + +console.log('Extension built → extension/dist/'); diff --git a/extension/icons/icon128.png b/extension/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..bf3666e11843ead35eeaee4450d36ea5ee7bab9e GIT binary patch literal 306 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRdwrKRsO>Ln;{GUNq!oFyJ|`;gi(v z#(TCBwn>@{N|onoqjIkCKVoFKz{k)~#=x+JnPGt}1H)Seh75KF2Wtj~TSHcAH-BO0 Wu~6gim36>CVDNPHb6Mw<&;$U2v`bL{ literal 0 HcmV?d00001 diff --git a/extension/icons/icon16.png b/extension/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..d48885ab7ff10cb374c7a3172f673f351c012ee2 GIT binary patch literal 79 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|VxBIJAr*|t8}fd+2_;EfHCUDS cBa4Bdoqe-F>d`HGfGQX~UHx3vIVCg!0D*E86aWAK literal 0 HcmV?d00001 diff --git a/extension/icons/icon48.png b/extension/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..60d28201d376b43ae661bfc04344c6a2b2090112 GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1SD@H"], + "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/extension/src/background/network-handlers.ts b/extension/src/background/network-handlers.ts new file mode 100644 index 0000000..d1a3b1b --- /dev/null +++ b/extension/src/background/network-handlers.ts @@ -0,0 +1,75 @@ +/** + * Network/cookie handlers using Chrome Extensions API. + */ + +export interface CookieInfo { + name: string; + value: string; + domain: string; + path: string; + secure: boolean; + httpOnly: boolean; + sameSite: string; + expirationDate?: number; +} + +function toCookieInfo(c: chrome.cookies.Cookie): CookieInfo { + return { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + sameSite: c.sameSite, + expirationDate: c.expirationDate, + }; +} + +export async function handleGetCookies(params: { + url?: string; + domain?: string; +}): Promise<{ cookies: CookieInfo[] }> { + const query: chrome.cookies.GetAllDetails = {}; + if (params.url) query.url = params.url; + if (params.domain) query.domain = params.domain; + + const cookies = await chrome.cookies.getAll(query); + return { cookies: cookies.map(toCookieInfo) }; +} + +export async function handleSetCookie(params: { + url: string; + name: string; + value: string; + domain?: string; + path?: string; + secure?: boolean; + httpOnly?: boolean; + sameSite?: 'no_restriction' | 'lax' | 'strict'; + expirationDate?: number; +}): Promise<{ ok: true }> { + await chrome.cookies.set({ + url: params.url, + name: params.name, + value: params.value, + domain: params.domain, + path: params.path, + secure: params.secure, + httpOnly: params.httpOnly, + sameSite: params.sameSite, + expirationDate: params.expirationDate, + }); + return { ok: true }; +} + +export async function handleDeleteCookies(params: { + url: string; + name: string; +}): Promise<{ ok: true }> { + await chrome.cookies.remove({ + url: params.url, + name: params.name, + }); + return { ok: true }; +} diff --git a/extension/src/background/page-handlers.ts b/extension/src/background/page-handlers.ts new file mode 100644 index 0000000..cf762d6 --- /dev/null +++ b/extension/src/background/page-handlers.ts @@ -0,0 +1,63 @@ +/** + * Page-level handlers: screenshot, eval, getUrl, getTitle. + */ + +export async function handlePageScreenshot(params: { + tabId?: number; + format?: 'png' | 'jpeg'; + quality?: number; +}): Promise<{ data: string; format: string }> { + // Get the window for the tab (or current window) + let windowId: number | undefined; + if (params.tabId !== undefined) { + const tab = await chrome.tabs.get(params.tabId); + windowId = tab.windowId; + // Ensure the tab is active in its window for captureVisibleTab + if (!tab.active) { + await chrome.tabs.update(params.tabId, { active: true }); + // Brief delay for the tab to render + await new Promise(r => setTimeout(r, 150)); + } + } + + const format = params.format ?? 'png'; + const dataUrl = await chrome.tabs.captureVisibleTab(windowId ?? chrome.windows.WINDOW_ID_CURRENT, { + format, + quality: params.quality, + }); + + // Strip the data URL prefix to return raw base64 + const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, ''); + return { data: base64, format }; +} + +export async function handlePageEval(params: { + tabId: number; + expression: string; +}): Promise<{ result: unknown }> { + const results = await chrome.scripting.executeScript({ + target: { tabId: params.tabId }, + func: (expr: string) => { + // eslint-disable-next-line no-eval + return eval(expr); + }, + args: [params.expression], + world: 'MAIN', + }); + + const frame = results[0]; + if (!frame) { + return { result: undefined }; + } + return { result: frame.result }; +} + +export async function handlePageGetUrl(params: { tabId: number }): Promise<{ url: string }> { + const tab = await chrome.tabs.get(params.tabId); + return { url: tab.url ?? '' }; +} + +export async function handlePageGetTitle(params: { tabId: number }): Promise<{ title: string }> { + const tab = await chrome.tabs.get(params.tabId); + return { title: tab.title ?? '' }; +} diff --git a/extension/src/background/service-worker.ts b/extension/src/background/service-worker.ts new file mode 100644 index 0000000..bc1d71f --- /dev/null +++ b/extension/src/background/service-worker.ts @@ -0,0 +1,167 @@ +/** + * Service worker — main background script for the Chrome extension. + * + * Ensures the offscreen document stays alive, routes incoming JSON-RPC + * requests from the server (relayed via offscreen) to appropriate handlers, + * and sends responses back. + */ + +import { handleTabsList, handleTabCreate, handleTabClose, handleTabNavigate, handleTabActivate, handleTabReload } from './tab-handlers'; +import { handlePageScreenshot, handlePageEval, handlePageGetUrl, handlePageGetTitle } from './page-handlers'; +import { handleGetCookies, handleSetCookie, handleDeleteCookies } from './network-handlers'; +import { Methods, ErrorCodes, makeSuccess, makeError } from '@browseruse/protocol'; + +// --------------------------------------------------------------------------- +// Offscreen document management +// --------------------------------------------------------------------------- + +let offscreenCreating: Promise | null = null; + +async function ensureOffscreen(): Promise { + const existingContexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + }); + if (existingContexts.length > 0) return; + + if (offscreenCreating) { + await offscreenCreating; + return; + } + + offscreenCreating = chrome.offscreen.createDocument({ + url: 'offscreen/offscreen.html', + reasons: [chrome.offscreen.Reason.WEB_RTC as any], + justification: 'Maintain persistent WebSocket connection to browseruse server', + }); + + await offscreenCreating; + offscreenCreating = null; +} + +// Create offscreen document on install/startup +chrome.runtime.onInstalled.addListener(() => { ensureOffscreen(); }); +chrome.runtime.onStartup.addListener(() => { ensureOffscreen(); }); + +// Also ensure it exists when the service worker wakes up +ensureOffscreen(); + +// --------------------------------------------------------------------------- +// Connection state tracking +// --------------------------------------------------------------------------- + +let wsConnected = false; + +// --------------------------------------------------------------------------- +// Message routing from offscreen document +// --------------------------------------------------------------------------- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Only handle messages from our own extension + if (sender.id !== chrome.runtime.id) return; + + if (message.type === 'ws-state') { + wsConnected = message.connected; + // Notify popup about state change + chrome.runtime.sendMessage({ type: 'connection-state', connected: wsConnected }).catch(() => {}); + return; + } + + if (message.type === 'ws-message') { + handleServerMessage(message.data); + return; + } + + if (message.type === 'get-status') { + sendResponse({ connected: wsConnected }); + 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.PAGE_SCREENSHOT]: handlePageScreenshot, + [Methods.PAGE_EVAL]: handlePageEval, + [Methods.PAGE_GET_URL]: handlePageGetUrl, + [Methods.PAGE_GET_TITLE]: handlePageGetTitle, + [Methods.NETWORK_GET_COOKIES]: handleGetCookies, + [Methods.NETWORK_SET_COOKIE]: handleSetCookie, + [Methods.NETWORK_DELETE_COOKIES]: handleDeleteCookies, +}; + +// DOM methods are forwarded to the content script +const DOM_METHODS = new Set([ + Methods.DOM_QUERY, Methods.DOM_QUERY_ALL, Methods.DOM_CLICK, + Methods.DOM_TYPE, Methods.DOM_GET_TEXT, Methods.DOM_GET_HTML, +]); + +async function handleServerMessage(raw: string): Promise { + let msg: any; + try { + msg = JSON.parse(raw); + } catch { + return; + } + + // Only handle requests (have id + method) + if (!msg.id || !msg.method) return; + + const { id, method, params } = msg; + + try { + let result: unknown; + + if (DOM_METHODS.has(method)) { + result = await forwardToContentScript(method, params ?? {}); + } else 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))); + } +} + +// --------------------------------------------------------------------------- +// Content script communication +// --------------------------------------------------------------------------- + +async function forwardToContentScript(method: string, params: Record): Promise { + const tabId = params.tabId as number; + if (tabId === undefined) { + throw { code: ErrorCodes.INVALID_PARAMS, message: 'Missing tabId param' }; + } + + // Send message to content script in the target tab + const response = await chrome.tabs.sendMessage(tabId, { method, params }); + + if (response?.error) { + throw { code: response.error.code ?? ErrorCodes.INTERNAL_ERROR, message: response.error.message }; + } + + return response?.result; +} + +// --------------------------------------------------------------------------- +// Send response back to server via offscreen +// --------------------------------------------------------------------------- + +function sendToServer(msg: object): void { + chrome.runtime.sendMessage({ type: 'ws-send', data: JSON.stringify(msg) }).catch(() => { + // Offscreen may not be ready + }); +} diff --git a/extension/src/background/tab-handlers.ts b/extension/src/background/tab-handlers.ts new file mode 100644 index 0000000..833bec0 --- /dev/null +++ b/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/extension/src/content/content-script.ts b/extension/src/content/content-script.ts new file mode 100644 index 0000000..a896e7a --- /dev/null +++ b/extension/src/content/content-script.ts @@ -0,0 +1,344 @@ +/** + * Content script — runs in every page, handles DOM manipulation commands + * from the service worker. + * + * Visual feedback inspired by Playwriter: + * - 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. + */ + +import { ErrorCodes } from '@browseruse/protocol'; + +// --------------------------------------------------------------------------- +// 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 highlightElement(el: Element): void { + const rect = el.getBoundingClientRect(); + const container = ensureOverlay(); + + Object.assign(container.style, { + top: `${rect.top}px`, + left: `${rect.left}px`, + width: `${rect.width}px`, + height: `${rect.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); +} + +// --------------------------------------------------------------------------- +// DOM command handlers +// --------------------------------------------------------------------------- + +type DomHandler = (params: Record) => unknown; + +const domHandlers: Record = { + 'dom.query'(params) { + const el = document.querySelector(params.selector as string); + if (!el) return { found: false }; + highlightElement(el); + const attrs: Record = {}; + for (const attr of el.attributes) { + attrs[attr.name] = attr.value; + } + return { + found: true, + text: el.textContent?.trim() ?? '', + tagName: el.tagName.toLowerCase(), + attributes: attrs, + }; + }, + + 'dom.queryAll'(params) { + const els = document.querySelectorAll(params.selector as string); + const elements = Array.from(els).map((el, index) => { + const attrs: Record = {}; + for (const attr of el.attributes) { + attrs[attr.name] = attr.value; + } + return { + index, + text: el.textContent?.trim() ?? '', + tagName: el.tagName.toLowerCase(), + attributes: attrs, + }; + }); + return { count: elements.length, elements }; + }, + + 'dom.click'(params) { + const el = document.querySelector(params.selector as string); + if (!el) throw { code: ErrorCodes.ELEMENT_NOT_FOUND, message: `Element not found: ${params.selector}` }; + highlightElement(el); + const rect = el.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + showClickAt(x, y); + (el as HTMLElement).click(); + return { ok: true }; + }, + + 'dom.type'(params) { + const el = document.querySelector(params.selector as string) as HTMLInputElement | HTMLTextAreaElement | null; + if (!el) throw { code: ErrorCodes.ELEMENT_NOT_FOUND, message: `Element not found: ${params.selector}` }; + highlightElement(el); + // Move cursor to element + const rect = el.getBoundingClientRect(); + moveCursorTo(rect.left + rect.width / 2, rect.top + rect.height / 2, true); + el.focus(); + if (params.clear) { + el.value = ''; + el.dispatchEvent(new Event('input', { bubbles: true })); + } + const text = params.text as string; + for (const char of text) { + el.value += char; + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: char, inputType: 'insertText' })); + } + el.dispatchEvent(new Event('change', { bubbles: true })); + return { ok: true }; + }, + + 'dom.getText'(params) { + const el = document.querySelector(params.selector as string); + if (!el) throw { code: ErrorCodes.ELEMENT_NOT_FOUND, message: `Element not found: ${params.selector}` }; + highlightElement(el); + return { text: el.textContent?.trim() ?? '' }; + }, + + 'dom.getHtml'(params) { + const el = document.querySelector(params.selector as string); + if (!el) throw { code: ErrorCodes.ELEMENT_NOT_FOUND, message: `Element not found: ${params.selector}` }; + return { html: params.outer ? el.outerHTML : el.innerHTML }; + }, +}; + +// --------------------------------------------------------------------------- +// Message listener +// --------------------------------------------------------------------------- + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + const { method, params } = message; + + if (!method || !domHandlers[method]) { + return false; + } + + try { + const result = domHandlers[method](params ?? {}); + sendResponse({ result }); + } catch (err: any) { + sendResponse({ + error: { + code: err.code ?? -32603, + message: err.message ?? String(err), + }, + }); + } + + return true; +}); diff --git a/extension/src/offscreen/offscreen.html b/extension/src/offscreen/offscreen.html new file mode 100644 index 0000000..83795a7 --- /dev/null +++ b/extension/src/offscreen/offscreen.html @@ -0,0 +1,7 @@ + + +browseruse offscreen + + + + diff --git a/extension/src/offscreen/offscreen.ts b/extension/src/offscreen/offscreen.ts new file mode 100644 index 0000000..0e62f0f --- /dev/null +++ b/extension/src/offscreen/offscreen.ts @@ -0,0 +1,110 @@ +/** + * Offscreen document — persistent WebSocket connection to the REPL server. + * + * Chrome Manifest V3 service workers cannot hold long-lived WebSocket connections + * (they get terminated after ~30s of inactivity). The offscreen document runs as + * a hidden page that keeps the WebSocket alive and relays JSON-RPC messages + * between the server and the service worker via chrome.runtime messaging. + */ + +const DEFAULT_URL = 'ws://127.0.0.1:9876/ws'; +const RECONNECT_BASE_MS = 1000; +const RECONNECT_MAX_MS = 30000; + +let ws: WebSocket | null = null; +let reconnectAttempts = 0; +let serverUrl = DEFAULT_URL; + +// Load stored URL +chrome.storage.local.get('serverUrl', (data) => { + if (data.serverUrl) serverUrl = data.serverUrl; + connect(); +}); + +// Listen for URL changes from popup +chrome.storage.onChanged.addListener((changes) => { + if (changes.serverUrl?.newValue) { + serverUrl = changes.serverUrl.newValue; + if (ws) { + ws.close(); + ws = null; + } + reconnectAttempts = 0; + connect(); + } +}); + +function connect(): void { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + return; + } + + try { + ws = new WebSocket(serverUrl); + } catch { + scheduleReconnect(); + return; + } + + ws.onopen = () => { + reconnectAttempts = 0; + notifyServiceWorker({ type: 'ws-state', connected: true }); + + // Send handshake + const handshake = { + jsonrpc: '2.0', + id: 'handshake-1', + method: 'session.handshake', + params: { clientType: 'extension', version: chrome.runtime.getManifest().version }, + }; + ws!.send(JSON.stringify(handshake)); + }; + + ws.onmessage = (event) => { + // Forward server messages to the service worker + notifyServiceWorker({ type: 'ws-message', data: event.data }); + }; + + ws.onclose = () => { + ws = null; + notifyServiceWorker({ type: 'ws-state', connected: false }); + scheduleReconnect(); + }; + + ws.onerror = () => { + // onclose will fire after this + }; +} + +function scheduleReconnect(): void { + const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_MS); + reconnectAttempts++; + setTimeout(connect, delay); +} + +function notifyServiceWorker(msg: object): void { + chrome.runtime.sendMessage(msg).catch(() => { + // Service worker may not be listening yet + }); +} + +// Listen for messages from the service worker to send over WebSocket +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === 'ws-send') { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(typeof message.data === 'string' ? message.data : JSON.stringify(message.data)); + sendResponse({ ok: true }); + } else { + sendResponse({ ok: false, error: 'WebSocket not connected' }); + } + return true; // async response + } + + if (message.type === 'ws-status') { + sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + url: serverUrl, + }); + return true; + } +}); diff --git a/extension/src/popup/popup.html b/extension/src/popup/popup.html new file mode 100644 index 0000000..ca0fa8b --- /dev/null +++ b/extension/src/popup/popup.html @@ -0,0 +1,130 @@ + + + + + + + +
+

browseruse

+
+ +
+ + Disconnected +
+ +
+ + +
+ +
+ + +
+ +
+ + + + diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts new file mode 100644 index 0000000..2d3339f --- /dev/null +++ b/extension/src/popup/popup.ts @@ -0,0 +1,67 @@ +/** + * Popup script — connection status and server URL configuration. + */ + +const statusEl = document.getElementById('status')!; +const dotEl = document.getElementById('dot')!; +const statusTextEl = document.getElementById('statusText')!; +const urlInput = document.getElementById('urlInput') as HTMLInputElement; +const saveBtn = document.getElementById('saveBtn')!; +const reconnectBtn = document.getElementById('reconnectBtn')!; +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'; + } +} + +// Load saved URL +chrome.storage.local.get('serverUrl', (data) => { + if (data.serverUrl) { + urlInput.value = data.serverUrl; + } +}); + +// Get current connection status +chrome.runtime.sendMessage({ type: 'get-status' }, (response) => { + if (chrome.runtime.lastError) { + setConnected(false); + return; + } + setConnected(response?.connected ?? false); +}); + +// Listen for state changes +chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'connection-state') { + setConnected(message.connected); + } + if (message.type === 'ws-state') { + setConnected(message.connected); + } +}); + +// Save URL +saveBtn.addEventListener('click', () => { + const url = urlInput.value.trim(); + if (!url) return; + chrome.storage.local.set({ serverUrl: url }); +}); + +// Reconnect +reconnectBtn.addEventListener('click', () => { + const url = urlInput.value.trim(); + if (url) { + // Setting the URL triggers reconnect in offscreen.ts + chrome.storage.local.set({ serverUrl: url }); + } +}); diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..9c60b19 --- /dev/null +++ b/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/package.json b/package.json index 5b2f73d..4b7104c 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,25 @@ "version": "0.3.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 extension/build.ts" + }, + "dependencies": { + "@browseruse/cli": "workspace:*", + "@browseruse/protocol": "workspace:*" }, "devDependencies": { "@types/bun": "latest", + "@types/chrome": "^0.0.287", + "esbuild": "^0.24.0", "typescript": "^5.5.0" } } diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..4b3050f --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,12 @@ +{ + "name": "@browseruse/cli", + "version": "0.3.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 100% rename from src/browser.ts rename to packages/cli/src/browser.ts diff --git a/src/browser_protocol.json b/packages/cli/src/browser_protocol.json similarity index 100% rename from src/browser_protocol.json rename to packages/cli/src/browser_protocol.json diff --git a/src/cli.ts b/packages/cli/src/cli.ts similarity index 100% rename from src/cli.ts rename to packages/cli/src/cli.ts 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 89% rename from src/repl.ts rename to packages/cli/src/repl.ts index 0711449..d6ccbd8 100644 --- a/src/repl.ts +++ b/packages/cli/src/repl.ts @@ -11,6 +11,7 @@ * 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. * * State: `session`, the active sessionId, event subscribers, and any * `globalThis.` you set persist across requests for the lifetime of @@ -20,6 +21,7 @@ import { Session, listPageTargets, resolveWsUrl, detectBrowsers } from './session.ts'; import { launchBrowser, getManagedBrowser, closeManagedBrowser } from './browser.ts'; import * as Generated from './generated.ts'; +import { handleWsOpen, handleWsClose, handleWsMessage, type WsData } from './ws-handler.ts'; const session = new Session(); (globalThis as any).session = session; @@ -70,12 +72,21 @@ function renderResult(v: unknown): string { } export function runServer(): void { - const server = Bun.serve({ + 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({ @@ -168,6 +179,17 @@ 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 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..31967af --- /dev/null +++ b/packages/cli/src/ws-handler.ts @@ -0,0 +1,299 @@ +/** + * 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.3.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: { + if (!session.isConnected()) { + throw { code: ErrorCodes.NOT_CONNECTED, message: 'CDP session not connected' }; + } + const cdpMethod = params.method as string; + const cdpParams = (params.params as Record) ?? {}; + if (!cdpMethod) { + throw { code: ErrorCodes.INVALID_PARAMS, message: 'Missing "method" param' }; + } + try { + const result = await session._call(cdpMethod, cdpParams); + return result; + } catch (err: any) { + throw { code: ErrorCodes.CDP_ERROR, message: err.message ?? String(err) }; + } + } + + 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/protocol/errors.ts b/packages/protocol/errors.ts new file mode 100644 index 0000000..8260829 --- /dev/null +++ b/packages/protocol/errors.ts @@ -0,0 +1,26 @@ +/** + * 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, +} 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..7ea3c28 --- /dev/null +++ b/packages/protocol/events.ts @@ -0,0 +1,52 @@ +/** + * 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', +} 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; +} 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..27e3b32 --- /dev/null +++ b/packages/protocol/methods.ts @@ -0,0 +1,298 @@ +/** + * 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; +} + +// --------------------------------------------------------------------------- +// 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; +} + +// --------------------------------------------------------------------------- +// DOM methods +// --------------------------------------------------------------------------- + +export interface DomQueryParams { + tabId: number; + selector: string; +} + +export interface DomQueryResult { + found: boolean; + text?: string; + tagName?: string; + attributes?: Record; +} + +export interface DomQueryAllParams { + tabId: number; + selector: string; +} + +export interface DomQueryAllResult { + count: number; + elements: Array<{ + index: number; + text: string; + tagName: string; + attributes: Record; + }>; +} + +export interface DomClickParams { + tabId: number; + selector: string; +} + +export interface DomTypeParams { + tabId: number; + selector: string; + text: string; + clear?: boolean; +} + +export interface DomGetTextParams { + tabId: number; + selector: string; +} + +export interface DomGetTextResult { + text: string; +} + +export interface DomGetHtmlParams { + tabId: number; + selector: string; + outer?: boolean; +} + +export interface DomGetHtmlResult { + html: string; +} + +// --------------------------------------------------------------------------- +// Page methods +// --------------------------------------------------------------------------- + +export interface PageScreenshotParams { + tabId?: number; + format?: 'png' | 'jpeg'; + quality?: number; +} + +export interface PageScreenshotResult { + data: string; // base64 + format: string; +} + +export interface PageEvalParams { + tabId: number; + expression: string; +} + +export interface PageEvalResult { + result: unknown; +} + +export interface PageGetUrlParams { + tabId: number; +} + +export interface PageGetUrlResult { + url: string; +} + +export interface PageGetTitleParams { + tabId: number; +} + +export interface PageGetTitleResult { + title: string; +} + +// --------------------------------------------------------------------------- +// Network / Cookie methods +// --------------------------------------------------------------------------- + +export interface CookieInfo { + name: string; + value: string; + domain: string; + path: string; + secure: boolean; + httpOnly: boolean; + sameSite: string; + expirationDate?: number; +} + +export interface NetworkGetCookiesParams { + url?: string; + domain?: string; +} + +export interface NetworkGetCookiesResult { + cookies: CookieInfo[]; +} + +export interface NetworkSetCookieParams { + url: string; + name: string; + value: string; + domain?: string; + path?: string; + secure?: boolean; + httpOnly?: boolean; + sameSite?: 'no_restriction' | 'lax' | 'strict'; + expirationDate?: number; +} + +export interface NetworkDeleteCookiesParams { + url: string; + name: string; +} + +// --------------------------------------------------------------------------- +// 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', + + // DOM + DOM_QUERY: 'dom.query', + DOM_QUERY_ALL: 'dom.queryAll', + DOM_CLICK: 'dom.click', + DOM_TYPE: 'dom.type', + DOM_GET_TEXT: 'dom.getText', + DOM_GET_HTML: 'dom.getHtml', + + // Page + PAGE_SCREENSHOT: 'page.screenshot', + PAGE_EVAL: 'page.eval', + PAGE_GET_URL: 'page.getUrl', + PAGE_GET_TITLE: 'page.getTitle', + + // Network + NETWORK_GET_COOKIES: 'network.getCookies', + NETWORK_SET_COOKIE: 'network.setCookie', + NETWORK_DELETE_COOKIES: 'network.deleteCookies', +} 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.DOM_QUERY, + Methods.DOM_QUERY_ALL, + Methods.DOM_CLICK, + Methods.DOM_TYPE, + Methods.DOM_GET_TEXT, + Methods.DOM_GET_HTML, + Methods.PAGE_SCREENSHOT, + Methods.PAGE_EVAL, + Methods.PAGE_GET_URL, + Methods.PAGE_GET_TITLE, + Methods.NETWORK_GET_COOKIES, + Methods.NETWORK_SET_COOKIE, + Methods.NETWORK_DELETE_COOKIES, +]); diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000..7386271 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,8 @@ +{ + "name": "@browseruse/protocol", + "version": "0.3.0", + "type": "module", + "private": true, + "main": "index.ts", + "types": "index.ts" +} diff --git a/tsconfig.json b/tsconfig.json index c1482cd..504dadc 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": "extension" } + ] } From d5f678f6838ef676ae08f016dc8539e113ae1dc2 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Mon, 11 May 2026 18:41:56 +0100 Subject: [PATCH 02/14] feat(extension)!: migrate to native messaging + chrome.debugger for full CDP access Replace the offscreen WebSocket bridge with native messaging (chrome.runtime.connectNative) and replace chrome.tabs/scripting/cookies APIs with chrome.debugger for full CDP protocol access (all 56 domains, 652 methods) without requiring --remote-debugging-port=9222. - Add native messaging host bridge (native-host.ts) that relays between Chrome's stdin/stdout protocol and the REPL server WebSocket - Add install script for native host manifest (macOS/Linux) - Add debugger-handler.ts for chrome.debugger attach/detach/sendCommand - Rewrite service worker to use connectNative instead of offscreen doc - Reduce content script to visual feedback only (cursor, highlight, ripple) - Update ws-handler to fall back to extension debugger for session.cdpRaw - Remove offscreen document, page-handlers, network-handlers BREAKING CHANGE: extension no longer uses offscreen WebSocket bridge or chrome.scripting/cookies APIs. All page/DOM/network operations must now go through session.cdpRaw or debugger.sendCommand via chrome.debugger. --- extension/build.ts | 4 +- extension/install.ts | 93 ++++++++++ extension/manifest.json | 6 +- extension/native-messaging-host.json | 7 + extension/src/background/debugger-handler.ts | 144 +++++++++++++++ extension/src/background/network-handlers.ts | 75 -------- extension/src/background/page-handlers.ts | 63 ------- extension/src/background/service-worker.ts | 171 +++++++++--------- extension/src/content/content-script.ts | 149 ++++------------ extension/src/offscreen/offscreen.html | 7 - extension/src/offscreen/offscreen.ts | 110 ------------ extension/src/popup/popup.html | 76 ++++---- extension/src/popup/popup.ts | 39 ++--- package.json | 6 +- packages/cli/src/native-host.ts | 149 ++++++++++++++++ packages/cli/src/ws-handler.ts | 29 +++- packages/protocol/errors.ts | 3 + packages/protocol/events.ts | 15 ++ packages/protocol/methods.ts | 174 ++----------------- 19 files changed, 607 insertions(+), 713 deletions(-) create mode 100644 extension/install.ts create mode 100644 extension/native-messaging-host.json create mode 100644 extension/src/background/debugger-handler.ts delete mode 100644 extension/src/background/network-handlers.ts delete mode 100644 extension/src/background/page-handlers.ts delete mode 100644 extension/src/offscreen/offscreen.html delete mode 100644 extension/src/offscreen/offscreen.ts create mode 100644 packages/cli/src/native-host.ts diff --git a/extension/build.ts b/extension/build.ts index 587c114..a84f3d4 100644 --- a/extension/build.ts +++ b/extension/build.ts @@ -9,7 +9,7 @@ import { build } from 'esbuild'; import { cpSync, mkdirSync, rmSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; +import { join } from 'path'; const EXT_DIR = import.meta.dir; const ROOT = join(EXT_DIR, '..'); @@ -25,7 +25,6 @@ mkdirSync(DIST, { recursive: true }); await build({ entryPoints: [ join(SRC, 'background/service-worker.ts'), - join(SRC, 'offscreen/offscreen.ts'), join(SRC, 'content/content-script.ts'), join(SRC, 'popup/popup.ts'), ], @@ -46,7 +45,6 @@ await build({ // 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, 'offscreen/offscreen.html'), join(DIST, 'offscreen/offscreen.html')); cpSync(join(SRC, 'popup/popup.html'), join(DIST, 'popup/popup.html')); console.log('Extension built → extension/dist/'); diff --git a/extension/install.ts b/extension/install.ts new file mode 100644 index 0000000..0bff35b --- /dev/null +++ b/extension/install.ts @@ -0,0 +1,93 @@ +/** + * Install script for the browseruse native messaging host. + * + * Sets up the native messaging host manifest so Chrome can spawn the + * native host bridge process when the extension calls connectNative(). + * + * Usage: + * bun extension/install.ts [--extension-id ] + * + * The script: + * 1. Creates a wrapper shell script that invokes `bun native-host.ts` + * 2. Generates the Chrome native messaging host manifest with correct paths + * 3. Places the manifest in the platform-appropriate directory + */ + +import { writeFileSync, mkdirSync, chmodSync, existsSync } from 'fs'; +import { join, resolve } from 'path'; +import { platform, homedir } from 'os'; + +const HOST_NAME = 'com.browseruse.host'; +const DEFAULT_EXTENSION_ID = 'your-extension-id-here'; + +// Parse CLI args +function parseArgs(): { extensionId: string } { + const args = process.argv.slice(2); + let extensionId = DEFAULT_EXTENSION_ID; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--extension-id' && args[i + 1]) { + extensionId = args[i + 1]; + i++; + } + } + + return { extensionId }; +} + +function getNativeHostDir(): string { + 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: + throw new Error(`Unsupported platform: ${os}. Only macOS and Linux are supported.`); + } +} + +function main(): void { + const { extensionId } = parseArgs(); + const rootDir = resolve(import.meta.dir, '..'); + const nativeHostScript = resolve(rootDir, 'packages', 'cli', 'src', 'native-host.ts'); + + // 1. Create wrapper shell script + const wrapperPath = resolve(rootDir, 'extension', 'native-host-wrapper.sh'); + const wrapperContent = `#!/bin/sh +exec bun "${nativeHostScript}" "$@" +`; + writeFileSync(wrapperPath, wrapperContent); + chmodSync(wrapperPath, 0o755); + console.log(`Created wrapper script: ${wrapperPath}`); + + // 2. Generate the native messaging host manifest + const manifest = { + name: HOST_NAME, + description: 'browseruse native messaging host', + path: wrapperPath, + type: 'stdio', + allowed_origins: [`chrome-extension://${extensionId}/`], + }; + + // 3. Place manifest in the platform-appropriate directory + const hostDir = getNativeHostDir(); + if (!existsSync(hostDir)) { + mkdirSync(hostDir, { recursive: true }); + } + + const manifestPath = join(hostDir, `${HOST_NAME}.json`); + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + console.log(`Installed native messaging host manifest: ${manifestPath}`); + console.log(`\nExtension ID: ${extensionId}`); + console.log(`Native host: ${wrapperPath}`); + + if (extensionId === DEFAULT_EXTENSION_ID) { + console.log('\nWARNING: Using default extension ID. After loading the extension in Chrome,'); + console.log('re-run with: bun extension/install.ts --extension-id '); + } +} + +main(); diff --git a/extension/manifest.json b/extension/manifest.json index 784cbb9..cc4a225 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -4,11 +4,9 @@ "version": "0.3.0", "description": "Connect AI agents to your browser via browseruse.", "permissions": [ + "debugger", + "nativeMessaging", "tabs", - "activeTab", - "scripting", - "cookies", - "offscreen", "storage" ], "host_permissions": [""], diff --git a/extension/native-messaging-host.json b/extension/native-messaging-host.json new file mode 100644 index 0000000..c45098f --- /dev/null +++ b/extension/native-messaging-host.json @@ -0,0 +1,7 @@ +{ + "name": "com.browseruse.host", + "description": "browseruse native messaging host", + "path": "__NATIVE_HOST_PATH__", + "type": "stdio", + "allowed_origins": ["chrome-extension://__EXTENSION_ID__/"] +} diff --git a/extension/src/background/debugger-handler.ts b/extension/src/background/debugger-handler.ts new file mode 100644 index 0000000..11263dc --- /dev/null +++ b/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/extension/src/background/network-handlers.ts b/extension/src/background/network-handlers.ts deleted file mode 100644 index d1a3b1b..0000000 --- a/extension/src/background/network-handlers.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Network/cookie handlers using Chrome Extensions API. - */ - -export interface CookieInfo { - name: string; - value: string; - domain: string; - path: string; - secure: boolean; - httpOnly: boolean; - sameSite: string; - expirationDate?: number; -} - -function toCookieInfo(c: chrome.cookies.Cookie): CookieInfo { - return { - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - sameSite: c.sameSite, - expirationDate: c.expirationDate, - }; -} - -export async function handleGetCookies(params: { - url?: string; - domain?: string; -}): Promise<{ cookies: CookieInfo[] }> { - const query: chrome.cookies.GetAllDetails = {}; - if (params.url) query.url = params.url; - if (params.domain) query.domain = params.domain; - - const cookies = await chrome.cookies.getAll(query); - return { cookies: cookies.map(toCookieInfo) }; -} - -export async function handleSetCookie(params: { - url: string; - name: string; - value: string; - domain?: string; - path?: string; - secure?: boolean; - httpOnly?: boolean; - sameSite?: 'no_restriction' | 'lax' | 'strict'; - expirationDate?: number; -}): Promise<{ ok: true }> { - await chrome.cookies.set({ - url: params.url, - name: params.name, - value: params.value, - domain: params.domain, - path: params.path, - secure: params.secure, - httpOnly: params.httpOnly, - sameSite: params.sameSite, - expirationDate: params.expirationDate, - }); - return { ok: true }; -} - -export async function handleDeleteCookies(params: { - url: string; - name: string; -}): Promise<{ ok: true }> { - await chrome.cookies.remove({ - url: params.url, - name: params.name, - }); - return { ok: true }; -} diff --git a/extension/src/background/page-handlers.ts b/extension/src/background/page-handlers.ts deleted file mode 100644 index cf762d6..0000000 --- a/extension/src/background/page-handlers.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Page-level handlers: screenshot, eval, getUrl, getTitle. - */ - -export async function handlePageScreenshot(params: { - tabId?: number; - format?: 'png' | 'jpeg'; - quality?: number; -}): Promise<{ data: string; format: string }> { - // Get the window for the tab (or current window) - let windowId: number | undefined; - if (params.tabId !== undefined) { - const tab = await chrome.tabs.get(params.tabId); - windowId = tab.windowId; - // Ensure the tab is active in its window for captureVisibleTab - if (!tab.active) { - await chrome.tabs.update(params.tabId, { active: true }); - // Brief delay for the tab to render - await new Promise(r => setTimeout(r, 150)); - } - } - - const format = params.format ?? 'png'; - const dataUrl = await chrome.tabs.captureVisibleTab(windowId ?? chrome.windows.WINDOW_ID_CURRENT, { - format, - quality: params.quality, - }); - - // Strip the data URL prefix to return raw base64 - const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, ''); - return { data: base64, format }; -} - -export async function handlePageEval(params: { - tabId: number; - expression: string; -}): Promise<{ result: unknown }> { - const results = await chrome.scripting.executeScript({ - target: { tabId: params.tabId }, - func: (expr: string) => { - // eslint-disable-next-line no-eval - return eval(expr); - }, - args: [params.expression], - world: 'MAIN', - }); - - const frame = results[0]; - if (!frame) { - return { result: undefined }; - } - return { result: frame.result }; -} - -export async function handlePageGetUrl(params: { tabId: number }): Promise<{ url: string }> { - const tab = await chrome.tabs.get(params.tabId); - return { url: tab.url ?? '' }; -} - -export async function handlePageGetTitle(params: { tabId: number }): Promise<{ title: string }> { - const tab = await chrome.tabs.get(params.tabId); - return { title: tab.title ?? '' }; -} diff --git a/extension/src/background/service-worker.ts b/extension/src/background/service-worker.ts index bc1d71f..c11e79a 100644 --- a/extension/src/background/service-worker.ts +++ b/extension/src/background/service-worker.ts @@ -1,78 +1,98 @@ /** * Service worker — main background script for the Chrome extension. * - * Ensures the offscreen document stays alive, routes incoming JSON-RPC - * requests from the server (relayed via offscreen) to appropriate handlers, - * and sends responses back. + * Uses native messaging (chrome.runtime.connectNative) to communicate with + * the browseruse REPL server via the native host bridge. 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 { handlePageScreenshot, handlePageEval, handlePageGetUrl, handlePageGetTitle } from './page-handlers'; -import { handleGetCookies, handleSetCookie, handleDeleteCookies } from './network-handlers'; -import { Methods, ErrorCodes, makeSuccess, makeError } from '@browseruse/protocol'; +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 NATIVE_HOST_NAME = 'com.browseruse.host'; // --------------------------------------------------------------------------- -// Offscreen document management +// Native messaging connection // --------------------------------------------------------------------------- -let offscreenCreating: Promise | null = null; +let nativePort: chrome.runtime.Port | null = null; +let nativeConnected = false; -async function ensureOffscreen(): Promise { - const existingContexts = await chrome.runtime.getContexts({ - contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], - }); - if (existingContexts.length > 0) return; - - if (offscreenCreating) { - await offscreenCreating; +function connectNative(): void { + try { + nativePort = chrome.runtime.connectNative(NATIVE_HOST_NAME); + } catch (err) { + nativeConnected = false; + scheduleReconnect(); return; } - offscreenCreating = chrome.offscreen.createDocument({ - url: 'offscreen/offscreen.html', - reasons: [chrome.offscreen.Reason.WEB_RTC as any], - justification: 'Maintain persistent WebSocket connection to browseruse server', + nativePort.onMessage.addListener((message) => { + handleServerMessage(message); + }); + + nativePort.onDisconnect.addListener(() => { + const error = chrome.runtime.lastError; + nativeConnected = false; + nativePort = null; + // Notify popup + chrome.runtime.sendMessage({ type: 'connection-state', connected: false }).catch(() => {}); + scheduleReconnect(); }); - await offscreenCreating; - offscreenCreating = null; + nativeConnected = true; + // Notify popup + chrome.runtime.sendMessage({ type: 'connection-state', connected: true }).catch(() => {}); } -// Create offscreen document on install/startup -chrome.runtime.onInstalled.addListener(() => { ensureOffscreen(); }); -chrome.runtime.onStartup.addListener(() => { ensureOffscreen(); }); +let reconnectTimer: ReturnType | null = null; -// Also ensure it exists when the service worker wakes up -ensureOffscreen(); +function scheduleReconnect(): void { + if (reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connectNative(); + }, 3000); +} + +// Connect on startup +chrome.runtime.onInstalled.addListener(() => { connectNative(); }); +chrome.runtime.onStartup.addListener(() => { connectNative(); }); +connectNative(); // --------------------------------------------------------------------------- -// Connection state tracking +// Debugger event forwarding // --------------------------------------------------------------------------- -let wsConnected = false; +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 offscreen document +// Message routing from popup // --------------------------------------------------------------------------- chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - // Only handle messages from our own extension if (sender.id !== chrome.runtime.id) return; - if (message.type === 'ws-state') { - wsConnected = message.connected; - // Notify popup about state change - chrome.runtime.sendMessage({ type: 'connection-state', connected: wsConnected }).catch(() => {}); - return; - } - - if (message.type === 'ws-message') { - handleServerMessage(message.data); - return; - } - if (message.type === 'get-status') { - sendResponse({ connected: wsConnected }); + sendResponse({ + connected: nativeConnected, + attachedTabs: getAttachedTabs(), + }); return true; } }); @@ -90,40 +110,21 @@ const handlers: Record = { [Methods.TABS_NAVIGATE]: handleTabNavigate, [Methods.TABS_ACTIVATE]: handleTabActivate, [Methods.TABS_RELOAD]: handleTabReload, - [Methods.PAGE_SCREENSHOT]: handlePageScreenshot, - [Methods.PAGE_EVAL]: handlePageEval, - [Methods.PAGE_GET_URL]: handlePageGetUrl, - [Methods.PAGE_GET_TITLE]: handlePageGetTitle, - [Methods.NETWORK_GET_COOKIES]: handleGetCookies, - [Methods.NETWORK_SET_COOKIE]: handleSetCookie, - [Methods.NETWORK_DELETE_COOKIES]: handleDeleteCookies, + [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), }; -// DOM methods are forwarded to the content script -const DOM_METHODS = new Set([ - Methods.DOM_QUERY, Methods.DOM_QUERY_ALL, Methods.DOM_CLICK, - Methods.DOM_TYPE, Methods.DOM_GET_TEXT, Methods.DOM_GET_HTML, -]); - -async function handleServerMessage(raw: string): Promise { - let msg: any; - try { - msg = JSON.parse(raw); - } catch { - return; - } - +async function handleServerMessage(msg: any): Promise { // Only handle requests (have id + method) - if (!msg.id || !msg.method) return; + if (!msg || !msg.id || !msg.method) return; const { id, method, params } = msg; try { let result: unknown; - if (DOM_METHODS.has(method)) { - result = await forwardToContentScript(method, params ?? {}); - } else if (handlers[method]) { + if (handlers[method]) { result = await handlers[method](params ?? {}); } else { sendToServer(makeError(id, ErrorCodes.METHOD_NOT_FOUND, `Unknown method: ${method}`)); @@ -137,31 +138,15 @@ async function handleServerMessage(raw: string): Promise { } // --------------------------------------------------------------------------- -// Content script communication -// --------------------------------------------------------------------------- - -async function forwardToContentScript(method: string, params: Record): Promise { - const tabId = params.tabId as number; - if (tabId === undefined) { - throw { code: ErrorCodes.INVALID_PARAMS, message: 'Missing tabId param' }; - } - - // Send message to content script in the target tab - const response = await chrome.tabs.sendMessage(tabId, { method, params }); - - if (response?.error) { - throw { code: response.error.code ?? ErrorCodes.INTERNAL_ERROR, message: response.error.message }; - } - - return response?.result; -} - -// --------------------------------------------------------------------------- -// Send response back to server via offscreen +// Send response back to server via native messaging // --------------------------------------------------------------------------- function sendToServer(msg: object): void { - chrome.runtime.sendMessage({ type: 'ws-send', data: JSON.stringify(msg) }).catch(() => { - // Offscreen may not be ready - }); + if (nativePort) { + try { + nativePort.postMessage(msg); + } catch { + // Port may be disconnected + } + } } diff --git a/extension/src/content/content-script.ts b/extension/src/content/content-script.ts index a896e7a..3ad7613 100644 --- a/extension/src/content/content-script.ts +++ b/extension/src/content/content-script.ts @@ -1,16 +1,18 @@ /** - * Content script — runs in every page, handles DOM manipulation commands - * from the service worker. + * Content script — runs in every page, provides visual feedback only. * - * Visual feedback inspired by Playwriter: + * 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. */ -import { ErrorCodes } from '@browseruse/protocol'; - // --------------------------------------------------------------------------- // Ghost cursor (Playwriter-style two-element cursor) // --------------------------------------------------------------------------- @@ -210,15 +212,14 @@ function ensureOverlay(): HTMLDivElement { return container; } -function highlightElement(el: Element): void { - const rect = el.getBoundingClientRect(); +function highlightRect(x: number, y: number, width: number, height: number): void { const container = ensureOverlay(); Object.assign(container.style, { - top: `${rect.top}px`, - left: `${rect.left}px`, - width: `${rect.width}px`, - height: `${rect.height}px`, + top: `${y}px`, + left: `${x}px`, + width: `${width}px`, + height: `${height}px`, display: 'block', opacity: '1', }); @@ -231,114 +232,30 @@ function highlightElement(el: Element): void { } // --------------------------------------------------------------------------- -// DOM command handlers -// --------------------------------------------------------------------------- - -type DomHandler = (params: Record) => unknown; - -const domHandlers: Record = { - 'dom.query'(params) { - const el = document.querySelector(params.selector as string); - if (!el) return { found: false }; - highlightElement(el); - const attrs: Record = {}; - for (const attr of el.attributes) { - attrs[attr.name] = attr.value; - } - return { - found: true, - text: el.textContent?.trim() ?? '', - tagName: el.tagName.toLowerCase(), - attributes: attrs, - }; - }, - - 'dom.queryAll'(params) { - const els = document.querySelectorAll(params.selector as string); - const elements = Array.from(els).map((el, index) => { - const attrs: Record = {}; - for (const attr of el.attributes) { - attrs[attr.name] = attr.value; - } - return { - index, - text: el.textContent?.trim() ?? '', - tagName: el.tagName.toLowerCase(), - attributes: attrs, - }; - }); - return { count: elements.length, elements }; - }, - - 'dom.click'(params) { - const el = document.querySelector(params.selector as string); - if (!el) throw { code: ErrorCodes.ELEMENT_NOT_FOUND, message: `Element not found: ${params.selector}` }; - highlightElement(el); - const rect = el.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; - showClickAt(x, y); - (el as HTMLElement).click(); - return { ok: true }; - }, - - 'dom.type'(params) { - const el = document.querySelector(params.selector as string) as HTMLInputElement | HTMLTextAreaElement | null; - if (!el) throw { code: ErrorCodes.ELEMENT_NOT_FOUND, message: `Element not found: ${params.selector}` }; - highlightElement(el); - // Move cursor to element - const rect = el.getBoundingClientRect(); - moveCursorTo(rect.left + rect.width / 2, rect.top + rect.height / 2, true); - el.focus(); - if (params.clear) { - el.value = ''; - el.dispatchEvent(new Event('input', { bubbles: true })); - } - const text = params.text as string; - for (const char of text) { - el.value += char; - el.dispatchEvent(new InputEvent('input', { bubbles: true, data: char, inputType: 'insertText' })); - } - el.dispatchEvent(new Event('change', { bubbles: true })); - return { ok: true }; - }, - - 'dom.getText'(params) { - const el = document.querySelector(params.selector as string); - if (!el) throw { code: ErrorCodes.ELEMENT_NOT_FOUND, message: `Element not found: ${params.selector}` }; - highlightElement(el); - return { text: el.textContent?.trim() ?? '' }; - }, - - 'dom.getHtml'(params) { - const el = document.querySelector(params.selector as string); - if (!el) throw { code: ErrorCodes.ELEMENT_NOT_FOUND, message: `Element not found: ${params.selector}` }; - return { html: params.outer ? el.outerHTML : el.innerHTML }; - }, -}; - -// --------------------------------------------------------------------------- -// Message listener +// Message listener — visual commands only // --------------------------------------------------------------------------- chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { const { method, params } = message; - - if (!method || !domHandlers[method]) { - return false; + 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; } - - try { - const result = domHandlers[method](params ?? {}); - sendResponse({ result }); - } catch (err: any) { - sendResponse({ - error: { - code: err.code ?? -32603, - message: err.message ?? String(err), - }, - }); - } - - return true; }); diff --git a/extension/src/offscreen/offscreen.html b/extension/src/offscreen/offscreen.html deleted file mode 100644 index 83795a7..0000000 --- a/extension/src/offscreen/offscreen.html +++ /dev/null @@ -1,7 +0,0 @@ - - -browseruse offscreen - - - - diff --git a/extension/src/offscreen/offscreen.ts b/extension/src/offscreen/offscreen.ts deleted file mode 100644 index 0e62f0f..0000000 --- a/extension/src/offscreen/offscreen.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Offscreen document — persistent WebSocket connection to the REPL server. - * - * Chrome Manifest V3 service workers cannot hold long-lived WebSocket connections - * (they get terminated after ~30s of inactivity). The offscreen document runs as - * a hidden page that keeps the WebSocket alive and relays JSON-RPC messages - * between the server and the service worker via chrome.runtime messaging. - */ - -const DEFAULT_URL = 'ws://127.0.0.1:9876/ws'; -const RECONNECT_BASE_MS = 1000; -const RECONNECT_MAX_MS = 30000; - -let ws: WebSocket | null = null; -let reconnectAttempts = 0; -let serverUrl = DEFAULT_URL; - -// Load stored URL -chrome.storage.local.get('serverUrl', (data) => { - if (data.serverUrl) serverUrl = data.serverUrl; - connect(); -}); - -// Listen for URL changes from popup -chrome.storage.onChanged.addListener((changes) => { - if (changes.serverUrl?.newValue) { - serverUrl = changes.serverUrl.newValue; - if (ws) { - ws.close(); - ws = null; - } - reconnectAttempts = 0; - connect(); - } -}); - -function connect(): void { - if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { - return; - } - - try { - ws = new WebSocket(serverUrl); - } catch { - scheduleReconnect(); - return; - } - - ws.onopen = () => { - reconnectAttempts = 0; - notifyServiceWorker({ type: 'ws-state', connected: true }); - - // Send handshake - const handshake = { - jsonrpc: '2.0', - id: 'handshake-1', - method: 'session.handshake', - params: { clientType: 'extension', version: chrome.runtime.getManifest().version }, - }; - ws!.send(JSON.stringify(handshake)); - }; - - ws.onmessage = (event) => { - // Forward server messages to the service worker - notifyServiceWorker({ type: 'ws-message', data: event.data }); - }; - - ws.onclose = () => { - ws = null; - notifyServiceWorker({ type: 'ws-state', connected: false }); - scheduleReconnect(); - }; - - ws.onerror = () => { - // onclose will fire after this - }; -} - -function scheduleReconnect(): void { - const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_MS); - reconnectAttempts++; - setTimeout(connect, delay); -} - -function notifyServiceWorker(msg: object): void { - chrome.runtime.sendMessage(msg).catch(() => { - // Service worker may not be listening yet - }); -} - -// Listen for messages from the service worker to send over WebSocket -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { - if (message.type === 'ws-send') { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(typeof message.data === 'string' ? message.data : JSON.stringify(message.data)); - sendResponse({ ok: true }); - } else { - sendResponse({ ok: false, error: 'WebSocket not connected' }); - } - return true; // async response - } - - if (message.type === 'ws-status') { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - url: serverUrl, - }); - return true; - } -}); diff --git a/extension/src/popup/popup.html b/extension/src/popup/popup.html index ca0fa8b..d16387d 100644 --- a/extension/src/popup/popup.html +++ b/extension/src/popup/popup.html @@ -46,55 +46,36 @@ } .dot.green { background: #10b981; } .dot.red { background: #ef4444; } - .field { + .info { + padding: 10px 12px; + border-radius: 8px; + background: #f9fafb; margin-bottom: 12px; - } - .field label { - display: block; - font-size: 11px; - font-weight: 500; + font-size: 12px; color: #6b7280; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 4px; - } - .field input { - width: 100%; - padding: 6px 8px; - border: 1px solid #d1d5db; - border-radius: 6px; - font-size: 13px; - font-family: 'SF Mono', 'Fira Code', monospace; - outline: none; + line-height: 1.5; } - .field input:focus { - border-color: #3b82f6; - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); + .info .label { + font-weight: 500; + color: #374151; } - .actions { + .info-row { display: flex; - gap: 8px; - } - .btn { - flex: 1; - padding: 7px 12px; - border: none; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: background 0.15s; + justify-content: space-between; + align-items: center; } - .btn-primary { - background: #3b82f6; - color: #fff; + .info-row + .info-row { + margin-top: 4px; } - .btn-primary:hover { background: #2563eb; } - .btn-secondary { - background: #f3f4f6; - color: #374151; + .note { + padding: 8px 12px; + border-radius: 8px; + background: #fffbeb; + color: #92400e; + font-size: 11px; + line-height: 1.4; + margin-bottom: 12px; } - .btn-secondary:hover { background: #e5e7eb; } .version { text-align: center; color: #9ca3af; @@ -113,14 +94,15 @@

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/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 2d3339f..cc6ba7a 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -1,13 +1,11 @@ /** - * Popup script — connection status and server URL configuration. + * 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 urlInput = document.getElementById('urlInput') as HTMLInputElement; -const saveBtn = document.getElementById('saveBtn')!; -const reconnectBtn = document.getElementById('reconnectBtn')!; +const attachedCountEl = document.getElementById('attachedCount')!; const versionEl = document.getElementById('version')!; versionEl.textContent = `v${chrome.runtime.getManifest().version}`; @@ -24,12 +22,12 @@ function setConnected(connected: boolean): void { } } -// Load saved URL -chrome.storage.local.get('serverUrl', (data) => { - if (data.serverUrl) { - urlInput.value = data.serverUrl; +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) => { @@ -37,7 +35,9 @@ chrome.runtime.sendMessage({ type: 'get-status' }, (response) => { setConnected(false); return; } - setConnected(response?.connected ?? false); + if (response) { + updateStatus(response); + } }); // Listen for state changes @@ -45,23 +45,4 @@ chrome.runtime.onMessage.addListener((message) => { if (message.type === 'connection-state') { setConnected(message.connected); } - if (message.type === 'ws-state') { - setConnected(message.connected); - } -}); - -// Save URL -saveBtn.addEventListener('click', () => { - const url = urlInput.value.trim(); - if (!url) return; - chrome.storage.local.set({ serverUrl: url }); -}); - -// Reconnect -reconnectBtn.addEventListener('click', () => { - const url = urlInput.value.trim(); - if (url) { - // Setting the URL triggers reconnect in offscreen.ts - chrome.storage.local.set({ serverUrl: url }); - } }); diff --git a/package.json b/package.json index 4b7104c..5e0e991 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,13 @@ "repl": "bun packages/cli/src/repl.ts", "start": "bun packages/cli/src/cli.ts --start", "test": "bun test", - "build:ext": "bun extension/build.ts" + "build:ext": "bun extension/build.ts", + "install:ext": "bun extension/install.ts" }, "dependencies": { "@browseruse/cli": "workspace:*", - "@browseruse/protocol": "workspace:*" + "@browseruse/protocol": "workspace:*", + "puppeteer-core": "^24.43.1" }, "devDependencies": { "@types/bun": "latest", diff --git a/packages/cli/src/native-host.ts b/packages/cli/src/native-host.ts new file mode 100644 index 0000000..d735680 --- /dev/null +++ b/packages/cli/src/native-host.ts @@ -0,0 +1,149 @@ +/** + * Native messaging host bridge. + * + * Chrome spawns this process when the extension calls `chrome.runtime.connectNative()`. + * It bridges Chrome's native messaging protocol (4-byte length-prefixed JSON on + * stdin/stdout) with the REPL server's WebSocket endpoint. + * + * Usage: bun packages/cli/src/native-host.ts + */ + +const WS_URL = 'ws://127.0.0.1:9876/ws'; + +// --------------------------------------------------------------------------- +// Native messaging I/O helpers (stdin/stdout, 4-byte LE length prefix) +// --------------------------------------------------------------------------- + +/** + * Read a single native message from stdin. + * Returns null when stdin is closed. + */ +async function readNativeMessage(reader: ReadableStreamDefaultReader): Promise { + // Read exactly 4 bytes for the length prefix + let header = new Uint8Array(4); + let headerOffset = 0; + + while (headerOffset < 4) { + const { value, done } = await reader.read(); + if (done || !value) return null; + + const needed = 4 - headerOffset; + const toCopy = Math.min(value.length, needed); + header.set(value.subarray(0, toCopy), headerOffset); + headerOffset += toCopy; + + // If we got extra bytes beyond the header, we need to account for them + if (value.length > needed) { + // This shouldn't happen with Chrome's native messaging, but handle it + const extra = value.subarray(needed); + const msgLen = new DataView(header.buffer).getUint32(0, true); + const body = new Uint8Array(msgLen); + body.set(extra, 0); + let bodyOffset = extra.length; + + while (bodyOffset < msgLen) { + const { value: chunk, done: chunkDone } = await reader.read(); + if (chunkDone || !chunk) return null; + const chunkToCopy = Math.min(chunk.length, msgLen - bodyOffset); + body.set(chunk.subarray(0, chunkToCopy), bodyOffset); + bodyOffset += chunkToCopy; + } + + return new TextDecoder().decode(body); + } + } + + const msgLen = new DataView(header.buffer).getUint32(0, true); + if (msgLen === 0) return null; + + // Read the message body + const body = new Uint8Array(msgLen); + let bodyOffset = 0; + + while (bodyOffset < msgLen) { + const { value, done } = await reader.read(); + if (done || !value) return null; + const toCopy = Math.min(value.length, msgLen - bodyOffset); + body.set(value.subarray(0, toCopy), bodyOffset); + bodyOffset += toCopy; + } + + return new TextDecoder().decode(body); +} + +/** + * Write a native message to stdout (4-byte LE length prefix + JSON). + */ +function writeNativeMessage(data: string): void { + const encoded = new TextEncoder().encode(data); + const header = new Uint8Array(4); + new DataView(header.buffer).setUint32(0, encoded.length, true); + + const output = new Uint8Array(4 + encoded.length); + output.set(header, 0); + output.set(encoded, 4); + + Bun.write(Bun.stdout, output); +} + +// --------------------------------------------------------------------------- +// WebSocket connection to REPL server +// --------------------------------------------------------------------------- + +let ws: WebSocket | null = null; +let connected = false; + +function connectWebSocket(): void { + ws = new WebSocket(WS_URL); + + ws.onopen = () => { + connected = true; + // Send handshake identifying as extension relay + ws!.send(JSON.stringify({ + jsonrpc: '2.0', + id: 'native-host-handshake', + method: 'session.handshake', + params: { clientType: 'extension', version: '0.3.0' }, + })); + }; + + ws.onmessage = (event) => { + // Forward server messages to Chrome extension via stdout + const data = typeof event.data === 'string' ? event.data : event.data.toString(); + writeNativeMessage(data); + }; + + ws.onclose = () => { + connected = false; + // Attempt to reconnect after a short delay + setTimeout(connectWebSocket, 2000); + }; + + ws.onerror = () => { + // onclose will handle reconnection + }; +} + +// --------------------------------------------------------------------------- +// Main: read stdin, relay to WebSocket; WebSocket replies go to stdout +// --------------------------------------------------------------------------- + +connectWebSocket(); + +const reader = Bun.stdin.stream().getReader(); + +(async () => { + while (true) { + const msg = await readNativeMessage(reader); + if (msg === null) { + // stdin closed — Chrome disconnected the native host + if (ws) ws.close(); + process.exit(0); + } + + // Forward from extension to WebSocket server + if (ws && connected) { + ws.send(msg); + } + } +})(); diff --git a/packages/cli/src/ws-handler.ts b/packages/cli/src/ws-handler.ts index 31967af..757f23d 100644 --- a/packages/cli/src/ws-handler.ts +++ b/packages/cli/src/ws-handler.ts @@ -200,20 +200,33 @@ async function handleServerMethod( } case Methods.SESSION_CDP_RAW: { - if (!session.isConnected()) { - throw { code: ErrorCodes.NOT_CONNECTED, message: 'CDP session not connected' }; - } 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' }; } - try { - const result = await session._call(cdpMethod, cdpParams); - return result; - } catch (err: any) { - throw { code: ErrorCodes.CDP_ERROR, message: err.message ?? String(err) }; + + // 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: diff --git a/packages/protocol/errors.ts b/packages/protocol/errors.ts index 8260829..66c575c 100644 --- a/packages/protocol/errors.ts +++ b/packages/protocol/errors.ts @@ -21,6 +21,9 @@ export const ErrorCodes = { ELEMENT_NOT_FOUND: -32004, EVAL_ERROR: -32005, CDP_ERROR: -32006, + DEBUGGER_ATTACH_FAILED: -32007, + DEBUGGER_DETACHED: -32008, + NATIVE_HOST_ERROR: -32009, } as const; export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; diff --git a/packages/protocol/events.ts b/packages/protocol/events.ts index 7ea3c28..b5ef5eb 100644 --- a/packages/protocol/events.ts +++ b/packages/protocol/events.ts @@ -15,6 +15,10 @@ export const Events = { 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]; @@ -50,3 +54,14 @@ export interface TabUpdatedEvent { 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/methods.ts b/packages/protocol/methods.ts index 27e3b32..c279028 100644 --- a/packages/protocol/methods.ts +++ b/packages/protocol/methods.ts @@ -33,6 +33,7 @@ export interface StatusResult { export interface CdpRawParams { method: string; params?: Record; + tabId?: number; } // --------------------------------------------------------------------------- @@ -83,146 +84,31 @@ export interface TabReloadParams { } // --------------------------------------------------------------------------- -// DOM methods +// Debugger methods // --------------------------------------------------------------------------- -export interface DomQueryParams { +export interface DebuggerAttachParams { tabId: number; - selector: string; -} - -export interface DomQueryResult { - found: boolean; - text?: string; - tagName?: string; - attributes?: Record; } -export interface DomQueryAllParams { +export interface DebuggerAttachResult { + ok: true; tabId: number; - selector: string; -} - -export interface DomQueryAllResult { - count: number; - elements: Array<{ - index: number; - text: string; - tagName: string; - attributes: Record; - }>; } -export interface DomClickParams { +export interface DebuggerDetachParams { tabId: number; - selector: string; } -export interface DomTypeParams { +export interface DebuggerDetachResult { + ok: true; tabId: number; - selector: string; - text: string; - clear?: boolean; } -export interface DomGetTextParams { +export interface DebuggerSendCommandParams { tabId: number; - selector: string; -} - -export interface DomGetTextResult { - text: string; -} - -export interface DomGetHtmlParams { - tabId: number; - selector: string; - outer?: boolean; -} - -export interface DomGetHtmlResult { - html: string; -} - -// --------------------------------------------------------------------------- -// Page methods -// --------------------------------------------------------------------------- - -export interface PageScreenshotParams { - tabId?: number; - format?: 'png' | 'jpeg'; - quality?: number; -} - -export interface PageScreenshotResult { - data: string; // base64 - format: string; -} - -export interface PageEvalParams { - tabId: number; - expression: string; -} - -export interface PageEvalResult { - result: unknown; -} - -export interface PageGetUrlParams { - tabId: number; -} - -export interface PageGetUrlResult { - url: string; -} - -export interface PageGetTitleParams { - tabId: number; -} - -export interface PageGetTitleResult { - title: string; -} - -// --------------------------------------------------------------------------- -// Network / Cookie methods -// --------------------------------------------------------------------------- - -export interface CookieInfo { - name: string; - value: string; - domain: string; - path: string; - secure: boolean; - httpOnly: boolean; - sameSite: string; - expirationDate?: number; -} - -export interface NetworkGetCookiesParams { - url?: string; - domain?: string; -} - -export interface NetworkGetCookiesResult { - cookies: CookieInfo[]; -} - -export interface NetworkSetCookieParams { - url: string; - name: string; - value: string; - domain?: string; - path?: string; - secure?: boolean; - httpOnly?: boolean; - sameSite?: 'no_restriction' | 'lax' | 'strict'; - expirationDate?: number; -} - -export interface NetworkDeleteCookiesParams { - url: string; - name: string; + method: string; + params?: Record; } // --------------------------------------------------------------------------- @@ -244,24 +130,10 @@ export const Methods = { TABS_ACTIVATE: 'tabs.activate', TABS_RELOAD: 'tabs.reload', - // DOM - DOM_QUERY: 'dom.query', - DOM_QUERY_ALL: 'dom.queryAll', - DOM_CLICK: 'dom.click', - DOM_TYPE: 'dom.type', - DOM_GET_TEXT: 'dom.getText', - DOM_GET_HTML: 'dom.getHtml', - - // Page - PAGE_SCREENSHOT: 'page.screenshot', - PAGE_EVAL: 'page.eval', - PAGE_GET_URL: 'page.getUrl', - PAGE_GET_TITLE: 'page.getTitle', - - // Network - NETWORK_GET_COOKIES: 'network.getCookies', - NETWORK_SET_COOKIE: 'network.setCookie', - NETWORK_DELETE_COOKIES: 'network.deleteCookies', + // Debugger + DEBUGGER_ATTACH: 'debugger.attach', + DEBUGGER_DETACH: 'debugger.detach', + DEBUGGER_SEND_COMMAND: 'debugger.sendCommand', } as const; export type MethodName = (typeof Methods)[keyof typeof Methods]; @@ -282,17 +154,7 @@ export const EXTENSION_METHODS = new Set([ Methods.TABS_NAVIGATE, Methods.TABS_ACTIVATE, Methods.TABS_RELOAD, - Methods.DOM_QUERY, - Methods.DOM_QUERY_ALL, - Methods.DOM_CLICK, - Methods.DOM_TYPE, - Methods.DOM_GET_TEXT, - Methods.DOM_GET_HTML, - Methods.PAGE_SCREENSHOT, - Methods.PAGE_EVAL, - Methods.PAGE_GET_URL, - Methods.PAGE_GET_TITLE, - Methods.NETWORK_GET_COOKIES, - Methods.NETWORK_SET_COOKIE, - Methods.NETWORK_DELETE_COOKIES, + Methods.DEBUGGER_ATTACH, + Methods.DEBUGGER_DETACH, + Methods.DEBUGGER_SEND_COMMAND, ]); From 456b0d6c9fa95dc842dbf84c6c014064fcbd7955 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Tue, 12 May 2026 22:36:35 +0100 Subject: [PATCH 03/14] refactor: move extension/ into packages/extension/ Update root package.json scripts, tsconfig references, .gitignore, and fix relative paths in build.ts and install.ts to reflect the new location two levels below the repo root. --- .gitignore | 2 +- package.json | 4 ++-- .../cli/interaction-skills}/connection.md | 0 .../cli/interaction-skills}/cookies.md | 0 .../cli/interaction-skills}/cross-origin-iframes.md | 0 .../cli/interaction-skills}/dialogs.md | 0 .../cli/interaction-skills}/downloads.md | 0 .../cli/interaction-skills}/drag-and-drop.md | 0 .../cli/interaction-skills}/dropdowns.md | 0 .../cli/interaction-skills}/iframes.md | 0 .../cli/interaction-skills}/network-requests.md | 0 .../cli/interaction-skills}/print-as-pdf.md | 0 .../cli/interaction-skills}/screenshots.md | 0 .../cli/interaction-skills}/scrolling.md | 0 .../cli/interaction-skills}/shadow-dom.md | 0 .../cli/interaction-skills}/tabs.md | 0 .../cli/interaction-skills}/uploads.md | 0 .../cli/interaction-skills}/viewport.md | 0 {extension => packages/extension}/build.ts | 4 ++-- {extension => packages/extension}/icons/icon128.png | Bin {extension => packages/extension}/icons/icon16.png | Bin {extension => packages/extension}/icons/icon48.png | Bin {extension => packages/extension}/install.ts | 6 +++--- {extension => packages/extension}/manifest.json | 0 .../extension}/native-messaging-host.json | 0 .../extension}/src/background/debugger-handler.ts | 0 .../extension}/src/background/service-worker.ts | 0 .../extension}/src/background/tab-handlers.ts | 0 .../extension}/src/content/content-script.ts | 0 .../extension}/src/popup/popup.html | 0 .../extension}/src/popup/popup.ts | 0 {extension => packages/extension}/tsconfig.json | 0 tsconfig.json | 2 +- 33 files changed, 9 insertions(+), 9 deletions(-) rename {interaction-skills => packages/cli/interaction-skills}/connection.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/cookies.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/cross-origin-iframes.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/dialogs.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/downloads.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/drag-and-drop.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/dropdowns.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/iframes.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/network-requests.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/print-as-pdf.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/screenshots.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/scrolling.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/shadow-dom.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/tabs.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/uploads.md (100%) rename {interaction-skills => packages/cli/interaction-skills}/viewport.md (100%) rename {extension => packages/extension}/build.ts (93%) rename {extension => packages/extension}/icons/icon128.png (100%) rename {extension => packages/extension}/icons/icon16.png (100%) rename {extension => packages/extension}/icons/icon48.png (100%) rename {extension => packages/extension}/install.ts (91%) rename {extension => packages/extension}/manifest.json (100%) rename {extension => packages/extension}/native-messaging-host.json (100%) rename {extension => packages/extension}/src/background/debugger-handler.ts (100%) rename {extension => packages/extension}/src/background/service-worker.ts (100%) rename {extension => packages/extension}/src/background/tab-handlers.ts (100%) rename {extension => packages/extension}/src/content/content-script.ts (100%) rename {extension => packages/extension}/src/popup/popup.html (100%) rename {extension => packages/extension}/src/popup/popup.ts (100%) rename {extension => packages/extension}/tsconfig.json (100%) diff --git a/.gitignore b/.gitignore index 9244e6e..6c27f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ node_modules/ bun.lock /tmp/browseruse.log .DS_Store -extension/dist/ +packages/extension/dist/ diff --git a/package.json b/package.json index 5e0e991..775071f 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "repl": "bun packages/cli/src/repl.ts", "start": "bun packages/cli/src/cli.ts --start", "test": "bun test", - "build:ext": "bun extension/build.ts", - "install:ext": "bun extension/install.ts" + "build:ext": "bun packages/extension/build.ts", + "install:ext": "bun packages/extension/install.ts" }, "dependencies": { "@browseruse/cli": "workspace:*", 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/extension/build.ts b/packages/extension/build.ts similarity index 93% rename from extension/build.ts rename to packages/extension/build.ts index a84f3d4..8400a0f 100644 --- a/extension/build.ts +++ b/packages/extension/build.ts @@ -12,7 +12,7 @@ import { cpSync, mkdirSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; const EXT_DIR = import.meta.dir; -const ROOT = join(EXT_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'); @@ -47,4 +47,4 @@ 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 → extension/dist/'); +console.log('Extension built → packages/extension/dist/'); diff --git a/extension/icons/icon128.png b/packages/extension/icons/icon128.png similarity index 100% rename from extension/icons/icon128.png rename to packages/extension/icons/icon128.png diff --git a/extension/icons/icon16.png b/packages/extension/icons/icon16.png similarity index 100% rename from extension/icons/icon16.png rename to packages/extension/icons/icon16.png diff --git a/extension/icons/icon48.png b/packages/extension/icons/icon48.png similarity index 100% rename from extension/icons/icon48.png rename to packages/extension/icons/icon48.png diff --git a/extension/install.ts b/packages/extension/install.ts similarity index 91% rename from extension/install.ts rename to packages/extension/install.ts index 0bff35b..3a98d2f 100644 --- a/extension/install.ts +++ b/packages/extension/install.ts @@ -51,11 +51,11 @@ function getNativeHostDir(): string { function main(): void { const { extensionId } = parseArgs(); - const rootDir = resolve(import.meta.dir, '..'); + const rootDir = resolve(import.meta.dir, '../..'); const nativeHostScript = resolve(rootDir, 'packages', 'cli', 'src', 'native-host.ts'); // 1. Create wrapper shell script - const wrapperPath = resolve(rootDir, 'extension', 'native-host-wrapper.sh'); + const wrapperPath = resolve(rootDir, 'packages', 'extension', 'native-host-wrapper.sh'); const wrapperContent = `#!/bin/sh exec bun "${nativeHostScript}" "$@" `; @@ -86,7 +86,7 @@ exec bun "${nativeHostScript}" "$@" if (extensionId === DEFAULT_EXTENSION_ID) { console.log('\nWARNING: Using default extension ID. After loading the extension in Chrome,'); - console.log('re-run with: bun extension/install.ts --extension-id '); + console.log('re-run with: bun packages/extension/install.ts --extension-id '); } } diff --git a/extension/manifest.json b/packages/extension/manifest.json similarity index 100% rename from extension/manifest.json rename to packages/extension/manifest.json diff --git a/extension/native-messaging-host.json b/packages/extension/native-messaging-host.json similarity index 100% rename from extension/native-messaging-host.json rename to packages/extension/native-messaging-host.json diff --git a/extension/src/background/debugger-handler.ts b/packages/extension/src/background/debugger-handler.ts similarity index 100% rename from extension/src/background/debugger-handler.ts rename to packages/extension/src/background/debugger-handler.ts diff --git a/extension/src/background/service-worker.ts b/packages/extension/src/background/service-worker.ts similarity index 100% rename from extension/src/background/service-worker.ts rename to packages/extension/src/background/service-worker.ts diff --git a/extension/src/background/tab-handlers.ts b/packages/extension/src/background/tab-handlers.ts similarity index 100% rename from extension/src/background/tab-handlers.ts rename to packages/extension/src/background/tab-handlers.ts diff --git a/extension/src/content/content-script.ts b/packages/extension/src/content/content-script.ts similarity index 100% rename from extension/src/content/content-script.ts rename to packages/extension/src/content/content-script.ts diff --git a/extension/src/popup/popup.html b/packages/extension/src/popup/popup.html similarity index 100% rename from extension/src/popup/popup.html rename to packages/extension/src/popup/popup.html diff --git a/extension/src/popup/popup.ts b/packages/extension/src/popup/popup.ts similarity index 100% rename from extension/src/popup/popup.ts rename to packages/extension/src/popup/popup.ts diff --git a/extension/tsconfig.json b/packages/extension/tsconfig.json similarity index 100% rename from extension/tsconfig.json rename to packages/extension/tsconfig.json diff --git a/tsconfig.json b/tsconfig.json index 504dadc..2b13c5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,6 @@ "references": [ { "path": "packages/protocol" }, { "path": "packages/cli" }, - { "path": "extension" } + { "path": "packages/extension" } ] } From 1526e71369703446b83f3da93025b18ef4c97395 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Tue, 12 May 2026 22:36:42 +0100 Subject: [PATCH 04/14] docs: update README file tree for interaction-skills move --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ``` From 115167dae1d8c2e0ba85b47d938ba5a1942ebb50 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Tue, 12 May 2026 23:27:31 +0100 Subject: [PATCH 05/14] feat(extension)!: replace native messaging with direct WebSocket Drop chrome.runtime.connectNative / native-host bridge in favor of a direct WebSocket connection to ws://127.0.0.1:9876/ws from the service worker. Remove nativeMessaging permission, delete native-host.ts and native-messaging-host.json template. Rewrite install.ts to clean up legacy native host manifests. Add AUTH_FAILED error code to protocol. BREAKING CHANGE: the extension no longer uses native messaging; the native host bridge and install --extension-id workflow are removed. --- packages/cli/src/native-host.ts | 149 ------------------ packages/extension/install.ts | 101 +++++------- packages/extension/manifest.json | 1 - packages/extension/native-messaging-host.json | 7 - .../src/background/service-worker.ts | 70 ++++---- packages/protocol/errors.ts | 1 + 6 files changed, 83 insertions(+), 246 deletions(-) delete mode 100644 packages/cli/src/native-host.ts delete mode 100644 packages/extension/native-messaging-host.json diff --git a/packages/cli/src/native-host.ts b/packages/cli/src/native-host.ts deleted file mode 100644 index d735680..0000000 --- a/packages/cli/src/native-host.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Native messaging host bridge. - * - * Chrome spawns this process when the extension calls `chrome.runtime.connectNative()`. - * It bridges Chrome's native messaging protocol (4-byte length-prefixed JSON on - * stdin/stdout) with the REPL server's WebSocket endpoint. - * - * Usage: bun packages/cli/src/native-host.ts - */ - -const WS_URL = 'ws://127.0.0.1:9876/ws'; - -// --------------------------------------------------------------------------- -// Native messaging I/O helpers (stdin/stdout, 4-byte LE length prefix) -// --------------------------------------------------------------------------- - -/** - * Read a single native message from stdin. - * Returns null when stdin is closed. - */ -async function readNativeMessage(reader: ReadableStreamDefaultReader): Promise { - // Read exactly 4 bytes for the length prefix - let header = new Uint8Array(4); - let headerOffset = 0; - - while (headerOffset < 4) { - const { value, done } = await reader.read(); - if (done || !value) return null; - - const needed = 4 - headerOffset; - const toCopy = Math.min(value.length, needed); - header.set(value.subarray(0, toCopy), headerOffset); - headerOffset += toCopy; - - // If we got extra bytes beyond the header, we need to account for them - if (value.length > needed) { - // This shouldn't happen with Chrome's native messaging, but handle it - const extra = value.subarray(needed); - const msgLen = new DataView(header.buffer).getUint32(0, true); - const body = new Uint8Array(msgLen); - body.set(extra, 0); - let bodyOffset = extra.length; - - while (bodyOffset < msgLen) { - const { value: chunk, done: chunkDone } = await reader.read(); - if (chunkDone || !chunk) return null; - const chunkToCopy = Math.min(chunk.length, msgLen - bodyOffset); - body.set(chunk.subarray(0, chunkToCopy), bodyOffset); - bodyOffset += chunkToCopy; - } - - return new TextDecoder().decode(body); - } - } - - const msgLen = new DataView(header.buffer).getUint32(0, true); - if (msgLen === 0) return null; - - // Read the message body - const body = new Uint8Array(msgLen); - let bodyOffset = 0; - - while (bodyOffset < msgLen) { - const { value, done } = await reader.read(); - if (done || !value) return null; - const toCopy = Math.min(value.length, msgLen - bodyOffset); - body.set(value.subarray(0, toCopy), bodyOffset); - bodyOffset += toCopy; - } - - return new TextDecoder().decode(body); -} - -/** - * Write a native message to stdout (4-byte LE length prefix + JSON). - */ -function writeNativeMessage(data: string): void { - const encoded = new TextEncoder().encode(data); - const header = new Uint8Array(4); - new DataView(header.buffer).setUint32(0, encoded.length, true); - - const output = new Uint8Array(4 + encoded.length); - output.set(header, 0); - output.set(encoded, 4); - - Bun.write(Bun.stdout, output); -} - -// --------------------------------------------------------------------------- -// WebSocket connection to REPL server -// --------------------------------------------------------------------------- - -let ws: WebSocket | null = null; -let connected = false; - -function connectWebSocket(): void { - ws = new WebSocket(WS_URL); - - ws.onopen = () => { - connected = true; - // Send handshake identifying as extension relay - ws!.send(JSON.stringify({ - jsonrpc: '2.0', - id: 'native-host-handshake', - method: 'session.handshake', - params: { clientType: 'extension', version: '0.3.0' }, - })); - }; - - ws.onmessage = (event) => { - // Forward server messages to Chrome extension via stdout - const data = typeof event.data === 'string' ? event.data : event.data.toString(); - writeNativeMessage(data); - }; - - ws.onclose = () => { - connected = false; - // Attempt to reconnect after a short delay - setTimeout(connectWebSocket, 2000); - }; - - ws.onerror = () => { - // onclose will handle reconnection - }; -} - -// --------------------------------------------------------------------------- -// Main: read stdin, relay to WebSocket; WebSocket replies go to stdout -// --------------------------------------------------------------------------- - -connectWebSocket(); - -const reader = Bun.stdin.stream().getReader(); - -(async () => { - while (true) { - const msg = await readNativeMessage(reader); - if (msg === null) { - // stdin closed — Chrome disconnected the native host - if (ws) ws.close(); - process.exit(0); - } - - // Forward from extension to WebSocket server - if (ws && connected) { - ws.send(msg); - } - } -})(); diff --git a/packages/extension/install.ts b/packages/extension/install.ts index 3a98d2f..5bb5333 100644 --- a/packages/extension/install.ts +++ b/packages/extension/install.ts @@ -1,41 +1,21 @@ /** - * Install script for the browseruse native messaging host. + * Install script for the browseruse Chrome extension. * - * Sets up the native messaging host manifest so Chrome can spawn the - * native host bridge process when the extension calls connectNative(). + * 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 extension/install.ts [--extension-id ] - * - * The script: - * 1. Creates a wrapper shell script that invokes `bun native-host.ts` - * 2. Generates the Chrome native messaging host manifest with correct paths - * 3. Places the manifest in the platform-appropriate directory + * bun packages/extension/install.ts [--cleanup] */ -import { writeFileSync, mkdirSync, chmodSync, existsSync } from 'fs'; -import { join, resolve } from 'path'; +import { unlinkSync, existsSync } from 'fs'; +import { join } from 'path'; import { platform, homedir } from 'os'; const HOST_NAME = 'com.browseruse.host'; -const DEFAULT_EXTENSION_ID = 'your-extension-id-here'; - -// Parse CLI args -function parseArgs(): { extensionId: string } { - const args = process.argv.slice(2); - let extensionId = DEFAULT_EXTENSION_ID; - - for (let i = 0; i < args.length; i++) { - if (args[i] === '--extension-id' && args[i + 1]) { - extensionId = args[i + 1]; - i++; - } - } - - return { extensionId }; -} -function getNativeHostDir(): string { +function getNativeHostDir(): string | null { const os = platform(); const home = homedir(); @@ -45,49 +25,46 @@ function getNativeHostDir(): string { case 'linux': return join(home, '.config', 'google-chrome', 'NativeMessagingHosts'); default: - throw new Error(`Unsupported platform: ${os}. Only macOS and Linux are supported.`); + return null; } } -function main(): void { - const { extensionId } = parseArgs(); - const rootDir = resolve(import.meta.dir, '../..'); - const nativeHostScript = resolve(rootDir, 'packages', 'cli', 'src', 'native-host.ts'); +function cleanupLegacyNativeHost(): void { + const hostDir = getNativeHostDir(); + if (!hostDir) return; - // 1. Create wrapper shell script - const wrapperPath = resolve(rootDir, 'packages', 'extension', 'native-host-wrapper.sh'); - const wrapperContent = `#!/bin/sh -exec bun "${nativeHostScript}" "$@" -`; - writeFileSync(wrapperPath, wrapperContent); - chmodSync(wrapperPath, 0o755); - console.log(`Created wrapper script: ${wrapperPath}`); + 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}`); + } + } +} - // 2. Generate the native messaging host manifest - const manifest = { - name: HOST_NAME, - description: 'browseruse native messaging host', - path: wrapperPath, - type: 'stdio', - allowed_origins: [`chrome-extension://${extensionId}/`], - }; +function main(): void { + const args = process.argv.slice(2); - // 3. Place manifest in the platform-appropriate directory - const hostDir = getNativeHostDir(); - if (!existsSync(hostDir)) { - mkdirSync(hostDir, { recursive: true }); + if (args.includes('--cleanup')) { + cleanupLegacyNativeHost(); + console.log('Cleanup complete.'); + return; } - const manifestPath = join(hostDir, `${HOST_NAME}.json`); - writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); - console.log(`Installed native messaging host manifest: ${manifestPath}`); - console.log(`\nExtension ID: ${extensionId}`); - console.log(`Native host: ${wrapperPath}`); + // Clean up any legacy native messaging host + cleanupLegacyNativeHost(); - if (extensionId === DEFAULT_EXTENSION_ID) { - console.log('\nWARNING: Using default extension ID. After loading the extension in Chrome,'); - console.log('re-run with: bun packages/extension/install.ts --extension-id '); - } + 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 index cc4a225..5d4591f 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -5,7 +5,6 @@ "description": "Connect AI agents to your browser via browseruse.", "permissions": [ "debugger", - "nativeMessaging", "tabs", "storage" ], diff --git a/packages/extension/native-messaging-host.json b/packages/extension/native-messaging-host.json deleted file mode 100644 index c45098f..0000000 --- a/packages/extension/native-messaging-host.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "com.browseruse.host", - "description": "browseruse native messaging host", - "path": "__NATIVE_HOST_PATH__", - "type": "stdio", - "allowed_origins": ["chrome-extension://__EXTENSION_ID__/"] -} diff --git a/packages/extension/src/background/service-worker.ts b/packages/extension/src/background/service-worker.ts index c11e79a..ba246a6 100644 --- a/packages/extension/src/background/service-worker.ts +++ b/packages/extension/src/background/service-worker.ts @@ -1,9 +1,9 @@ /** * Service worker — main background script for the Chrome extension. * - * Uses native messaging (chrome.runtime.connectNative) to communicate with - * the browseruse REPL server via the native host bridge. Routes JSON-RPC - * requests to tab handlers (chrome.tabs) or debugger handlers (chrome.debugger). + * 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'; @@ -17,40 +17,56 @@ import { } from './debugger-handler'; import { Methods, ErrorCodes, Events, makeSuccess, makeError, makeNotification } from '@browseruse/protocol'; -const NATIVE_HOST_NAME = 'com.browseruse.host'; +const WS_URL = 'ws://127.0.0.1:9876/ws'; // --------------------------------------------------------------------------- -// Native messaging connection +// WebSocket connection // --------------------------------------------------------------------------- -let nativePort: chrome.runtime.Port | null = null; +let ws: WebSocket | null = null; let nativeConnected = false; -function connectNative(): void { +function connectWebSocket(): void { try { - nativePort = chrome.runtime.connectNative(NATIVE_HOST_NAME); - } catch (err) { + ws = new WebSocket(WS_URL); + } catch { nativeConnected = false; scheduleReconnect(); return; } - nativePort.onMessage.addListener((message) => { - handleServerMessage(message); - }); + ws.onopen = () => { + ws!.send(JSON.stringify({ + jsonrpc: '2.0', + id: 'ext-handshake', + method: 'session.handshake', + params: { clientType: 'extension', version: '0.3.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 + } + }; - nativePort.onDisconnect.addListener(() => { - const error = chrome.runtime.lastError; + ws.onclose = () => { nativeConnected = false; - nativePort = null; + ws = null; // Notify popup chrome.runtime.sendMessage({ type: 'connection-state', connected: false }).catch(() => {}); scheduleReconnect(); - }); + }; - nativeConnected = true; - // Notify popup - chrome.runtime.sendMessage({ type: 'connection-state', connected: true }).catch(() => {}); + ws.onerror = () => { + // onclose will fire after this and handle reconnection + }; } let reconnectTimer: ReturnType | null = null; @@ -59,14 +75,14 @@ function scheduleReconnect(): void { if (reconnectTimer) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; - connectNative(); + connectWebSocket(); }, 3000); } // Connect on startup -chrome.runtime.onInstalled.addListener(() => { connectNative(); }); -chrome.runtime.onStartup.addListener(() => { connectNative(); }); -connectNative(); +chrome.runtime.onInstalled.addListener(() => { connectWebSocket(); }); +chrome.runtime.onStartup.addListener(() => { connectWebSocket(); }); +connectWebSocket(); // --------------------------------------------------------------------------- // Debugger event forwarding @@ -138,15 +154,15 @@ async function handleServerMessage(msg: any): Promise { } // --------------------------------------------------------------------------- -// Send response back to server via native messaging +// Send response back to server via WebSocket // --------------------------------------------------------------------------- function sendToServer(msg: object): void { - if (nativePort) { + if (ws?.readyState === WebSocket.OPEN) { try { - nativePort.postMessage(msg); + ws.send(JSON.stringify(msg)); } catch { - // Port may be disconnected + // WebSocket may be closing } } } diff --git a/packages/protocol/errors.ts b/packages/protocol/errors.ts index 66c575c..e8643ac 100644 --- a/packages/protocol/errors.ts +++ b/packages/protocol/errors.ts @@ -24,6 +24,7 @@ export const ErrorCodes = { DEBUGGER_ATTACH_FAILED: -32007, DEBUGGER_DETACHED: -32008, NATIVE_HOST_ERROR: -32009, + AUTH_FAILED: -32010, } as const; export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; From e664dfc95660777322d5f6edb53cb177b5dc641e Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Tue, 12 May 2026 23:27:42 +0100 Subject: [PATCH 06/14] feat(cli): add Unix control socket to REPL server Listen on ~/.browseruse/browseruse.sock for NDJSON control protocol, enabling Sarea and scripts to communicate with the REPL without HTTP. Refactor runServer() to accept ServerOptions (silent, controlSocket) and return ServerContext (session, server, startedAt). Add socket cleanup on SIGINT/SIGTERM and /quit. --- packages/cli/src/control-socket.ts | 210 +++++++++++++++++++++++++++++ packages/cli/src/repl.ts | 85 +++++++++--- 2 files changed, 276 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/control-socket.ts 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/packages/cli/src/repl.ts b/packages/cli/src/repl.ts index d6ccbd8..bc5b085 100644 --- a/packages/cli/src/repl.ts +++ b/packages/cli/src/repl.ts @@ -13,6 +13,9 @@ * 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 * the process. @@ -22,19 +25,10 @@ import { Session, listPageTargets, resolveWsUrl, detectBrowsers } from './sessio import { launchBrowser, getManagedBrowser, closeManagedBrowser } from './browser.ts'; import * as Generated from './generated.ts'; import { handleWsOpen, handleWsClose, handleWsMessage, type WsData } from './ws-handler.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 { 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(); @@ -71,7 +65,48 @@ function renderResult(v: unknown): string { return JSON.stringify(s); } -export function runServer(): void { +// --------------------------------------------------------------------------- +// 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', @@ -169,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(); @@ -192,24 +228,35 @@ export function runServer(): void { }, }); - // 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) { From 45a3d54e0ab247024222cbffb00fe019c3f9f8c9 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 13 May 2026 16:43:02 +0100 Subject: [PATCH 07/14] fix(protocol): add missing tsconfig.json for project references The root tsconfig.json references packages/protocol but the package had no tsconfig.json, causing tsc --noEmit to fail. --- packages/protocol/tsconfig.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 packages/protocol/tsconfig.json 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"] +} From 14d9e2104f05ee4b382b28747f72177abbe44ff8 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 13 May 2026 16:43:07 +0100 Subject: [PATCH 08/14] fix(extension): add health check before WebSocket connection Pre-flight fetch to /health verifies the server is a browseruse REPL before attempting a WebSocket upgrade, preventing 404 errors when a different service or older REPL without /ws occupies the port. --- .../src/background/service-worker.ts | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/background/service-worker.ts b/packages/extension/src/background/service-worker.ts index ba246a6..81bd6e1 100644 --- a/packages/extension/src/background/service-worker.ts +++ b/packages/extension/src/background/service-worker.ts @@ -17,7 +17,10 @@ import { } from './debugger-handler'; import { Methods, ErrorCodes, Events, makeSuccess, makeError, makeNotification } from '@browseruse/protocol'; -const WS_URL = 'ws://127.0.0.1:9876/ws'; +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 @@ -26,7 +29,32 @@ const WS_URL = 'ws://127.0.0.1:9876/ws'; let ws: WebSocket | null = null; let nativeConnected = false; -function connectWebSocket(): void { +/** + * 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 { From 0791188a63265d7f1da0196b2401293987732517 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 13 May 2026 16:43:11 +0100 Subject: [PATCH 09/14] feat(extension): add Open Sarea button to popup when disconnected Shows an "Open Sarea" button in the extension popup when the REPL server is not connected, allowing users to launch the Sarea app directly via its io.corespeed.sarea:// URL scheme. --- packages/extension/src/popup/popup.html | 22 ++++++++++++++++++++++ packages/extension/src/popup/popup.ts | 9 +++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/extension/src/popup/popup.html b/packages/extension/src/popup/popup.html index d16387d..78f3271 100644 --- a/packages/extension/src/popup/popup.html +++ b/packages/extension/src/popup/popup.html @@ -76,6 +76,26 @@ line-height: 1.4; margin-bottom: 12px; } + .open-sarea { + display: none; + width: 100%; + padding: 10px 12px; + border: none; + border-radius: 8px; + background: #2563eb; + color: #fff; + font-size: 13px; + font-weight: 500; + cursor: pointer; + margin-bottom: 12px; + font-family: inherit; + } + .open-sarea:hover { + background: #1d4ed8; + } + .open-sarea:active { + background: #1e40af; + } .version { text-align: center; color: #9ca3af; @@ -101,6 +121,8 @@

browseruse

+ +
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 index cc6ba7a..1eb5e8e 100644 --- a/packages/extension/src/popup/popup.ts +++ b/packages/extension/src/popup/popup.ts @@ -2,23 +2,32 @@ * Popup script — connection status display for native messaging mode. */ +const SAREA_URL_SCHEME = 'io.corespeed.sarea://'; + 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')!; +const openSareaBtn = document.getElementById('openSarea') as HTMLButtonElement; versionEl.textContent = `v${chrome.runtime.getManifest().version}`; +openSareaBtn.addEventListener('click', () => { + chrome.tabs.create({ url: SAREA_URL_SCHEME }); +}); + function setConnected(connected: boolean): void { if (connected) { statusEl.className = 'status connected'; dotEl.className = 'dot green'; statusTextEl.textContent = 'Connected'; + openSareaBtn.style.display = 'none'; } else { statusEl.className = 'status disconnected'; dotEl.className = 'dot red'; statusTextEl.textContent = 'Disconnected'; + openSareaBtn.style.display = 'block'; } } From 559edd8f4c9dfa5d013461a247a66c0a1bb336b8 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 13 May 2026 17:17:05 +0100 Subject: [PATCH 10/14] feat(extension): use Sarea icon and clean up popup Replace placeholder extension icons with Sarea lion cub icon (16/48/128px). Remove Open Sarea button from popup to keep the UI minimal. --- packages/extension/icons/icon128.png | Bin 306 -> 21927 bytes packages/extension/icons/icon16.png | Bin 79 -> 1168 bytes packages/extension/icons/icon48.png | Bin 123 -> 4264 bytes packages/extension/src/popup/popup.html | 24 +----------------------- packages/extension/src/popup/popup.ts | 9 --------- 5 files changed, 1 insertion(+), 32 deletions(-) diff --git a/packages/extension/icons/icon128.png b/packages/extension/icons/icon128.png index bf3666e11843ead35eeaee4450d36ea5ee7bab9e..2e52b0091ec1fa4eadc1dc6e16bf4a12d696fa0c 100644 GIT binary patch literal 21927 zcmcG#V{~RgvoQL^wrx#p+qN^YZF}P6iJgf(v2AN&8xz~km-n3WoqN~)e|xRkRb9KV zt9zrnx_6|Kf+PYgE-U~5K#-OaQ~Ab${{d*o@8f`g(Zn|bw-A;W1_0{f;NFcOzU#y$ zQY!KQfH&p0TnGU0`XBjY0Kkn205~-S0C>^?04&Gf?aF-LCp^uxq|N2!0YAQJXaG0} zF5o{FK)yi$1n+-o2@q-k*njas0f2BT0QmpND177p6sd3ckIsK%uso3ek^bi8f&Q;_ zU>?~2L;pw32ZTxJ8^JhAX}bUbaA^Ml5J2W{>~Cw@R%%+VTJmx{CJuHCMy3wNW(=No zj{ngD@OkollXhmVM#P?Ww)QSOp8TZ$CBgGe|A);;O8j3Ut~UInTJlQ7q7Kex#2gGv z3{0c~u*Af~e9orkJSt)m|D*n0<0rLrb#>%nWc2XxVDMmNaB#L@Waj4PW@KVvWMQHI zmY{d>vUfG|q_=k=`)?!v*N&K(i;1(9qpOvJJ@J3+8W}sdx$={e{>Rb(mjAg5Z9t?9uHBTzLa+($7Ys8k}ONg4NvazuZ`nO@OL#3<$ zMQj&w3uCZNk;B?Y-&=M0UOwV~PX#YTUyliF9OeG?F|~Ji zSYdH7MOz3kWoYyOZgz1!ZSwtcNdon^bsGbG*82La8vRglzq>1>h!ZOB=b*~VBzW(X z1GhlL?wSlf!MS9cFZ#d2fwF>+lv|@l5BI${p8^%`sHT6Rmc92LkN2eHFDqI(aYHXS z8^SJZEzOj2j;@})7Ll0H$S}~x4nC|b(1UI73iqj+o?l|H{-`vb=AiN1)x(zVWIE0x zbxqwt0JXWWW;ged&rknkL_`x04=DV=ml06gswy!bO$S#bcySwDNjkn9U@?^o4BGSO z-kftXE&GNzYia>I%Z4;gkfj)QSi??ub|@OkGYt7o26W)AKBrb!hSF68a2jxoC{tW! zTtWMfB`YTyjIBKiZ)FSQ*z6g{K#-S}U{%EBclW)Dt83#BC}?f_Jvpv!)GV|@dO$t) zNYfAO0(grV>^At3e_WDK#ikIM*5OW-roTJ=k745I{HLU7tU6+{t|J~)$=`hfUX?)HCCP;z=knEMmD>( z7V)qGwB^9cdiQgwBj8Jj77@VevZJ3>&y1F{qGgT{bp3u^81T7)%sjO$xs!RcX0WN& zE-!8M>4UyI&f{h2*4207>)JkZCmC8kpMw$XUE^jv!FA0pWEq4%=ebuTZZqxDjI~BR4yjRq=SE#vtAIhc;TE zfg~CtX?3FD#Kao|Zsd+*`O8Zjs2bNEr95~Atq;fH483=s(WO5C)&T=I%513~sK-g` z+rt|cFemOFkG&v1&yK6zA1mwY(JCiQ%`F}`AGWUtY`YsyF+ljC{yCXR6he)gr-9PH z!z;&*?dVSG!yKn9dqKQ0>cqhb-&B| zp(+NT&1wdU6Dw`63#n!VWxFPCl|JvoSJb7k zu5Sfigr5r#a)cdI8v{6ip=~k3SYKO1&9-fvRa*t-k6RC(vOO2-Y_nXCS=5X(h#XEI zU_)!}VJH5`h$-|#jHN9n6vvc3d>WA~ezg#M+t1w%aOwHv?Rf*1x_{WZOPZ14{(+k5 z3<@cK)zByb_8AOyaC5s|=JXPDyH)4=MvlS5J^ZCWZ;III0A#(%+3A_Ha##Q@ z8vnt&4U{FZ&c?zT=-Nn;aqyyC&*La0?i*IbNduieP3GKN*J zm`7uaIPFUyeqYz3$ka@)Px{+>Qd~hBV1GqiAm~~pXyzZ=1hxoS2fa@#W}PG&c78(> zw|9H{-t53w>z5K*NR-26X)S!&fwr6c&VdCeB`wGOzUNiU>c1XIO8UrEY{B8gg1eFUI9)dgS`rFeVl#4tC}Bw`Dun zOe`;ljxLzpp42m5^MELp>k6db^Y9(w77eNnUSC^FUtT#n_ysz)psFe*!i<7&s|_V% zQL@hI?N=F{Gr?YDE(q>xIjqok^QsEO3Vu8`d2)4tLsE+eP0h1lpI#Kc;Mr7IGBq;7 z<_K-AQ8r$>yEd6H=ee07%HyA~XmGmEQ-1aJyfuhX(Mm5pO+aqo1d?xlddSX(8jtCBc$Kv8@Yez z#98I*Mh>z$2r54y-udv-viC3@K=S$T>O;Tt;V^5KmkaaA+lcOJD}qw!h&s>xVl1ib zpQiDXld7xU2PulJs*KJD8*Kl46X5w(qjol>VIV>BuBG`2HR8;)?}zOvW~O@RJ9q!@ ztJ3qlRla^k4jUgN4ab(`)*#%G1PF9&T#`}nt!@`acNsH|aA%glypTD=00+Hp$JDi8 z!bu-hbZPczw4xXw^&>SwL!xCE-TvFMy@ZDcBdBJi;M-wt@qSK+C&Yz7ip)%$d&~FO z$g$F|`sDLhc?cOp4lluz!{&}2zfo9FZXnse;2Wt=_9E5-Ee%IV>3AhS>>rw`MF87q=klf;ePnPQPIUes;cI zcD9iRU8I!*CpraLk*{b)!jbn4zNRR8ngh0QC!w}AOpSfdOAU0+(!zEDtPy8;L9?3D zL(oYut;GtWD{RWT`SlF&Jow{rp%u2me4h{>s9JC9M-Y5r{>K{O1107_B{i?FrnKr| z#Qe=oAEdqeXG_=m7uIxRvIq^*Xcd?RdF6GxH1_%5+Lj-VP6G6 z9$M&;6q(_R4s)^FsXUMol6@;4aF~QKj7x*ZJKCuA*50!>{btzPK!kSJ4i#)kuv2|r zGHf5Y{9n#`kQvFKgPFJkyK^p0Zx!R>#1s`7Gqd6zV;R6F!cdZP5$futo*eJ?;}Rcc z=F;`XLDsyXPsqdI`Qswk4$X;1posiXMZtbuBeZD+iQy6nru;}`+W<0U%q@`ZTNK7k zyJF()#OGyZ={hlvh%rr!E#jjx>vgfA$eOaHMPmBzR`dvk^gaO=n-BFGk=@kl9C4WX z6erGT+5`8gYj-oPE8OpAUmw9R=ftK+)OpVw-#eHBC8bTkP)qeOOf4;-Irw-$F}VOm z^n)oASo?9XFdh$fb+7-QX zL(#0&dz|sVODf9a;hdK{M-`U#k|Lj*n?GmQwj#xag=*;#w2JEby1B&3jYv)rQTkpX zkBkOBCRdKN`Z;QPZjD{{*Sg`lJ^%fysDo`~!=riros}IoH(9mP^(2hXFy$*FiHGu0 zrkQANn~|KETlMv}I{onEzp(xFyv@&!wKIe}5xq4*rv^et9ItxT`#|$=9X2h%(J?tu z7^Cq6+QSe%uZJUMDct|x#k8cPh2}U~t1#Ad?9fR<5PDLY3cFI6R%ga^wRl}2D@Vi_ z4H}RSlyq!iI_w@1kz#(mclZpKgE+E!yOp4DfUGqo!SiLj`1s-qEJ+Ab__fjffkL>v z&g-qH{D??U;%%fb<=E_D%yuK!SBk&i{CCeiDdsQo#SL6>TD+TKT1HXL8nk{6)#ch` z@J8?)FSh83`aF!Yrk~!Znr&CdgmP{OdWbbwaKVD@Q^CXVF=nYuEKgR)mAVV<#X~{3 zPYurW7^JE91Et|Nxvx&oha;Ui1puKY(ed$dt55pd#<`Z6_&BEsY?DhG?AG>G31AyD zN;eL_Xs^kjsh1gr!1I>mjqcZrLjkh}(!l2|x^qZa9K15~Z@iE^5~re$z}qHA*T-2h znemYoWLR0^Sc&Q1K>a=ckJe=pETgZ!wO0*W+myZYRo0uwX+IR5HeQN!*yndlQQ7Xq zL(tF5%>ZOS=!u2wtOd$uD(AC*Y5Fn7K3Jgo(ik#XS`8f@SE8t9`xsn7HbmPI6hE?; znq`p{MQ7)=2Ym*a>LezWR>i25X=#Q7*6)(aX5AM_4%Wl zQ|geH_R0_t5M0v`)0N%uukFv^0UTqm@QkVE(J|(ZSc=U)`BQ&>pom;Afafs`Ns(gB zfM>JxAe-VfbOL#u3h7B~7*y;gr|I+j48oBGe}a+Kb=Tux1w4VpE@SVP zDvtE7SYe_&N)Q-vKoLy!YtYMw?{mK6i{H^pWoH>g5Y$I~Pe6S^@|JV-#6;<)^>BYOzTHPEP+C}Y9dsnRgzV$Z z<7w|Hq_~w27h?cu=`EB`q@ndrIni$}aHndAH)CF+Oe_SmpsV|%mxTQRs*q`mr^&P~ zKw^4TXhd+A#dX0}yoeU<&;<9kuQX{`y;G+pBB$3ikozXg*aRw-51UWZRpv!@%p%;C z3i?6RmVR+w(B3pif-4ytt=+s~ygLD>XllpIFHhaTd=?O@ixO*1IQGtqX?LRJeZ5eo zh=?78Y;dA!1(?8_o{Nm5u+UzIZ5W4;dl?i>qIPCO{xauR49=V{{KDq-2{KRiCyiVs zho9@)%)S^ieu4*9uaU?SD5R%?RTP_{5);L2HW9Wd52$R{I5m zR7Jl}cz6Whx0zMiiWblt7Sd#v!IKTiv%8nYN6`?770xtq{?1o8$%~N~>bZABJ?O|{ z<|Rn|p?mBzXm^Jd8BfUjDB>;XSV0HV$`W1j!=k^B9C&B<#)8mU*EZ4r3!eCwE&PY#)`!lI>zPRer2And2dtiT#hbQFhoDIG3XI{3 z=QTT0s3!H9BN3#+#ng;dsktdg4l-~Y4!)7JkmTT5u$)?@!S@jLAyD;DA&}?mUsn{D zyZuAa(yfFFTk;tbechj8O!A7;C=@aKq*5+mgd+;s3YhyzaWzx|c|fswIL4k34ZJ@b zCiH725NT7+u2fw>2=xQ!qG+ti zAJxIKGI6Em;7eiktAscOltb*6s-qnl{=JYGP2TmqJ=6>$s$B}enIQ#%8g>PcaoR{G zd}_<{3~tcyrDkSmY>~lUa+c*M;$;+!IK81J2PTjS7Cav!d+<;Fi`wR#4TqP9Yl#`wfl zis!`zHM+7co6zXu;k`c3qJzS4f{dz-!)r^9jHrxd*%VN2ce(JsBG&>J|7n33raDH# zEZfRb+X-nS4OV4ws+yeBgvPPYfVQ7dEJ=jT$JoR&X^121zTm{O2d`k4>eL_TjMvF^ zd>FVxecv2nL^6&z^ECBXz5fi4+r7OCKd1w(3}0b|4>6O~tfVJ)wprX5lnwbU8rY4r^+rs;xAAW5?GQit?_#|Lg*r;8*0Me=olvS$ z1@+Jc7TR-A%G}#K^SCG?&p0yTs|mau0}(EDgw$XU`JaSuxY2l13-cf=NRF8U7dLH4n- z94OJ`=3Xs!E*$pcWmPv29P)c;ddn)~J#F%-9{jF6**mMI8ob6ZI72KGIFV8DyR&!% zZn0kΣ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 JPDHLkV1kLML2>{9 literal 306 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRdwrKRsO>Ln;{GUNq!oFyJ|`;gi(v z#(TCBwn>@{N|onoqjIkCKVoFKz{k)~#=x+JnPGt}1H)Seh75KF2Wtj~TSHcAH-BO0 Wu~6gim36>CVDNPHb6Mw<&;$U2v`bL{ diff --git a/packages/extension/icons/icon16.png b/packages/extension/icons/icon16.png index d48885ab7ff10cb374c7a3172f673f351c012ee2..7792de386c7be732a61313b2d2f0a57136ae17c9 100644 GIT binary patch literal 1168 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBC{d9b;hE;^%b*2hb1<+l zN-=;;U<6`2Mrk)1Mw+X6zEku zE}%|Wu-S33{Es=oz`!Ky>Eak-A-Hx@WVUdiMB9AJnYxpg-n^`~Z5c;bR7jNTo=@w4 zS+nSCg)Zq*kmy(&v2tmrubTJd8zwW4<~J?7vf9bkz`XtEy!Wwv-yU7{J7L)4x` zFaMd9>=kcKZH)~Q;EjrESX{XvmUq$rSW#Z}Cd1Zya)q~3GQ=&fc{5FtUHGEQ?&7!i zkqjnFH&ogG`0-A+GiTyfFS+W>xf8t>9e3%S+&DwC=Ydc%V~1JGKKJ|*x4#+vQ(qO& zIjDFr#m(lOLB^qyr~4d=Kh5_yIIsDHNx}Qej6V4co7;CBIDKY{9Sxf2FvZ_5%3`k7 zu?x#WPB?Emaqvo_VA7O>79AgdT#V()oW1O}JEwt#{No5V^J`&|+RtB__E&7)>$`oU zca*O_!$elyiAT+nzOXx&*}c7ND?8b1xxtfj;;!2sKj1r`X|m7Yv&}izw@!{z&gi5W z&OG;Qq3xQfslWA^Qa62mXsjQ<-`%r8`>4!p>GFWBx^4?Q0?vT-8XsTqKEL|KjXCD}(wLQ(Y(Hm~SE(2? znro|ODmkvcIYo?1g?F+{?41=!Z+G;iOQ-~EcJ-ai+iUpVdfurCA0B?&%bw)<@$GB7 zx7RivU>2I@caASoUw`5DdN13TJRf~J!>%v2zieSS_rsTyV)Mi=ye!N3?r!pJ-{h@R luUH(L5N>ra<<`p&(nkZXJovjpZZ#;+dAjEakt!I-=u@0XiUlEhVmRf#{c7#P~wHw&a5-LeO$ Og2B_(&t;ucLK6TGK@!#g diff --git a/packages/extension/icons/icon48.png b/packages/extension/icons/icon48.png index 60d28201d376b43ae661bfc04344c6a2b2090112..9f3c962e5478867fe465d14329e22899a927d182 100644 GIT binary patch literal 4264 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 - -
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 index 1eb5e8e..cc6ba7a 100644 --- a/packages/extension/src/popup/popup.ts +++ b/packages/extension/src/popup/popup.ts @@ -2,32 +2,23 @@ * Popup script — connection status display for native messaging mode. */ -const SAREA_URL_SCHEME = 'io.corespeed.sarea://'; - 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')!; -const openSareaBtn = document.getElementById('openSarea') as HTMLButtonElement; versionEl.textContent = `v${chrome.runtime.getManifest().version}`; -openSareaBtn.addEventListener('click', () => { - chrome.tabs.create({ url: SAREA_URL_SCHEME }); -}); - function setConnected(connected: boolean): void { if (connected) { statusEl.className = 'status connected'; dotEl.className = 'dot green'; statusTextEl.textContent = 'Connected'; - openSareaBtn.style.display = 'none'; } else { statusEl.className = 'status disconnected'; dotEl.className = 'dot red'; statusTextEl.textContent = 'Disconnected'; - openSareaBtn.style.display = 'block'; } } From 272b45b424aefb6d6ee1053dddf4f39fe0d007df Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 13 May 2026 17:17:13 +0100 Subject: [PATCH 11/14] feat(cli): auto-load extension on Chrome launch Resolve the browseruse extension from the Sarea app bundle (Resources/browseruse-extension/) or from the development tree (packages/extension/dist/). Pass --load-extension and --disable-extensions-except flags so the extension is loaded automatically without manual installation. Also update the release workflow to build and package the extension as browseruse-extension-{version}.zip alongside the CLI binaries. --- .github/workflows/release.yml | 12 +++++++++++- packages/cli/src/browser.ts | 31 +++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c965593..ed94f36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,15 @@ 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}" @@ -36,7 +45,7 @@ jobs: 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/packages/cli/src/browser.ts b/packages/cli/src/browser.ts index 95c748c..5039a27 100644 --- a/packages/cli/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,17 @@ export async function launchBrowser(opts: LaunchOptions = {}): Promise Date: Wed, 13 May 2026 17:17:18 +0100 Subject: [PATCH 12/14] chore: bump version to 0.4.0 --- package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 2 +- packages/cli/src/ws-handler.ts | 2 +- packages/extension/manifest.json | 2 +- packages/extension/src/background/service-worker.ts | 2 +- packages/protocol/package.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 775071f..8d9344a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browseruse", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "private": true, "workspaces": ["packages/*"], diff --git a/packages/cli/package.json b/packages/cli/package.json index 4b3050f..9bb5c95 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@browseruse/cli", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "private": true, "bin": { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index dcc1c6f..d1e9bef 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -493,7 +493,7 @@ async function main(): 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/ws-handler.ts b/packages/cli/src/ws-handler.ts index 757f23d..3b84dd5 100644 --- a/packages/cli/src/ws-handler.ts +++ b/packages/cli/src/ws-handler.ts @@ -179,7 +179,7 @@ async function handleServerMethod( } return { - serverVersion: '0.3.0', + serverVersion: '0.4.0', sessionConnected: session.isConnected(), clientId, }; diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 5d4591f..d8ae2fb 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browseruse", - "version": "0.3.0", + "version": "0.4.0", "description": "Connect AI agents to your browser via browseruse.", "permissions": [ "debugger", diff --git a/packages/extension/src/background/service-worker.ts b/packages/extension/src/background/service-worker.ts index 81bd6e1..c61780c 100644 --- a/packages/extension/src/background/service-worker.ts +++ b/packages/extension/src/background/service-worker.ts @@ -68,7 +68,7 @@ async function connectWebSocket(): Promise { jsonrpc: '2.0', id: 'ext-handshake', method: 'session.handshake', - params: { clientType: 'extension', version: '0.3.0' }, + params: { clientType: 'extension', version: '0.4.0' }, })); nativeConnected = true; // Notify popup diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 7386271..fea97d2 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@browseruse/protocol", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "private": true, "main": "index.ts", From ff5d6bd4d8923c9ce386fc7ee2721a603eabebb7 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 13 May 2026 17:22:58 +0100 Subject: [PATCH 13/14] fix(ci): update release workflow paths for monorepo structure The CLI entry point moved from src/cli.ts to packages/cli/src/cli.ts during the monorepo restructure. --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed94f36..d952916 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,12 +35,12 @@ jobs: 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 From 111c496c7af20626be5243793a361e24bf710456 Mon Sep 17 00:00:00 2001 From: "Jiwei,Yuan" Date: Wed, 13 May 2026 18:05:59 +0100 Subject: [PATCH 14/14] refactor(cli): prefer Chrome Web Store extension over auto-load Only use --load-extension for non-system profiles where the store extension isn't installed. For system profiles, skip it to avoid loading a duplicate alongside the Chrome Web Store version. Also remove --disable-extensions-except so user-installed extensions (including the store version) are not disabled. --- packages/cli/src/browser.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/browser.ts b/packages/cli/src/browser.ts index 5039a27..8be1155 100644 --- a/packages/cli/src/browser.ts +++ b/packages/cli/src/browser.ts @@ -203,10 +203,15 @@ export async function launchBrowser(opts: LaunchOptions = {}): Promise