const DEFAULT_PORT = 18792 const BADGE = { on: { text: 'ON', color: '#FF5A36' }, off: { text: '', color: '#000000' }, connecting: { text: '…', color: '#F59E0B' }, error: { text: '!', color: '#B91C1C' }, } /** @type {WebSocket|null} */ let relayWs = null /** @type {Promise|null} */ let relayConnectPromise = null let debuggerListenersInstalled = false let nextSession = 1 /** @type {Map} */ const tabs = new Map() /** @type {Map} */ const tabBySession = new Map() /** @type {Map} */ const childSessionToTab = new Map() /** @type {Mapvoid, reject:(e:Error)=>void}>} */ const pending = new Map() function nowStack() { try { return new Error().stack || '' } catch { return '' } } async function getRelayPort() { const stored = await chrome.storage.local.get(['relayPort']) const raw = stored.relayPort const n = Number.parseInt(String(raw || ''), 10) if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT return n } function setBadge(tabId, kind) { const cfg = BADGE[kind] void chrome.action.setBadgeText({ tabId, text: cfg.text }) void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color }) void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) } async function ensureRelayConnection() { if (relayWs && relayWs.readyState === WebSocket.OPEN) return if (relayConnectPromise) return await relayConnectPromise relayConnectPromise = (async () => { const port = await getRelayPort() const httpBase = `http://127.0.0.1:${port}` const wsUrl = `ws://127.0.0.1:${port}/extension` // Fast preflight: is the relay server up? try { await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) } catch (err) { throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) } const ws = new WebSocket(wsUrl) relayWs = ws await new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000) ws.onopen = () => { clearTimeout(t) resolve() } ws.onerror = () => { clearTimeout(t) reject(new Error('WebSocket connect failed')) } ws.onclose = (ev) => { clearTimeout(t) reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) } }) ws.onmessage = (event) => void onRelayMessage(String(event.data || '')) ws.onclose = () => onRelayClosed('closed') ws.onerror = () => onRelayClosed('error') if (!debuggerListenersInstalled) { debuggerListenersInstalled = true chrome.debugger.onEvent.addListener(onDebuggerEvent) chrome.debugger.onDetach.addListener(onDebuggerDetach) } })() try { await relayConnectPromise } finally { relayConnectPromise = null } } function onRelayClosed(reason) { relayWs = null for (const [id, p] of pending.entries()) { pending.delete(id) p.reject(new Error(`Relay disconnected (${reason})`)) } for (const tabId of tabs.keys()) { void chrome.debugger.detach({ tabId }).catch(() => {}) setBadge(tabId, 'connecting') void chrome.action.setTitle({ tabId, title: 'Clawdbot Browser Relay: disconnected (click to re-attach)', }) } tabs.clear() tabBySession.clear() childSessionToTab.clear() } function sendToRelay(payload) { const ws = relayWs if (!ws || ws.readyState !== WebSocket.OPEN) { throw new Error('Relay not connected') } ws.send(JSON.stringify(payload)) } async function maybeOpenHelpOnce() { try { const stored = await chrome.storage.local.get(['helpOnErrorShown']) if (stored.helpOnErrorShown === true) return await chrome.storage.local.set({ helpOnErrorShown: true }) await chrome.runtime.openOptionsPage() } catch { // ignore } } function requestFromRelay(command) { const id = command.id return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }) try { sendToRelay(command) } catch (err) { pending.delete(id) reject(err instanceof Error ? err : new Error(String(err))) } }) } async function onRelayMessage(text) { /** @type {any} */ let msg try { msg = JSON.parse(text) } catch { return } if (msg && msg.method === 'ping') { try { sendToRelay({ method: 'pong' }) } catch { // ignore } return } if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) { const p = pending.get(msg.id) if (!p) return pending.delete(msg.id) if (msg.error) p.reject(new Error(String(msg.error))) else p.resolve(msg.result) return } if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') { try { const result = await handleForwardCdpCommand(msg) sendToRelay({ id: msg.id, result }) } catch (err) { sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) } } } function getTabBySessionId(sessionId) { const direct = tabBySession.get(sessionId) if (direct) return { tabId: direct, kind: 'main' } const child = childSessionToTab.get(sessionId) if (child) return { tabId: child, kind: 'child' } return null } function getTabByTargetId(targetId) { for (const [tabId, tab] of tabs.entries()) { if (tab.targetId === targetId) return tabId } return null } async function attachTab(tabId, opts = {}) { const debuggee = { tabId } await chrome.debugger.attach(debuggee, '1.3') await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {}) const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')) const targetInfo = info?.targetInfo const targetId = String(targetInfo?.targetId || '').trim() if (!targetId) { throw new Error('Target.getTargetInfo returned no targetId') } const sessionId = `cb-tab-${nextSession++}` const attachOrder = nextSession tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) tabBySession.set(sessionId, tabId) void chrome.action.setTitle({ tabId, title: 'Clawdbot Browser Relay: attached (click to detach)', }) if (!opts.skipAttachedEvent) { sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.attachedToTarget', params: { sessionId, targetInfo: { ...targetInfo, attached: true }, waitingForDebugger: false, }, }, }) } setBadge(tabId, 'on') return { sessionId, targetId } } async function detachTab(tabId, reason) { const tab = tabs.get(tabId) if (tab?.sessionId && tab?.targetId) { try { sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.detachedFromTarget', params: { sessionId: tab.sessionId, targetId: tab.targetId, reason }, }, }) } catch { // ignore } } if (tab?.sessionId) tabBySession.delete(tab.sessionId) tabs.delete(tabId) for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { if (parentTabId === tabId) childSessionToTab.delete(childSessionId) } try { await chrome.debugger.detach({ tabId }) } catch { // ignore } setBadge(tabId, 'off') void chrome.action.setTitle({ tabId, title: 'Clawdbot Browser Relay (click to attach/detach)', }) } async function connectOrToggleForActiveTab() { const [active] = await chrome.tabs.query({ active: true, currentWindow: true }) const tabId = active?.id if (!tabId) return const existing = tabs.get(tabId) if (existing?.state === 'connected') { await detachTab(tabId, 'toggle') return } tabs.set(tabId, { state: 'connecting' }) setBadge(tabId, 'connecting') void chrome.action.setTitle({ tabId, title: 'Clawdbot Browser Relay: connecting to local relay…', }) try { await ensureRelayConnection() await attachTab(tabId) } catch (err) { tabs.delete(tabId) setBadge(tabId, 'error') void chrome.action.setTitle({ tabId, title: 'Clawdbot Browser Relay: relay not running (open options for setup)', }) void maybeOpenHelpOnce() // Extra breadcrumbs in chrome://extensions service worker logs. const message = err instanceof Error ? err.message : String(err) console.warn('attach failed', message, nowStack()) } } async function handleForwardCdpCommand(msg) { const method = String(msg?.params?.method || '').trim() const params = msg?.params?.params || undefined const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined // Map command to tab const bySession = sessionId ? getTabBySessionId(sessionId) : null const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined const tabId = bySession?.tabId || (targetId ? getTabByTargetId(targetId) : null) || (() => { // No sessionId: pick the first connected tab (stable-ish). for (const [id, tab] of tabs.entries()) { if (tab.state === 'connected') return id } return null })() if (!tabId) throw new Error(`No attached tab for method ${method}`) /** @type {chrome.debugger.DebuggerSession} */ const debuggee = { tabId } if (method === 'Runtime.enable') { try { await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') await new Promise((r) => setTimeout(r, 50)) } catch { // ignore } return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params) } if (method === 'Target.createTarget') { const url = typeof params?.url === 'string' ? params.url : 'about:blank' const tab = await chrome.tabs.create({ url, active: false }) if (!tab.id) throw new Error('Failed to create tab') await new Promise((r) => setTimeout(r, 100)) const attached = await attachTab(tab.id) return { targetId: attached.targetId } } if (method === 'Target.closeTarget') { const target = typeof params?.targetId === 'string' ? params.targetId : '' const toClose = target ? getTabByTargetId(target) : tabId if (!toClose) return { success: false } try { await chrome.tabs.remove(toClose) } catch { return { success: false } } return { success: true } } if (method === 'Target.activateTarget') { const target = typeof params?.targetId === 'string' ? params.targetId : '' const toActivate = target ? getTabByTargetId(target) : tabId if (!toActivate) return {} const tab = await chrome.tabs.get(toActivate).catch(() => null) if (!tab) return {} if (tab.windowId) { await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) } await chrome.tabs.update(toActivate, { active: true }).catch(() => {}) return {} } const tabState = tabs.get(tabId) const mainSessionId = tabState?.sessionId const debuggerSession = sessionId && mainSessionId && sessionId !== mainSessionId ? { ...debuggee, sessionId } : debuggee return await chrome.debugger.sendCommand(debuggerSession, method, params) } function onDebuggerEvent(source, method, params) { const tabId = source.tabId if (!tabId) return const tab = tabs.get(tabId) if (!tab?.sessionId) return if (method === 'Target.attachedToTarget' && params?.sessionId) { childSessionToTab.set(String(params.sessionId), tabId) } if (method === 'Target.detachedFromTarget' && params?.sessionId) { childSessionToTab.delete(String(params.sessionId)) } try { sendToRelay({ method: 'forwardCDPEvent', params: { sessionId: source.sessionId || tab.sessionId, method, params, }, }) } catch { // ignore } } function onDebuggerDetach(source, reason) { const tabId = source.tabId if (!tabId) return if (!tabs.has(tabId)) return void detachTab(tabId, reason) } chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab()) chrome.runtime.onInstalled.addListener(() => { // Useful: first-time instructions. void chrome.runtime.openOptionsPage() })