@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.
- package/LICENSE +22 -0
- package/README.md +445 -0
- package/docs/README.zh-CN.md +421 -0
- package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
- package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
- package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
- package/docs/benchmarks/browser-readiness-cost.json +106 -0
- package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
- package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
- package/docs/benchmarks/browser-runtime-balanced.json +412 -0
- package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
- package/docs/benchmarks/browser-runtime.json +254 -0
- package/docs/benchmarks/browser-use-parity.json +54 -0
- package/docs/benchmarks/codex-extension-audit.json +131 -0
- package/docs/benchmarks/codex-native-protocol.md +69 -0
- package/docs/benchmarks/codex-native-replay.js +116 -0
- package/docs/benchmarks/codex-native-status.json +81 -0
- package/docs/benchmarks/codex-native.json +1003 -0
- package/docs/benchmarks/extension-sessions.png +0 -0
- package/docs/benchmarks/extension-tasks.png +0 -0
- package/docs/benchmarks/iframe-routing-regression.json +37 -0
- package/docs/browser-use-comparison.md +331 -0
- package/docs/browser-use-gap-audit.md +141 -0
- package/docs/browser-use-parity.md +105 -0
- package/docs/demo/intranet.html +103 -0
- package/docs/releases/v1.5.0.md +59 -0
- package/docs/releases/v1.5.1.md +16 -0
- package/docs/releases/v1.5.2.md +42 -0
- package/docs/releases/v1.5.3.md +33 -0
- package/docs/releases/v1.5.4.md +42 -0
- package/docs/releases/v1.6.0.md +16 -0
- package/docs/remote-control-hub.md +523 -0
- package/extension/activity.js +328 -0
- package/extension/automation.js +1839 -0
- package/extension/background.js +1854 -0
- package/extension/i18n.js +149 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon32.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +47 -0
- package/extension/observations.js +109 -0
- package/extension/options.html +289 -0
- package/extension/options.js +269 -0
- package/extension/popup.html +74 -0
- package/extension/popup.js +105 -0
- package/extension/protocol.js +45 -0
- package/extension/remote-auth.js +18 -0
- package/extension/sessions.js +134 -0
- package/extension/snapshot.js +161 -0
- package/extension/task-groups.js +108 -0
- package/extension/tasks.js +186 -0
- package/extension/wait.js +89 -0
- package/hub/README.md +42 -0
- package/hub/package-lock.json +1544 -0
- package/hub/package.json +13 -0
- package/hub/src/rpc.js +41 -0
- package/hub/src/worker.js +322 -0
- package/hub/wrangler.example.toml +19 -0
- package/package.json +83 -0
- package/server/cdp-bridge.js +200 -0
- package/server/cli.js +1798 -0
- package/server/hub-server.js +258 -0
- package/server/install.js +250 -0
- package/server/mcp-server.js +504 -0
- package/server/npx-runner.js +96 -0
- package/server/relay-server.js +1356 -0
- package/server/remote-protocol.js +76 -0
- package/server/runtime-worker.js +166 -0
- package/server/script-runtime.js +189 -0
- package/server/sdk.js +307 -0
- package/server/service-state.js +103 -0
- package/server/snapshot.js +161 -0
- package/server/uninstall.js +61 -0
- package/server/windows-service-entry.js +58 -0
- package/server/windows-service.js +360 -0
- package/skills/browser-relay/SKILL.md +192 -0
- package/skills/browser-relay/references/legacy-api.md +163 -0
- 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
|
+
}
|