feat: add Chrome extension browser relay
This commit is contained in:
407
assets/chrome-extension/background.js
Normal file
407
assets/chrome-extension/background.js
Normal file
@@ -0,0 +1,407 @@
|
||||
const DEFAULT_PORT = 18792
|
||||
|
||||
const BADGE = {
|
||||
on: { text: 'ON', color: '#0B6E4F' },
|
||||
off: { text: '', color: '#000000' },
|
||||
connecting: { text: '…', color: '#B45309' },
|
||||
error: { text: '!', color: '#B91C1C' },
|
||||
}
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
let relayWs = null
|
||||
/** @type {Promise<void>|null} */
|
||||
let relayConnectPromise = null
|
||||
|
||||
let debuggerListenersInstalled = false
|
||||
|
||||
let nextSession = 1
|
||||
|
||||
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
|
||||
const tabs = new Map()
|
||||
/** @type {Map<string, number>} */
|
||||
const tabBySession = new Map()
|
||||
/** @type {Map<string, number>} */
|
||||
const childSessionToTab = new Map()
|
||||
|
||||
/** @type {Map<number, {resolve:(v:any)=>void, 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 })
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
try {
|
||||
await ensureRelayConnection()
|
||||
await attachTab(tabId)
|
||||
} catch (err) {
|
||||
tabs.delete(tabId)
|
||||
setBadge(tabId, 'error')
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
// Service worker: best-effort surface via title.
|
||||
void chrome.action.setTitle({ tabId, title: `Clawdbot: ${message}` })
|
||||
// Extra breadcrumbs in chrome://extensions service worker logs.
|
||||
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()
|
||||
})
|
||||
Reference in New Issue
Block a user