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.
@@ -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
- while (networkRequests.length > CONFIG.maxRequestsTracked) {
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
- while (requestMap.size > CONFIG.maxRequestsTracked * 2) {
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
- scriptMap = new Map()
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 [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
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
- scriptMap = new Map()
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 === "getLastError") result = await handleGetLastError(params)
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
- // 主动推送状态给 popup
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
- let tabUrl = ''
1675
- let tabTitle = ''
1676
-
1677
- if (attachedTabId) {
1678
- chrome.tabs.get(attachedTabId).then(t => {
1679
- tabUrl = t.url
1680
- tabTitle = t.title
1681
- doBroadcast()
1682
- }).catch(() => doBroadcast())
1683
- } else {
1684
- doBroadcast()
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
- function doBroadcast() {
1688
- const actualErrors = lastErrors.filter(e => e.severity === 'error')
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
- // 这里的 ensureAttached() 会自动处理从旧 Tab detach 并 attach 到新 Tab
1719
- await ensureAttached()
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().catch((e) => log(`attach 失败:${e.message}`))
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
- let status
1798
- if (!state.enabled) {
1799
- status = 'disconnected'
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
- let tabUrl = ''
1807
- let tabTitle = ''
1808
- if (attachedTabId) {
1809
- chrome.tabs.get(attachedTabId).then(t => {
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
- function sendStatusResponse() {
1821
- const actualErrors = lastErrors.filter(e => e.severity === 'error')
1822
- sendResponse({
1823
- status,
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