browser, extracted from the platform — extension, relay and all
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>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Officer Browser Relay — Setup Guide
|
||||
|
||||
## Prerequisites
|
||||
- Officer server running (`bun dev`)
|
||||
- Google Chrome or Chromium-based browser on your machine
|
||||
- Both machines on the same Tailscale network (or localhost for local dev)
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Verify relay is running
|
||||
Check the server logs for `[browser-relay] listening on port 18792`.
|
||||
|
||||
### 2. Get your relay credentials
|
||||
Open Officer dashboard → **Browser** screen (or `/browser`).
|
||||
The setup card shows your **server address**, **port**, and **relay token**.
|
||||
|
||||
### 3. Load the extension in Chrome
|
||||
1. Open `chrome://extensions`
|
||||
2. Enable **Developer mode** (top-right toggle)
|
||||
3. Click **Load unpacked**
|
||||
4. Select `src/extensions/browser-relay/`
|
||||
|
||||
### 4. Configure the extension
|
||||
1. The options page opens automatically after install (or right-click extension → Options)
|
||||
2. Enter the **Server address** (Officer's IP — e.g. your Tailscale IP, or `127.0.0.1` for local)
|
||||
3. Enter the **Port** (default `18792`)
|
||||
4. Paste your **Relay token** from step 2
|
||||
5. Click **Save** — status should show green "Relay reachable and authenticated"
|
||||
|
||||
### 5. Attach a tab
|
||||
1. Navigate to any webpage in Chrome
|
||||
2. Click the Officer Browser Relay extension icon in the toolbar
|
||||
3. Badge shows **ON** (cyan) = attached
|
||||
|
||||
### 6. Use from the dashboard
|
||||
1. Open `/browser` in Officer
|
||||
2. Your attached tab appears in the left panel
|
||||
3. Click it to see a live screenshot in the right panel
|
||||
4. Use the URL bar to navigate, or the JS console to evaluate expressions
|
||||
|
||||
## Troubleshooting
|
||||
- **Red `!` badge**: Relay not reachable — check server address/port/token
|
||||
- **`…` badge**: Reconnecting — relay was temporarily unavailable
|
||||
- **Tab not appearing**: Make sure the extension badge shows ON, and refresh the dashboard
|
||||
- **Screenshot blank**: The tab may be on a `chrome://` page (not supported by CDP)
|
||||
@@ -0,0 +1,51 @@
|
||||
// Based on OpenClaw Browser Extension Relay (MIT License)
|
||||
// https://github.com/nicepkg/openclaw
|
||||
|
||||
export function reconnectDelayMs(
|
||||
attempt,
|
||||
opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random },
|
||||
) {
|
||||
const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000;
|
||||
const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000;
|
||||
const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000;
|
||||
const random = typeof opts.random === "function" ? opts.random : Math.random;
|
||||
const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0);
|
||||
const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs);
|
||||
return backoff + Math.max(0, jitterMs) * random();
|
||||
}
|
||||
|
||||
export async function deriveRelayToken(gatewayToken, port) {
|
||||
const enc = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(gatewayToken),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
enc.encode(`officer-browser-relay-v1:${port}`),
|
||||
);
|
||||
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function buildRelayWsUrl(host, port, gatewayToken) {
|
||||
const token = String(gatewayToken || "").trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Missing relay token in extension settings (chrome.storage.local.relayToken)",
|
||||
);
|
||||
}
|
||||
// Token is already derived server-side — use it directly
|
||||
return `ws://${host}:${port}/extension?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function isRetryableReconnectError(err) {
|
||||
const message = err instanceof Error ? err.message : String(err || "");
|
||||
if (message.includes("Missing relay token")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
// 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
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 614 B |
Binary file not shown.
|
After Width: | Height: | Size: 265 B |
Binary file not shown.
|
After Width: | Height: | Size: 318 B |
Binary file not shown.
|
After Width: | Height: | Size: 370 B |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Officer Browser Relay",
|
||||
"version": "0.1.0",
|
||||
"description": "Connect your Chrome browser to Officer",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": { "service_worker": "background.js", "type": "module" },
|
||||
"action": {
|
||||
"default_title": "Officer Browser Relay (click to attach/detach)",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"options_ui": { "page": "options.html", "open_in_tab": true }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Based on OpenClaw Browser Extension Relay (MIT License)
|
||||
// https://github.com/nicepkg/openclaw
|
||||
|
||||
function hasCdpVersionShape(data) {
|
||||
return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data
|
||||
}
|
||||
|
||||
export function classifyRelayCheckResponse(res, host, port) {
|
||||
if (!res) {
|
||||
return { action: 'throw', error: 'No response from service worker' }
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return { action: 'status', kind: 'error', message: 'Relay token rejected. Check token and save again.' }
|
||||
}
|
||||
|
||||
if (res.error) {
|
||||
return { action: 'throw', error: res.error }
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return { action: 'throw', error: `HTTP ${res.status}` }
|
||||
}
|
||||
|
||||
const contentType = String(res.contentType || '')
|
||||
if (!contentType.includes('application/json')) {
|
||||
return {
|
||||
action: 'status',
|
||||
kind: 'error',
|
||||
message: `Wrong endpoint: expected Officer browser relay at this address.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCdpVersionShape(res.json)) {
|
||||
const browser = res.json?.Browser ?? ''
|
||||
if (browser && !browser.startsWith('Officer/')) {
|
||||
return {
|
||||
action: 'status',
|
||||
kind: 'error',
|
||||
message: `Wrong relay: found "${browser}" instead of Officer/browser-relay.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
action: 'status',
|
||||
kind: 'error',
|
||||
message: `Wrong endpoint: expected relay /json/version response.`,
|
||||
}
|
||||
}
|
||||
|
||||
return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://${host}:${port}/` }
|
||||
}
|
||||
|
||||
export function classifyRelayCheckException(err, host, port) {
|
||||
const message = String(err || '').toLowerCase()
|
||||
if (message.includes('json') || message.includes('syntax')) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Wrong endpoint: this is not a relay endpoint.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Relay not reachable at http://${host}:${port}/. Verify the server address and relay token.`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Officer Browser Relay</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--accent: #06b6d4;
|
||||
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||
--border: color-mix(in oklab, canvasText 18%, transparent);
|
||||
--muted: color-mix(in oklab, canvasText 70%, transparent);
|
||||
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
|
||||
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
|
||||
"SF Pro Display", "Segoe UI", sans-serif;
|
||||
line-height: 1.4;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
|
||||
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
|
||||
canvas;
|
||||
color: canvasText;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 820px;
|
||||
margin: 36px auto;
|
||||
padding: 0 24px 48px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in oklab, var(--accent) 18%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 2px 0 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.card p {
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||
color: canvasText;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
|
||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
|
||||
}
|
||||
input.host-input {
|
||||
width: 240px;
|
||||
}
|
||||
input.port-input {
|
||||
width: 100px;
|
||||
}
|
||||
input.token-input {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
button {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--accent) 80%, white 20%),
|
||||
var(--accent)
|
||||
);
|
||||
color: white;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
a {
|
||||
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
|
||||
min-height: 16px;
|
||||
}
|
||||
.status[data-kind='ok'] {
|
||||
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
|
||||
}
|
||||
.status[data-kind='error'] {
|
||||
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo" aria-hidden="true">🌐</div>
|
||||
<div>
|
||||
<h1>Officer Browser Relay</h1>
|
||||
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Getting started</h2>
|
||||
<p>
|
||||
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
|
||||
Make sure Officer is running and enter the correct server address, port, and relay token below.
|
||||
</p>
|
||||
<p>
|
||||
You can find your relay token in the Officer dashboard under <strong>Browser</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Relay connection</h2>
|
||||
|
||||
<label for="host">Server address</label>
|
||||
<div class="row">
|
||||
<input id="host" class="host-input" placeholder="e.g. 100.64.0.1" />
|
||||
</div>
|
||||
|
||||
<label for="port" style="margin-top: 10px">Port</label>
|
||||
<div class="row">
|
||||
<input id="port" class="port-input" inputmode="numeric" pattern="[0-9]*" />
|
||||
</div>
|
||||
|
||||
<label for="token" style="margin-top: 10px">Relay token</label>
|
||||
<div class="row">
|
||||
<input id="token" class="token-input" type="password" autocomplete="off" />
|
||||
<button id="save" type="button">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
Default port: <code>18792</code>. Extension connects to: <code id="relay-url">http://<host>:<port>/</code>.
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="options.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
|
||||
|
||||
const DEFAULT_PORT = 18792
|
||||
const DEFAULT_HOST = '127.0.0.1'
|
||||
|
||||
function clampPort(value) {
|
||||
const n = Number.parseInt(String(value || ''), 10)
|
||||
if (!Number.isFinite(n)) return DEFAULT_PORT
|
||||
if (n <= 0 || n > 65535) return DEFAULT_PORT
|
||||
return n
|
||||
}
|
||||
|
||||
function updateRelayUrl(host, port) {
|
||||
const el = document.getElementById('relay-url')
|
||||
if (!el) return
|
||||
el.textContent = `http://${host}:${port}/`
|
||||
}
|
||||
|
||||
function setStatus(kind, message) {
|
||||
const status = document.getElementById('status')
|
||||
if (!status) return
|
||||
status.dataset.kind = kind || ''
|
||||
status.textContent = message || ''
|
||||
}
|
||||
|
||||
async function checkRelayReachable(host, port, token) {
|
||||
const url = `http://${host}:${port}/json/version`
|
||||
const trimmedToken = String(token || '').trim()
|
||||
if (!trimmedToken) {
|
||||
setStatus('error', 'Relay token required. Save your token to connect.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Token is already derived server-side — use it directly
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: 'relayCheck',
|
||||
url,
|
||||
token: trimmedToken,
|
||||
})
|
||||
const result = classifyRelayCheckResponse(res, host, port)
|
||||
if (result.action === 'throw') throw new Error(result.error)
|
||||
setStatus(result.kind, result.message)
|
||||
} catch (err) {
|
||||
const result = classifyRelayCheckException(err, host, port)
|
||||
setStatus(result.kind, result.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const stored = await chrome.storage.local.get(['relayPort', 'relayHost', 'relayToken'])
|
||||
const port = clampPort(stored.relayPort)
|
||||
const host = String(stored.relayHost || '').trim() || DEFAULT_HOST
|
||||
const token = String(stored.relayToken || '').trim()
|
||||
document.getElementById('host').value = host
|
||||
document.getElementById('port').value = String(port)
|
||||
document.getElementById('token').value = token
|
||||
updateRelayUrl(host, port)
|
||||
await checkRelayReachable(host, port, token)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const hostInput = document.getElementById('host')
|
||||
const portInput = document.getElementById('port')
|
||||
const tokenInput = document.getElementById('token')
|
||||
const host = String(hostInput.value || '').trim() || DEFAULT_HOST
|
||||
const port = clampPort(portInput.value)
|
||||
const token = String(tokenInput.value || '').trim()
|
||||
await chrome.storage.local.set({ relayHost: host, relayPort: port, relayToken: token })
|
||||
hostInput.value = host
|
||||
portInput.value = String(port)
|
||||
tokenInput.value = token
|
||||
updateRelayUrl(host, port)
|
||||
await checkRelayReachable(host, port, token)
|
||||
}
|
||||
|
||||
document.getElementById('save').addEventListener('click', () => void save())
|
||||
void load()
|
||||
Reference in New Issue
Block a user