yunti-browser-runtime 0.1.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 +21 -0
- package/README.md +256 -0
- package/bin/yunti-browser-runtime.js +86 -0
- package/docs/EXECUTION_PLAN.md +1278 -0
- package/docs/INSTALL.md +205 -0
- package/docs/PROJECT_INTENT.md +44 -0
- package/docs/PROJECT_STATUS.md +263 -0
- package/docs/PUBLISHING_BLOCKERS.md +110 -0
- package/docs/RELEASE.md +148 -0
- package/docs/ROADMAP.md +42 -0
- package/docs/SECURITY.md +56 -0
- package/docs/TOOL_GUIDE.md +69 -0
- package/extension/background.js +55 -0
- package/extension/cdp.js +582 -0
- package/extension/content.css +9 -0
- package/extension/content.js +946 -0
- package/extension/manifest.json +35 -0
- package/extension/network-monitor.js +140 -0
- package/extension/popup.css +66 -0
- package/extension/popup.html +30 -0
- package/extension/popup.js +55 -0
- package/extension/session-manager.js +332 -0
- package/extension/settings.js +94 -0
- package/extension/tool-handlers.js +1158 -0
- package/lib/runtime-paths.js +39 -0
- package/mcp/bridge-hub.js +604 -0
- package/mcp/http-server.js +326 -0
- package/mcp/json-rpc.js +35 -0
- package/mcp/memory.js +126 -0
- package/mcp/redaction.js +94 -0
- package/mcp/server.js +269 -0
- package/mcp/tools.js +1092 -0
- package/package.json +60 -0
- package/scripts/check-package-metadata.js +131 -0
- package/scripts/check-published-package.js +113 -0
- package/scripts/doctor.js +163 -0
- package/scripts/package-extension.js +137 -0
- package/scripts/print-config.js +116 -0
- package/scripts/release-check.js +472 -0
- package/skills/yunti-browser-runtime/SKILL.md +77 -0
package/extension/cdp.js
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
export function createCdpController({
|
|
2
|
+
sessionsByTab,
|
|
3
|
+
pollers,
|
|
4
|
+
postBridge,
|
|
5
|
+
startPolling,
|
|
6
|
+
forwardConsoleEvent,
|
|
7
|
+
}) {
|
|
8
|
+
const cdpAttachedTabs = new Set()
|
|
9
|
+
const cdpEnabledDomains = new Map()
|
|
10
|
+
const traceBuffers = new Map()
|
|
11
|
+
|
|
12
|
+
function installCdpEventForwarder() {
|
|
13
|
+
if (!chrome.debugger?.onEvent || !chrome.debugger?.onDetach) return
|
|
14
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
15
|
+
const tabId = source?.tabId
|
|
16
|
+
if (!tabId) return
|
|
17
|
+
const session = sessionsByTab.get(tabId)
|
|
18
|
+
if (!session) return
|
|
19
|
+
recordTraceEvent(tabId, method, params)
|
|
20
|
+
void postBridge("/extension/cdp-event", {
|
|
21
|
+
browserSessionId: session.browserSessionId,
|
|
22
|
+
tabId,
|
|
23
|
+
method,
|
|
24
|
+
params: params || {},
|
|
25
|
+
receivedAt: new Date().toISOString(),
|
|
26
|
+
}).catch(() => {})
|
|
27
|
+
|
|
28
|
+
if (method === "Runtime.consoleAPICalled" || method === "Log.entryAdded") {
|
|
29
|
+
void forwardConsoleEvent(session, tabId, method, params).catch(() => {})
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
chrome.debugger.onDetach.addListener((source, reason) => {
|
|
33
|
+
const tabId = source?.tabId
|
|
34
|
+
if (!tabId) return
|
|
35
|
+
cdpAttachedTabs.delete(tabId)
|
|
36
|
+
cdpEnabledDomains.delete(tabId)
|
|
37
|
+
traceBuffers.delete(tabId)
|
|
38
|
+
const session = sessionsByTab.get(tabId)
|
|
39
|
+
if (!session) return
|
|
40
|
+
void postBridge("/extension/cdp-event", {
|
|
41
|
+
browserSessionId: session.browserSessionId,
|
|
42
|
+
tabId,
|
|
43
|
+
method: "Debugger.detached",
|
|
44
|
+
params: { reason: reason || "unknown" },
|
|
45
|
+
receivedAt: new Date().toISOString(),
|
|
46
|
+
}).catch(() => {})
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function sendCdpCommand(tabId, session, args = {}) {
|
|
51
|
+
const method = String(args.method || "").trim()
|
|
52
|
+
if (!method) {
|
|
53
|
+
throw new Error("yunti_cdp_send_command requires method, for example { method: \"Runtime.evaluate\", params: { expression: \"document.title\", returnByValue: true } }. Do not run CDP method names in a shell.")
|
|
54
|
+
}
|
|
55
|
+
if (args.params !== undefined && (typeof args.params !== "object" || args.params === null || Array.isArray(args.params))) {
|
|
56
|
+
throw new Error("yunti_cdp_send_command params must be an object when provided. Example: { method: \"Runtime.evaluate\", params: { expression: \"document.title\" } }.")
|
|
57
|
+
}
|
|
58
|
+
const params = args.params || {}
|
|
59
|
+
const protocolVersion = String(args.protocolVersion || "1.3")
|
|
60
|
+
const targetTabId = await resolveCdpTargetTabId(tabId, args, params)
|
|
61
|
+
const explicitTargetId = explicitCdpTargetId(args, params)
|
|
62
|
+
const targetParams =
|
|
63
|
+
method.startsWith("Target.") && !params.targetId && explicitTargetId
|
|
64
|
+
? { ...params, targetId: explicitTargetId }
|
|
65
|
+
: params
|
|
66
|
+
|
|
67
|
+
if (method === "Target.getTargets") {
|
|
68
|
+
return interceptTargetGetTargets(session)
|
|
69
|
+
}
|
|
70
|
+
if (method === "Target.closeTarget") {
|
|
71
|
+
return interceptTargetCloseTarget(targetParams, session)
|
|
72
|
+
}
|
|
73
|
+
if (method === "Target.createTarget") {
|
|
74
|
+
return interceptTargetCreateTarget(targetParams, session)
|
|
75
|
+
}
|
|
76
|
+
if (method === "Target.attachToTarget") {
|
|
77
|
+
return interceptTargetAttachToTarget(targetParams, protocolVersion)
|
|
78
|
+
}
|
|
79
|
+
if (method === "Target.detachFromTarget") {
|
|
80
|
+
return interceptTargetDetachFromTarget(targetParams)
|
|
81
|
+
}
|
|
82
|
+
if (method === "Target.activateTarget") {
|
|
83
|
+
return interceptTargetActivateTarget(targetParams)
|
|
84
|
+
}
|
|
85
|
+
if (method === "Target.getTargetInfo") {
|
|
86
|
+
return interceptTargetGetTargetInfo(targetParams)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let attach = { attached: false, reused: cdpAttachedTabs.has(targetTabId) }
|
|
90
|
+
if (args.attach !== false) {
|
|
91
|
+
attach = await ensureCdpAttached(targetTabId, protocolVersion)
|
|
92
|
+
}
|
|
93
|
+
const result = await chromeDebuggerSendCommand({ tabId: targetTabId }, method, params)
|
|
94
|
+
return {
|
|
95
|
+
browserSessionId: session.browserSessionId,
|
|
96
|
+
tabId: targetTabId,
|
|
97
|
+
routeTabId: tabId,
|
|
98
|
+
method,
|
|
99
|
+
params,
|
|
100
|
+
result: result || {},
|
|
101
|
+
attach,
|
|
102
|
+
sentAt: new Date().toISOString(),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function interceptTargetGetTargets(session) {
|
|
107
|
+
const targetInfos = []
|
|
108
|
+
if (chrome.debugger?.getTargets) {
|
|
109
|
+
try {
|
|
110
|
+
const targets = await chromeDebuggerGetTargets()
|
|
111
|
+
for (const target of targets) {
|
|
112
|
+
const tabId =
|
|
113
|
+
Number.isFinite(Number(target.tabId)) && Number(target.tabId) > 0
|
|
114
|
+
? Number(target.tabId)
|
|
115
|
+
: null
|
|
116
|
+
const tab = tabId ? await chrome.tabs.get(tabId).catch(() => null) : null
|
|
117
|
+
targetInfos.push({
|
|
118
|
+
targetId: target.id || (tabId ? `tab-${tabId}` : ""),
|
|
119
|
+
type: target.type || "other",
|
|
120
|
+
title: target.title || tab?.title || "",
|
|
121
|
+
url: target.url || tab?.url || "",
|
|
122
|
+
tabId,
|
|
123
|
+
windowId: tab?.windowId ?? null,
|
|
124
|
+
active: Boolean(tab?.active),
|
|
125
|
+
index: Number.isFinite(Number(tab?.index)) ? Number(tab.index) : null,
|
|
126
|
+
attached: tabId ? cdpAttachedTabs.has(tabId) : Boolean(target.attached),
|
|
127
|
+
canAccessOpener: false,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// Fall through to chrome.tabs fallback.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!targetInfos.length) {
|
|
135
|
+
const tabs = await chrome.tabs.query({})
|
|
136
|
+
for (const tab of tabs) {
|
|
137
|
+
if (!tab.id) continue
|
|
138
|
+
targetInfos.push({
|
|
139
|
+
targetId: `tab-${tab.id}`,
|
|
140
|
+
type: "page",
|
|
141
|
+
title: tab.title || "",
|
|
142
|
+
url: tab.url || "",
|
|
143
|
+
tabId: tab.id,
|
|
144
|
+
windowId: tab.windowId ?? null,
|
|
145
|
+
active: Boolean(tab.active),
|
|
146
|
+
index: Number.isFinite(Number(tab.index)) ? Number(tab.index) : null,
|
|
147
|
+
attached: cdpAttachedTabs.has(tab.id),
|
|
148
|
+
canAccessOpener: false,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
browserSessionId: session.browserSessionId,
|
|
154
|
+
method: "Target.getTargets",
|
|
155
|
+
result: { targetInfos },
|
|
156
|
+
intercepted: true,
|
|
157
|
+
sentAt: new Date().toISOString(),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function listBrowserTargets(session) {
|
|
162
|
+
const cdpResult = await interceptTargetGetTargets(session)
|
|
163
|
+
const targetInfos = Array.isArray(cdpResult?.result?.targetInfos)
|
|
164
|
+
? cdpResult.result.targetInfos
|
|
165
|
+
: []
|
|
166
|
+
const pages = targetInfos
|
|
167
|
+
.filter((target) => target.type === "page")
|
|
168
|
+
.map((target) => ({
|
|
169
|
+
browserSessionId: session.browserSessionId,
|
|
170
|
+
routeBrowserSessionId: session.browserSessionId,
|
|
171
|
+
targetId: target.targetId || "",
|
|
172
|
+
tabId: target.tabId ?? null,
|
|
173
|
+
type: target.type || "",
|
|
174
|
+
url: target.url || "",
|
|
175
|
+
title: target.title || "",
|
|
176
|
+
active: Boolean(target.attached),
|
|
177
|
+
source: "Target.getTargets",
|
|
178
|
+
}))
|
|
179
|
+
return {
|
|
180
|
+
browserSessionId: session.browserSessionId,
|
|
181
|
+
pages,
|
|
182
|
+
targets: targetInfos,
|
|
183
|
+
targetInfos,
|
|
184
|
+
pageCount: pages.length,
|
|
185
|
+
total: targetInfos.length,
|
|
186
|
+
method: "Target.getTargets",
|
|
187
|
+
source: cdpResult.intercepted ? "extension.chrome.debugger" : "extension",
|
|
188
|
+
listedAt: new Date().toISOString(),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function getBrowserTarget(session, args = {}) {
|
|
193
|
+
const targetId = String(args.targetId || "").trim()
|
|
194
|
+
const tabId = Number(args.tabId)
|
|
195
|
+
if (!targetId && !Number.isFinite(tabId)) {
|
|
196
|
+
throw new Error("targetId or tabId is required")
|
|
197
|
+
}
|
|
198
|
+
const inventory = await listBrowserTargets(session)
|
|
199
|
+
const target = inventory.targets.find((item) => {
|
|
200
|
+
if (targetId && item.targetId === targetId) return true
|
|
201
|
+
if (Number.isFinite(tabId) && Number(item.tabId) === tabId) return true
|
|
202
|
+
return false
|
|
203
|
+
})
|
|
204
|
+
if (!target) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
targetId
|
|
207
|
+
? `browser target not found: ${targetId}`
|
|
208
|
+
: `browser target not found for tabId: ${tabId}`
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
browserSessionId: session.browserSessionId,
|
|
213
|
+
target,
|
|
214
|
+
method: "Target.getTargets",
|
|
215
|
+
source: inventory.source,
|
|
216
|
+
listedAt: inventory.listedAt,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function interceptTargetCloseTarget(params = {}, session = null) {
|
|
221
|
+
const targetId = String(params.targetId || "")
|
|
222
|
+
if (!targetId) {
|
|
223
|
+
throw new Error("Target.closeTarget requires targetId. Pass top-level tabId or targetId to yunti_cdp_send_command after calling yunti_list_browser_targets.")
|
|
224
|
+
}
|
|
225
|
+
const tabId = await resolveTabIdFromTarget(targetId)
|
|
226
|
+
if (!tabId || !Number.isFinite(tabId)) {
|
|
227
|
+
throw new Error(`Cannot resolve tabId from targetId: ${targetId}. Call yunti_list_browser_targets and pass a returned top-level tabId or targetId to yunti_cdp_send_command.`)
|
|
228
|
+
}
|
|
229
|
+
const connected = [...sessionsByTab.keys()]
|
|
230
|
+
if (connected.length <= 1 && connected.includes(tabId)) {
|
|
231
|
+
throw new Error("Cannot close the last connected tab")
|
|
232
|
+
}
|
|
233
|
+
await detachCdpTab(tabId, null, "target_closed").catch(() => {})
|
|
234
|
+
await chrome.tabs.remove(tabId)
|
|
235
|
+
sessionsByTab.delete(tabId)
|
|
236
|
+
pollers.get(tabId)?.abort()
|
|
237
|
+
pollers.delete(tabId)
|
|
238
|
+
return {
|
|
239
|
+
browserSessionId: session?.browserSessionId || "",
|
|
240
|
+
method: "Target.closeTarget",
|
|
241
|
+
result: { success: true },
|
|
242
|
+
intercepted: true,
|
|
243
|
+
tabId,
|
|
244
|
+
sentAt: new Date().toISOString(),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function interceptTargetCreateTarget(params = {}, parentSession = null) {
|
|
249
|
+
const url = String(params.url || "about:blank")
|
|
250
|
+
const tab = await chrome.tabs.create({ url, active: true })
|
|
251
|
+
const browserSessionId = `yunti-${tab.id}-${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`
|
|
252
|
+
const sessionData = {
|
|
253
|
+
browserSessionId,
|
|
254
|
+
userId: parentSession?.userId || "",
|
|
255
|
+
displayName: parentSession?.displayName || "",
|
|
256
|
+
tabId: tab.id,
|
|
257
|
+
windowId: tab.windowId,
|
|
258
|
+
url: tab.url || url,
|
|
259
|
+
title: tab.title || "",
|
|
260
|
+
registeredAt: new Date().toISOString(),
|
|
261
|
+
}
|
|
262
|
+
sessionsByTab.set(tab.id, sessionData)
|
|
263
|
+
await postBridge("/sessions/register", sessionData).catch(() => null)
|
|
264
|
+
startPolling(tab.id)
|
|
265
|
+
return {
|
|
266
|
+
browserSessionId,
|
|
267
|
+
method: "Target.createTarget",
|
|
268
|
+
result: { targetId: `tab-${tab.id}` },
|
|
269
|
+
intercepted: true,
|
|
270
|
+
tabId: tab.id,
|
|
271
|
+
sentAt: new Date().toISOString(),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function interceptTargetAttachToTarget(params = {}, protocolVersion = "1.3") {
|
|
276
|
+
const targetId = String(params.targetId || "")
|
|
277
|
+
const tabId = await resolveTabIdFromTarget(targetId)
|
|
278
|
+
if (!tabId) {
|
|
279
|
+
throw new Error(`Cannot resolve targetId to tab: ${targetId}. Use chrome.debugger.attach directly for non-tab targets.`)
|
|
280
|
+
}
|
|
281
|
+
if (cdpAttachedTabs.has(tabId)) {
|
|
282
|
+
return {
|
|
283
|
+
method: "Target.attachToTarget",
|
|
284
|
+
result: { sessionId: `yunti-session-${tabId}` },
|
|
285
|
+
intercepted: true,
|
|
286
|
+
tabId,
|
|
287
|
+
reused: true,
|
|
288
|
+
sentAt: new Date().toISOString(),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
await chromeDebuggerAttach({ tabId }, protocolVersion)
|
|
292
|
+
cdpAttachedTabs.add(tabId)
|
|
293
|
+
return {
|
|
294
|
+
method: "Target.attachToTarget",
|
|
295
|
+
result: { sessionId: `yunti-session-${tabId}` },
|
|
296
|
+
intercepted: true,
|
|
297
|
+
tabId,
|
|
298
|
+
attached: true,
|
|
299
|
+
sentAt: new Date().toISOString(),
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function interceptTargetDetachFromTarget(params = {}) {
|
|
304
|
+
const targetId = String(params.targetId || "")
|
|
305
|
+
let tabId = await resolveTabIdFromTarget(targetId)
|
|
306
|
+
if (!tabId) {
|
|
307
|
+
const sessionMatch = targetId.match(/^yunti-session-(\d+)$/)
|
|
308
|
+
if (sessionMatch) tabId = Number(sessionMatch[1])
|
|
309
|
+
}
|
|
310
|
+
if (!tabId || !cdpAttachedTabs.has(tabId)) {
|
|
311
|
+
return {
|
|
312
|
+
method: "Target.detachFromTarget",
|
|
313
|
+
result: {},
|
|
314
|
+
intercepted: true,
|
|
315
|
+
detached: false,
|
|
316
|
+
reason: tabId ? "not_attached" : "unknown_target",
|
|
317
|
+
sentAt: new Date().toISOString(),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
await chromeDebuggerDetach({ tabId })
|
|
321
|
+
cdpAttachedTabs.delete(tabId)
|
|
322
|
+
return {
|
|
323
|
+
method: "Target.detachFromTarget",
|
|
324
|
+
result: {},
|
|
325
|
+
intercepted: true,
|
|
326
|
+
tabId,
|
|
327
|
+
detached: true,
|
|
328
|
+
sentAt: new Date().toISOString(),
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function interceptTargetActivateTarget(params = {}) {
|
|
333
|
+
const targetId = String(params.targetId || "")
|
|
334
|
+
const tabId = await resolveTabIdFromTarget(targetId)
|
|
335
|
+
if (!tabId) throw new Error(`Cannot resolve targetId to tab: ${targetId}`)
|
|
336
|
+
await chrome.tabs.update(tabId, { active: true })
|
|
337
|
+
return {
|
|
338
|
+
method: "Target.activateTarget",
|
|
339
|
+
result: {},
|
|
340
|
+
intercepted: true,
|
|
341
|
+
tabId,
|
|
342
|
+
sentAt: new Date().toISOString(),
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function interceptTargetGetTargetInfo(params = {}) {
|
|
347
|
+
const targetId = String(params.targetId || "")
|
|
348
|
+
const tabId = await resolveTabIdFromTarget(targetId)
|
|
349
|
+
let info = { targetId, type: "page", title: "", url: "", attached: false, canAccessOpener: false }
|
|
350
|
+
if (tabId) {
|
|
351
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
|
352
|
+
info = {
|
|
353
|
+
targetId,
|
|
354
|
+
type: "page",
|
|
355
|
+
title: tab?.title || "",
|
|
356
|
+
url: tab?.url || "",
|
|
357
|
+
attached: cdpAttachedTabs.has(tabId),
|
|
358
|
+
canAccessOpener: false,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
method: "Target.getTargetInfo",
|
|
363
|
+
result: { targetInfo: info },
|
|
364
|
+
intercepted: true,
|
|
365
|
+
sentAt: new Date().toISOString(),
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function resolveCdpTargetTabId(defaultTabId, args = {}, params = {}) {
|
|
370
|
+
const directTabId = Number(args.tabId ?? params.tabId)
|
|
371
|
+
if (Number.isFinite(directTabId) && directTabId > 0) return directTabId
|
|
372
|
+
const targetId = String(args.targetId || params.targetId || "").trim()
|
|
373
|
+
if (!targetId) return defaultTabId
|
|
374
|
+
const tabId = await resolveTabIdFromTarget(targetId)
|
|
375
|
+
if (!tabId) throw new Error(`Cannot resolve targetId to tab: ${targetId}`)
|
|
376
|
+
return tabId
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function explicitCdpTargetId(args = {}, params = {}) {
|
|
380
|
+
const targetId = String(args.targetId || params.targetId || "").trim()
|
|
381
|
+
if (targetId) return targetId
|
|
382
|
+
const tabId = Number(args.tabId ?? params.tabId)
|
|
383
|
+
if (Number.isFinite(tabId) && tabId > 0) return `tab-${tabId}`
|
|
384
|
+
return ""
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function resolveTabIdFromTargetSync(targetId) {
|
|
388
|
+
if (!targetId) return null
|
|
389
|
+
if (targetId.startsWith("tab-")) {
|
|
390
|
+
const id = Number(targetId.slice(4))
|
|
391
|
+
return Number.isFinite(id) && id > 0 ? id : null
|
|
392
|
+
}
|
|
393
|
+
const numericId = Number(targetId)
|
|
394
|
+
if (/^\d+$/.test(targetId) && Number.isFinite(numericId) && numericId > 0) {
|
|
395
|
+
return numericId
|
|
396
|
+
}
|
|
397
|
+
for (const [tabId, session] of sessionsByTab) {
|
|
398
|
+
if (session.browserSessionId === targetId) return tabId
|
|
399
|
+
}
|
|
400
|
+
return null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function resolveTabIdFromTarget(targetId) {
|
|
404
|
+
const syncTabId = resolveTabIdFromTargetSync(targetId)
|
|
405
|
+
if (syncTabId) return syncTabId
|
|
406
|
+
if (chrome.debugger?.getTargets) {
|
|
407
|
+
try {
|
|
408
|
+
const targets = await chromeDebuggerGetTargets()
|
|
409
|
+
const found = targets.find((target) => target.id === targetId)
|
|
410
|
+
const tabId = Number(found?.tabId)
|
|
411
|
+
if (Number.isFinite(tabId) && tabId > 0) return tabId
|
|
412
|
+
} catch {}
|
|
413
|
+
}
|
|
414
|
+
return null
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function chromeDebuggerGetTargets() {
|
|
418
|
+
return new Promise((resolve, reject) => {
|
|
419
|
+
chrome.debugger.getTargets((targets) => {
|
|
420
|
+
const error = chrome.runtime.lastError
|
|
421
|
+
if (error) reject(new Error(error.message))
|
|
422
|
+
else resolve(targets || [])
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function ensureCdpAttached(tabId, protocolVersion = "1.3") {
|
|
428
|
+
if (!chrome.debugger?.attach) {
|
|
429
|
+
throw new Error("chrome.debugger API is unavailable")
|
|
430
|
+
}
|
|
431
|
+
if (cdpAttachedTabs.has(tabId)) {
|
|
432
|
+
await ensureCdpDomains(tabId, ["Page", "Runtime", "Log"])
|
|
433
|
+
return { attached: false, reused: true, protocolVersion }
|
|
434
|
+
}
|
|
435
|
+
await chromeDebuggerAttach({ tabId }, protocolVersion)
|
|
436
|
+
cdpAttachedTabs.add(tabId)
|
|
437
|
+
await ensureCdpDomains(tabId, ["Page", "Runtime", "Log"])
|
|
438
|
+
return { attached: true, reused: false, protocolVersion }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function ensureCdpDomains(tabId, domains = []) {
|
|
442
|
+
if (!cdpEnabledDomains.has(tabId)) cdpEnabledDomains.set(tabId, new Set())
|
|
443
|
+
const enabled = cdpEnabledDomains.get(tabId)
|
|
444
|
+
for (const domain of domains) {
|
|
445
|
+
if (enabled.has(domain)) continue
|
|
446
|
+
await chromeDebuggerSendCommand({ tabId }, `${domain}.enable`, {})
|
|
447
|
+
enabled.add(domain)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function detachCdpTab(tabId, session = null, reason = "manual") {
|
|
452
|
+
if (!tabId || !cdpAttachedTabs.has(tabId)) {
|
|
453
|
+
return {
|
|
454
|
+
browserSessionId: session?.browserSessionId || "",
|
|
455
|
+
tabId,
|
|
456
|
+
detached: false,
|
|
457
|
+
reason: "not_attached",
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
await chromeDebuggerDetach({ tabId })
|
|
461
|
+
cdpAttachedTabs.delete(tabId)
|
|
462
|
+
cdpEnabledDomains.delete(tabId)
|
|
463
|
+
traceBuffers.delete(tabId)
|
|
464
|
+
return {
|
|
465
|
+
browserSessionId: session?.browserSessionId || "",
|
|
466
|
+
tabId,
|
|
467
|
+
detached: true,
|
|
468
|
+
reason,
|
|
469
|
+
detachedAt: new Date().toISOString(),
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function chromeDebuggerAttach(target, protocolVersion) {
|
|
474
|
+
return new Promise((resolve, reject) => {
|
|
475
|
+
chrome.debugger.attach(target, protocolVersion, () => {
|
|
476
|
+
const error = chrome.runtime.lastError
|
|
477
|
+
if (error) reject(new Error(error.message))
|
|
478
|
+
else resolve(null)
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function chromeDebuggerDetach(target) {
|
|
484
|
+
return new Promise((resolve, reject) => {
|
|
485
|
+
chrome.debugger.detach(target, () => {
|
|
486
|
+
const error = chrome.runtime.lastError
|
|
487
|
+
if (error) reject(new Error(error.message))
|
|
488
|
+
else resolve(null)
|
|
489
|
+
})
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function chromeDebuggerSendCommand(target, method, params) {
|
|
494
|
+
return new Promise((resolve, reject) => {
|
|
495
|
+
chrome.debugger.sendCommand(target, method, params, (result) => {
|
|
496
|
+
const error = chrome.runtime.lastError
|
|
497
|
+
if (error) reject(new Error(error.message))
|
|
498
|
+
else resolve(result)
|
|
499
|
+
})
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function startPerformanceTrace(tabId, session, args = {}) {
|
|
504
|
+
const categoriesStr = String(args.categories || "blink,loading,rendering,page").trim()
|
|
505
|
+
const includedCategories = categoriesStr
|
|
506
|
+
.split(",")
|
|
507
|
+
.map((category) => category.trim())
|
|
508
|
+
.filter(Boolean)
|
|
509
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
510
|
+
traceBuffers.set(tabId, {
|
|
511
|
+
events: [],
|
|
512
|
+
startedAt: Date.now(),
|
|
513
|
+
completed: false,
|
|
514
|
+
})
|
|
515
|
+
await chromeDebuggerSendCommand({ tabId }, "Tracing.start", {
|
|
516
|
+
traceConfig: {
|
|
517
|
+
includedCategories,
|
|
518
|
+
memoryDumpConfig: {},
|
|
519
|
+
},
|
|
520
|
+
})
|
|
521
|
+
return { tracing: true, categories: categoriesStr, browserSessionId: session.browserSessionId }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function stopPerformanceTrace(tabId, session) {
|
|
525
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
526
|
+
const buffer = traceBuffers.get(tabId) || {
|
|
527
|
+
events: [],
|
|
528
|
+
startedAt: Date.now(),
|
|
529
|
+
completed: false,
|
|
530
|
+
}
|
|
531
|
+
traceBuffers.set(tabId, buffer)
|
|
532
|
+
await chromeDebuggerSendCommand({ tabId }, "Tracing.end", {})
|
|
533
|
+
await waitForTraceComplete(tabId, 5000)
|
|
534
|
+
const events = buffer.events || []
|
|
535
|
+
const durationMs = Date.now() - (buffer.startedAt || Date.now())
|
|
536
|
+
traceBuffers.delete(tabId)
|
|
537
|
+
return {
|
|
538
|
+
events: events.slice(0, 1000),
|
|
539
|
+
eventsTruncated: events.length > 1000,
|
|
540
|
+
durationMs,
|
|
541
|
+
browserSessionId: session.browserSessionId,
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function recordTraceEvent(tabId, method, params = {}) {
|
|
546
|
+
const buffer = traceBuffers.get(tabId)
|
|
547
|
+
if (!buffer) return
|
|
548
|
+
if (method === "Tracing.dataCollected") {
|
|
549
|
+
const value = Array.isArray(params.value) ? params.value : []
|
|
550
|
+
buffer.events.push(...value)
|
|
551
|
+
} else if (method === "Tracing.tracingComplete") {
|
|
552
|
+
buffer.completed = true
|
|
553
|
+
buffer.stream = params.stream || ""
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function waitForTraceComplete(tabId, timeoutMs = 5000) {
|
|
558
|
+
const start = Date.now()
|
|
559
|
+
while (Date.now() - start < timeoutMs) {
|
|
560
|
+
const buffer = traceBuffers.get(tabId)
|
|
561
|
+
if (!buffer || buffer.completed) return
|
|
562
|
+
await delayCdp(100)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function delayCdp(ms) {
|
|
567
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
chromeDebuggerSendCommand,
|
|
572
|
+
delayCdp,
|
|
573
|
+
detachCdpTab,
|
|
574
|
+
ensureCdpAttached,
|
|
575
|
+
getBrowserTarget,
|
|
576
|
+
installCdpEventForwarder,
|
|
577
|
+
listBrowserTargets,
|
|
578
|
+
sendCdpCommand,
|
|
579
|
+
startPerformanceTrace,
|
|
580
|
+
stopPerformanceTrace,
|
|
581
|
+
}
|
|
582
|
+
}
|