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>
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 = `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
|
|
})
|