ghost-bridge 0.7.0 → 0.8.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/README.md +20 -18
- package/dist/cli.js +266 -140
- package/dist/server.js +137 -38
- package/extension/background.js +246 -628
- package/extension/bg-dom.js +441 -0
- package/extension/bg-network.js +194 -0
- package/extension/manifest.json +1 -3
- package/extension/offscreen.js +64 -21
- package/extension/popup.html +72 -54
- package/extension/popup.js +72 -42
- package/package.json +1 -1
- package/extension/icon-32.png +0 -0
package/extension/background.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
importScripts('bg-network.js', 'bg-dom.js')
|
|
2
|
+
|
|
1
3
|
const DEFAULT_TOKEN = 'ghost-bridge-local'
|
|
2
4
|
|
|
3
5
|
const CONFIG = {
|
|
@@ -11,13 +13,15 @@ const CONFIG = {
|
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
let attachedTabId = null
|
|
16
|
+
let targetMode = 'focused' // focused | pinned
|
|
17
|
+
let pinnedTabId = null
|
|
14
18
|
let scriptMap = new Map()
|
|
15
19
|
let scriptSourceCache = new Map()
|
|
16
20
|
let lastErrors = []
|
|
17
21
|
let lastErrorLocation = null
|
|
18
22
|
let requestMap = new Map()
|
|
19
23
|
let networkRequests = []
|
|
20
|
-
let state = { enabled: false, connected: false, port: null, currentPort: null, connectionStatus: 'disconnected', connectionError: '' }
|
|
24
|
+
let state = { enabled: false, connected: false, port: null, currentPort: null, connectionStatus: 'disconnected', connectionError: '', serverInfo: null }
|
|
21
25
|
|
|
22
26
|
// 待处理的请求(等待 offscreen 响应)
|
|
23
27
|
const pendingRequests = new Map()
|
|
@@ -235,196 +239,18 @@ function pushNetworkRequest(entry) {
|
|
|
235
239
|
trimNetworkRequests()
|
|
236
240
|
}
|
|
237
241
|
|
|
238
|
-
function getApiSignalScore(entry) {
|
|
239
|
-
const url = (entry.url || '').toLowerCase()
|
|
240
|
-
let score = 0
|
|
241
|
-
if (url.includes('/api/')) score += 80
|
|
242
|
-
if (url.includes('graphql')) score += 80
|
|
243
|
-
if (url.includes('/rpc/')) score += 60
|
|
244
|
-
if (url.includes('/rest/')) score += 40
|
|
245
|
-
if ((entry.method || 'GET').toUpperCase() !== 'GET') score += 25
|
|
246
|
-
return score
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function getResourceTypeScore(entry, mode = 'debug') {
|
|
250
|
-
const type = (entry.resourceType || '').toLowerCase()
|
|
251
|
-
const debugScores = {
|
|
252
|
-
fetch: 140,
|
|
253
|
-
xhr: 140,
|
|
254
|
-
websocket: 120,
|
|
255
|
-
document: 90,
|
|
256
|
-
script: 45,
|
|
257
|
-
stylesheet: 25,
|
|
258
|
-
other: 0,
|
|
259
|
-
image: -40,
|
|
260
|
-
font: -40,
|
|
261
|
-
media: -50,
|
|
262
|
-
}
|
|
263
|
-
const apiScores = {
|
|
264
|
-
fetch: 220,
|
|
265
|
-
xhr: 220,
|
|
266
|
-
websocket: 160,
|
|
267
|
-
document: 40,
|
|
268
|
-
script: -10,
|
|
269
|
-
stylesheet: -20,
|
|
270
|
-
other: 0,
|
|
271
|
-
image: -80,
|
|
272
|
-
font: -80,
|
|
273
|
-
media: -90,
|
|
274
|
-
}
|
|
275
|
-
const table = mode === 'api' ? apiScores : debugScores
|
|
276
|
-
return table[type] ?? table.other
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function getStatusScore(entry) {
|
|
280
|
-
if (entry.status === 'failed') return 360
|
|
281
|
-
if (entry.status === 'error') return 330
|
|
282
|
-
if (entry.status === 'pending') return 280
|
|
283
|
-
if ((entry.statusCode || 0) >= 500) return 340
|
|
284
|
-
if ((entry.statusCode || 0) >= 400) return 300
|
|
285
|
-
return 80
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function getNetworkPriorityScore(entry, mode = 'debug') {
|
|
289
|
-
if (mode === 'recent') {
|
|
290
|
-
return entry.timestamp || 0
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
let score = getStatusScore(entry)
|
|
294
|
-
score += getResourceTypeScore(entry, mode)
|
|
295
|
-
score += getApiSignalScore(entry)
|
|
296
|
-
|
|
297
|
-
if (entry.fromCache) score -= 20
|
|
298
|
-
if ((entry.encodedDataLength || 0) === 0 && entry.status === 'success') score -= 10
|
|
299
|
-
|
|
300
|
-
return score
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function compareNetworkEntries(a, b, mode = 'debug') {
|
|
304
|
-
if (mode === 'recent') {
|
|
305
|
-
return (b.timestamp || 0) - (a.timestamp || 0)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const scoreDiff = getNetworkPriorityScore(b, mode) - getNetworkPriorityScore(a, mode)
|
|
309
|
-
if (scoreDiff !== 0) return scoreDiff
|
|
310
|
-
return (b.timestamp || 0) - (a.timestamp || 0)
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const MAX_NETWORK_URL_OUTPUT_LENGTH = 240
|
|
314
|
-
const NETWORK_URL_HEAD_LENGTH = 180
|
|
315
|
-
const NETWORK_URL_TAIL_LENGTH = 40
|
|
316
|
-
const MAX_DATA_URL_OUTPUT_LENGTH = 256
|
|
317
|
-
|
|
318
|
-
function summarizeNetworkUrl(url) {
|
|
319
|
-
if (!url) return { displayUrl: url }
|
|
320
|
-
|
|
321
|
-
const urlOriginalLength = url.length
|
|
322
|
-
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(url)
|
|
323
|
-
const urlScheme = schemeMatch?.[1]?.toLowerCase()
|
|
324
|
-
|
|
325
|
-
if (urlScheme === 'data') {
|
|
326
|
-
const commaIndex = url.indexOf(',')
|
|
327
|
-
const meta = commaIndex >= 0 ? url.slice(5, commaIndex) : url.slice(5)
|
|
328
|
-
const dataUrlMimeType = (meta.split(';')[0] || 'text/plain').toLowerCase()
|
|
329
|
-
const isBase64 = meta.includes(';base64')
|
|
330
|
-
|
|
331
|
-
if (!isBase64 && urlOriginalLength <= MAX_DATA_URL_OUTPUT_LENGTH) {
|
|
332
|
-
return {
|
|
333
|
-
displayUrl: url,
|
|
334
|
-
urlOriginalLength,
|
|
335
|
-
urlScheme,
|
|
336
|
-
urlTruncated: false,
|
|
337
|
-
dataUrlMimeType,
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return {
|
|
342
|
-
displayUrl: `data:${dataUrlMimeType}${isBase64 ? ';base64' : ''},<omitted ${urlOriginalLength} chars>`,
|
|
343
|
-
urlOriginalLength,
|
|
344
|
-
urlScheme,
|
|
345
|
-
urlTruncated: true,
|
|
346
|
-
dataUrlMimeType,
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (urlOriginalLength > MAX_NETWORK_URL_OUTPUT_LENGTH) {
|
|
351
|
-
return {
|
|
352
|
-
displayUrl: `${url.slice(0, NETWORK_URL_HEAD_LENGTH)}...${url.slice(-NETWORK_URL_TAIL_LENGTH)}`,
|
|
353
|
-
urlOriginalLength,
|
|
354
|
-
urlScheme,
|
|
355
|
-
urlTruncated: true,
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
displayUrl: url,
|
|
361
|
-
urlOriginalLength,
|
|
362
|
-
urlScheme,
|
|
363
|
-
urlTruncated: false,
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
function buildNetworkRequestSummary(entry) {
|
|
368
|
-
const urlMeta = summarizeNetworkUrl(entry.url)
|
|
369
|
-
return {
|
|
370
|
-
requestId: entry.requestId,
|
|
371
|
-
url: urlMeta.displayUrl,
|
|
372
|
-
...(urlMeta.urlTruncated ? { urlTruncated: true, urlOriginalLength: urlMeta.urlOriginalLength } : {}),
|
|
373
|
-
...(urlMeta.urlScheme ? { urlScheme: urlMeta.urlScheme } : {}),
|
|
374
|
-
...(urlMeta.dataUrlMimeType ? { dataUrlMimeType: urlMeta.dataUrlMimeType } : {}),
|
|
375
|
-
method: entry.method,
|
|
376
|
-
status: entry.status,
|
|
377
|
-
statusCode: entry.statusCode,
|
|
378
|
-
resourceType: entry.resourceType,
|
|
379
|
-
mimeType: entry.mimeType,
|
|
380
|
-
duration: entry.duration,
|
|
381
|
-
encodedDataLength: entry.encodedDataLength,
|
|
382
|
-
fromCache: entry.fromCache,
|
|
383
|
-
timestamp: entry.timestamp,
|
|
384
|
-
errorText: entry.errorText,
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
242
|
function trimNetworkRequests() {
|
|
389
|
-
|
|
390
|
-
let worstIndex = 0
|
|
391
|
-
for (let i = 1; i < networkRequests.length; i++) {
|
|
392
|
-
const candidate = networkRequests[i]
|
|
393
|
-
const worst = networkRequests[worstIndex]
|
|
394
|
-
const cmp = compareNetworkEntries(candidate, worst, 'debug')
|
|
395
|
-
if (cmp < 0 || (cmp === 0 && (candidate.timestamp || 0) < (worst.timestamp || 0))) {
|
|
396
|
-
worstIndex = i
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
networkRequests.splice(worstIndex, 1)
|
|
400
|
-
}
|
|
243
|
+
GhostBridgeNetwork.trimTrackedRequests(networkRequests, CONFIG.maxRequestsTracked)
|
|
401
244
|
}
|
|
402
245
|
|
|
403
246
|
function trimPendingRequests() {
|
|
404
|
-
|
|
405
|
-
const entries = [...requestMap.entries()]
|
|
406
|
-
let worstKey = entries[0]?.[0]
|
|
407
|
-
let worstValue = entries[0]?.[1]
|
|
408
|
-
for (let i = 1; i < entries.length; i++) {
|
|
409
|
-
const [key, value] = entries[i]
|
|
410
|
-
const cmp = compareNetworkEntries(value, worstValue, 'debug')
|
|
411
|
-
if (cmp < 0 || (cmp === 0 && (value.timestamp || 0) < (worstValue.timestamp || 0))) {
|
|
412
|
-
worstKey = key
|
|
413
|
-
worstValue = value
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
if (!worstKey) break
|
|
417
|
-
requestMap.delete(worstKey)
|
|
418
|
-
}
|
|
247
|
+
GhostBridgeNetwork.trimPendingRequestMap(requestMap, CONFIG.maxRequestsTracked * 2)
|
|
419
248
|
}
|
|
420
249
|
|
|
421
250
|
chrome.debugger.onDetach.addListener((source, reason) => {
|
|
422
251
|
if (source.tabId && source.tabId === attachedTabId) {
|
|
423
252
|
attachedTabId = null
|
|
424
|
-
|
|
425
|
-
scriptSourceCache = new Map()
|
|
426
|
-
networkRequests = []
|
|
427
|
-
requestMap = new Map()
|
|
253
|
+
resetDebuggerState()
|
|
428
254
|
|
|
429
255
|
if (!state.enabled) return
|
|
430
256
|
if (reason === "canceled_by_user") {
|
|
@@ -468,20 +294,66 @@ function compactStack(stackTrace) {
|
|
|
468
294
|
|
|
469
295
|
// ========== Debugger 操作 ==========
|
|
470
296
|
|
|
297
|
+
function resetDebuggerState() {
|
|
298
|
+
scriptMap = new Map()
|
|
299
|
+
scriptSourceCache = new Map()
|
|
300
|
+
networkRequests = []
|
|
301
|
+
requestMap = new Map()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function summarizeTab(tab) {
|
|
305
|
+
if (!tab) return null
|
|
306
|
+
return {
|
|
307
|
+
id: tab.id,
|
|
308
|
+
windowId: tab.windowId,
|
|
309
|
+
index: tab.index,
|
|
310
|
+
active: !!tab.active,
|
|
311
|
+
title: tab.title || '',
|
|
312
|
+
url: tab.url || '',
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function getFocusedTabOrThrow() {
|
|
317
|
+
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
|
|
318
|
+
if (!tab || tab.id === undefined) throw new Error("没有激活的标签页")
|
|
319
|
+
return tab
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function getTabOrThrow(tabId) {
|
|
323
|
+
if (!Number.isInteger(tabId) || tabId < 0) throw new Error("需要提供有效的 tabId")
|
|
324
|
+
try {
|
|
325
|
+
const tab = await chrome.tabs.get(tabId)
|
|
326
|
+
if (!tab || tab.id === undefined) throw new Error("标签页不可用")
|
|
327
|
+
return tab
|
|
328
|
+
} catch (e) {
|
|
329
|
+
throw new Error(`标签页 ${tabId} 不可用:${e.message}`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function resolveTargetTab(params = {}) {
|
|
334
|
+
if (params.tabId !== undefined) {
|
|
335
|
+
return getTabOrThrow(Number(params.tabId))
|
|
336
|
+
}
|
|
337
|
+
if (targetMode === 'pinned') {
|
|
338
|
+
if (pinnedTabId === null) throw new Error("锁定的标签页不可用,请重新 pin")
|
|
339
|
+
return getTabOrThrow(pinnedTabId)
|
|
340
|
+
}
|
|
341
|
+
return getFocusedTabOrThrow()
|
|
342
|
+
}
|
|
343
|
+
|
|
471
344
|
// attach 互斥锁:防止并发调用 ensureAttached 导致重复 attach / 状态竞态
|
|
472
345
|
let _attachLock = Promise.resolve()
|
|
473
346
|
|
|
474
|
-
async function ensureAttached() {
|
|
347
|
+
async function ensureAttached(params = {}) {
|
|
475
348
|
let _release
|
|
476
349
|
const _prev = _attachLock
|
|
477
350
|
_attachLock = new Promise(r => _release = r)
|
|
478
351
|
await _prev
|
|
479
352
|
try {
|
|
480
353
|
if (!state.enabled) throw new Error("扩展已暂停,点击图标开启后再试")
|
|
481
|
-
const
|
|
482
|
-
if (!tab) throw new Error("没有激活的标签页")
|
|
354
|
+
const tab = await resolveTargetTab(params)
|
|
483
355
|
if (attachedTabId !== tab.id) {
|
|
484
|
-
if (attachedTabId) {
|
|
356
|
+
if (attachedTabId !== null) {
|
|
485
357
|
try { await chrome.debugger.detach({ tabId: attachedTabId }) } catch (e) {}
|
|
486
358
|
}
|
|
487
359
|
try {
|
|
@@ -497,10 +369,7 @@ async function ensureAttached() {
|
|
|
497
369
|
throw e
|
|
498
370
|
}
|
|
499
371
|
attachedTabId = tab.id
|
|
500
|
-
|
|
501
|
-
scriptSourceCache = new Map()
|
|
502
|
-
networkRequests = []
|
|
503
|
-
requestMap = new Map()
|
|
372
|
+
resetDebuggerState()
|
|
504
373
|
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Runtime.enable")
|
|
505
374
|
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Log.enable")
|
|
506
375
|
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Console.enable").catch(() => {})
|
|
@@ -522,13 +391,14 @@ async function ensureAttached() {
|
|
|
522
391
|
}
|
|
523
392
|
|
|
524
393
|
async function maybeDetach(force = false) {
|
|
525
|
-
if ((CONFIG.autoDetach || force) && attachedTabId) {
|
|
394
|
+
if ((CONFIG.autoDetach || force) && attachedTabId !== null) {
|
|
526
395
|
try {
|
|
527
396
|
await chrome.debugger.detach({ tabId: attachedTabId })
|
|
528
397
|
} catch (e) {
|
|
529
398
|
log(`detach 失败:${e.message}`)
|
|
530
399
|
} finally {
|
|
531
400
|
attachedTabId = null
|
|
401
|
+
resetDebuggerState()
|
|
532
402
|
}
|
|
533
403
|
}
|
|
534
404
|
}
|
|
@@ -555,10 +425,105 @@ async function detachAllTargets() {
|
|
|
555
425
|
}
|
|
556
426
|
} catch {}
|
|
557
427
|
attachedTabId = null
|
|
428
|
+
resetDebuggerState()
|
|
558
429
|
}
|
|
559
430
|
|
|
560
431
|
// ========== 命令处理 ==========
|
|
561
432
|
|
|
433
|
+
async function buildTargetInfo() {
|
|
434
|
+
const viewingTab = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
|
|
435
|
+
.then(([tab]) => tab || null)
|
|
436
|
+
.catch(() => null)
|
|
437
|
+
|
|
438
|
+
let targetTab = null
|
|
439
|
+
let targetError = ''
|
|
440
|
+
const targetTabId = targetMode === 'pinned' ? pinnedTabId : attachedTabId
|
|
441
|
+
|
|
442
|
+
if (targetTabId !== null && targetTabId !== undefined) {
|
|
443
|
+
try {
|
|
444
|
+
targetTab = await chrome.tabs.get(targetTabId)
|
|
445
|
+
} catch (e) {
|
|
446
|
+
targetError = e.message
|
|
447
|
+
}
|
|
448
|
+
} else if (targetMode === 'focused') {
|
|
449
|
+
targetTab = viewingTab
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
targetMode,
|
|
454
|
+
pinnedTabId,
|
|
455
|
+
attachedTabId,
|
|
456
|
+
targetTab: summarizeTab(targetTab),
|
|
457
|
+
viewingTab: summarizeTab(viewingTab),
|
|
458
|
+
targetError,
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function handleListTabs() {
|
|
463
|
+
const tabs = await chrome.tabs.query({})
|
|
464
|
+
const targetInfo = await buildTargetInfo()
|
|
465
|
+
return {
|
|
466
|
+
...targetInfo,
|
|
467
|
+
tabs: tabs
|
|
468
|
+
.filter((tab) => tab.id !== undefined)
|
|
469
|
+
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
|
|
470
|
+
.map(summarizeTab),
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function pinTab(tab) {
|
|
475
|
+
const previousMode = targetMode
|
|
476
|
+
const previousPinnedTabId = pinnedTabId
|
|
477
|
+
targetMode = 'pinned'
|
|
478
|
+
pinnedTabId = tab.id
|
|
479
|
+
try {
|
|
480
|
+
await ensureAttached({ tabId: tab.id })
|
|
481
|
+
} catch (e) {
|
|
482
|
+
targetMode = previousMode
|
|
483
|
+
pinnedTabId = previousPinnedTabId
|
|
484
|
+
throw e
|
|
485
|
+
}
|
|
486
|
+
broadcastStatus()
|
|
487
|
+
return buildTargetInfo()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function handlePinCurrentTab() {
|
|
491
|
+
return pinTab(await getFocusedTabOrThrow())
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function handlePinTab(params = {}) {
|
|
495
|
+
if (params.tabId !== undefined) {
|
|
496
|
+
return pinTab(await getTabOrThrow(Number(params.tabId)))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const urlContains = String(params.urlContains || '')
|
|
500
|
+
const titleContains = String(params.titleContains || '')
|
|
501
|
+
if (!urlContains && !titleContains) {
|
|
502
|
+
throw new Error("需要提供 tabId、urlContains 或 titleContains")
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const tabs = await chrome.tabs.query({})
|
|
506
|
+
const matches = tabs.filter((tab) => {
|
|
507
|
+
const urlOk = !urlContains || (tab.url || '').includes(urlContains)
|
|
508
|
+
const titleOk = !titleContains || (tab.title || '').includes(titleContains)
|
|
509
|
+
return tab.id !== undefined && urlOk && titleOk
|
|
510
|
+
})
|
|
511
|
+
if (matches.length === 0) throw new Error("没有找到匹配的标签页")
|
|
512
|
+
|
|
513
|
+
matches.sort((a, b) => Number(b.active) - Number(a.active) || (a.windowId - b.windowId) || (a.index - b.index))
|
|
514
|
+
return pinTab(matches[0])
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function handleUnpinTab() {
|
|
518
|
+
targetMode = 'focused'
|
|
519
|
+
pinnedTabId = null
|
|
520
|
+
if (state.enabled && state.connected) {
|
|
521
|
+
await ensureAttached()
|
|
522
|
+
}
|
|
523
|
+
broadcastStatus()
|
|
524
|
+
return buildTargetInfo()
|
|
525
|
+
}
|
|
526
|
+
|
|
562
527
|
async function handleGetLastError(params = {}) {
|
|
563
528
|
await ensureAttached()
|
|
564
529
|
const severity = params.severity || "error"
|
|
@@ -740,14 +705,14 @@ async function handleListNetworkRequests(params = {}) {
|
|
|
740
705
|
results = results.filter(r => r.resourceType?.toLowerCase() === lowerType)
|
|
741
706
|
}
|
|
742
707
|
|
|
743
|
-
results.sort((a, b) => compareNetworkEntries(a, b, priorityMode))
|
|
708
|
+
results.sort((a, b) => GhostBridgeNetwork.compareNetworkEntries(a, b, priorityMode))
|
|
744
709
|
results = results.slice(0, limit)
|
|
745
710
|
|
|
746
711
|
return {
|
|
747
712
|
total: networkRequests.length + requestMap.size,
|
|
748
713
|
filtered: results.length,
|
|
749
714
|
priorityMode,
|
|
750
|
-
requests: results.map(buildNetworkRequestSummary),
|
|
715
|
+
requests: results.map((entry) => GhostBridgeNetwork.buildNetworkRequestSummary(entry)),
|
|
751
716
|
}
|
|
752
717
|
}
|
|
753
718
|
|
|
@@ -760,7 +725,7 @@ async function handleGetNetworkDetail(params = {}) {
|
|
|
760
725
|
if (!entry) entry = networkRequests.find(r => r.requestId === requestId)
|
|
761
726
|
if (!entry) throw new Error(`未找到请求: ${requestId}`)
|
|
762
727
|
|
|
763
|
-
const urlMeta = summarizeNetworkUrl(entry.url)
|
|
728
|
+
const urlMeta = GhostBridgeNetwork.summarizeNetworkUrl(entry.url)
|
|
764
729
|
const result = {
|
|
765
730
|
...entry,
|
|
766
731
|
url: urlMeta.displayUrl,
|
|
@@ -1041,186 +1006,7 @@ async function handleCaptureScreenshot(params = {}) {
|
|
|
1041
1006
|
async function handleInspectPageSnapshot(params = {}) {
|
|
1042
1007
|
const target = await ensureAttached()
|
|
1043
1008
|
const { selector, includeInteractive = true, maxElements = 30 } = params
|
|
1044
|
-
|
|
1045
|
-
const selectorStr = selector ? JSON.stringify(selector) : 'null'
|
|
1046
|
-
|
|
1047
|
-
const expression = `(function() {
|
|
1048
|
-
try {
|
|
1049
|
-
if (document.readyState === 'loading') {
|
|
1050
|
-
return { error: '页面尚未加载完成,请稍后重试', readyState: document.readyState };
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
const includeInteractive = ${includeInteractive};
|
|
1054
|
-
const maxEls = ${maxElements};
|
|
1055
|
-
const selector = ${selectorStr};
|
|
1056
|
-
const result = {};
|
|
1057
|
-
let targetElement = document.body;
|
|
1058
|
-
|
|
1059
|
-
if (selector) {
|
|
1060
|
-
try {
|
|
1061
|
-
targetElement = document.querySelector(selector);
|
|
1062
|
-
if (!targetElement) {
|
|
1063
|
-
return { error: '选择器未匹配到任何元素', selector: selector, suggestion: '请检查选择器是否正确' };
|
|
1064
|
-
}
|
|
1065
|
-
result.selector = selector;
|
|
1066
|
-
result.matchedTag = targetElement.tagName.toLowerCase();
|
|
1067
|
-
} catch (e) {
|
|
1068
|
-
return { error: '无效的 CSS 选择器: ' + e.message, selector: selector };
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
result.metadata = {
|
|
1073
|
-
title: document.title || '',
|
|
1074
|
-
url: window.location.href,
|
|
1075
|
-
description: document.querySelector('meta[name="description"]')?.content || '',
|
|
1076
|
-
keywords: document.querySelector('meta[name="keywords"]')?.content || '',
|
|
1077
|
-
charset: document.characterSet,
|
|
1078
|
-
language: document.documentElement.lang || '',
|
|
1079
|
-
};
|
|
1080
|
-
|
|
1081
|
-
const structured = {};
|
|
1082
|
-
const headings = targetElement.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
|
1083
|
-
structured.headings = Array.from(headings).slice(0, 50).map(h => ({
|
|
1084
|
-
level: parseInt(h.tagName[1]),
|
|
1085
|
-
text: h.innerText.trim().slice(0, 200)
|
|
1086
|
-
}));
|
|
1087
|
-
const links = targetElement.querySelectorAll('a[href]');
|
|
1088
|
-
structured.links = Array.from(links).slice(0, 100).map(a => ({
|
|
1089
|
-
text: (a.innerText || '').trim().slice(0, 100),
|
|
1090
|
-
href: a.href
|
|
1091
|
-
})).filter(l => l.href && !l.href.startsWith('javascript:'));
|
|
1092
|
-
const buttons = targetElement.querySelectorAll('button, input[type="button"], input[type="submit"], [role="button"]');
|
|
1093
|
-
structured.buttons = Array.from(buttons).slice(0, 50).map(b => ({
|
|
1094
|
-
text: (b.innerText || b.value || b.getAttribute('aria-label') || '').trim().slice(0, 100),
|
|
1095
|
-
type: b.type || 'button',
|
|
1096
|
-
disabled: b.disabled || false
|
|
1097
|
-
}));
|
|
1098
|
-
const forms = targetElement.querySelectorAll('form');
|
|
1099
|
-
structured.forms = Array.from(forms).slice(0, 20).map(f => {
|
|
1100
|
-
const fields = Array.from(f.querySelectorAll('input, select, textarea')).slice(0, 30);
|
|
1101
|
-
return {
|
|
1102
|
-
action: f.action || '',
|
|
1103
|
-
method: (f.method || 'GET').toUpperCase(),
|
|
1104
|
-
fieldCount: fields.length,
|
|
1105
|
-
fields: fields.map(field => ({
|
|
1106
|
-
tag: field.tagName.toLowerCase(),
|
|
1107
|
-
type: field.type || '',
|
|
1108
|
-
name: field.name || '',
|
|
1109
|
-
placeholder: field.placeholder || '',
|
|
1110
|
-
required: field.required || false
|
|
1111
|
-
}))
|
|
1112
|
-
};
|
|
1113
|
-
});
|
|
1114
|
-
const images = targetElement.querySelectorAll('img');
|
|
1115
|
-
structured.images = Array.from(images).slice(0, 50).map(img => ({
|
|
1116
|
-
alt: img.alt || '',
|
|
1117
|
-
src: img.src ? img.src.slice(0, 200) : ''
|
|
1118
|
-
})).filter(img => img.src);
|
|
1119
|
-
const tables = targetElement.querySelectorAll('table');
|
|
1120
|
-
structured.tables = Array.from(tables).slice(0, 10).map(table => {
|
|
1121
|
-
const headers = Array.from(table.querySelectorAll('th')).map(th => th.innerText.trim().slice(0, 50));
|
|
1122
|
-
const rows = table.querySelectorAll('tr');
|
|
1123
|
-
return { headers: headers.slice(0, 20), rowCount: rows.length };
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
result.page = {
|
|
1127
|
-
metadata: result.metadata,
|
|
1128
|
-
...(result.selector ? { selector: result.selector, matchedTag: result.matchedTag } : {}),
|
|
1129
|
-
structured,
|
|
1130
|
-
counts: {
|
|
1131
|
-
headings: structured.headings.length,
|
|
1132
|
-
links: structured.links.length,
|
|
1133
|
-
buttons: structured.buttons.length,
|
|
1134
|
-
forms: structured.forms.length,
|
|
1135
|
-
images: structured.images.length,
|
|
1136
|
-
tables: structured.tables.length
|
|
1137
|
-
},
|
|
1138
|
-
mode: 'structured'
|
|
1139
|
-
};
|
|
1140
|
-
|
|
1141
|
-
if (!includeInteractive) {
|
|
1142
|
-
result.interactive = null;
|
|
1143
|
-
return result;
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
let refCounter = 0;
|
|
1147
|
-
const elements = [];
|
|
1148
|
-
const INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="radio"],[role="switch"],[role="combobox"],[tabindex]:not([tabindex="-1"]),[contenteditable="true"],[onclick]';
|
|
1149
|
-
|
|
1150
|
-
function isVisible(el) {
|
|
1151
|
-
const style = window.getComputedStyle(el);
|
|
1152
|
-
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return null;
|
|
1153
|
-
if (!el.offsetParent && el.tagName !== 'HTML' && el.tagName !== 'BODY' &&
|
|
1154
|
-
style.position !== 'fixed' && style.position !== 'sticky') return null;
|
|
1155
|
-
const rect = el.getBoundingClientRect();
|
|
1156
|
-
if (rect.width === 0 && rect.height === 0) return null;
|
|
1157
|
-
return rect;
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
function buildEntry(el, rect) {
|
|
1161
|
-
refCounter++;
|
|
1162
|
-
const ref = 'e' + refCounter;
|
|
1163
|
-
el.setAttribute('data-ghost-ref', ref);
|
|
1164
|
-
const tag = el.tagName.toLowerCase();
|
|
1165
|
-
const entry = { ref, tag, cx: Math.round(rect.left + rect.width / 2), cy: Math.round(rect.top + rect.height / 2) };
|
|
1166
|
-
if (el.type) entry.type = el.type;
|
|
1167
|
-
if (el.name) entry.name = el.name;
|
|
1168
|
-
if (el.getAttribute('role')) entry.role = el.getAttribute('role');
|
|
1169
|
-
if (el.placeholder) entry.placeholder = el.placeholder.slice(0, 80);
|
|
1170
|
-
if (el.value && tag !== 'textarea') entry.value = el.value.slice(0, 80);
|
|
1171
|
-
if (tag === 'a') entry.href = (el.href || '').slice(0, 150);
|
|
1172
|
-
if (tag === 'select') {
|
|
1173
|
-
entry.options = Array.from(el.options).slice(0, 10).map(o => ({
|
|
1174
|
-
value: o.value, text: o.text.slice(0, 50), selected: o.selected
|
|
1175
|
-
}));
|
|
1176
|
-
}
|
|
1177
|
-
const text = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
|
|
1178
|
-
if (text && text.length <= 100) entry.text = text;
|
|
1179
|
-
else if (text) entry.text = text.slice(0, 97) + '...';
|
|
1180
|
-
if (el.disabled) entry.disabled = true;
|
|
1181
|
-
return entry;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
function scanRoot(root) {
|
|
1185
|
-
const candidates = root.querySelectorAll(INTERACTIVE_SELECTOR);
|
|
1186
|
-
for (let i = 0; i < candidates.length && elements.length < maxEls; i++) {
|
|
1187
|
-
const rect = isVisible(candidates[i]);
|
|
1188
|
-
if (rect) elements.push(buildEntry(candidates[i], rect));
|
|
1189
|
-
}
|
|
1190
|
-
if (elements.length < maxEls) {
|
|
1191
|
-
const all = root.querySelectorAll('*');
|
|
1192
|
-
for (let i = 0; i < all.length && elements.length < maxEls; i++) {
|
|
1193
|
-
const el = all[i];
|
|
1194
|
-
if (el.shadowRoot) scanRoot(el.shadowRoot);
|
|
1195
|
-
if (el.onclick && !el.hasAttribute('data-ghost-ref')) {
|
|
1196
|
-
const rect = isVisible(el);
|
|
1197
|
-
if (rect) elements.push(buildEntry(el, rect));
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
document.querySelectorAll('[data-ghost-ref]').forEach(el => el.removeAttribute('data-ghost-ref'));
|
|
1204
|
-
scanRoot(targetElement);
|
|
1205
|
-
|
|
1206
|
-
result.interactive = {
|
|
1207
|
-
url: window.location.href,
|
|
1208
|
-
title: document.title,
|
|
1209
|
-
elementCount: elements.length,
|
|
1210
|
-
viewport: {
|
|
1211
|
-
width: window.innerWidth,
|
|
1212
|
-
height: window.innerHeight,
|
|
1213
|
-
scrollX: Math.round(window.scrollX),
|
|
1214
|
-
scrollY: Math.round(window.scrollY),
|
|
1215
|
-
},
|
|
1216
|
-
elements
|
|
1217
|
-
};
|
|
1218
|
-
|
|
1219
|
-
return result;
|
|
1220
|
-
} catch (e) {
|
|
1221
|
-
return { error: e.message };
|
|
1222
|
-
}
|
|
1223
|
-
})()`
|
|
1009
|
+
const expression = GhostBridgeDom.buildInspectPageExpression({ selector, includeInteractive, maxElements })
|
|
1224
1010
|
|
|
1225
1011
|
const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
|
|
1226
1012
|
expression,
|
|
@@ -1234,95 +1020,7 @@ async function handleInspectPageSnapshot(params = {}) {
|
|
|
1234
1020
|
async function handleGetPageContent(params = {}) {
|
|
1235
1021
|
const target = await ensureAttached()
|
|
1236
1022
|
const { mode = "text", selector, maxLength = 50000, includeMetadata = true } = params
|
|
1237
|
-
|
|
1238
|
-
const selectorStr = selector ? JSON.stringify(selector) : 'null'
|
|
1239
|
-
const modeStr = JSON.stringify(mode)
|
|
1240
|
-
|
|
1241
|
-
const expression = `(function() {
|
|
1242
|
-
try {
|
|
1243
|
-
const result = {};
|
|
1244
|
-
if (document.readyState === 'loading') {
|
|
1245
|
-
return { error: '页面尚未加载完成,请稍后重试', readyState: document.readyState };
|
|
1246
|
-
}
|
|
1247
|
-
let targetElement = document.body;
|
|
1248
|
-
const selector = ${selectorStr};
|
|
1249
|
-
if (selector) {
|
|
1250
|
-
try {
|
|
1251
|
-
targetElement = document.querySelector(selector);
|
|
1252
|
-
if (!targetElement) {
|
|
1253
|
-
return { error: '选择器未匹配到任何元素', selector: selector, suggestion: '请检查选择器是否正确' };
|
|
1254
|
-
}
|
|
1255
|
-
result.selector = selector;
|
|
1256
|
-
result.matchedTag = targetElement.tagName.toLowerCase();
|
|
1257
|
-
} catch (e) {
|
|
1258
|
-
return { error: '无效的 CSS 选择器: ' + e.message, selector: selector };
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
const includeMetadata = ${includeMetadata};
|
|
1262
|
-
if (includeMetadata) {
|
|
1263
|
-
result.metadata = {
|
|
1264
|
-
title: document.title || '',
|
|
1265
|
-
url: window.location.href,
|
|
1266
|
-
description: document.querySelector('meta[name="description"]')?.content || '',
|
|
1267
|
-
keywords: document.querySelector('meta[name="keywords"]')?.content || '',
|
|
1268
|
-
charset: document.characterSet,
|
|
1269
|
-
language: document.documentElement.lang || '',
|
|
1270
|
-
};
|
|
1271
|
-
}
|
|
1272
|
-
const mode = ${modeStr};
|
|
1273
|
-
const maxLength = ${maxLength};
|
|
1274
|
-
if (mode === 'text') {
|
|
1275
|
-
let text = targetElement.innerText || targetElement.textContent || '';
|
|
1276
|
-
text = text.replace(/\\n{3,}/g, '\\n\\n').trim();
|
|
1277
|
-
result.contentLength = text.length;
|
|
1278
|
-
if (text.length > maxLength) {
|
|
1279
|
-
result.content = text.slice(0, maxLength);
|
|
1280
|
-
result.truncated = true;
|
|
1281
|
-
} else {
|
|
1282
|
-
result.content = text;
|
|
1283
|
-
result.truncated = false;
|
|
1284
|
-
}
|
|
1285
|
-
} else if (mode === 'html') {
|
|
1286
|
-
let html = targetElement.outerHTML || '';
|
|
1287
|
-
result.contentLength = html.length;
|
|
1288
|
-
if (html.length > maxLength) {
|
|
1289
|
-
result.content = html.slice(0, maxLength);
|
|
1290
|
-
result.truncated = true;
|
|
1291
|
-
result.note = 'HTML 已截断,可能不完整';
|
|
1292
|
-
} else {
|
|
1293
|
-
result.content = html;
|
|
1294
|
-
result.truncated = false;
|
|
1295
|
-
}
|
|
1296
|
-
} else if (mode === 'structured') {
|
|
1297
|
-
const structured = {};
|
|
1298
|
-
const headings = targetElement.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
|
1299
|
-
structured.headings = Array.from(headings).slice(0, 50).map(h => ({ level: parseInt(h.tagName[1]), text: h.innerText.trim().slice(0, 200) }));
|
|
1300
|
-
const links = targetElement.querySelectorAll('a[href]');
|
|
1301
|
-
structured.links = Array.from(links).slice(0, 100).map(a => ({ text: (a.innerText || '').trim().slice(0, 100), href: a.href })).filter(l => l.href && !l.href.startsWith('javascript:'));
|
|
1302
|
-
const buttons = targetElement.querySelectorAll('button, input[type="button"], input[type="submit"], [role="button"]');
|
|
1303
|
-
structured.buttons = Array.from(buttons).slice(0, 50).map(b => ({ text: (b.innerText || b.value || b.getAttribute('aria-label') || '').trim().slice(0, 100), type: b.type || 'button', disabled: b.disabled || false }));
|
|
1304
|
-
const forms = targetElement.querySelectorAll('form');
|
|
1305
|
-
structured.forms = Array.from(forms).slice(0, 20).map(f => {
|
|
1306
|
-
const fields = Array.from(f.querySelectorAll('input, select, textarea')).slice(0, 30);
|
|
1307
|
-
return { action: f.action || '', method: (f.method || 'GET').toUpperCase(), fieldCount: fields.length, fields: fields.map(field => ({ tag: field.tagName.toLowerCase(), type: field.type || '', name: field.name || '', placeholder: field.placeholder || '', required: field.required || false })) };
|
|
1308
|
-
});
|
|
1309
|
-
const images = targetElement.querySelectorAll('img');
|
|
1310
|
-
structured.images = Array.from(images).slice(0, 50).map(img => ({ alt: img.alt || '', src: img.src ? img.src.slice(0, 200) : '' })).filter(img => img.src);
|
|
1311
|
-
const tables = targetElement.querySelectorAll('table');
|
|
1312
|
-
structured.tables = Array.from(tables).slice(0, 10).map(table => {
|
|
1313
|
-
const headers = Array.from(table.querySelectorAll('th')).map(th => th.innerText.trim().slice(0, 50));
|
|
1314
|
-
const rows = table.querySelectorAll('tr');
|
|
1315
|
-
return { headers: headers.slice(0, 20), rowCount: rows.length };
|
|
1316
|
-
});
|
|
1317
|
-
result.structured = structured;
|
|
1318
|
-
result.counts = { headings: structured.headings.length, links: structured.links.length, buttons: structured.buttons.length, forms: structured.forms.length, images: structured.images.length, tables: structured.tables.length };
|
|
1319
|
-
}
|
|
1320
|
-
result.mode = mode;
|
|
1321
|
-
return result;
|
|
1322
|
-
} catch (e) {
|
|
1323
|
-
return { error: e.message };
|
|
1324
|
-
}
|
|
1325
|
-
})()`
|
|
1023
|
+
const expression = GhostBridgeDom.buildPageContentExpression({ mode, selector, maxLength, includeMetadata })
|
|
1326
1024
|
|
|
1327
1025
|
const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
|
|
1328
1026
|
expression,
|
|
@@ -1338,103 +1036,7 @@ async function handleGetPageContent(params = {}) {
|
|
|
1338
1036
|
async function handleGetInteractiveSnapshot(params = {}) {
|
|
1339
1037
|
const target = await ensureAttached()
|
|
1340
1038
|
const { selector, includeText = true, maxElements = 100 } = params
|
|
1341
|
-
|
|
1342
|
-
const selectorStr = selector ? JSON.stringify(selector) : 'null'
|
|
1343
|
-
|
|
1344
|
-
const expression = `(function() {
|
|
1345
|
-
try {
|
|
1346
|
-
let refCounter = 0;
|
|
1347
|
-
const elements = [];
|
|
1348
|
-
|
|
1349
|
-
const maxEls = ${maxElements};
|
|
1350
|
-
// 候选集选择器——用浏览器原生选择器引擎代替全树 JS 递归
|
|
1351
|
-
const INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="radio"],[role="switch"],[role="combobox"],[tabindex]:not([tabindex="-1"]),[contenteditable="true"],[onclick]';
|
|
1352
|
-
|
|
1353
|
-
// 可见性检测(单次 getComputedStyle,返回 rect 复用)
|
|
1354
|
-
function isVisible(el) {
|
|
1355
|
-
const style = window.getComputedStyle(el);
|
|
1356
|
-
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return null;
|
|
1357
|
-
if (!el.offsetParent && el.tagName !== 'HTML' && el.tagName !== 'BODY' &&
|
|
1358
|
-
style.position !== 'fixed' && style.position !== 'sticky') return null;
|
|
1359
|
-
const rect = el.getBoundingClientRect();
|
|
1360
|
-
if (rect.width === 0 && rect.height === 0) return null;
|
|
1361
|
-
return rect;
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
function buildEntry(el, rect) {
|
|
1365
|
-
refCounter++;
|
|
1366
|
-
const ref = 'e' + refCounter;
|
|
1367
|
-
el.setAttribute('data-ghost-ref', ref);
|
|
1368
|
-
const tag = el.tagName.toLowerCase();
|
|
1369
|
-
const entry = { ref, tag, cx: Math.round(rect.left + rect.width / 2), cy: Math.round(rect.top + rect.height / 2) };
|
|
1370
|
-
if (el.type) entry.type = el.type;
|
|
1371
|
-
if (el.name) entry.name = el.name;
|
|
1372
|
-
if (el.getAttribute('role')) entry.role = el.getAttribute('role');
|
|
1373
|
-
if (${includeText}) {
|
|
1374
|
-
if (el.placeholder) entry.placeholder = el.placeholder.slice(0, 80);
|
|
1375
|
-
if (el.value && tag !== 'textarea') entry.value = el.value.slice(0, 80);
|
|
1376
|
-
if (tag === 'a') entry.href = (el.href || '').slice(0, 150);
|
|
1377
|
-
if (tag === 'select') {
|
|
1378
|
-
entry.options = Array.from(el.options).slice(0, 10).map(o => ({
|
|
1379
|
-
value: o.value, text: o.text.slice(0, 50), selected: o.selected
|
|
1380
|
-
}));
|
|
1381
|
-
}
|
|
1382
|
-
const text = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
|
|
1383
|
-
if (text && text.length <= 100) entry.text = text;
|
|
1384
|
-
else if (text) entry.text = text.slice(0, 97) + '...';
|
|
1385
|
-
}
|
|
1386
|
-
if (el.disabled) entry.disabled = true;
|
|
1387
|
-
return entry;
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
// 候选集扫描(含 Shadow DOM 穿透)
|
|
1391
|
-
function scanRoot(root) {
|
|
1392
|
-
const candidates = root.querySelectorAll(INTERACTIVE_SELECTOR);
|
|
1393
|
-
for (let i = 0; i < candidates.length && elements.length < maxEls; i++) {
|
|
1394
|
-
const rect = isVisible(candidates[i]);
|
|
1395
|
-
if (rect) elements.push(buildEntry(candidates[i], rect));
|
|
1396
|
-
}
|
|
1397
|
-
// 穿透 Shadow DOM + 兜底检测 el.onclick = fn 形式的 JS 属性绑定
|
|
1398
|
-
if (elements.length < maxEls) {
|
|
1399
|
-
const all = root.querySelectorAll('*');
|
|
1400
|
-
for (let i = 0; i < all.length && elements.length < maxEls; i++) {
|
|
1401
|
-
const el = all[i];
|
|
1402
|
-
if (el.shadowRoot) scanRoot(el.shadowRoot);
|
|
1403
|
-
// CSS 选择器只能匹配 [onclick] 属性,这里兜住 el.onclick = fn 的情况
|
|
1404
|
-
if (el.onclick && !el.hasAttribute('data-ghost-ref')) {
|
|
1405
|
-
const rect = isVisible(el);
|
|
1406
|
-
if (rect) elements.push(buildEntry(el, rect));
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
// 清理旧的 ref 标记
|
|
1413
|
-
document.querySelectorAll('[data-ghost-ref]').forEach(el => el.removeAttribute('data-ghost-ref'));
|
|
1414
|
-
|
|
1415
|
-
let rootEl = document.body;
|
|
1416
|
-
const sel = ${selectorStr};
|
|
1417
|
-
if (sel) {
|
|
1418
|
-
rootEl = document.querySelector(sel);
|
|
1419
|
-
if (!rootEl) return { error: '选择器未匹配到任何元素', selector: sel };
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
scanRoot(rootEl);
|
|
1423
|
-
|
|
1424
|
-
return {
|
|
1425
|
-
url: window.location.href,
|
|
1426
|
-
title: document.title,
|
|
1427
|
-
elementCount: elements.length,
|
|
1428
|
-
viewport: {
|
|
1429
|
-
width: window.innerWidth,
|
|
1430
|
-
height: window.innerHeight,
|
|
1431
|
-
scrollX: Math.round(window.scrollX),
|
|
1432
|
-
scrollY: Math.round(window.scrollY),
|
|
1433
|
-
},
|
|
1434
|
-
elements: elements,
|
|
1435
|
-
};
|
|
1436
|
-
} catch (e) { return { error: e.message }; }
|
|
1437
|
-
})()`
|
|
1039
|
+
const expression = GhostBridgeDom.buildInteractiveSnapshotExpression({ selector, includeText, maxElements })
|
|
1438
1040
|
|
|
1439
1041
|
const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
|
|
1440
1042
|
expression,
|
|
@@ -1628,7 +1230,12 @@ async function handleCommand(message) {
|
|
|
1628
1230
|
}
|
|
1629
1231
|
try {
|
|
1630
1232
|
let result
|
|
1631
|
-
if (command === "
|
|
1233
|
+
if (command === "listTabs") result = await handleListTabs()
|
|
1234
|
+
else if (command === "getTargetTab") result = await buildTargetInfo()
|
|
1235
|
+
else if (command === "pinCurrentTab") result = await handlePinCurrentTab()
|
|
1236
|
+
else if (command === "pinTab") result = await handlePinTab(params)
|
|
1237
|
+
else if (command === "unpinTab") result = await handleUnpinTab()
|
|
1238
|
+
else if (command === "getLastError") result = await handleGetLastError(params)
|
|
1632
1239
|
else if (command === "getScriptSource") result = await handleGetScriptSource(params)
|
|
1633
1240
|
else if (command === "coverageSnapshot") result = await handleCoverageSnapshot(params)
|
|
1634
1241
|
else if (command === "findByString") result = await handleFindByString(params)
|
|
@@ -1660,8 +1267,7 @@ function sendToServer(data) {
|
|
|
1660
1267
|
|
|
1661
1268
|
// ========== 状态广播 ==========
|
|
1662
1269
|
|
|
1663
|
-
|
|
1664
|
-
function broadcastStatus() {
|
|
1270
|
+
function getConnectionStatus() {
|
|
1665
1271
|
let status
|
|
1666
1272
|
if (!state.enabled) {
|
|
1667
1273
|
status = 'disconnected'
|
|
@@ -1670,43 +1276,50 @@ function broadcastStatus() {
|
|
|
1670
1276
|
} else {
|
|
1671
1277
|
status = state.connectionStatus || 'connecting'
|
|
1672
1278
|
}
|
|
1279
|
+
return status
|
|
1280
|
+
}
|
|
1673
1281
|
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1282
|
+
async function buildPopupState() {
|
|
1283
|
+
const status = getConnectionStatus()
|
|
1284
|
+
const targetInfo = await buildTargetInfo()
|
|
1285
|
+
const targetTab = targetInfo.targetTab
|
|
1286
|
+
const viewingTab = targetInfo.viewingTab
|
|
1287
|
+
return {
|
|
1288
|
+
status,
|
|
1289
|
+
enabled: state.enabled,
|
|
1290
|
+
port: state.port,
|
|
1291
|
+
currentPort: state.currentPort,
|
|
1292
|
+
basePort: CONFIG.basePort,
|
|
1293
|
+
connectionError: state.connectionError,
|
|
1294
|
+
serverInfo: state.serverInfo,
|
|
1295
|
+
targetMode: targetInfo.targetMode,
|
|
1296
|
+
pinnedTabId: targetInfo.pinnedTabId,
|
|
1297
|
+
attachedTabId: targetInfo.attachedTabId,
|
|
1298
|
+
targetTab,
|
|
1299
|
+
viewingTab,
|
|
1300
|
+
targetError: targetInfo.targetError,
|
|
1301
|
+
tabTitle: targetTab?.title || '',
|
|
1302
|
+
tabUrl: targetTab?.url || '',
|
|
1303
|
+
viewingTitle: viewingTab?.title || '',
|
|
1304
|
+
viewingUrl: viewingTab?.url || '',
|
|
1685
1305
|
}
|
|
1306
|
+
}
|
|
1686
1307
|
|
|
1687
|
-
|
|
1688
|
-
|
|
1308
|
+
// 主动推送状态给 popup
|
|
1309
|
+
async function broadcastStatus() {
|
|
1310
|
+
try {
|
|
1689
1311
|
chrome.runtime.sendMessage({
|
|
1690
1312
|
type: 'statusUpdate',
|
|
1691
|
-
state:
|
|
1692
|
-
status,
|
|
1693
|
-
enabled: state.enabled,
|
|
1694
|
-
port: state.port,
|
|
1695
|
-
currentPort: state.currentPort,
|
|
1696
|
-
basePort: CONFIG.basePort,
|
|
1697
|
-
connectionError: state.connectionError,
|
|
1698
|
-
errorCount: actualErrors.length,
|
|
1699
|
-
recentErrors: actualErrors.slice(0, 5),
|
|
1700
|
-
tabTitle,
|
|
1701
|
-
tabUrl,
|
|
1702
|
-
}
|
|
1313
|
+
state: await buildPopupState()
|
|
1703
1314
|
}).catch(() => {}) // popup 可能未打开,忽略错误
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
log(`状态广播失败:${e.message}`)
|
|
1704
1317
|
}
|
|
1705
1318
|
}
|
|
1706
1319
|
|
|
1707
1320
|
// 监听被调试页面的导航变化,实时推送到 popup
|
|
1708
1321
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
|
1709
|
-
if (tabId === attachedTabId && (changeInfo.title || changeInfo.url)) {
|
|
1322
|
+
if ((tabId === attachedTabId || tab.active) && (changeInfo.title || changeInfo.url)) {
|
|
1710
1323
|
if (state.connected) broadcastStatus()
|
|
1711
1324
|
}
|
|
1712
1325
|
})
|
|
@@ -1715,8 +1328,10 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
|
|
1715
1328
|
chrome.tabs.onActivated.addListener(async (activeInfo) => {
|
|
1716
1329
|
if (state.enabled && state.connected) {
|
|
1717
1330
|
try {
|
|
1718
|
-
|
|
1719
|
-
|
|
1331
|
+
if (targetMode === 'focused') {
|
|
1332
|
+
// focused 模式保持旧行为:切换 Tab 时自动跟随
|
|
1333
|
+
await ensureAttached()
|
|
1334
|
+
}
|
|
1720
1335
|
broadcastStatus()
|
|
1721
1336
|
} catch (e) {
|
|
1722
1337
|
log(`自动跟随切换 Tab 失败:${e.message}`)
|
|
@@ -1724,6 +1339,18 @@ chrome.tabs.onActivated.addListener(async (activeInfo) => {
|
|
|
1724
1339
|
}
|
|
1725
1340
|
})
|
|
1726
1341
|
|
|
1342
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
1343
|
+
if (tabId === pinnedTabId) {
|
|
1344
|
+
targetMode = 'focused'
|
|
1345
|
+
pinnedTabId = null
|
|
1346
|
+
}
|
|
1347
|
+
if (tabId === attachedTabId) {
|
|
1348
|
+
attachedTabId = null
|
|
1349
|
+
resetDebuggerState()
|
|
1350
|
+
}
|
|
1351
|
+
if (state.connected) broadcastStatus()
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1727
1354
|
|
|
1728
1355
|
// ========== 消息监听 ==========
|
|
1729
1356
|
|
|
@@ -1746,29 +1373,36 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1746
1373
|
state.currentPort = message.port
|
|
1747
1374
|
state.connectionStatus = 'connected'
|
|
1748
1375
|
state.connectionError = ''
|
|
1376
|
+
state.serverInfo = message.serverInfo || null
|
|
1749
1377
|
setBadgeState('on')
|
|
1750
1378
|
log(`✅ 已连接到 ghost-bridge 服务 (端口 ${message.port})`)
|
|
1751
|
-
ensureAttached()
|
|
1379
|
+
ensureAttached()
|
|
1380
|
+
.then(() => broadcastStatus())
|
|
1381
|
+
.catch((e) => log(`attach 失败:${e.message}`))
|
|
1752
1382
|
} else if (message.status === 'disconnected') {
|
|
1753
1383
|
state.connected = false
|
|
1754
1384
|
state.port = null
|
|
1755
1385
|
state.connectionStatus = 'connecting'
|
|
1756
1386
|
state.connectionError = ''
|
|
1387
|
+
state.serverInfo = null
|
|
1757
1388
|
if (state.enabled) setBadgeState('connecting')
|
|
1758
1389
|
} else if (message.status === 'connecting') {
|
|
1759
1390
|
state.currentPort = message.currentPort
|
|
1760
1391
|
state.connectionStatus = 'connecting'
|
|
1761
1392
|
state.connectionError = ''
|
|
1393
|
+
state.serverInfo = null
|
|
1762
1394
|
setBadgeState('connecting')
|
|
1763
1395
|
} else if (message.status === 'error') {
|
|
1764
1396
|
state.currentPort = message.currentPort
|
|
1765
1397
|
state.connectionStatus = 'error'
|
|
1766
1398
|
state.connectionError = message.errorMessage || ''
|
|
1399
|
+
state.serverInfo = null
|
|
1767
1400
|
setBadgeState('err')
|
|
1768
1401
|
} else if (message.status === 'not_found') {
|
|
1769
1402
|
state.currentPort = message.currentPort
|
|
1770
1403
|
state.connectionStatus = 'not_found'
|
|
1771
|
-
state.connectionError = ''
|
|
1404
|
+
state.connectionError = message.errorMessage || ''
|
|
1405
|
+
state.serverInfo = null
|
|
1772
1406
|
setBadgeState('connecting')
|
|
1773
1407
|
}
|
|
1774
1408
|
broadcastStatus() // 状态变化时主动推送
|
|
@@ -1794,44 +1428,25 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1794
1428
|
|
|
1795
1429
|
// 来自 popup 的状态查询
|
|
1796
1430
|
if (message.type === 'getStatus') {
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
} else if (state.connected) {
|
|
1801
|
-
status = 'connected'
|
|
1802
|
-
} else {
|
|
1803
|
-
status = state.connectionStatus || 'connecting'
|
|
1804
|
-
}
|
|
1431
|
+
buildPopupState().then(sendResponse).catch((e) => sendResponse({ status: 'error', connectionError: e.message }))
|
|
1432
|
+
return true
|
|
1433
|
+
}
|
|
1805
1434
|
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
tabUrl = t.url
|
|
1811
|
-
tabTitle = t.title
|
|
1812
|
-
sendStatusResponse()
|
|
1813
|
-
}).catch(() => {
|
|
1814
|
-
sendStatusResponse()
|
|
1815
|
-
})
|
|
1816
|
-
} else {
|
|
1817
|
-
sendStatusResponse()
|
|
1435
|
+
if (message.type === 'pinCurrentTab') {
|
|
1436
|
+
if (!state.enabled || !state.connected) {
|
|
1437
|
+
sendResponse({ ok: false, error: "Ghost Bridge 尚未连接" })
|
|
1438
|
+
return true
|
|
1818
1439
|
}
|
|
1440
|
+
handlePinCurrentTab().then((result) => sendResponse({ ok: true, result })).catch((e) => sendResponse({ ok: false, error: e.message }))
|
|
1441
|
+
return true
|
|
1442
|
+
}
|
|
1819
1443
|
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
sendResponse({
|
|
1823
|
-
|
|
1824
|
-
enabled: state.enabled,
|
|
1825
|
-
port: state.port,
|
|
1826
|
-
currentPort: state.currentPort,
|
|
1827
|
-
basePort: CONFIG.basePort,
|
|
1828
|
-
connectionError: state.connectionError,
|
|
1829
|
-
errorCount: actualErrors.length,
|
|
1830
|
-
recentErrors: actualErrors.slice(0, 5),
|
|
1831
|
-
tabTitle,
|
|
1832
|
-
tabUrl,
|
|
1833
|
-
})
|
|
1444
|
+
if (message.type === 'unpinTab') {
|
|
1445
|
+
if (!state.enabled || !state.connected) {
|
|
1446
|
+
sendResponse({ ok: false, error: "Ghost Bridge 尚未连接" })
|
|
1447
|
+
return true
|
|
1834
1448
|
}
|
|
1449
|
+
handleUnpinTab().then((result) => sendResponse({ ok: true, result })).catch((e) => sendResponse({ ok: false, error: e.message }))
|
|
1835
1450
|
return true
|
|
1836
1451
|
}
|
|
1837
1452
|
|
|
@@ -1841,13 +1456,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1841
1456
|
CONFIG.basePort = message.port
|
|
1842
1457
|
chrome.storage.local.set({ basePort: message.port })
|
|
1843
1458
|
}
|
|
1844
|
-
CONFIG.token = DEFAULT_TOKEN
|
|
1845
1459
|
state.enabled = true
|
|
1846
1460
|
state.connected = false
|
|
1847
1461
|
state.port = null
|
|
1848
1462
|
state.currentPort = CONFIG.basePort
|
|
1849
1463
|
state.connectionStatus = 'connecting'
|
|
1850
1464
|
state.connectionError = ''
|
|
1465
|
+
state.serverInfo = null
|
|
1851
1466
|
setBadgeState('connecting')
|
|
1852
1467
|
|
|
1853
1468
|
// 启动 offscreen 并开始连接
|
|
@@ -1871,6 +1486,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1871
1486
|
state.currentPort = null
|
|
1872
1487
|
state.connectionStatus = 'disconnected'
|
|
1873
1488
|
state.connectionError = ''
|
|
1489
|
+
state.serverInfo = null
|
|
1490
|
+
targetMode = 'focused'
|
|
1491
|
+
pinnedTabId = null
|
|
1874
1492
|
setBadgeState('off')
|
|
1875
1493
|
detachAllTargets().catch(() => {})
|
|
1876
1494
|
|