mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
The extension relay server authenticates using an HMAC-SHA256 derived
token (`openclaw-extension-relay-v1:<port>`), but the Chrome extension
was sending the raw gateway token. This caused both the WebSocket
connection and the options page validation to fail with 401 Unauthorized.
Additionally, the options page validation request triggered a CORS
preflight (due to the custom `x-openclaw-relay-token` header) which the
relay rejects because OPTIONS requests lack auth headers. The options
page now delegates the check to the background service worker which has
host_permissions and bypasses CORS preflight.
Fixes #23842
Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit bbc654b9f0)
814 lines
24 KiB
JavaScript
814 lines
24 KiB
JavaScript
import { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
|
|
|
|
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<void>|null} */
|
|
let relayConnectPromise = null
|
|
|
|
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()
|
|
|
|
// Per-tab operation locks prevent double-attach races.
|
|
/** @type {Set<number>} */
|
|
const tabOperationLocks = new Set()
|
|
|
|
// Reconnect state for exponential backoff.
|
|
let reconnectAttempt = 0
|
|
let reconnectTimer = null
|
|
|
|
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
|
|
}
|
|
|
|
async function getGatewayToken() {
|
|
const stored = await chrome.storage.local.get(['gatewayToken'])
|
|
const token = String(stored.gatewayToken || '').trim()
|
|
return token || ''
|
|
}
|
|
|
|
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(() => {})
|
|
}
|
|
|
|
// Persist attached tab state to survive MV3 service worker restarts.
|
|
async function persistState() {
|
|
try {
|
|
const tabEntries = []
|
|
for (const [tabId, tab] of tabs.entries()) {
|
|
if (tab.state === 'connected' && tab.sessionId && tab.targetId) {
|
|
tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder })
|
|
}
|
|
}
|
|
await chrome.storage.session.set({
|
|
persistedTabs: tabEntries,
|
|
nextSession,
|
|
})
|
|
} catch {
|
|
// chrome.storage.session may not be available in all contexts.
|
|
}
|
|
}
|
|
|
|
// Rehydrate tab state on service worker startup. Fast path — just restores
|
|
// maps and badges. Relay reconnect happens separately in background.
|
|
async function rehydrateState() {
|
|
try {
|
|
const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession'])
|
|
if (stored.nextSession) {
|
|
nextSession = Math.max(nextSession, stored.nextSession)
|
|
}
|
|
const entries = stored.persistedTabs || []
|
|
// Phase 1: optimistically restore state and badges.
|
|
for (const entry of entries) {
|
|
tabs.set(entry.tabId, {
|
|
state: 'connected',
|
|
sessionId: entry.sessionId,
|
|
targetId: entry.targetId,
|
|
attachOrder: entry.attachOrder,
|
|
})
|
|
tabBySession.set(entry.sessionId, entry.tabId)
|
|
setBadge(entry.tabId, 'on')
|
|
}
|
|
// Phase 2: validate asynchronously, remove dead tabs.
|
|
for (const entry of entries) {
|
|
try {
|
|
await chrome.tabs.get(entry.tabId)
|
|
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
|
|
expression: '1',
|
|
returnByValue: true,
|
|
})
|
|
} catch {
|
|
tabs.delete(entry.tabId)
|
|
tabBySession.delete(entry.sessionId)
|
|
setBadge(entry.tabId, 'off')
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore rehydration errors.
|
|
}
|
|
}
|
|
|
|
async function ensureRelayConnection() {
|
|
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
|
|
if (relayConnectPromise) return await relayConnectPromise
|
|
|
|
relayConnectPromise = (async () => {
|
|
const port = await getRelayPort()
|
|
const gatewayToken = await getGatewayToken()
|
|
const httpBase = `http://127.0.0.1:${port}`
|
|
const wsUrl = await buildRelayWsUrl(port, gatewayToken)
|
|
|
|
// 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'})`))
|
|
}
|
|
})
|
|
|
|
// Bind permanent handlers. Guard against stale socket: if this WS was
|
|
// replaced before its close fires, the handler is a no-op.
|
|
ws.onmessage = (event) => {
|
|
if (ws !== relayWs) return
|
|
void whenReady(() => onRelayMessage(String(event.data || '')))
|
|
}
|
|
ws.onclose = () => {
|
|
if (ws !== relayWs) return
|
|
onRelayClosed('closed')
|
|
}
|
|
ws.onerror = () => {
|
|
if (ws !== relayWs) return
|
|
onRelayClosed('error')
|
|
}
|
|
})()
|
|
|
|
try {
|
|
await relayConnectPromise
|
|
reconnectAttempt = 0
|
|
} finally {
|
|
relayConnectPromise = null
|
|
}
|
|
}
|
|
|
|
// Relay closed — update badges, reject pending requests, auto-reconnect.
|
|
// Debugger sessions are kept alive so they survive transient WS drops.
|
|
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, tab] of tabs.entries()) {
|
|
if (tab.state === 'connected') {
|
|
setBadge(tabId, 'connecting')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'OpenClaw Browser Relay: relay reconnecting…',
|
|
})
|
|
}
|
|
}
|
|
|
|
scheduleReconnect()
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer)
|
|
reconnectTimer = null
|
|
}
|
|
|
|
const delay = reconnectDelayMs(reconnectAttempt)
|
|
reconnectAttempt++
|
|
|
|
console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`)
|
|
|
|
reconnectTimer = setTimeout(async () => {
|
|
reconnectTimer = null
|
|
try {
|
|
await ensureRelayConnection()
|
|
reconnectAttempt = 0
|
|
console.log('Reconnected successfully')
|
|
await reannounceAttachedTabs()
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`)
|
|
if (!isRetryableReconnectError(err)) {
|
|
return
|
|
}
|
|
scheduleReconnect()
|
|
}
|
|
}, delay)
|
|
}
|
|
|
|
function cancelReconnect() {
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer)
|
|
reconnectTimer = null
|
|
}
|
|
reconnectAttempt = 0
|
|
}
|
|
|
|
// Re-announce all attached tabs to the relay after reconnect.
|
|
async function reannounceAttachedTabs() {
|
|
for (const [tabId, tab] of tabs.entries()) {
|
|
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
|
|
|
|
// Verify debugger is still attached.
|
|
try {
|
|
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
|
expression: '1',
|
|
returnByValue: true,
|
|
})
|
|
} catch {
|
|
tabs.delete(tabId)
|
|
if (tab.sessionId) tabBySession.delete(tab.sessionId)
|
|
setBadge(tabId, 'off')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'OpenClaw Browser Relay (click to attach/detach)',
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Send fresh attach event to relay.
|
|
try {
|
|
const info = /** @type {any} */ (
|
|
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
|
|
)
|
|
const targetInfo = info?.targetInfo
|
|
|
|
sendToRelay({
|
|
method: 'forwardCDPEvent',
|
|
params: {
|
|
method: 'Target.attachedToTarget',
|
|
params: {
|
|
sessionId: tab.sessionId,
|
|
targetInfo: { ...targetInfo, attached: true },
|
|
waitingForDebugger: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
setBadge(tabId, 'on')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'OpenClaw Browser Relay: attached (click to detach)',
|
|
})
|
|
} catch {
|
|
setBadge(tabId, 'on')
|
|
}
|
|
}
|
|
|
|
await persistState()
|
|
}
|
|
|
|
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) => {
|
|
const timer = setTimeout(() => {
|
|
pending.delete(id)
|
|
reject(new Error('Relay request timeout (30s)'))
|
|
}, 30000)
|
|
pending.set(id, {
|
|
resolve: (v) => { clearTimeout(timer); resolve(v) },
|
|
reject: (e) => { clearTimeout(timer); reject(e) },
|
|
})
|
|
try {
|
|
sendToRelay(command)
|
|
} catch (err) {
|
|
clearTimeout(timer)
|
|
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 sid = nextSession++
|
|
const sessionId = `cb-tab-${sid}`
|
|
const attachOrder = sid
|
|
|
|
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
|
|
tabBySession.set(sessionId, tabId)
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'OpenClaw 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')
|
|
await persistState()
|
|
|
|
return { sessionId, targetId }
|
|
}
|
|
|
|
async function detachTab(tabId, reason) {
|
|
const tab = tabs.get(tabId)
|
|
|
|
// Send detach events for child sessions first.
|
|
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
|
if (parentTabId === tabId) {
|
|
try {
|
|
sendToRelay({
|
|
method: 'forwardCDPEvent',
|
|
params: {
|
|
method: 'Target.detachedFromTarget',
|
|
params: { sessionId: childSessionId, reason: 'parent_detached' },
|
|
},
|
|
})
|
|
} catch {
|
|
// Relay may be down.
|
|
}
|
|
childSessionToTab.delete(childSessionId)
|
|
}
|
|
}
|
|
|
|
// Send detach event for main session.
|
|
if (tab?.sessionId && tab?.targetId) {
|
|
try {
|
|
sendToRelay({
|
|
method: 'forwardCDPEvent',
|
|
params: {
|
|
method: 'Target.detachedFromTarget',
|
|
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
|
|
},
|
|
})
|
|
} catch {
|
|
// Relay may be down.
|
|
}
|
|
}
|
|
|
|
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
|
|
tabs.delete(tabId)
|
|
|
|
try {
|
|
await chrome.debugger.detach({ tabId })
|
|
} catch {
|
|
// May already be detached.
|
|
}
|
|
|
|
setBadge(tabId, 'off')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'OpenClaw Browser Relay (click to attach/detach)',
|
|
})
|
|
|
|
await persistState()
|
|
}
|
|
|
|
async function connectOrToggleForActiveTab() {
|
|
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
|
|
const tabId = active?.id
|
|
if (!tabId) return
|
|
|
|
// Prevent concurrent operations on the same tab.
|
|
if (tabOperationLocks.has(tabId)) return
|
|
tabOperationLocks.add(tabId)
|
|
|
|
try {
|
|
const existing = tabs.get(tabId)
|
|
if (existing?.state === 'connected') {
|
|
await detachTab(tabId, 'toggle')
|
|
return
|
|
}
|
|
|
|
// User is manually connecting — cancel any pending reconnect.
|
|
cancelReconnect()
|
|
|
|
tabs.set(tabId, { state: 'connecting' })
|
|
setBadge(tabId, 'connecting')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'OpenClaw 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: 'OpenClaw Browser Relay: relay not running (open options for setup)',
|
|
})
|
|
void maybeOpenHelpOnce()
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
console.warn('attach failed', message, nowStack())
|
|
}
|
|
} finally {
|
|
tabOperationLocks.delete(tabId)
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
const bySession = sessionId ? getTabBySessionId(sessionId) : null
|
|
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
|
|
const tabId =
|
|
bySession?.tabId ||
|
|
(targetId ? getTabByTargetId(targetId) : null) ||
|
|
(() => {
|
|
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 {
|
|
// Relay may be down.
|
|
}
|
|
}
|
|
|
|
// Navigation/reload fires target_closed but the tab is still alive — Chrome
|
|
// just swaps the renderer process. Suppress the detach event to the relay and
|
|
// seamlessly re-attach after a short grace period.
|
|
function onDebuggerDetach(source, reason) {
|
|
const tabId = source.tabId
|
|
if (!tabId) return
|
|
if (!tabs.has(tabId)) return
|
|
|
|
if (reason === 'target_closed') {
|
|
const oldState = tabs.get(tabId)
|
|
setBadge(tabId, 'connecting')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'OpenClaw Browser Relay: re-attaching after navigation…',
|
|
})
|
|
|
|
setTimeout(async () => {
|
|
try {
|
|
// If user manually detached during the grace period, bail out.
|
|
if (!tabs.has(tabId)) return
|
|
const tab = await chrome.tabs.get(tabId)
|
|
if (tab && relayWs?.readyState === WebSocket.OPEN) {
|
|
console.log(`Re-attaching tab ${tabId} after navigation`)
|
|
if (oldState?.sessionId) tabBySession.delete(oldState.sessionId)
|
|
tabs.delete(tabId)
|
|
await attachTab(tabId, { skipAttachedEvent: false })
|
|
} else {
|
|
// Tab gone or relay down — full cleanup.
|
|
void detachTab(tabId, reason)
|
|
}
|
|
} catch (err) {
|
|
console.warn(`Failed to re-attach tab ${tabId} after navigation:`, err.message)
|
|
void detachTab(tabId, reason)
|
|
}
|
|
}, 500)
|
|
return
|
|
}
|
|
|
|
// Non-navigation detach (user action, crash, etc.) — full cleanup.
|
|
void detachTab(tabId, reason)
|
|
}
|
|
|
|
// Tab lifecycle listeners — clean up stale entries.
|
|
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
|
|
if (!tabs.has(tabId)) return
|
|
const tab = tabs.get(tabId)
|
|
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
|
|
tabs.delete(tabId)
|
|
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
|
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
|
|
}
|
|
if (tab?.sessionId && tab?.targetId) {
|
|
try {
|
|
sendToRelay({
|
|
method: 'forwardCDPEvent',
|
|
params: {
|
|
method: 'Target.detachedFromTarget',
|
|
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' },
|
|
},
|
|
})
|
|
} catch {
|
|
// Relay may be down.
|
|
}
|
|
}
|
|
void persistState()
|
|
}))
|
|
|
|
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => {
|
|
const tab = tabs.get(removedTabId)
|
|
if (!tab) return
|
|
tabs.delete(removedTabId)
|
|
tabs.set(addedTabId, tab)
|
|
if (tab.sessionId) {
|
|
tabBySession.set(tab.sessionId, addedTabId)
|
|
}
|
|
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
|
if (parentTabId === removedTabId) {
|
|
childSessionToTab.set(childSessionId, addedTabId)
|
|
}
|
|
}
|
|
setBadge(addedTabId, 'on')
|
|
void persistState()
|
|
}))
|
|
|
|
// Register debugger listeners at module scope so detach/event handling works
|
|
// even when the relay WebSocket is down.
|
|
chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args)))
|
|
chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args)))
|
|
|
|
chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab()))
|
|
|
|
// Refresh badge after navigation completes — service worker may have restarted
|
|
// during navigation, losing ephemeral badge state.
|
|
chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => {
|
|
if (frameId !== 0) return
|
|
const tab = tabs.get(tabId)
|
|
if (tab?.state === 'connected') {
|
|
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
|
}
|
|
}))
|
|
|
|
// Refresh badge when user switches to an attached tab.
|
|
chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => {
|
|
const tab = tabs.get(tabId)
|
|
if (tab?.state === 'connected') {
|
|
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
|
}
|
|
}))
|
|
|
|
chrome.runtime.onInstalled.addListener(() => {
|
|
void chrome.runtime.openOptionsPage()
|
|
})
|
|
|
|
// MV3 keepalive via chrome.alarms — more reliable than setInterval across
|
|
// service worker restarts. Checks relay health and refreshes badges.
|
|
chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 })
|
|
|
|
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
|
if (alarm.name !== 'relay-keepalive') return
|
|
await initPromise
|
|
|
|
if (tabs.size === 0) return
|
|
|
|
// Refresh badges (ephemeral in MV3).
|
|
for (const [tabId, tab] of tabs.entries()) {
|
|
if (tab.state === 'connected') {
|
|
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
|
}
|
|
}
|
|
|
|
// If relay is down and no reconnect is in progress, trigger one.
|
|
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
|
|
if (!relayConnectPromise && !reconnectTimer) {
|
|
console.log('Keepalive: WebSocket unhealthy, triggering reconnect')
|
|
await ensureRelayConnection().catch(() => {
|
|
// ensureRelayConnection may throw without triggering onRelayClosed
|
|
// (e.g. preflight fetch fails before WS is created), so ensure
|
|
// reconnect is always scheduled on failure.
|
|
if (!reconnectTimer) {
|
|
scheduleReconnect()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// Rehydrate state on service worker startup. Split: rehydration is the gate
|
|
// (fast), relay reconnect runs in background (slow, non-blocking).
|
|
const initPromise = rehydrateState()
|
|
|
|
initPromise.then(() => {
|
|
if (tabs.size > 0) {
|
|
ensureRelayConnection().then(() => {
|
|
reconnectAttempt = 0
|
|
return reannounceAttachedTabs()
|
|
}).catch(() => {
|
|
scheduleReconnect()
|
|
})
|
|
}
|
|
})
|
|
|
|
// Shared gate: all state-dependent handlers await this before accessing maps.
|
|
async function whenReady(fn) {
|
|
await initPromise
|
|
return fn()
|
|
}
|
|
|
|
// Relay check handler for the options page. The service worker has
|
|
// host_permissions and bypasses CORS preflight, so the options page
|
|
// delegates token-validation requests here.
|
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
if (msg?.type !== 'relayCheck') return false
|
|
const { url, token } = msg
|
|
const headers = token ? { 'x-openclaw-relay-token': token } : {}
|
|
fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) })
|
|
.then((res) => sendResponse({ status: res.status, ok: res.ok }))
|
|
.catch((err) => sendResponse({ status: 0, ok: false, error: String(err) }))
|
|
return true
|
|
})
|