Your own Chrome, driven from Officer. Not a headless one: it attaches to the browser you are already
signed into, which is the whole difference from /scrape, and /scrape stays behind in the platform.
The relay is a sidecar now, and that is the point. It was a second Bun.serve inside the platform
process on its own port, which is exactly why it was switched off on 2026-08-13 — "the platform serves
one port". A sidecar is a process allowed its own listeners, so the objection stops applying instead of
being argued with.
The boundary across it is deliberately small: two questions, who is connected and what tabs they have,
because only those live in memory on the relay side. Screenshots, evaluation and navigation still go
straight down cdp.ts's own WebSocket to the relay's /cdp endpoint, exactly as before — routing page
bytes through the platform would be work for nothing.
Three normally-breaking changes taken now because now they are free, each already recommended in a
comment by whoever switched the relay off. Its own signing key instead of borrowing getKey('jwt'). Its
own table instead of the platform's user_integrations. And the port kept at 18792 on purpose, because
the port is an input to the token HMAC as well as an address — moving it fails every pairing silently,
and the extension blames the address. user_integrations held zero browser-relay rows, checked in
Postgres rather than assumed, so there was nothing to unpair.
The screen became panels. BrowserScreen.tsx rendered TabList and TabPreview through appType: null
placeholders; both components were already self-contained and took no props, so the screen had nothing
to pass and simply went.
Not tested end to end — no browser has been paired since the move, and that needs a Chrome and a
person. BLOCKERS.md says so rather than the commit implying otherwise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
979 lines
27 KiB
JavaScript
979 lines
27 KiB
JavaScript
// Based on OpenClaw Browser Extension Relay (MIT License)
|
|
// https://github.com/nicepkg/openclaw
|
|
|
|
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
|
|
|
|
const DEFAULT_PORT = 18792
|
|
const DEFAULT_HOST = '127.0.0.1'
|
|
|
|
const BADGE = {
|
|
on: { text: 'ON', color: '#06b6d4' },
|
|
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 relayGatewayToken = ''
|
|
/** @type {string|null} */
|
|
let relayConnectRequestId = 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()
|
|
|
|
/** @type {Set<number>} */
|
|
const tabOperationLocks = new Set()
|
|
|
|
/** @type {Set<number>} */
|
|
const reattachPending = new Set()
|
|
|
|
/** @type {Set<number>} */
|
|
const reattachingTabs = new Set()
|
|
|
|
let reconnectAttempt = 0
|
|
let reconnectTimer = null
|
|
|
|
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 getRelayHost() {
|
|
const stored = await chrome.storage.local.get(['relayHost'])
|
|
const host = String(stored.relayHost || '').trim()
|
|
return host || DEFAULT_HOST
|
|
}
|
|
|
|
async function getRelayToken() {
|
|
const stored = await chrome.storage.local.get(['relayToken'])
|
|
const token = String(stored.relayToken || '').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(() => {})
|
|
}
|
|
|
|
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.
|
|
}
|
|
}
|
|
|
|
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 || []
|
|
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')
|
|
}
|
|
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 host = await getRelayHost()
|
|
const token = await getRelayToken()
|
|
const httpBase = `http://${host}:${port}`
|
|
const wsUrl = await buildRelayWsUrl(host, port, token)
|
|
|
|
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
|
|
relayGatewayToken = token
|
|
ws.onmessage = (event) => {
|
|
if (ws !== relayWs) return
|
|
void whenReady(() => onRelayMessage(String(event.data || '')))
|
|
}
|
|
|
|
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.onclose = (ev) => {
|
|
if (ws !== relayWs) return
|
|
if (ev.code === 4000) {
|
|
onRelayReplaced()
|
|
return
|
|
}
|
|
onRelayClosed('closed')
|
|
}
|
|
ws.onerror = () => {
|
|
if (ws !== relayWs) return
|
|
onRelayClosed('error')
|
|
}
|
|
})()
|
|
|
|
try {
|
|
await relayConnectPromise
|
|
reconnectAttempt = 0
|
|
} finally {
|
|
relayConnectPromise = null
|
|
}
|
|
}
|
|
|
|
function onRelayClosed(reason) {
|
|
relayWs = null
|
|
relayGatewayToken = ''
|
|
relayConnectRequestId = null
|
|
|
|
for (const [id, p] of pending.entries()) {
|
|
pending.delete(id)
|
|
p.reject(new Error(`Relay disconnected (${reason})`))
|
|
}
|
|
|
|
reattachPending.clear()
|
|
|
|
for (const [tabId, tab] of tabs.entries()) {
|
|
if (tab.state === 'connected') {
|
|
setBadge(tabId, 'connecting')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'Officer Browser Relay: relay reconnecting…',
|
|
})
|
|
}
|
|
}
|
|
|
|
scheduleReconnect()
|
|
}
|
|
|
|
function onRelayReplaced() {
|
|
relayWs = null
|
|
relayGatewayToken = ''
|
|
relayConnectRequestId = null
|
|
|
|
for (const [id, p] of pending.entries()) {
|
|
pending.delete(id)
|
|
p.reject(new Error('Replaced by another browser session'))
|
|
}
|
|
|
|
reattachPending.clear()
|
|
|
|
for (const [tabId, tab] of tabs.entries()) {
|
|
if (tab.state === 'connected') {
|
|
setBadge(tabId, 'error')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'Officer Browser Relay: replaced by another browser — click to reconnect',
|
|
})
|
|
}
|
|
}
|
|
// Do NOT schedule reconnect — user must click to take over again
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async function reannounceAttachedTabs() {
|
|
for (const [tabId, tab] of tabs.entries()) {
|
|
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
|
|
|
|
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: 'Officer Browser Relay (click to attach/detach)',
|
|
})
|
|
continue
|
|
}
|
|
|
|
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: 'Officer 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))
|
|
}
|
|
|
|
function ensureGatewayHandshakeStarted(payload) {
|
|
if (relayConnectRequestId) return
|
|
const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
|
|
relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
|
sendToRelay({
|
|
type: 'req',
|
|
id: relayConnectRequestId,
|
|
method: 'connect',
|
|
params: {
|
|
minProtocol: 3,
|
|
maxProtocol: 3,
|
|
client: {
|
|
id: 'officer-browser-relay-extension',
|
|
version: '1.0.0',
|
|
platform: 'chrome-extension',
|
|
mode: 'webchat',
|
|
},
|
|
role: 'operator',
|
|
scopes: ['operator.read', 'operator.write'],
|
|
caps: [],
|
|
commands: [],
|
|
nonce: nonce || undefined,
|
|
auth: relayGatewayToken ? { token: relayGatewayToken } : undefined,
|
|
},
|
|
})
|
|
}
|
|
|
|
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.type === 'event' && msg.event === 'connect.challenge') {
|
|
try {
|
|
ensureGatewayHandshakeStarted(msg.payload)
|
|
} catch (err) {
|
|
console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err))
|
|
relayConnectRequestId = null
|
|
const ws = relayWs
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.close(1008, 'gateway connect failed')
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) {
|
|
relayConnectRequestId = null
|
|
if (!msg.ok) {
|
|
const detail = msg?.error?.message || msg?.error || 'gateway connect failed'
|
|
console.warn('gateway connect handshake rejected', String(detail))
|
|
const ws = relayWs
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.close(1008, 'gateway connect failed')
|
|
}
|
|
}
|
|
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: 'Officer 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)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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: 'Officer Browser Relay (click to attach/detach)',
|
|
})
|
|
|
|
await persistState()
|
|
}
|
|
|
|
function describeError(err) {
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
if (msg.includes('not reachable')) return 'relay not reachable — check server address in options'
|
|
if (msg.includes('Missing relay token')) return 'no token configured — open options to set up'
|
|
if (msg.includes('401') || msg.includes('Unauthorized')) return 'token rejected — check token in options'
|
|
if (msg.toLowerCase().includes('timeout')) return 'connection timed out'
|
|
return msg.length > 80 ? msg.slice(0, 80) + '…' : msg
|
|
}
|
|
|
|
async function connectOrToggleForActiveTab() {
|
|
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
|
|
const tabId = active?.id
|
|
if (!tabId) return
|
|
|
|
if (tabOperationLocks.has(tabId)) return
|
|
tabOperationLocks.add(tabId)
|
|
|
|
try {
|
|
if (reattachPending.has(tabId)) {
|
|
reattachPending.delete(tabId)
|
|
setBadge(tabId, 'off')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'Officer Browser Relay (click to attach/detach)',
|
|
})
|
|
return
|
|
}
|
|
|
|
const existing = tabs.get(tabId)
|
|
if (existing?.state === 'connected') {
|
|
await detachTab(tabId, 'toggle')
|
|
return
|
|
}
|
|
|
|
cancelReconnect()
|
|
|
|
tabs.set(tabId, { state: 'connecting' })
|
|
setBadge(tabId, 'connecting')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'Officer Browser Relay: connecting to relay…',
|
|
})
|
|
|
|
try {
|
|
await ensureRelayConnection()
|
|
await attachTab(tabId)
|
|
} catch (err) {
|
|
tabs.delete(tabId)
|
|
setBadge(tabId, 'error')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: `Officer Browser Relay: ${describeError(err)}`,
|
|
})
|
|
void maybeOpenHelpOnce()
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
console.warn('attach failed', message)
|
|
}
|
|
} 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
|
|
|
|
try {
|
|
return await chrome.debugger.sendCommand(debuggerSession, method, params)
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
if (!message.includes('Session with given id not found')) throw err
|
|
|
|
// Chrome's internal debugger session became stale — re-attach and retry once
|
|
console.warn(`Stale session for tab ${tabId}, re-attaching debugger`)
|
|
reattachingTabs.add(tabId)
|
|
try {
|
|
await chrome.debugger.detach(debuggee).catch(() => {})
|
|
await chrome.debugger.attach(debuggee, '1.3')
|
|
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
|
|
return await chrome.debugger.sendCommand(debuggee, method, params)
|
|
} finally {
|
|
reattachingTabs.delete(tabId)
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
}
|
|
}
|
|
|
|
async function onDebuggerDetach(source, reason) {
|
|
const tabId = source.tabId
|
|
if (!tabId) return
|
|
if (!tabs.has(tabId)) return
|
|
if (reattachingTabs.has(tabId)) return
|
|
|
|
if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') {
|
|
void detachTab(tabId, reason)
|
|
return
|
|
}
|
|
|
|
let tabInfo
|
|
try {
|
|
tabInfo = await chrome.tabs.get(tabId)
|
|
} catch {
|
|
void detachTab(tabId, reason)
|
|
return
|
|
}
|
|
|
|
if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) {
|
|
void detachTab(tabId, reason)
|
|
return
|
|
}
|
|
|
|
if (reattachPending.has(tabId)) return
|
|
|
|
const oldTab = tabs.get(tabId)
|
|
const oldSessionId = oldTab?.sessionId
|
|
const oldTargetId = oldTab?.targetId
|
|
|
|
if (oldSessionId) tabBySession.delete(oldSessionId)
|
|
tabs.delete(tabId)
|
|
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
|
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
|
|
}
|
|
|
|
if (oldSessionId && oldTargetId) {
|
|
try {
|
|
sendToRelay({
|
|
method: 'forwardCDPEvent',
|
|
params: {
|
|
method: 'Target.detachedFromTarget',
|
|
params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' },
|
|
},
|
|
})
|
|
} catch {
|
|
// Relay may be down.
|
|
}
|
|
}
|
|
|
|
reattachPending.add(tabId)
|
|
setBadge(tabId, 'connecting')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'Officer Browser Relay: re-attaching after navigation…',
|
|
})
|
|
|
|
const delays = [300, 700, 1500]
|
|
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
await new Promise((r) => setTimeout(r, delays[attempt]))
|
|
|
|
if (!reattachPending.has(tabId)) return
|
|
|
|
try {
|
|
await chrome.tabs.get(tabId)
|
|
} catch {
|
|
reattachPending.delete(tabId)
|
|
setBadge(tabId, 'off')
|
|
return
|
|
}
|
|
|
|
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
|
|
reattachPending.delete(tabId)
|
|
setBadge(tabId, 'error')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'Officer Browser Relay: relay disconnected during re-attach',
|
|
})
|
|
return
|
|
}
|
|
|
|
try {
|
|
await attachTab(tabId)
|
|
reattachPending.delete(tabId)
|
|
return
|
|
} catch {
|
|
// continue retries
|
|
}
|
|
}
|
|
|
|
reattachPending.delete(tabId)
|
|
setBadge(tabId, 'off')
|
|
void chrome.action.setTitle({
|
|
tabId,
|
|
title: 'Officer Browser Relay: re-attach failed (click to retry)',
|
|
})
|
|
}
|
|
|
|
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
|
|
reattachPending.delete(tabId)
|
|
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()
|
|
}))
|
|
|
|
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()))
|
|
|
|
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')
|
|
}
|
|
}))
|
|
|
|
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()
|
|
})
|
|
|
|
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
|
|
|
|
for (const [tabId, tab] of tabs.entries()) {
|
|
if (tab.state === 'connected') {
|
|
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
|
}
|
|
}
|
|
|
|
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
|
|
if (!relayConnectPromise && !reconnectTimer) {
|
|
console.log('Keepalive: WebSocket unhealthy, triggering reconnect')
|
|
await ensureRelayConnection().catch(() => {
|
|
if (!reconnectTimer) {
|
|
scheduleReconnect()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
const initPromise = rehydrateState()
|
|
|
|
initPromise.then(() => {
|
|
if (tabs.size > 0) {
|
|
ensureRelayConnection().then(() => {
|
|
reconnectAttempt = 0
|
|
return reannounceAttachedTabs()
|
|
}).catch(() => {
|
|
scheduleReconnect()
|
|
})
|
|
}
|
|
})
|
|
|
|
async function whenReady(fn) {
|
|
await initPromise
|
|
return fn()
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
if (msg?.type !== 'relayCheck') return false
|
|
const { url, token } = msg
|
|
const headers = token ? { 'x-officer-relay-token': token } : {}
|
|
fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) })
|
|
.then(async (res) => {
|
|
const contentType = String(res.headers.get('content-type') || '')
|
|
let json = null
|
|
if (contentType.includes('application/json')) {
|
|
try {
|
|
json = await res.json()
|
|
} catch {
|
|
json = null
|
|
}
|
|
}
|
|
sendResponse({ status: res.status, ok: res.ok, contentType, json })
|
|
})
|
|
.catch((err) => sendResponse({ status: 0, ok: false, error: String(err) }))
|
|
return true
|
|
})
|