@xnng/browser-relay 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +445 -0
  3. package/docs/README.zh-CN.md +421 -0
  4. package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
  5. package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
  6. package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
  7. package/docs/benchmarks/browser-readiness-cost.json +106 -0
  8. package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
  9. package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
  10. package/docs/benchmarks/browser-runtime-balanced.json +412 -0
  11. package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
  12. package/docs/benchmarks/browser-runtime.json +254 -0
  13. package/docs/benchmarks/browser-use-parity.json +54 -0
  14. package/docs/benchmarks/codex-extension-audit.json +131 -0
  15. package/docs/benchmarks/codex-native-protocol.md +69 -0
  16. package/docs/benchmarks/codex-native-replay.js +116 -0
  17. package/docs/benchmarks/codex-native-status.json +81 -0
  18. package/docs/benchmarks/codex-native.json +1003 -0
  19. package/docs/benchmarks/extension-sessions.png +0 -0
  20. package/docs/benchmarks/extension-tasks.png +0 -0
  21. package/docs/benchmarks/iframe-routing-regression.json +37 -0
  22. package/docs/browser-use-comparison.md +331 -0
  23. package/docs/browser-use-gap-audit.md +141 -0
  24. package/docs/browser-use-parity.md +105 -0
  25. package/docs/demo/intranet.html +103 -0
  26. package/docs/releases/v1.5.0.md +59 -0
  27. package/docs/releases/v1.5.1.md +16 -0
  28. package/docs/releases/v1.5.2.md +42 -0
  29. package/docs/releases/v1.5.3.md +33 -0
  30. package/docs/releases/v1.5.4.md +42 -0
  31. package/docs/releases/v1.6.0.md +16 -0
  32. package/docs/remote-control-hub.md +523 -0
  33. package/extension/activity.js +328 -0
  34. package/extension/automation.js +1839 -0
  35. package/extension/background.js +1854 -0
  36. package/extension/i18n.js +149 -0
  37. package/extension/icons/icon128.png +0 -0
  38. package/extension/icons/icon16.png +0 -0
  39. package/extension/icons/icon32.png +0 -0
  40. package/extension/icons/icon48.png +0 -0
  41. package/extension/manifest.json +47 -0
  42. package/extension/observations.js +109 -0
  43. package/extension/options.html +289 -0
  44. package/extension/options.js +269 -0
  45. package/extension/popup.html +74 -0
  46. package/extension/popup.js +105 -0
  47. package/extension/protocol.js +45 -0
  48. package/extension/remote-auth.js +18 -0
  49. package/extension/sessions.js +134 -0
  50. package/extension/snapshot.js +161 -0
  51. package/extension/task-groups.js +108 -0
  52. package/extension/tasks.js +186 -0
  53. package/extension/wait.js +89 -0
  54. package/hub/README.md +42 -0
  55. package/hub/package-lock.json +1544 -0
  56. package/hub/package.json +13 -0
  57. package/hub/src/rpc.js +41 -0
  58. package/hub/src/worker.js +322 -0
  59. package/hub/wrangler.example.toml +19 -0
  60. package/package.json +83 -0
  61. package/server/cdp-bridge.js +200 -0
  62. package/server/cli.js +1798 -0
  63. package/server/hub-server.js +258 -0
  64. package/server/install.js +250 -0
  65. package/server/mcp-server.js +504 -0
  66. package/server/npx-runner.js +96 -0
  67. package/server/relay-server.js +1356 -0
  68. package/server/remote-protocol.js +76 -0
  69. package/server/runtime-worker.js +166 -0
  70. package/server/script-runtime.js +189 -0
  71. package/server/sdk.js +307 -0
  72. package/server/service-state.js +103 -0
  73. package/server/snapshot.js +161 -0
  74. package/server/uninstall.js +61 -0
  75. package/server/windows-service-entry.js +58 -0
  76. package/server/windows-service.js +360 -0
  77. package/skills/browser-relay/SKILL.md +192 -0
  78. package/skills/browser-relay/references/legacy-api.md +163 -0
  79. package/skills/browser-relay/references/runtime.md +240 -0
@@ -0,0 +1,1854 @@
1
+ // Browser Relay Extension — Universal CDP agent bridge
2
+ // Core logic adapted from openclaw auto-attach fork, stripped of gateway handshake
3
+
4
+ import { createTaskGroups } from './task-groups.js'
5
+ import { SNAPSHOT_JS } from './snapshot.js'
6
+ import { createAutomation } from './automation.js'
7
+ import { isAutomationPath, PROTOCOL_VERSION, FEATURES } from './protocol.js'
8
+ import { buildWaitExpression, normalizeWaitOptions } from './wait.js'
9
+ import { createRemoteAuthMessageHandler } from './remote-auth.js'
10
+ import { createActivityTracker, createTabActivityRenderer, isUserCdpCommand } from './activity.js'
11
+
12
+ const DEFAULT_PORT = 18795
13
+ const executorInstanceId = crypto.randomUUID()
14
+ const executorInfo = () => ({extensionVersion:chrome.runtime.getManifest().version_name || chrome.runtime.getManifest().version, runtimeId:executorInstanceId, protocolVersion:PROTOCOL_VERSION, features:FEATURES})
15
+ const DEFAULT_REMOTE_HOST = 'https://relay.linso.ai'
16
+ // Soft-detach a tab's debugger after this many idle seconds so Chrome's
17
+ // "started debugging this browser" infobar disappears while inactive.
18
+ // 0 disables it (debugger stays attached, infobar always shown).
19
+ // Default 10 min: short enough to hide the bar when genuinely idle, long
20
+ // enough that it won't recycle a tab an agent is still working with.
21
+ const DEFAULT_IDLE_DETACH_SECONDS = 600
22
+ const IDLE_DETACH_DEFAULT_MIGRATION_KEY = 'idleDetachDefaultMigratedTo600'
23
+
24
+ const BADGE = {
25
+ on: { text: 'ON', color: '#22c55e' },
26
+ off: { text: '', color: '#000000' },
27
+ connecting: { text: '...', color: '#F59E0B' },
28
+ error: { text: '!', color: '#B91C1C' },
29
+ idle: { text: '·', color: '#6B7280' },
30
+ working: { text: '·', color: '#158c73' },
31
+ }
32
+
33
+ const activityRenderer = createTabActivityRenderer(chrome)
34
+ const tabActivity = createActivityTracker({
35
+ show: (tabId) => {
36
+ activityRenderer.start(tabId)
37
+ setBadge(tabId, 'working')
38
+ },
39
+ hide: (tabId) => {
40
+ activityRenderer.stop(tabId)
41
+ const tab = tabs.get(tabId)
42
+ if (tab?.state === 'connected' && !tab.idle)
43
+ setBadge(tabId, relayWs?.readyState === WebSocket.OPEN ? 'on' : 'connecting')
44
+ },
45
+ })
46
+ function beginTabActivity(tabId) {
47
+ const tab = tabs.get(tabId)
48
+ if (tab) tab.lastActivity = Date.now()
49
+ return tabActivity.begin(tabId)
50
+ }
51
+ function clearTabActivity(tabId) { tabActivity.clear(tabId) }
52
+ const legacyActivities = new Set()
53
+ function endLegacyActivities(transport) {
54
+ for (const activity of legacyActivities) {
55
+ if (activity.transport !== transport) continue
56
+ activity.finish(true)
57
+ legacyActivities.delete(activity)
58
+ }
59
+ }
60
+ async function withCdpActivity(tabId, method, run, transport = 'local') {
61
+ if (!isUserCdpCommand(method)) return run()
62
+ const finish = beginTabActivity(tabId)
63
+ const activity = { finish, transport }
64
+ legacyActivities.add(activity)
65
+ try { return await run() }
66
+ finally { legacyActivities.delete(activity); finish() }
67
+ }
68
+
69
+ /** @type {WebSocket|null} */
70
+ let relayWs = null
71
+ /** @type {Promise<void>|null} */
72
+ let relayConnectPromise = null
73
+ /** @type {WebSocket|null} */
74
+ let remoteWs = null
75
+ /** @type {Promise<void>|null} */
76
+ let remoteConnectPromise = null
77
+ let remoteReconnectTimer = null
78
+ let remoteConfig = null
79
+ let remoteConnectedAt = null
80
+ let remoteLastError = null
81
+ let nextSession = 1
82
+
83
+ /** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number, idle?:boolean, lastActivity?:number}>} */
84
+ const tabs = new Map()
85
+ /** @type {Map<string, number>} */
86
+ const tabBySession = new Map()
87
+ /** @type {Map<string, number>} */
88
+ const childSessionToTab = new Map()
89
+ const PUBLIC_TAB_ID_PATTERN = /^t_[A-Za-z0-9_-]{10}$/
90
+ const PUBLIC_TAB_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
91
+ /** @type {Map<number, string>} */
92
+ const publicTabIds = new Map()
93
+ /** IDs already issued in this browser session are never reused. */
94
+ const issuedPublicTabIds = new Set()
95
+
96
+ function createPublicTabId() {
97
+ let id
98
+ do {
99
+ const random = crypto.getRandomValues(new Uint8Array(10))
100
+ let token = ''
101
+ for (const byte of random) token += PUBLIC_TAB_ID_ALPHABET[byte & 63]
102
+ id = `t_${token}`
103
+ } while (issuedPublicTabIds.has(id))
104
+ issuedPublicTabIds.add(id)
105
+ return id
106
+ }
107
+
108
+ function publicTabIdFor(chromeTabId) {
109
+ let id = publicTabIds.get(chromeTabId)
110
+ if (!id) {
111
+ id = createPublicTabId()
112
+ publicTabIds.set(chromeTabId, id)
113
+ }
114
+ return id
115
+ }
116
+
117
+ /** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
118
+ const pending = new Map()
119
+
120
+ const tabOperationLocks = new Set()
121
+ const reattachPending = new Set()
122
+ // Tabs we are deliberately detaching for idleness — guards onDebuggerDetach
123
+ // from treating the self-initiated detach as a navigation/teardown event.
124
+ const idleDetaching = new Set()
125
+
126
+ let reconnectAttempt = 0
127
+ let reconnectTimer = null
128
+ let lastConnectError = null
129
+
130
+ async function getRelayPort() {
131
+ const stored = await chrome.storage.local.get(['relayPort'])
132
+ const n = Number.parseInt(String(stored.relayPort || ''), 10)
133
+ if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
134
+ return n
135
+ }
136
+
137
+ async function getIdleDetachMs() {
138
+ const stored = await chrome.storage.local.get(['idleDetachSeconds', IDLE_DETACH_DEFAULT_MIGRATION_KEY])
139
+ const raw = stored.idleDetachSeconds
140
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_IDLE_DETACH_SECONDS * 1000
141
+ const n = Number.parseInt(String(raw), 10)
142
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_IDLE_DETACH_SECONDS * 1000
143
+
144
+ if (n === 30 && !stored[IDLE_DETACH_DEFAULT_MIGRATION_KEY]) {
145
+ await chrome.storage.local.set({
146
+ idleDetachSeconds: DEFAULT_IDLE_DETACH_SECONDS,
147
+ [IDLE_DETACH_DEFAULT_MIGRATION_KEY]: true,
148
+ })
149
+ return DEFAULT_IDLE_DETACH_SECONDS * 1000
150
+ }
151
+
152
+ return n * 1000 // 0 => idle-detach disabled
153
+ }
154
+
155
+ function setBadge(tabId, kind) {
156
+ if (kind === 'on' && tabActivity.active(tabId)) kind = 'working'
157
+ const cfg = BADGE[kind]
158
+ void chrome.action.setBadgeText({ tabId, text: cfg.text }).catch(() => {})
159
+ void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color }).catch(() => {})
160
+ void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
161
+ }
162
+
163
+ async function persistState() {
164
+ try {
165
+ const entries = []
166
+ for (const [tabId, tab] of tabs.entries()) {
167
+ if (tab.state === 'connected' && tab.sessionId && tab.targetId) {
168
+ entries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder, idle: !!tab.idle })
169
+ }
170
+ }
171
+ await chrome.storage.session.set({
172
+ persistedTabs: entries,
173
+ persistedPublicTabIds: [...publicTabIds.entries()],
174
+ issuedPublicTabIds: [...issuedPublicTabIds],
175
+ nextSession,
176
+ })
177
+ } catch {
178
+ // chrome.storage.session may not be available in all contexts
179
+ }
180
+ }
181
+
182
+ async function rehydrateState() {
183
+ try {
184
+ const stored = await chrome.storage.session.get(['persistedTabs', 'persistedPublicTabIds', 'issuedPublicTabIds', 'nextSession'])
185
+ if (stored.nextSession) nextSession = Math.max(nextSession, stored.nextSession)
186
+ for (const id of Array.isArray(stored.issuedPublicTabIds) ? stored.issuedPublicTabIds : []) {
187
+ if (PUBLIC_TAB_ID_PATTERN.test(id)) issuedPublicTabIds.add(id)
188
+ }
189
+ const restoredIds = new Set()
190
+ for (const pair of Array.isArray(stored.persistedPublicTabIds) ? stored.persistedPublicTabIds : []) {
191
+ if (!Array.isArray(pair) || pair.length !== 2) continue
192
+ const chromeTabId = Number(pair[0])
193
+ const id = pair[1]
194
+ if (!Number.isInteger(chromeTabId) || !PUBLIC_TAB_ID_PATTERN.test(id) || publicTabIds.has(chromeTabId) || restoredIds.has(id)) continue
195
+ publicTabIds.set(chromeTabId, id)
196
+ issuedPublicTabIds.add(id)
197
+ restoredIds.add(id)
198
+ }
199
+ const entries = stored.persistedTabs || []
200
+ for (const entry of entries) {
201
+ publicTabIdFor(entry.tabId)
202
+ tabs.set(entry.tabId, { state: 'connected', sessionId: entry.sessionId, targetId: entry.targetId, attachOrder: entry.attachOrder, idle: !!entry.idle, lastActivity: Date.now() })
203
+ tabBySession.set(entry.sessionId, entry.tabId)
204
+ setBadge(entry.tabId, entry.idle ? 'idle' : 'on')
205
+ }
206
+ for (const entry of entries) {
207
+ try {
208
+ await chrome.tabs.get(entry.tabId)
209
+ // Idle tabs are intentionally detached — don't probe (it would fail
210
+ // and wrongly evict them); they re-attach on the next command.
211
+ if (!entry.idle) {
212
+ await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', { expression: '1', returnByValue: true })
213
+ }
214
+ } catch {
215
+ tabs.delete(entry.tabId)
216
+ tabBySession.delete(entry.sessionId)
217
+ publicTabIds.delete(entry.tabId)
218
+ setBadge(entry.tabId, 'off')
219
+ }
220
+ }
221
+ for (const chromeTabId of publicTabIds.keys()) {
222
+ try { await chrome.tabs.get(chromeTabId) } catch { publicTabIds.delete(chromeTabId) }
223
+ }
224
+ } catch {
225
+ // Ignore rehydration errors
226
+ }
227
+ }
228
+
229
+ async function ensureRelayConnection() {
230
+ if (relayWs && relayWs.readyState === WebSocket.OPEN) return
231
+ if (relayConnectPromise) return await relayConnectPromise
232
+
233
+ relayConnectPromise = (async () => {
234
+ const port = await getRelayPort()
235
+ const httpBase = `http://127.0.0.1:${port}`
236
+ const wsUrl = `ws://127.0.0.1:${port}/extension`
237
+
238
+ let relayInfo = null
239
+ try {
240
+ const res = await fetch(`${httpBase}/api/debug`, { signal: AbortSignal.timeout(2000) })
241
+ relayInfo = await res.json().catch(() => null)
242
+ } catch (err) {
243
+ throw new Error(`Relay server not reachable at ${httpBase}`)
244
+ }
245
+
246
+ // The npm package ships the relay and this extension together: a version
247
+ // mismatch means the unpacked extension files on disk were updated by an
248
+ // npm upgrade while Chrome kept the old code in memory. Reload once per
249
+ // relay version to pick up the new files — the guard prevents a reload
250
+ // loop when the extension is loaded from elsewhere (e.g. a dev checkout).
251
+ const myVersion = chrome.runtime.getManifest().version_name || chrome.runtime.getManifest().version
252
+ if (relayInfo?.version && relayInfo.version !== myVersion) {
253
+ const stored = await chrome.storage.local.get('reloadedForRelayVersion')
254
+ if (stored.reloadedForRelayVersion !== relayInfo.version) {
255
+ await chrome.storage.local.set({ reloadedForRelayVersion: relayInfo.version })
256
+ chrome.runtime.reload()
257
+ throw new Error('Reloading extension to pick up new version')
258
+ }
259
+ }
260
+
261
+ const ws = new WebSocket(wsUrl)
262
+ relayWs = ws
263
+
264
+ ws.onmessage = (event) => {
265
+ if (ws !== relayWs) return
266
+ void whenReady(() => onRelayMessage(String(event.data || '')))
267
+ }
268
+
269
+ await new Promise((resolve, reject) => {
270
+ const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
271
+ ws.onopen = () => { clearTimeout(t); resolve() }
272
+ ws.onerror = () => { clearTimeout(t); reject(new Error('WebSocket connect failed')) }
273
+ ws.onclose = (ev) => { clearTimeout(t); reject(new Error(`WebSocket closed (${ev.code})`)) }
274
+ })
275
+
276
+ sendToRelay({method:'BrowserRelay.hello',params:executorInfo()})
277
+ ws.onclose = () => { if (ws !== relayWs) return; onRelayClosed('closed') }
278
+ ws.onerror = () => { if (ws !== relayWs) return; onRelayClosed('error') }
279
+ })()
280
+
281
+ try {
282
+ await relayConnectPromise
283
+ reconnectAttempt = 0
284
+ lastConnectError = null
285
+ } catch (err) {
286
+ lastConnectError = err instanceof Error ? err.message : String(err)
287
+ throw err
288
+ } finally {
289
+ relayConnectPromise = null
290
+ }
291
+ }
292
+
293
+ function onRelayClosed(reason) {
294
+ automation.disconnect("local")
295
+ endLegacyActivities('local')
296
+ relayWs = null
297
+
298
+ for (const [id, p] of pending.entries()) {
299
+ pending.delete(id)
300
+ p.reject(new Error(`Relay disconnected (${reason})`))
301
+ }
302
+ reattachPending.clear()
303
+
304
+ for (const [tabId, tab] of tabs.entries()) {
305
+ if (tab.state === 'connected' && !tab.idle) {
306
+ setBadge(tabId, 'connecting')
307
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay: reconnecting...' })
308
+ }
309
+ }
310
+ scheduleReconnect()
311
+ }
312
+
313
+ function scheduleReconnect() {
314
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
315
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 30000)
316
+ reconnectAttempt++
317
+ console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${delay}ms`)
318
+ reconnectTimer = setTimeout(async () => {
319
+ reconnectTimer = null
320
+ try {
321
+ await ensureRelayConnection()
322
+ reconnectAttempt = 0
323
+ console.log('Reconnected successfully')
324
+ await recoverRelaySession()
325
+ } catch (err) {
326
+ const message = err instanceof Error ? err.message : String(err)
327
+ console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`)
328
+ if (message.includes('not reachable')) {
329
+ scheduleReconnect()
330
+ }
331
+ }
332
+ }, delay)
333
+ }
334
+
335
+ function cancelReconnect() {
336
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
337
+ reconnectAttempt = 0
338
+ }
339
+
340
+ async function reannounceAttachedTabs() {
341
+ for (const [tabId, tab] of tabs.entries()) {
342
+ if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
343
+ if (tab.idle) {
344
+ // Wake briefly so the upstream relay learns this session still exists;
345
+ // the next idle sweep will soft-detach it again.
346
+ try { await wakeTab(tabId) } catch { /* fall through to probe/evict */ }
347
+ }
348
+ try {
349
+ await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { expression: '1', returnByValue: true })
350
+ } catch {
351
+ tabs.delete(tabId)
352
+ if (tab.sessionId) tabBySession.delete(tab.sessionId)
353
+ setBadge(tabId, 'off')
354
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay' })
355
+ continue
356
+ }
357
+ try {
358
+ const info = await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
359
+ sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.attachedToTarget', params: { sessionId: tab.sessionId, tabId: publicTabIdFor(tabId), targetInfo: { ...info?.targetInfo, attached: true }, waitingForDebugger: false } } })
360
+ setBadge(tabId, 'on')
361
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay: attached (click to detach)' })
362
+ } catch { setBadge(tabId, 'on') }
363
+ }
364
+ await persistState()
365
+ }
366
+
367
+ async function recoverRelaySession() {
368
+ // The relay drops all session state when a new extension socket connects —
369
+ // re-announce existing tabs or they vanish from the relay's tab list.
370
+ await reannounceAttachedTabs()
371
+ await autoAttachAllTabs()
372
+ }
373
+
374
+ function sendToRelay(payload) {
375
+ const ws = relayWs
376
+ if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error('Relay not connected')
377
+ ws.send(JSON.stringify(payload))
378
+ }
379
+
380
+ function requestFromRelay(command) {
381
+ const id = command.id
382
+ return new Promise((resolve, reject) => {
383
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error('Relay request timeout (30s)')) }, 30000)
384
+ pending.set(id, {
385
+ resolve: (v) => { clearTimeout(timer); resolve(v) },
386
+ reject: (e) => { clearTimeout(timer); reject(e) },
387
+ })
388
+ try { sendToRelay(command) }
389
+ catch (err) { clearTimeout(timer); pending.delete(id); reject(err instanceof Error ? err : new Error(String(err))) }
390
+ })
391
+ }
392
+
393
+ const DOWNLOAD_CONFLICT_ACTIONS = new Set(['uniquify', 'overwrite', 'prompt'])
394
+
395
+ function downloadOptionsFromParams(params = {}) {
396
+ const url = typeof params.url === 'string' ? params.url.trim() : ''
397
+ if (!url) throw new Error('url is required')
398
+
399
+ const options = { url }
400
+ if (typeof params.filename === 'string' && params.filename.trim()) {
401
+ options.filename = params.filename
402
+ }
403
+ if (params.saveAs === true) {
404
+ options.saveAs = true
405
+ }
406
+ if (typeof params.conflictAction === 'string' && params.conflictAction.trim()) {
407
+ if (!DOWNLOAD_CONFLICT_ACTIONS.has(params.conflictAction)) {
408
+ throw new Error('conflictAction must be uniquify, overwrite, or prompt')
409
+ }
410
+ options.conflictAction = params.conflictAction
411
+ }
412
+ return options
413
+ }
414
+
415
+ async function startBrowserDownload(params = {}) {
416
+ if (!chrome.downloads?.download) {
417
+ throw new Error('chrome.downloads API unavailable. Reload the extension after granting downloads permission.')
418
+ }
419
+ const options = downloadOptionsFromParams(params)
420
+ const id = await chrome.downloads.download(options)
421
+ return { id, options }
422
+ }
423
+
424
+ async function searchBrowserDownloads(params = {}) {
425
+ if (!chrome.downloads?.search) {
426
+ throw new Error('chrome.downloads API unavailable. Reload the extension after granting downloads permission.')
427
+ }
428
+
429
+ const query = {}
430
+ const id = Number(params.id)
431
+ if (Number.isInteger(id) && id > 0) query.id = id
432
+ if (typeof params.state === 'string' && params.state.trim()) query.state = params.state
433
+ if (typeof params.url === 'string' && params.url.trim()) query.url = params.url
434
+ if (typeof params.filename === 'string' && params.filename.trim()) query.filename = params.filename
435
+ if (typeof params.query === 'string' && params.query.trim()) query.query = [params.query]
436
+
437
+ const limit = Number(params.limit)
438
+ if (Number.isInteger(limit) && limit > 0) query.limit = Math.min(limit, 1000)
439
+
440
+ const downloads = await chrome.downloads.search(query)
441
+ return { downloads }
442
+ }
443
+
444
+ function forwardDownloadEvent(method, params) {
445
+ try { sendToRelay({ method: 'forwardCDPEvent', params: { method, params } }) } catch { /* Relay may be down */ }
446
+ }
447
+
448
+ async function onRelayMessage(text) {
449
+ let msg
450
+ try { msg = JSON.parse(text) } catch { return }
451
+
452
+ if (msg?.method === 'ping') {
453
+ try { sendToRelay({ method: 'pong' }) } catch { /* ignore */ }
454
+ return
455
+ }
456
+
457
+ if (typeof msg?.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
458
+ const p = pending.get(msg.id)
459
+ if (!p) return
460
+ pending.delete(msg.id)
461
+ if (msg.error) p.reject(new Error(String(msg.error)))
462
+ else p.resolve(msg.result)
463
+ return
464
+ }
465
+
466
+ if (typeof msg?.id === 'number' && msg.method === 'forwardCDPCommand') {
467
+ try {
468
+ const result = await handleForwardCdpCommand(msg)
469
+ sendToRelay({ id: msg.id, result })
470
+ } catch (err) {
471
+ sendToRelay({ id: msg.id, error: {message:err instanceof Error ? err.message : String(err),code:err.code,status:err.status} })
472
+ }
473
+ }
474
+ }
475
+
476
+ function getTabBySessionId(sessionId) {
477
+ const direct = tabBySession.get(sessionId)
478
+ if (direct) return { tabId: direct, kind: 'main' }
479
+ const child = childSessionToTab.get(sessionId)
480
+ if (child) return { tabId: child, kind: 'child' }
481
+ return null
482
+ }
483
+
484
+ function getTabByTargetId(targetId) {
485
+ for (const [tabId, tab] of tabs.entries()) { if (tab.targetId === targetId) return tabId }
486
+ return null
487
+ }
488
+
489
+ const attachPromises = new Map()
490
+ async function attachTab(tabId, opts = {}) {
491
+ const current = tabs.get(tabId)
492
+ if (current?.state === 'connected') {
493
+ if (current.idle) await wakeTab(tabId)
494
+ return {sessionId:current.sessionId,targetId:current.targetId,tabId:publicTabIdFor(tabId)}
495
+ }
496
+ if (attachPromises.has(tabId)) return attachPromises.get(tabId)
497
+ const pendingAttach = attachTabNow(tabId,opts)
498
+ attachPromises.set(tabId,pendingAttach)
499
+ try {return await pendingAttach} finally {attachPromises.delete(tabId)}
500
+ }
501
+
502
+ async function attachTabNow(tabId, opts = {}) {
503
+ const debuggee = { tabId }
504
+ await chrome.debugger.attach(debuggee, '1.3')
505
+ await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
506
+
507
+ const info = await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')
508
+ const targetInfo = info?.targetInfo
509
+ const targetId = String(targetInfo?.targetId || '').trim()
510
+ if (!targetId) throw new Error('Target.getTargetInfo returned no targetId')
511
+ await chrome.tabs.get(tabId) // Do not publish an id for a tab closed mid-attach.
512
+
513
+ const sid = nextSession++
514
+ const sessionId = `br-tab-${sid}`
515
+ const publicTabId = publicTabIdFor(tabId)
516
+
517
+ tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder: sid, idle: false, lastActivity: Date.now() })
518
+ tabBySession.set(sessionId, tabId)
519
+ // Fresh attach resets CDP domains — re-enable console/network capture on demand.
520
+ consoleCaptureTabs.delete(tabId)
521
+ networkCaptureTabs.delete(tabId)
522
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay: attached (click to detach)' })
523
+
524
+ if (!opts.skipAttachedEvent) {
525
+ // Best-effort notify the local daemon. Remote (External Control) attaches
526
+ // tabs the same way but has no daemon, so a closed relay must not fail attach.
527
+ try {
528
+ sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.attachedToTarget', params: { sessionId, tabId: publicTabId, targetInfo: { ...targetInfo, attached: true }, waitingForDebugger: false } } })
529
+ } catch { /* local relay down — fine for remote mode */ }
530
+ }
531
+
532
+ setBadge(tabId, 'on')
533
+ await persistState()
534
+ return { sessionId, targetId, tabId: publicTabId }
535
+ }
536
+
537
+ async function detachTab(tabId, reason) {
538
+ const tab = tabs.get(tabId)
539
+
540
+ for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
541
+ if (parentTabId === tabId) {
542
+ try { sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.detachedFromTarget', params: { sessionId: childSessionId, reason: 'parent_detached' } } }) } catch { /* ignore */ }
543
+ childSessionToTab.delete(childSessionId)
544
+ }
545
+ }
546
+
547
+ if (tab?.sessionId && tab?.targetId) {
548
+ try { sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.detachedFromTarget', params: { sessionId: tab.sessionId, targetId: tab.targetId, reason } } }) } catch { /* ignore */ }
549
+ }
550
+
551
+ if (tab?.sessionId) tabBySession.delete(tab.sessionId)
552
+ tabs.delete(tabId)
553
+ idleDetaching.delete(tabId)
554
+ clearTabActivity(tabId)
555
+
556
+ try { await chrome.debugger.detach({ tabId }) } catch { /* may already be detached */ }
557
+
558
+ setBadge(tabId, 'off')
559
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay (click to connect)' })
560
+ await persistState()
561
+ }
562
+
563
+ // Re-attach a tab that was soft-detached for idleness. Reuses the same
564
+ // br-tab sessionId so the upstream agent's session reference stays valid,
565
+ // and re-announces it (like reannounceAttachedTabs) so the relay refreshes
566
+ // its targetId mapping — the page may have navigated to a new target while
567
+ // the debugger was detached.
568
+ async function wakeTab(tabId) {
569
+ const tab = tabs.get(tabId)
570
+ if (!tab || !tab.idle) return
571
+ idleDetaching.delete(tabId)
572
+ try {
573
+ await chrome.debugger.attach({ tabId }, '1.3')
574
+ } catch (err) {
575
+ // attach() throws the same "Another debugger is already attached" message
576
+ // whether DevTools/another extension owns the tab or a stale session of
577
+ // ours survived — the text can't tell them apart. Probe instead: only
578
+ // treat the tab as awake if a command actually works. Otherwise stay
579
+ // idle (don't flip to connected) so the next command retries wakeTab
580
+ // once the blocking debugger goes away.
581
+ let alive = false
582
+ try {
583
+ await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { expression: '1', returnByValue: true })
584
+ alive = true
585
+ } catch { /* no usable debug session */ }
586
+ if (!alive) {
587
+ setBadge(tabId, 'error')
588
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay: tab busy — close DevTools/other debugger' })
589
+ throw err instanceof Error ? err : new Error(String(err))
590
+ }
591
+ }
592
+ await chrome.debugger.sendCommand({ tabId }, 'Page.enable').catch(() => {})
593
+ try {
594
+ const info = await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
595
+ const tid = String(info?.targetInfo?.targetId || '').trim()
596
+ if (tid) tab.targetId = tid
597
+ // Refresh the relay's sessionId->targetId map; targetId may have changed
598
+ // if the page navigated while we were detached.
599
+ try { sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.attachedToTarget', params: { sessionId: tab.sessionId, tabId: publicTabIdFor(tabId), targetInfo: { ...info?.targetInfo, attached: true }, waitingForDebugger: false } } }) } catch { /* relay may be down */ }
600
+ } catch { /* keep previous targetId */ }
601
+ tab.idle = false
602
+ tab.lastActivity = Date.now()
603
+ setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
604
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay: attached (click to detach)' })
605
+ await persistState()
606
+ }
607
+
608
+ // Detach the debugger from tabs idle longer than the configured timeout.
609
+ // This only drops the Chrome-level debugger (hiding the infobar); the logical
610
+ // relay session is kept so the next command transparently re-attaches.
611
+ async function softDetachIdleTabs() {
612
+ const idleMs = await getIdleDetachMs()
613
+ if (idleMs <= 0) return
614
+ const now = Date.now()
615
+ let changed = false
616
+ for (const [tabId, tab] of tabs.entries()) {
617
+ if (tab.state !== 'connected' || tab.idle) continue
618
+ if (tabOperationLocks.has(tabId) || reattachPending.has(tabId)) continue
619
+ if (!tab.lastActivity || now - tab.lastActivity < idleMs) continue
620
+ tab.idle = true
621
+ idleDetaching.add(tabId)
622
+ // chrome.debugger.detach destroys all of Chrome's real flat-mode child
623
+ // sessions (OOPIFs/related pages). Their sessionIds are now dead, so tell
624
+ // the relay to drop them and forget the mappings — otherwise a later
625
+ // command routed to a stale child session fails with CDP -32001
626
+ // "Session with given id not found". The main br-tab session is kept
627
+ // (it is addressed by tabId and survives the wake).
628
+ for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
629
+ if (parentTabId !== tabId) continue
630
+ try { sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.detachedFromTarget', params: { sessionId: childSessionId, reason: 'parent_detached' } } }) } catch { /* relay may be down */ }
631
+ childSessionToTab.delete(childSessionId)
632
+ }
633
+ try { await chrome.debugger.detach({ tabId }) } catch { /* may already be detached */ }
634
+ setBadge(tabId, 'idle')
635
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay: idle (re-attaches on next command)' })
636
+ changed = true
637
+ }
638
+ if (changed) await persistState()
639
+ }
640
+
641
+ function isAttachableUrl(url) {
642
+ if (!url) return false
643
+ if (url.startsWith('chrome://')) return false
644
+ if (url.startsWith('chrome-extension://')) return false
645
+ if (url.startsWith('devtools://')) return false
646
+ return true
647
+ }
648
+
649
+ async function autoAttachAllTabs() {
650
+ if (!relayWs || relayWs.readyState !== WebSocket.OPEN) return
651
+ const allTabs = await chrome.tabs.query({})
652
+ for (const tab of allTabs) {
653
+ const tabId = tab.id
654
+ if (!tabId) continue
655
+ if (tabs.has(tabId)) continue
656
+ if (!isAttachableUrl(tab.url)) continue
657
+ if (tabOperationLocks.has(tabId)) continue
658
+ if (reattachPending.has(tabId)) continue
659
+
660
+ tabOperationLocks.add(tabId)
661
+ try { await attachTab(tabId) } catch (err) {
662
+ console.warn(`Auto-attach tab ${tabId} failed: ${err instanceof Error ? err.message : String(err)}`)
663
+ } finally { tabOperationLocks.delete(tabId) }
664
+ }
665
+ }
666
+
667
+ async function connectOrToggle() {
668
+ cancelReconnect()
669
+ try {
670
+ await ensureRelayConnection()
671
+ await recoverRelaySession()
672
+ } catch (err) {
673
+ const message = err instanceof Error ? err.message : String(err)
674
+ console.warn('Connect failed:', message)
675
+ // Popup surfaces lastConnectError + install hint; no need to open options.
676
+ }
677
+ }
678
+
679
+ async function handleForwardCdpCommand(msg) {
680
+ const method = String(msg?.params?.method || '').trim()
681
+ const params = msg?.params?.params || undefined
682
+ const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
683
+
684
+ if (method === 'BrowserRelay.automation') {
685
+ try { return await automation.request(params.method, params.path, params.body) }
686
+ catch (error) { return { ok:false, code:error.code || 'automation_failed', message:error.message, error:error.message, status:error.status || 500 } }
687
+ }
688
+
689
+ if (method === 'BrowserRelay.download') return await startBrowserDownload(params)
690
+ if (method === 'BrowserRelay.searchDownloads') return await searchBrowserDownloads(params)
691
+
692
+ // Browser metadata is independent of any tab lease. Do not evaluate page JS
693
+ // in an arbitrary tab just to let a CDP client negotiate its connection.
694
+ if (method === 'Browser.getVersion') return {
695
+ protocolVersion: '1.3',
696
+ product: navigator.userAgent.match(/(?:HeadlessChrome|Chrome)\/[\d.]+/)?.[0] || 'Chrome/0.0.0.0',
697
+ revision: 'browser-relay', userAgent: navigator.userAgent, jsVersion: 'V8',
698
+ }
699
+
700
+ const bySession = sessionId ? getTabBySessionId(sessionId) : null
701
+ const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
702
+
703
+ // A command already routed to a closed tab must fail, never fall through to
704
+ // another tab that the user may be actively using.
705
+ if (sessionId && !bySession) throw new Error(`No attached tab for session ${sessionId}`)
706
+
707
+ // Target.createTarget spins up its own fresh tab, so it needs no existing
708
+ // attached tab. Handle it before the guard below so it works from a cold start
709
+ // (zero attached tabs) — otherwise the relay could never open the first tab.
710
+ if (method === 'Target.createTarget') {
711
+ const url = typeof params?.url === 'string' ? params.url : 'about:blank'
712
+ const tab = await chrome.tabs.create({ url, active: false })
713
+ if (!tab.id) throw new Error('Failed to create tab')
714
+ await new Promise((r) => setTimeout(r, 100))
715
+ const attached = await attachTab(tab.id)
716
+ return { targetId: attached.targetId, tabId: attached.tabId }
717
+ }
718
+
719
+ const tabId = bySession?.tabId || (targetId ? getTabByTargetId(targetId) : null) || (() => { for (const [id, tab] of tabs.entries()) { if (tab.state === 'connected') return id } return null })()
720
+
721
+ if (!tabId) throw new Error(`No attached tab for method ${method}`)
722
+ automation.sessions.check(tabId, undefined)
723
+
724
+ const activeTab = tabs.get(tabId)
725
+ if (activeTab) {
726
+ activeTab.lastActivity = Date.now()
727
+ }
728
+ // closeTarget/activateTarget use the tabs API and need no debugger — everything
729
+ // else must wake an idle tab first.
730
+ const noDebuggerMethods = method === 'Target.closeTarget' || method === 'Target.activateTarget'
731
+ if (activeTab?.idle && !noDebuggerMethods) {
732
+ await wakeTab(tabId)
733
+ }
734
+
735
+ const debuggee = { tabId }
736
+
737
+ if (method === 'Runtime.enable') {
738
+ try { await chrome.debugger.sendCommand(debuggee, 'Runtime.disable'); await new Promise((r) => setTimeout(r, 50)) } catch { /* ignore */ }
739
+ return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
740
+ }
741
+
742
+ if (method === 'Target.closeTarget') {
743
+ const target = typeof params?.targetId === 'string' ? params.targetId : ''
744
+ const toClose = target ? getTabByTargetId(target) : tabId
745
+ if (!toClose) return { success: false }
746
+ try { await chrome.tabs.remove(toClose) } catch { return { success: false } }
747
+ return { success: true }
748
+ }
749
+
750
+ if (method === 'Target.activateTarget') {
751
+ const target = typeof params?.targetId === 'string' ? params.targetId : ''
752
+ const toActivate = target ? getTabByTargetId(target) : tabId
753
+ if (!toActivate) return {}
754
+ const tab = await chrome.tabs.get(toActivate).catch(() => null)
755
+ if (!tab) return {}
756
+ if (tab.windowId) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
757
+ await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
758
+ return {}
759
+ }
760
+
761
+ const tabState = tabs.get(tabId)
762
+ const mainSessionId = tabState?.sessionId
763
+ const debuggerSession = sessionId && mainSessionId && sessionId !== mainSessionId ? { ...debuggee, sessionId } : debuggee
764
+ return await withCdpActivity(tabId, method, () => chrome.debugger.sendCommand(debuggerSession, method, params))
765
+ }
766
+
767
+ function onDebuggerEvent(source, method, params) {
768
+ const tabId = source.tabId
769
+ if (!tabId) return
770
+ if (method === 'Page.frameNavigated' && !params.frame?.parentId) {
771
+ automation.invalidate(tabId)
772
+ if (!source.sessionId) {
773
+ activityRenderer.navigated(tabId, tabActivity.running(tabId))
774
+ if (!tabActivity.running(tabId)) clearTabActivity(tabId)
775
+ }
776
+ }
777
+ const tab = tabs.get(tabId)
778
+ if (!tab?.sessionId) return
779
+
780
+ if (method === 'Target.attachedToTarget' && params?.sessionId) {
781
+ automation.attachChild(tabId, params)
782
+ childSessionToTab.set(String(params.sessionId), tabId)
783
+ }
784
+ if (method === 'Target.detachedFromTarget' && params?.sessionId) {
785
+ automation.detachChild(tabId, params.sessionId)
786
+ childSessionToTab.delete(String(params.sessionId))
787
+ }
788
+
789
+ // Buffer console/network events for remote /api/console and /api/network.
790
+ captureCdpEvent(tabId, method, params)
791
+
792
+ try {
793
+ sendToRelay({ method: 'forwardCDPEvent', params: { sessionId: source.sessionId || tab.sessionId, tabId: publicTabIdFor(tabId), method, params } })
794
+ } catch { /* Relay may be down */ }
795
+ }
796
+
797
+ async function onDebuggerDetach(source, reason) {
798
+ const tabId = source.tabId
799
+ if (!tabId) return
800
+ if (!tabs.has(tabId)) return
801
+
802
+ // We detached this tab ourselves for idleness — keep the logical session
803
+ // (same sessionId/targetId) so it transparently re-attaches on demand.
804
+ if (idleDetaching.has(tabId) || tabs.get(tabId)?.idle) return
805
+
806
+ if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') {
807
+ automation.close(tabId)
808
+ void detachTab(tabId, reason)
809
+ return
810
+ }
811
+
812
+ let tabInfo
813
+ try { tabInfo = await chrome.tabs.get(tabId) } catch {
814
+ void detachTab(tabId, reason)
815
+ return
816
+ }
817
+
818
+ if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) {
819
+ void detachTab(tabId, reason)
820
+ return
821
+ }
822
+
823
+ if (reattachPending.has(tabId)) return
824
+
825
+ const oldTab = tabs.get(tabId)
826
+ const oldSessionId = oldTab?.sessionId
827
+ const oldTargetId = oldTab?.targetId
828
+
829
+ if (oldSessionId) tabBySession.delete(oldSessionId)
830
+ tabs.delete(tabId)
831
+ for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
832
+ if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
833
+ }
834
+
835
+ if (oldSessionId && oldTargetId) {
836
+ try { sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.detachedFromTarget', params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' } } }) } catch { /* ignore */ }
837
+ }
838
+
839
+ reattachPending.add(tabId)
840
+ setBadge(tabId, 'connecting')
841
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay: re-attaching after navigation...' })
842
+
843
+ const delays = [300, 700, 1500]
844
+ for (let attempt = 0; attempt < delays.length; attempt++) {
845
+ await new Promise((r) => setTimeout(r, delays[attempt]))
846
+ if (!reattachPending.has(tabId)) return
847
+
848
+ try { await chrome.tabs.get(tabId) } catch {
849
+ reattachPending.delete(tabId)
850
+ setBadge(tabId, 'off')
851
+ return
852
+ }
853
+
854
+ if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
855
+ reattachPending.delete(tabId)
856
+ setBadge(tabId, 'error')
857
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay: relay disconnected during re-attach' })
858
+ return
859
+ }
860
+
861
+ try { await attachTab(tabId); reattachPending.delete(tabId); return } catch { /* continue */ }
862
+ }
863
+
864
+ reattachPending.delete(tabId)
865
+ setBadge(tabId, 'off')
866
+ void chrome.action.setTitle({ tabId, title: 'Browser Relay: re-attach failed (click to retry)' })
867
+ }
868
+
869
+ // Tab lifecycle listeners
870
+ chrome.webNavigation.onCreatedNavigationTarget.addListener(details => {
871
+ void whenReady(() => automation.adoptTab(details.tabId, details.sourceTabId))
872
+ .catch(error => console.warn('Could not group task popup:', error.message))
873
+ })
874
+ chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
875
+ automation.close(tabId)
876
+ reattachPending.delete(tabId)
877
+ idleDetaching.delete(tabId)
878
+ clearTabActivity(tabId)
879
+ const hadPublicTabId = publicTabIds.delete(tabId)
880
+ if (!tabs.has(tabId)) {
881
+ if (hadPublicTabId) void persistState()
882
+ return
883
+ }
884
+ const tab = tabs.get(tabId)
885
+ if (tab?.sessionId) tabBySession.delete(tab.sessionId)
886
+ tabs.delete(tabId)
887
+ for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
888
+ if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
889
+ }
890
+ if (tab?.sessionId && tab?.targetId) {
891
+ try { sendToRelay({ method: 'forwardCDPEvent', params: { method: 'Target.detachedFromTarget', params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' } } }) } catch { /* ignore */ }
892
+ }
893
+ void persistState()
894
+ }))
895
+
896
+ chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => {
897
+ clearTabActivity(removedTabId)
898
+ const publicTabId = publicTabIds.get(removedTabId)
899
+ publicTabIds.delete(removedTabId)
900
+ if (publicTabId) publicTabIds.set(addedTabId, publicTabId)
901
+ const tab = tabs.get(removedTabId)
902
+ if (!tab) {
903
+ if (publicTabId) void persistState()
904
+ return
905
+ }
906
+ tabs.delete(removedTabId)
907
+ tabs.set(addedTabId, tab)
908
+ if (tab.sessionId) tabBySession.set(tab.sessionId, addedTabId)
909
+ for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
910
+ if (parentTabId === removedTabId) childSessionToTab.set(childSessionId, addedTabId)
911
+ }
912
+ setBadge(addedTabId, 'on')
913
+ void persistState()
914
+ }))
915
+
916
+ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => void whenReady(async () => {
917
+ if (changeInfo.status !== 'complete') return
918
+ if (tabs.has(tabId)) return
919
+ if (!isAttachableUrl(tab.url)) return
920
+ if (tabOperationLocks.has(tabId)) return
921
+ if (reattachPending.has(tabId)) return
922
+ if (!relayWs || relayWs.readyState !== WebSocket.OPEN) return
923
+
924
+ tabOperationLocks.add(tabId)
925
+ try { await attachTab(tabId) } catch (err) {
926
+ console.warn(`Auto-attach tab ${tabId} on update failed: ${err instanceof Error ? err.message : String(err)}`)
927
+ } finally { tabOperationLocks.delete(tabId) }
928
+ }))
929
+
930
+ chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args)))
931
+ chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args)))
932
+
933
+ if (chrome.downloads) {
934
+ chrome.downloads.onCreated.addListener((item) => void whenReady(() => forwardDownloadEvent('BrowserRelay.downloadCreated', { item })))
935
+ chrome.downloads.onChanged.addListener((delta) => void whenReady(() => forwardDownloadEvent('BrowserRelay.downloadChanged', { delta })))
936
+ chrome.downloads.onErased.addListener((downloadId) => void whenReady(() => forwardDownloadEvent('BrowserRelay.downloadErased', { id: downloadId })))
937
+ }
938
+
939
+ chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggle()))
940
+
941
+ chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => {
942
+ if (frameId !== 0) return
943
+ const tab = tabs.get(tabId)
944
+ if (tab?.state === 'connected' && !tab.idle) {
945
+ setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
946
+ }
947
+ }))
948
+
949
+ chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => {
950
+ const tab = tabs.get(tabId)
951
+ if (tab?.state === 'connected' && !tab.idle) {
952
+ setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
953
+ }
954
+ }))
955
+
956
+ chrome.runtime.onInstalled.addListener(() => {
957
+ void chrome.runtime.openOptionsPage()
958
+ })
959
+
960
+ chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 })
961
+
962
+ chrome.alarms.onAlarm.addListener(async (alarm) => {
963
+ if (alarm.name !== 'relay-keepalive') return
964
+ automation.sessions.sweep()
965
+ await initPromise
966
+
967
+ for (const [tabId, tab] of tabs.entries()) {
968
+ if (tab.state === 'connected' && !tab.idle) {
969
+ setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
970
+ }
971
+ }
972
+
973
+ if (relayWs && relayWs.readyState === WebSocket.OPEN) {
974
+ await autoAttachAllTabs()
975
+ }
976
+
977
+ await softDetachIdleTabs()
978
+
979
+ if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
980
+ if (!relayConnectPromise && !reconnectTimer) {
981
+ console.log('Keepalive: WebSocket unhealthy, triggering reconnect')
982
+ try {
983
+ await ensureRelayConnection()
984
+ await recoverRelaySession()
985
+ } catch {
986
+ if (!reconnectTimer) scheduleReconnect()
987
+ }
988
+ }
989
+ }
990
+
991
+ // Keep the External Control hub connection alive across service-worker
992
+ // restarts: if enabled but not connected, reconnect on this 30s tick.
993
+ const remoteCfg = await getRemoteConfig()
994
+ if (remoteCfg && !remoteConnected() && !remoteConnectPromise) {
995
+ await ensureRemoteHubConnection().catch(() => {})
996
+ }
997
+ })
998
+
999
+ // ============================================================================
1000
+ // Remote hub (External Control): the extension connects OUT to the Cloudflare
1001
+ // hub over WSS and executes rpc.request frames itself, so a remote CLI can drive
1002
+ // this browser with no local port exposed. Local mode (relayWs) is untouched.
1003
+ // ============================================================================
1004
+
1005
+ const REMOTE_RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 15000]
1006
+ let remoteReconnectAttempt = 0
1007
+ let remoteAuthenticated = false
1008
+
1009
+ function remoteConnected() {
1010
+ return !!remoteWs && remoteWs.readyState === WebSocket.OPEN && remoteAuthenticated
1011
+ }
1012
+
1013
+ function remoteWsBase(host) {
1014
+ const h = String(host || DEFAULT_REMOTE_HOST).trim().replace(/\/+$/, '')
1015
+ if (h.startsWith('https://')) return `wss://${h.slice('https://'.length)}`
1016
+ if (h.startsWith('http://')) return `ws://${h.slice('http://'.length)}`
1017
+ if (h.startsWith('wss://') || h.startsWith('ws://')) return h
1018
+ return `wss://${h}`
1019
+ }
1020
+
1021
+ async function getRemoteConfig() {
1022
+ const s = await chrome.storage.local.get(['remoteControlEnabled', 'remoteHost', 'remoteRouteId', 'remoteSecret', 'remoteDeviceId'])
1023
+ if (!s.remoteControlEnabled || !s.remoteRouteId || !s.remoteSecret) return null
1024
+ return {
1025
+ remoteHost: s.remoteHost || DEFAULT_REMOTE_HOST,
1026
+ remoteRouteId: s.remoteRouteId,
1027
+ remoteSecret: s.remoteSecret,
1028
+ remoteDeviceId: s.remoteDeviceId,
1029
+ }
1030
+ }
1031
+
1032
+ function remoteStatusPayload() {
1033
+ return {
1034
+ enabled: !!remoteConfig,
1035
+ connected: remoteConnected(),
1036
+ deviceId: remoteConfig?.remoteDeviceId || null,
1037
+ connectedAt: remoteConnectedAt,
1038
+ lastError: remoteLastError,
1039
+ }
1040
+ }
1041
+
1042
+ function remoteHubUrl(config) {
1043
+ const base = remoteWsBase(config.remoteHost)
1044
+ return `${base}/v1/device/connect?routeId=${encodeURIComponent(config.remoteRouteId)}`
1045
+ }
1046
+
1047
+ async function ensureRemoteHubConnection() {
1048
+ const cfg = await getRemoteConfig()
1049
+ if (!cfg) { remoteConfig = null; return false }
1050
+ remoteConfig = cfg
1051
+ if (remoteConnected()) return true
1052
+ if (remoteConnectPromise) { try { await remoteConnectPromise } catch { /* fall through */ } return remoteConnected() }
1053
+
1054
+ remoteConnectPromise = (async () => {
1055
+ const ws = new WebSocket(remoteHubUrl(cfg))
1056
+ remoteWs = ws
1057
+ remoteAuthenticated = false
1058
+
1059
+ await new Promise((resolve, reject) => {
1060
+ const t = setTimeout(() => reject(new Error('Hub connect timeout')), 8000)
1061
+ const handleRemoteFrame = createRemoteAuthMessageHandler({
1062
+ onAuthenticated: () => {
1063
+ clearTimeout(t)
1064
+ remoteAuthenticated = true
1065
+ resolve()
1066
+ },
1067
+ onMessage: (text) => { void whenReady(() => handleHubMessage(text)) },
1068
+ })
1069
+ ws.onmessage = (event) => handleRemoteFrame(String(event.data || ''))
1070
+ ws.onopen = () => {
1071
+ ws.send(JSON.stringify({ type: 'device.auth', secret: cfg.remoteSecret }))
1072
+ }
1073
+ ws.onerror = () => { clearTimeout(t); reject(new Error('Hub connect failed')) }
1074
+ ws.onclose = (ev) => { clearTimeout(t); reject(new Error(`Hub closed (${ev.code})`)) }
1075
+ })
1076
+
1077
+ ws.send(JSON.stringify({
1078
+ type: 'device.hello',
1079
+ version: chrome.runtime.getManifest().version,
1080
+ routeId: cfg.remoteRouteId,
1081
+ deviceName: 'Browser Relay',
1082
+ capabilities: [...new Set(['eval','wait','snapshot','click','type','key','scroll','navigate','screenshot','console','network',...FEATURES])],
1083
+ executor: executorInfo(),
1084
+ }))
1085
+
1086
+ ws.onclose = () => { if (ws !== remoteWs) return; onRemoteHubClosed('closed') }
1087
+ ws.onerror = () => { if (ws !== remoteWs) return; onRemoteHubClosed('error') }
1088
+ })()
1089
+
1090
+ try {
1091
+ await remoteConnectPromise
1092
+ remoteReconnectAttempt = 0
1093
+ remoteConnectedAt = Date.now()
1094
+ remoteLastError = null
1095
+ return true
1096
+ } catch (err) {
1097
+ remoteLastError = err instanceof Error ? err.message : String(err)
1098
+ remoteAuthenticated = false
1099
+ try { remoteWs?.close() } catch { /* ignore */ }
1100
+ remoteWs = null
1101
+ scheduleRemoteReconnect()
1102
+ return false
1103
+ } finally {
1104
+ remoteConnectPromise = null
1105
+ }
1106
+ }
1107
+
1108
+ function onRemoteHubClosed(reason) {
1109
+ automation.disconnect('remote')
1110
+ endLegacyActivities('remote')
1111
+ remoteWs = null
1112
+ remoteAuthenticated = false
1113
+ remoteConnectedAt = null
1114
+ remoteLastError = `disconnected (${reason})`
1115
+ scheduleRemoteReconnect()
1116
+ }
1117
+
1118
+ function scheduleRemoteReconnect() {
1119
+ if (remoteReconnectTimer) return
1120
+ void getRemoteConfig().then((cfg) => {
1121
+ if (!cfg) return // disabled — stop reconnecting
1122
+ const delay = REMOTE_RECONNECT_DELAYS[Math.min(remoteReconnectAttempt, REMOTE_RECONNECT_DELAYS.length - 1)]
1123
+ remoteReconnectTimer = setTimeout(() => {
1124
+ remoteReconnectTimer = null
1125
+ remoteReconnectAttempt++
1126
+ void ensureRemoteHubConnection()
1127
+ }, delay)
1128
+ })
1129
+ }
1130
+
1131
+ function closeRemoteHub({ disable = false } = {}) {
1132
+ if (remoteReconnectTimer) { clearTimeout(remoteReconnectTimer); remoteReconnectTimer = null }
1133
+ remoteReconnectAttempt = 0
1134
+ const ws = remoteWs
1135
+ remoteWs = null
1136
+ remoteAuthenticated = false
1137
+ remoteConnectedAt = null
1138
+ if (ws) { try { ws.onclose = null; ws.onerror = null; ws.close() } catch { /* ignore */ } }
1139
+ if (disable) { remoteConfig = null; remoteLastError = null }
1140
+ }
1141
+
1142
+ // Execute one rpc.request from the hub, reply with rpc.response.
1143
+ async function handleHubMessage(text) {
1144
+ let msg
1145
+ try { msg = JSON.parse(text) } catch { return }
1146
+ if (msg?.type !== 'rpc.request') return
1147
+ let response
1148
+ try {
1149
+ const result = await executeRemoteApi(String(msg.method || 'GET'), String(msg.path || ''), msg.body)
1150
+ response = { type: 'rpc.response', id: msg.id, status: result.status || 200, headers: { 'content-type': 'application/json' }, body: result.body }
1151
+ } catch (err) {
1152
+ const message = err instanceof Error ? err.message : String(err)
1153
+ response = { type: 'rpc.response', id: msg.id, status: 500, headers: { 'content-type': 'application/json' }, body: { ok: false, code: 'remote_exec_failed', error: message, message } }
1154
+ }
1155
+ try { if (remoteConnected()) remoteWs.send(JSON.stringify(response)) } catch { /* hub gone */ }
1156
+ }
1157
+
1158
+ // ---- Remote command executor: run the CLI's /api/* semantics inside the
1159
+ // extension using chrome.debugger directly (no Node relay in the loop). ----
1160
+
1161
+ function apiError(code, message, status = 400, retryable = false, details) {
1162
+ return {
1163
+ status,
1164
+ body: {
1165
+ ok: false,
1166
+ code,
1167
+ error: message,
1168
+ message,
1169
+ status,
1170
+ retryable,
1171
+ ...(details === undefined ? {} : { details }),
1172
+ },
1173
+ }
1174
+ }
1175
+
1176
+ // Resolve the CLI's formal tabId to Chrome's internal numeric tab id. With no
1177
+ // param, preserve the existing active-tab behavior.
1178
+ async function resolveRemoteTabId(tabIdParam) {
1179
+ if (tabIdParam !== undefined && tabIdParam !== null && tabIdParam !== '') {
1180
+ const publicTabId = String(tabIdParam)
1181
+ if (!PUBLIC_TAB_ID_PATTERN.test(publicTabId)) throw new Error(`No tab matches ${publicTabId}`)
1182
+ for (const [chromeTabId, id] of publicTabIds.entries()) {
1183
+ if (id !== publicTabId) continue
1184
+ try { await chrome.tabs.get(chromeTabId); return chromeTabId } catch {
1185
+ publicTabIds.delete(chromeTabId)
1186
+ await persistState()
1187
+ break
1188
+ }
1189
+ }
1190
+ throw new Error(`No tab matches ${publicTabId}`)
1191
+ }
1192
+ const [active] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
1193
+ if (active && isAttachableUrl(active.url)) return active.id
1194
+ for (const t of await chrome.tabs.query({})) if (isAttachableUrl(t.url)) return t.id
1195
+ throw new Error('No controllable tab found')
1196
+ }
1197
+
1198
+ async function ensureRemoteAttached(tabId) {
1199
+ const tab = tabs.get(tabId)
1200
+ if (tab?.state === 'connected') {
1201
+ if (tab.idle) await wakeTab(tabId)
1202
+ return
1203
+ }
1204
+ await attachTab(tabId)
1205
+ }
1206
+
1207
+ // Run a CDP command against a chrome tab, attaching on demand.
1208
+ async function remoteCdp(tabId, method, params) {
1209
+ await ensureRemoteAttached(tabId)
1210
+ return await withCdpActivity(tabId, method, () => chrome.debugger.sendCommand({ tabId }, method, params || {}), 'remote')
1211
+ }
1212
+
1213
+ async function executeRemoteApi(method, path, body) {
1214
+ const u = new URL(String(path || '/'), 'http://relay.local')
1215
+ const p = u.pathname
1216
+ const payload = body && typeof body === 'object' ? body : {}
1217
+
1218
+ if (isAutomationPath(p)) {
1219
+ try { const result = await automation.request(method, path, payload, 'remote'); return {status:result.ok===false ? result.status || 400 : 200,body:result} }
1220
+ catch(error) { return apiError(error.code || 'automation_failed', error.message, error.status || 500) }
1221
+ }
1222
+
1223
+ if (method === 'GET' && p === '/api/tabs') return { status: 200, body: await apiListTabs() }
1224
+ if (!p.startsWith('/api/download')) automation.sessions.check(await resolveRemoteTabId(payload.tabId ?? u.searchParams.get('tabId')), undefined)
1225
+ if (method === 'POST' && p === '/api/eval') return { status: 200, body: await apiEval(payload, u.searchParams) }
1226
+ if (method === 'POST' && p === '/api/wait') return await apiWait(payload)
1227
+ if (method === 'POST' && p === '/api/navigate') return { status: 200, body: await apiNavigate(payload) }
1228
+ if (method === 'POST' && p === '/api/click') return { status: 200, body: await apiClick(payload) }
1229
+ if (method === 'POST' && p === '/api/type') return { status: 200, body: await apiType(payload) }
1230
+ if (method === 'POST' && p === '/api/key') return { status: 200, body: await apiKey(payload) }
1231
+ if (method === 'POST' && p === '/api/scroll') return { status: 200, body: await apiScroll(payload) }
1232
+ if (method === 'GET' && p === '/api/snapshot') return { status: 200, body: await apiSnapshot(payload, u.searchParams) }
1233
+ if ((method === 'GET' || method === 'POST') && p === '/api/screenshot') return { status: 200, body: await apiScreenshot(payload, u.searchParams) }
1234
+ if (method === 'GET' && p === '/api/console') return { status: 200, body: await apiConsole(payload, u.searchParams) }
1235
+ if (method === 'POST' && p === '/api/console/clear') return { status: 200, body: await apiConsoleClear(payload) }
1236
+ if (method === 'GET' && p === '/api/network') return { status: 200, body: await apiNetwork(payload, u.searchParams) }
1237
+ if (method === 'POST' && p === '/api/network/clear') return { status: 200, body: await apiNetworkClear(payload) }
1238
+
1239
+ return apiError('unknown_endpoint', `Unknown or not-yet-supported remote endpoint: ${method} ${p}`, 404)
1240
+ }
1241
+
1242
+ async function apiListTabs() {
1243
+ const list = []
1244
+ for (const t of await chrome.tabs.query({})) {
1245
+ if (!isAttachableUrl(t.url)) continue
1246
+ list.push({ id: publicTabIdFor(t.id), title: t.title || '', url: t.url || '', attached: tabs.get(t.id)?.state === 'connected' })
1247
+ }
1248
+ await persistState()
1249
+ return { ok: true, tabs: list }
1250
+ }
1251
+
1252
+ async function apiEval(body, searchParams) {
1253
+ const expression = String(body.expression ?? '')
1254
+ if (!expression) return apiError('validation_error', 'expression is required').body
1255
+ const tabId = await resolveRemoteTabId(body.tabId ?? searchParams.get('tabId'))
1256
+ const returnByValue = body.returnByValue !== false
1257
+ const result = await remoteCdp(tabId, 'Runtime.evaluate', { expression, returnByValue, awaitPromise: true })
1258
+ return { ok: true, result: result?.result || null, exceptionDetails: result?.exceptionDetails || null }
1259
+ }
1260
+
1261
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
1262
+
1263
+ function elementNotFound(selector) {
1264
+ return { ok: false, code: 'element_not_found', found: false, error: `Element not found: ${selector}`, message: `Element not found: ${selector}`, selector }
1265
+ }
1266
+
1267
+ async function evalValue(tabId, expression) {
1268
+ const r = await remoteCdp(tabId, 'Runtime.evaluate', { expression, returnByValue: true })
1269
+ return r?.result?.value
1270
+ }
1271
+
1272
+ async function apiWait(body) {
1273
+ const options = normalizeWaitOptions(body)
1274
+ if (!options.ok) return apiError('invalid_request', options.message, 400, false, { field: options.field })
1275
+
1276
+ let tabId
1277
+ try {
1278
+ tabId = await resolveRemoteTabId(options.tabId)
1279
+ } catch (error) {
1280
+ return apiError('tab_not_found', error instanceof Error ? error.message : String(error), 404)
1281
+ }
1282
+
1283
+ const expression = buildWaitExpression(options.selector, options.state)
1284
+ const startedAt = Date.now()
1285
+ let attempts = 0
1286
+
1287
+ while (true) {
1288
+ try {
1289
+ await chrome.tabs.get(tabId)
1290
+ } catch {
1291
+ return apiError('tab_not_found', `The target tab was closed while waiting: ${tabId}`, 404, false, { tabId })
1292
+ }
1293
+
1294
+ attempts += 1
1295
+ let evaluation
1296
+ try {
1297
+ evaluation = await evalValue(tabId, expression)
1298
+ } catch (error) {
1299
+ return apiError(
1300
+ 'wait_evaluation_failed',
1301
+ error instanceof Error ? error.message : String(error),
1302
+ 409,
1303
+ true,
1304
+ { selector: options.selector, state: options.state },
1305
+ )
1306
+ }
1307
+
1308
+ if (!evaluation || typeof evaluation !== 'object') {
1309
+ return apiError(
1310
+ 'wait_evaluation_failed',
1311
+ 'The page returned an invalid wait result',
1312
+ 409,
1313
+ true,
1314
+ { selector: options.selector, state: options.state },
1315
+ )
1316
+ }
1317
+ if (evaluation.invalidSelector) {
1318
+ return apiError('invalid_selector', `Invalid CSS selector: ${options.selector}`, 400, false, { selector: options.selector })
1319
+ }
1320
+
1321
+ const elapsedMs = Date.now() - startedAt
1322
+ if (evaluation.matched === true) {
1323
+ return {
1324
+ status: 200,
1325
+ body: {
1326
+ ok: true,
1327
+ matched: true,
1328
+ selector: options.selector,
1329
+ state: options.state,
1330
+ elapsedMs,
1331
+ attempts,
1332
+ matchCount: evaluation.matchCount ?? null,
1333
+ visibleCount: evaluation.visibleCount ?? null,
1334
+ },
1335
+ }
1336
+ }
1337
+
1338
+ if (elapsedMs >= options.timeoutMs) {
1339
+ return apiError(
1340
+ 'wait_timeout',
1341
+ `Timed out waiting for selector: ${options.selector}`,
1342
+ 408,
1343
+ true,
1344
+ {
1345
+ selector: options.selector,
1346
+ state: options.state,
1347
+ timeoutMs: options.timeoutMs,
1348
+ elapsedMs,
1349
+ attempts,
1350
+ },
1351
+ )
1352
+ }
1353
+
1354
+ await sleep(Math.min(options.pollMs, options.timeoutMs - elapsedMs))
1355
+ }
1356
+ }
1357
+
1358
+ async function apiNavigate(body) {
1359
+ const url = String(body.url ?? '')
1360
+ if (!url) return apiError('validation_error', 'url is required').body
1361
+ const tabId = await resolveRemoteTabId(body.tabId)
1362
+ const result = await remoteCdp(tabId, 'Page.navigate', { url })
1363
+ await sleep(500)
1364
+ let title = '', finalUrl = url
1365
+ try {
1366
+ title = (await evalValue(tabId, 'document.title')) || ''
1367
+ finalUrl = (await evalValue(tabId, 'location.href')) || url
1368
+ } catch { /* non-critical */ }
1369
+ return { ok: true, url: finalUrl, title, ...result }
1370
+ }
1371
+
1372
+ // findJs returns JSON {found, x, y, text} for the element centre in viewport coords.
1373
+ function findElementJs(selector) {
1374
+ return `(function() { var el = document.querySelector(${JSON.stringify(selector)}); if (!el) return JSON.stringify({ found: false }); var rect = el.getBoundingClientRect(); return JSON.stringify({ found: true, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, text: (el.innerText || el.textContent || '').trim().slice(0, 100) }); })()`
1375
+ }
1376
+
1377
+ async function apiClick(body) {
1378
+ const selector = body.selector
1379
+ if (!selector || typeof selector !== 'string') return apiError('validation_error', 'selector is required').body
1380
+ const tabId = await resolveRemoteTabId(body.tabId)
1381
+ const findJs = findElementJs(selector)
1382
+ const el = JSON.parse((await evalValue(tabId, findJs)) || '{"found":false}')
1383
+ if (!el.found) return elementNotFound(selector)
1384
+
1385
+ const button = body.button || 'left'
1386
+ const clickCount = body.doubleClick ? 2 : 1
1387
+ await remoteCdp(tabId, 'Runtime.evaluate', { expression: `document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block:'center'})`, returnByValue: true }).catch(() => {})
1388
+ const el2 = JSON.parse((await evalValue(tabId, findJs)) || '{"found":false}')
1389
+
1390
+ // CDP mouse input is ignored for background tabs. Use a normal DOM click so
1391
+ // remote agents do not have to steal focus from the user's active tab.
1392
+ const visibility = await evalValue(tabId, 'document.visibilityState')
1393
+ if (visibility === 'hidden' && button === 'left' && clickCount === 1) {
1394
+ const domClickJs = `(function() { var el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`
1395
+ if (await evalValue(tabId, domClickJs)) {
1396
+ return { ok: true, clicked: true, strategy: 'dom', elementText: el2.text || el.text || '', selector }
1397
+ }
1398
+ }
1399
+
1400
+ const fx = Math.round(el2.found ? el2.x : el.x)
1401
+ const fy = Math.round(el2.found ? el2.y : el.y)
1402
+
1403
+ await remoteCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: fx, y: fy })
1404
+ await remoteCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: fx, y: fy, button, clickCount })
1405
+ await remoteCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: fx, y: fy, button, clickCount })
1406
+ return { ok: true, clicked: true, strategy: 'mouse', elementText: el2.text || el.text || '', selector }
1407
+ }
1408
+
1409
+ async function apiType(body) {
1410
+ const text = body.text
1411
+ if (typeof text !== 'string') return apiError('validation_error', 'text is required').body
1412
+ const tabId = await resolveRemoteTabId(body.tabId)
1413
+ const selector = body.selector
1414
+
1415
+ if (selector) {
1416
+ const focusJs = `(function() { var el = document.querySelector(${JSON.stringify(selector)}); if (!el) return JSON.stringify({ found: false }); el.focus(); return JSON.stringify({ found: true }); })()`
1417
+ const info = JSON.parse((await evalValue(tabId, focusJs)) || '{"found":false}')
1418
+ if (!info.found) return elementNotFound(selector)
1419
+ }
1420
+
1421
+ if (body.clear) {
1422
+ await remoteCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2 })
1423
+ await remoteCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', code: 'KeyA', modifiers: 2 })
1424
+ await remoteCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace' })
1425
+ await remoteCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace' })
1426
+ }
1427
+
1428
+ await remoteCdp(tabId, 'Input.insertText', { text })
1429
+
1430
+ if (body.submit) {
1431
+ const enter = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }
1432
+ await remoteCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...enter })
1433
+ await remoteCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...enter })
1434
+ }
1435
+ return { ok: true, typed: true }
1436
+ }
1437
+
1438
+ // Pragmatic key support: common named keys + modifier combos (e.g. "ctrl+a",
1439
+ // "Enter", "ArrowDown"). Not the full relay key table, but covers typical use.
1440
+ const NAMED_KEYS = {
1441
+ enter: { key: 'Enter', code: 'Enter', vk: 13, text: '\r' },
1442
+ tab: { key: 'Tab', code: 'Tab', vk: 9 },
1443
+ escape: { key: 'Escape', code: 'Escape', vk: 27 },
1444
+ esc: { key: 'Escape', code: 'Escape', vk: 27 },
1445
+ backspace: { key: 'Backspace', code: 'Backspace', vk: 8 },
1446
+ delete: { key: 'Delete', code: 'Delete', vk: 46 },
1447
+ space: { key: ' ', code: 'Space', vk: 32, text: ' ' },
1448
+ arrowup: { key: 'ArrowUp', code: 'ArrowUp', vk: 38 },
1449
+ arrowdown: { key: 'ArrowDown', code: 'ArrowDown', vk: 40 },
1450
+ arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', vk: 37 },
1451
+ arrowright: { key: 'ArrowRight', code: 'ArrowRight', vk: 39 },
1452
+ home: { key: 'Home', code: 'Home', vk: 36 },
1453
+ end: { key: 'End', code: 'End', vk: 35 },
1454
+ pageup: { key: 'PageUp', code: 'PageUp', vk: 33 },
1455
+ pagedown: { key: 'PageDown', code: 'PageDown', vk: 34 },
1456
+ }
1457
+ const KEY_MODIFIERS = { alt: 1, control: 2, ctrl: 2, shift: 8, meta: 4, cmd: 4, command: 4 }
1458
+
1459
+ function buildKeyInput(body) {
1460
+ const raw = (typeof body.combo === 'string' && body.combo.trim()) || (typeof body.key === 'string' && body.key.trim()) || ''
1461
+ if (!raw) return null
1462
+ const parts = String(raw).split('+').map((s) => s.trim()).filter(Boolean)
1463
+ const keyPart = parts[parts.length - 1] || raw
1464
+ let modifiers = 0
1465
+ for (const m of parts.slice(0, -1)) modifiers |= (KEY_MODIFIERS[m.toLowerCase()] || 0)
1466
+
1467
+ const named = NAMED_KEYS[keyPart.toLowerCase()]
1468
+ if (named) {
1469
+ return { key: named.key, code: named.code, windowsVirtualKeyCode: named.vk, nativeVirtualKeyCode: named.vk, text: modifiers ? '' : (named.text || ''), modifiers }
1470
+ }
1471
+ if (keyPart.length === 1) {
1472
+ const code = /[a-zA-Z]/.test(keyPart) ? `Key${keyPart.toUpperCase()}` : /[0-9]/.test(keyPart) ? `Digit${keyPart}` : ''
1473
+ const vk = keyPart.toUpperCase().charCodeAt(0)
1474
+ return { key: keyPart, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, text: modifiers ? '' : keyPart, modifiers }
1475
+ }
1476
+ return null
1477
+ }
1478
+
1479
+ async function apiKey(body) {
1480
+ const input = buildKeyInput(body)
1481
+ if (!input) return apiError('validation_error', 'key or combo is required').body
1482
+ const tabId = await resolveRemoteTabId(body.tabId)
1483
+ const base = { key: input.key, code: input.code, windowsVirtualKeyCode: input.windowsVirtualKeyCode, nativeVirtualKeyCode: input.nativeVirtualKeyCode, modifiers: input.modifiers }
1484
+ const down = input.text ? { ...base, text: input.text, unmodifiedText: input.text } : base
1485
+ await remoteCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...down })
1486
+ await remoteCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...base })
1487
+ return { ok: true, pressed: true, ...input }
1488
+ }
1489
+
1490
+ async function apiScroll(body) {
1491
+ const tabId = await resolveRemoteTabId(body.tabId)
1492
+ const direction = body.direction || 'down'
1493
+ const amount = body.amount || 800
1494
+ const js = direction === 'bottom'
1495
+ ? 'window.scrollTo(0, document.body.scrollHeight)'
1496
+ : direction === 'top'
1497
+ ? 'window.scrollTo(0, 0)'
1498
+ : `window.scrollBy(0, ${direction === 'down' ? amount : -amount})`
1499
+ await remoteCdp(tabId, 'Runtime.evaluate', { expression: js, returnByValue: true })
1500
+ return { ok: true, scrolled: true, direction }
1501
+ }
1502
+
1503
+ async function apiSnapshot(body, searchParams) {
1504
+ const tabId = await resolveRemoteTabId(body.tabId ?? searchParams.get('tabId'))
1505
+ const format = (body.format ?? searchParams.get('format')) || 'text'
1506
+ const maxLength = parseInt(String(body.maxLength ?? searchParams.get('maxLength') ?? '100000'), 10)
1507
+
1508
+ if (format === 'html') {
1509
+ let html = (await evalValue(tabId, 'document.documentElement.outerHTML')) || ''
1510
+ const truncated = html.length > maxLength
1511
+ if (truncated) html = html.slice(0, maxLength)
1512
+ return { ok: true, url: (await evalValue(tabId, 'location.href')) || '', title: (await evalValue(tabId, 'document.title')) || '', html, truncated }
1513
+ }
1514
+
1515
+ const jsWithMaxLen = `var __maxLength = ${maxLength};\n${SNAPSHOT_JS}`
1516
+ const raw = await evalValue(tabId, jsWithMaxLen)
1517
+ let snapshot = '', truncated = false
1518
+ try { const parsed = JSON.parse(raw || '{}'); snapshot = parsed.snapshot || ''; truncated = parsed.truncated || false } catch { snapshot = raw || '' }
1519
+ return { ok: true, url: (await evalValue(tabId, 'location.href')) || '', title: (await evalValue(tabId, 'document.title')) || '', snapshot, truncated }
1520
+ }
1521
+
1522
+ function base64Bytes(d) {
1523
+ const len = String(d || '').length
1524
+ if (!len) return 0
1525
+ const pad = d.endsWith('==') ? 2 : d.endsWith('=') ? 1 : 0
1526
+ return Math.floor(len * 3 / 4) - pad
1527
+ }
1528
+
1529
+ async function apiScreenshot(body, searchParams) {
1530
+ const tabId = await resolveRemoteTabId(body.tabId ?? searchParams.get('tabId'))
1531
+ const fullPage = body.fullPage === true || searchParams.get('fullPage') === 'true'
1532
+
1533
+ if (fullPage) {
1534
+ let width = null, height = null, fallbackError = null
1535
+ try {
1536
+ const metrics = await remoteCdp(tabId, 'Page.getLayoutMetrics', {})
1537
+ const size = metrics?.cssContentSize || metrics?.contentSize || metrics?.cssLayoutViewport || metrics?.layoutViewport
1538
+ const rw = Number(size?.width), rh = Number(size?.height)
1539
+ if (Number.isFinite(rw) && Number.isFinite(rh) && rw > 0 && rh > 0) {
1540
+ width = Math.ceil(rw); height = Math.ceil(rh)
1541
+ const r = await remoteCdp(tabId, 'Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width, height, scale: 1 } })
1542
+ const data = r?.data || ''
1543
+ return { ok: true, data, format: 'png', fullPage: true, strategy: 'fullPageClip', width, height, bytes: base64Bytes(data) }
1544
+ }
1545
+ } catch (err) { fallbackError = err instanceof Error ? err.message : String(err) }
1546
+ const r = await remoteCdp(tabId, 'Page.captureScreenshot', { format: 'png', captureBeyondViewport: true })
1547
+ const data = r?.data || ''
1548
+ return { ok: true, data, format: 'png', fullPage: true, strategy: 'captureBeyondViewport', width, height, bytes: base64Bytes(data), fallbackError }
1549
+ }
1550
+
1551
+ const r = await remoteCdp(tabId, 'Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
1552
+ const data = r?.data || ''
1553
+ return { ok: true, data, format: 'png', fullPage: false, strategy: 'viewport', bytes: base64Bytes(data) }
1554
+ }
1555
+
1556
+ // ---- Remote console / network capture ----
1557
+ // Buffer CDP Log/Runtime/Network events per attached tab (in memory, capped) so
1558
+ // remote /api/console and /api/network can serve them, like the local daemon does.
1559
+ const MAX_CONSOLE_ENTRIES = 1000
1560
+ const MAX_NETWORK_ENTRIES = 1000
1561
+ const SENSITIVE_NETWORK_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization', 'set-cookie'])
1562
+ let consoleEntries = []
1563
+ let networkEntries = []
1564
+ let nextConsoleEntryId = 1
1565
+ let nextNetworkEntryId = 1
1566
+ const consoleCaptureTabs = new Set()
1567
+ const networkCaptureTabs = new Set()
1568
+
1569
+ function boundInt(value, dflt, min, max) {
1570
+ const n = parseInt(String(value ?? ''), 10)
1571
+ if (!Number.isFinite(n)) return dflt
1572
+ return Math.max(min, Math.min(max, n))
1573
+ }
1574
+
1575
+ function remoteObjectValue(obj) {
1576
+ if (!obj || typeof obj !== 'object') return ''
1577
+ if ('value' in obj) return obj.value
1578
+ if ('unserializableValue' in obj) return obj.unserializableValue
1579
+ return obj.description || obj.type || ''
1580
+ }
1581
+
1582
+ function stringifyConsoleValue(value) {
1583
+ if (typeof value === 'string') return value
1584
+ if (value === undefined) return 'undefined'
1585
+ try { return JSON.stringify(value) } catch { return String(value) }
1586
+ }
1587
+
1588
+ function redactNetworkHeaders(headers = {}) {
1589
+ const redacted = {}
1590
+ if (!headers || typeof headers !== 'object') return redacted
1591
+ for (const [name, value] of Object.entries(headers)) {
1592
+ redacted[name] = SENSITIVE_NETWORK_HEADERS.has(String(name).toLowerCase()) ? '[redacted]' : value
1593
+ }
1594
+ return redacted
1595
+ }
1596
+
1597
+ function appendConsoleEntry(entry) {
1598
+ consoleEntries.push({ id: nextConsoleEntryId++, receivedAt: new Date().toISOString(), ...entry })
1599
+ if (consoleEntries.length > MAX_CONSOLE_ENTRIES) consoleEntries = consoleEntries.slice(-MAX_CONSOLE_ENTRIES)
1600
+ }
1601
+
1602
+ function appendNetworkEntry(entry) {
1603
+ networkEntries.push({ id: nextNetworkEntryId++, receivedAt: new Date().toISOString(), ...entry })
1604
+ if (networkEntries.length > MAX_NETWORK_ENTRIES) networkEntries = networkEntries.slice(-MAX_NETWORK_ENTRIES)
1605
+ }
1606
+
1607
+ // Called from onDebuggerEvent for every attached tab; tabId is the chrome tab id.
1608
+ function captureCdpEvent(tabId, method, params = {}) {
1609
+ const base = { tabId: publicTabIdFor(tabId) }
1610
+
1611
+ if (method === 'Runtime.consoleAPICalled') {
1612
+ const args = (params.args || []).map(remoteObjectValue)
1613
+ return appendConsoleEntry({ ...base, source: 'runtime', level: params.type || 'log', text: args.map(stringifyConsoleValue).join(' '), args, stackTrace: params.stackTrace || null, timestamp: params.timestamp || null })
1614
+ }
1615
+ if (method === 'Runtime.exceptionThrown') {
1616
+ const details = params.exceptionDetails || {}
1617
+ return appendConsoleEntry({ ...base, source: 'runtime', level: 'error', text: details.exception?.description || details.text || 'Uncaught exception', exceptionDetails: details, timestamp: params.timestamp || null })
1618
+ }
1619
+ if (method === 'Log.entryAdded') {
1620
+ const entry = params.entry || {}
1621
+ return appendConsoleEntry({ ...base, source: entry.source || 'log', level: entry.level || 'info', text: entry.text || '', lineNumber: entry.lineNumber, url: entry.url || '', networkRequestId: entry.networkRequestId, timestamp: entry.timestamp || null })
1622
+ }
1623
+
1624
+ if (method === 'Network.requestWillBeSent') {
1625
+ const request = params.request || {}
1626
+ return appendNetworkEntry({ ...base, requestId: params.requestId || '', type: 'request', url: request.url || params.documentURL || '', method: request.method || '', documentURL: params.documentURL || '', frameId: params.frameId || '', resourceType: params.type || '', wallTime: params.wallTime ?? null, timestamp: params.timestamp ?? null, initiator: params.initiator || null, request: { url: request.url || '', method: request.method || '', headers: redactNetworkHeaders(request.headers) } })
1627
+ }
1628
+ if (method === 'Network.responseReceived') {
1629
+ const response = params.response || {}
1630
+ return appendNetworkEntry({ ...base, requestId: params.requestId || '', type: 'response', url: response.url || '', status: response.status ?? null, statusText: response.statusText || '', mimeType: response.mimeType || '', protocol: response.protocol || '', resourceType: params.type || '', timestamp: params.timestamp ?? null, response: { url: response.url || '', status: response.status ?? null, statusText: response.statusText || '', headers: redactNetworkHeaders(response.headers), mimeType: response.mimeType || '' } })
1631
+ }
1632
+ if (method === 'Network.loadingFinished') {
1633
+ return appendNetworkEntry({ ...base, requestId: params.requestId || '', type: 'finished', encodedDataLength: params.encodedDataLength ?? null, timestamp: params.timestamp ?? null })
1634
+ }
1635
+ if (method === 'Network.loadingFailed') {
1636
+ return appendNetworkEntry({ ...base, requestId: params.requestId || '', type: 'failed', resourceType: params.type || '', errorText: params.errorText || '', canceled: !!params.canceled, blockedReason: params.blockedReason || '', timestamp: params.timestamp ?? null })
1637
+ }
1638
+ }
1639
+
1640
+ async function ensureConsoleCapture(tabId) {
1641
+ if (consoleCaptureTabs.has(tabId)) return
1642
+ await ensureRemoteAttached(tabId)
1643
+ await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable').catch(() => {})
1644
+ await chrome.debugger.sendCommand({ tabId }, 'Log.enable').catch(() => {})
1645
+ consoleCaptureTabs.add(tabId)
1646
+ }
1647
+
1648
+ async function ensureNetworkCapture(tabId) {
1649
+ if (networkCaptureTabs.has(tabId)) return
1650
+ await ensureRemoteAttached(tabId)
1651
+ await chrome.debugger.sendCommand({ tabId }, 'Network.enable').catch(() => {})
1652
+ networkCaptureTabs.add(tabId)
1653
+ }
1654
+
1655
+ function attachedTabIds() {
1656
+ const ids = []
1657
+ for (const [id, tab] of tabs.entries()) if (tab.state === 'connected') ids.push(id)
1658
+ return ids
1659
+ }
1660
+
1661
+ async function apiConsole(body, params) {
1662
+ const tabIdParam = body.tabId ?? params.get('tabId')
1663
+ const level = body.level ?? params.get('level')
1664
+ const limit = boundInt(body.limit ?? params.get('limit'), 100, 0, MAX_CONSOLE_ENTRIES)
1665
+ const clear = String(body.clear ?? params.get('clear')) === 'true'
1666
+
1667
+ if (tabIdParam !== undefined && tabIdParam !== null && tabIdParam !== '') {
1668
+ await ensureConsoleCapture(await resolveRemoteTabId(tabIdParam)).catch(() => {})
1669
+ } else {
1670
+ await Promise.all(attachedTabIds().map((id) => ensureConsoleCapture(id).catch(() => {})))
1671
+ }
1672
+
1673
+ let entries = consoleEntries
1674
+ if (tabIdParam) entries = entries.filter((e) => String(e.tabId) === String(tabIdParam))
1675
+ if (level) entries = entries.filter((e) => e.level === level)
1676
+ const total = entries.length
1677
+ const selected = limit === 0 ? [] : entries.slice(-limit)
1678
+ if (clear) { const ids = new Set(selected.map((e) => e.id)); consoleEntries = consoleEntries.filter((e) => !ids.has(e.id)) }
1679
+ return { ok: true, entries: selected, count: selected.length, total, storedTotal: consoleEntries.length }
1680
+ }
1681
+
1682
+ async function apiConsoleClear(body) {
1683
+ const before = consoleEntries.length
1684
+ const { tabId, level } = body
1685
+ consoleEntries = consoleEntries.filter((e) => {
1686
+ if (tabId && String(e.tabId) !== String(tabId)) return true
1687
+ if (level && e.level !== level) return true
1688
+ return false
1689
+ })
1690
+ return { ok: true, cleared: before - consoleEntries.length, total: consoleEntries.length }
1691
+ }
1692
+
1693
+ function filterNetwork(entries, f) {
1694
+ let out = entries
1695
+ if (f.tabId) out = out.filter((e) => String(e.tabId) === String(f.tabId))
1696
+ if (f.type) out = out.filter((e) => e.type === f.type)
1697
+ if (f.method) out = out.filter((e) => String(e.method || e.request?.method || '').toUpperCase() === String(f.method).toUpperCase())
1698
+ if (f.status) out = out.filter((e) => Number(e.status ?? e.response?.status) === Number(f.status))
1699
+ if (f.requestId) out = out.filter((e) => e.requestId === f.requestId)
1700
+ if (f.url) out = out.filter((e) => String(e.url || e.request?.url || e.response?.url || '').includes(f.url))
1701
+ return out
1702
+ }
1703
+
1704
+ async function apiNetwork(body, params) {
1705
+ const f = {
1706
+ tabId: body.tabId ?? params.get('tabId') ?? undefined,
1707
+ type: body.type ?? params.get('type') ?? undefined,
1708
+ method: body.method ?? params.get('method') ?? undefined,
1709
+ status: body.status ?? params.get('status') ?? undefined,
1710
+ requestId: body.requestId ?? params.get('requestId') ?? undefined,
1711
+ url: body.url ?? params.get('url') ?? undefined,
1712
+ }
1713
+ const limit = boundInt(body.limit ?? params.get('limit'), 100, 0, MAX_NETWORK_ENTRIES)
1714
+ const clear = String(body.clear ?? params.get('clear')) === 'true'
1715
+
1716
+ if (f.tabId) await ensureNetworkCapture(await resolveRemoteTabId(f.tabId)).catch(() => {})
1717
+ else await Promise.all(attachedTabIds().map((id) => ensureNetworkCapture(id).catch(() => {})))
1718
+
1719
+ const matched = filterNetwork(networkEntries, f)
1720
+ const selected = limit === 0 ? [] : matched.slice(-limit)
1721
+ if (clear) { const ids = new Set(selected.map((e) => e.id)); networkEntries = networkEntries.filter((e) => !ids.has(e.id)) }
1722
+ return { ok: true, entries: selected, count: selected.length, total: matched.length, storedTotal: networkEntries.length }
1723
+ }
1724
+
1725
+ async function apiNetworkClear(body) {
1726
+ const before = networkEntries.length
1727
+ const f = { tabId: body.tabId, type: body.type, method: body.method, status: body.status, requestId: body.requestId, url: body.url }
1728
+ if (Object.values(f).some((v) => v !== undefined && v !== null && v !== '')) {
1729
+ const ids = new Set(filterNetwork(networkEntries, f).map((e) => e.id))
1730
+ networkEntries = networkEntries.filter((e) => !ids.has(e.id))
1731
+ } else {
1732
+ networkEntries = []
1733
+ }
1734
+ return { ok: true, cleared: before - networkEntries.length, total: networkEntries.length }
1735
+ }
1736
+
1737
+ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
1738
+ if (msg?.type === 'getAutomationTasks') {sendResponse({tasks:automation.activeTasks(),claims:automation.sessions.list()});return false}
1739
+ if (msg?.type === 'stopAutomationSession') {try{automation.sessions.stop(msg.sessionId);sendResponse({ok:true})}catch(error){sendResponse({ok:false,error:error.message})}return false}
1740
+ if (msg?.type === 'cancelAutomationTasks') {sendResponse({cancelled:automation.cancelAll()});return false}
1741
+ if (msg?.type === 'relayCheck') {
1742
+ const { url } = msg
1743
+ fetch(url, { method: 'GET', signal: AbortSignal.timeout(2000) })
1744
+ .then(async (res) => {
1745
+ const contentType = String(res.headers.get('content-type') || '')
1746
+ let json = null
1747
+ if (contentType.includes('application/json')) {
1748
+ try { json = await res.json() } catch { json = null }
1749
+ }
1750
+ sendResponse({ status: res.status, ok: res.ok, contentType, json })
1751
+ })
1752
+ .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) }))
1753
+ return true
1754
+ }
1755
+
1756
+ if (msg?.type === 'getStatus') {
1757
+ ;(async () => {
1758
+ const port = await getRelayPort()
1759
+ const connected = relayWs?.readyState === WebSocket.OPEN
1760
+ const connecting = !connected && (
1761
+ relayConnectPromise !== null ||
1762
+ relayWs?.readyState === WebSocket.CONNECTING ||
1763
+ reconnectTimer !== null
1764
+ )
1765
+ let attachedCount = 0
1766
+ for (const t of tabs.values()) if (t.state === 'connected') attachedCount++
1767
+ const manifest = chrome.runtime.getManifest()
1768
+ const version = manifest.version_name || manifest.version
1769
+ sendResponse({ connected, connecting, port, attachedCount, lastError: lastConnectError, version })
1770
+ })()
1771
+ return true
1772
+ }
1773
+
1774
+ if (msg?.type === 'reconnect') {
1775
+ ;(async () => {
1776
+ await connectOrToggle()
1777
+ sendResponse({ ok: true, error: lastConnectError })
1778
+ })()
1779
+ return true
1780
+ }
1781
+
1782
+ // options.js drives External Control through these three messages. It has
1783
+ // already written the capability to chrome.storage.local before enabling.
1784
+ if (msg?.type === 'enableRemoteControl') {
1785
+ ;(async () => {
1786
+ closeRemoteHub() // drop any stale connection (e.g. on Regenerate)
1787
+ const connected = await ensureRemoteHubConnection()
1788
+ sendResponse({ connected, lastError: remoteLastError })
1789
+ })()
1790
+ return true
1791
+ }
1792
+
1793
+ if (msg?.type === 'disableRemoteControl') {
1794
+ closeRemoteHub({ disable: true })
1795
+ sendResponse({ ok: true })
1796
+ return true
1797
+ }
1798
+
1799
+ if (msg?.type === 'getRemoteControlStatus') {
1800
+ ;(async () => {
1801
+ if (!remoteConnected()) await ensureRemoteHubConnection().catch(() => {})
1802
+ sendResponse(remoteStatusPayload())
1803
+ })()
1804
+ return true
1805
+ }
1806
+
1807
+ return false
1808
+ })
1809
+
1810
+ const automation = createAutomation({
1811
+ taskGroups: createTaskGroups(chrome),
1812
+ prepareTab: async (tabId) => {
1813
+ const tab = await chrome.tabs.get(tabId)
1814
+ const collapsed = tab.groupId !== chrome.tabGroups.TAB_GROUP_ID_NONE &&
1815
+ (await chrome.tabGroups.get(tab.groupId)).collapsed
1816
+ if (!tab.frozen && !collapsed) return
1817
+ await ensureRemoteAttached(tabId)
1818
+ // 收起分组可能冻结页面:CDP 求值仍成功,但 DOM 点击处理器不运行。
1819
+ // 仅恢复本次任务目标页的执行态,不展开分组、不激活标签、不切换窗口焦点。
1820
+ await chrome.debugger.sendCommand({ tabId }, 'Page.setWebLifecycleState', { state: 'active' })
1821
+ },
1822
+ onActivity: beginTabActivity,
1823
+ runtimeInfo: executorInfo,
1824
+ publicTabId: publicTabIdFor,
1825
+ resolveTab: resolveRemoteTabId,
1826
+ send: async (tabId, method, params, sessionId) => {
1827
+ await ensureRemoteAttached(tabId)
1828
+ return chrome.debugger.sendCommand({tabId, ...(sessionId ? {sessionId} : {})}, method, params)
1829
+ },
1830
+ createTab: async (url) => {
1831
+ if (url !== 'about:blank' && !/^https?:\/\//.test(url)) throw new Error('Only HTTP(S) and about:blank are supported')
1832
+ const tab = await chrome.tabs.create({url, active:false})
1833
+ const attached = await attachTab(tab.id)
1834
+ return {tabId:attached.tabId, url}
1835
+ },
1836
+ closeTab: (tabId) => chrome.tabs.remove(tabId),
1837
+ focusTab: async (tabId) => { const tab=await chrome.tabs.get(tabId); await chrome.windows.update(tab.windowId,{focused:true}); await chrome.tabs.update(tabId,{active:true}) },
1838
+ })
1839
+
1840
+ const initPromise = rehydrateState()
1841
+
1842
+ initPromise.then(() => {
1843
+ ensureRelayConnection().then(() => {
1844
+ reconnectAttempt = 0
1845
+ return recoverRelaySession()
1846
+ }).catch(() => { scheduleReconnect() })
1847
+ // Restore External Control on startup if the user left it enabled.
1848
+ void ensureRemoteHubConnection().catch(() => {})
1849
+ })
1850
+
1851
+ async function whenReady(fn) {
1852
+ await initPromise
1853
+ return fn()
1854
+ }