Files
pastilhasandClaude Opus 5 3346b4c853 extension: normalise the address, and always https
Pasting the address you use for Officer produced http://https://platform.officer.dev:18792/ — the
scheme was taken as part of the hostname and http:// prepended anyway. It failed as "Relay not
reachable", which reads as the relay being down rather than the URL being mangled.

normalizeHost strips a scheme, a path and credentials, keeps IPv6 brackets, and lifts a port out of
the address so pasting host:9000 moves the port field instead of dropping it. The field is rewritten
with the result, so what is shown is what is stored.

The scheme is now always https, and wss for the socket, whatever was typed. The relay carries a
pairing token on every request and drives a browser someone is signed into; plaintext because a field
was left blank is not a default worth having.

CONSEQUENCE, stated rather than discovered: the relay itself still serves plain HTTP on 18792, so the
extension cannot connect until it is behind TLS. That is a proxy host away and it is the right shape —
the raw port was never reachable from anywhere but the tailnet anyway.

The unreachable message now says what was tried and that the port is normally private, instead of
"verify the server address", which is true and useless when the address is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:47:31 +00:00

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 = `https://${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
})