ghost-bridge 0.7.1 → 0.9.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.
@@ -12,18 +12,83 @@ const CONFIG = {
12
12
  maxRequestBodySize: 500000, // 提升至 500KB,容纳较大的 API 请求
13
13
  }
14
14
 
15
- let attachedTabId = null
16
- let scriptMap = new Map()
17
- let scriptSourceCache = new Map()
18
- let lastErrors = []
19
- let lastErrorLocation = null
20
- let requestMap = new Map()
21
- let networkRequests = []
22
- let state = { enabled: false, connected: false, port: null, currentPort: null, connectionStatus: 'disconnected', connectionError: '' }
15
+ let attachedTabId = null // Last touched attached tab, kept for popup/backward-compatible status.
16
+ let focusedTabId = null
17
+ const sessionsByTabId = new Map() // tabId -> per-tab debugger state
18
+
19
+ // 每个服务器侧 MCP 连接(会话)独立的 target 命名空间:
20
+ // pin 状态与 bind 的命名 target 互不可见,多 Agent / 同 Agent 多会话不会互相覆盖。
21
+ // 服务器在每条命令上带 clientId;不带时(本地会话)落到 'local' 桶
22
+ const clientSessions = new Map() // clientId -> { targetMode, pinnedTabId, namedTargets: Map }
23
+ const DEFAULT_CLIENT_ID = 'local'
24
+
25
+ function getClientSession(clientId) {
26
+ const key = clientId || DEFAULT_CLIENT_ID
27
+ let client = clientSessions.get(key)
28
+ if (!client) {
29
+ client = { targetMode: 'focused', pinnedTabId: null, namedTargets: new Map() }
30
+ clientSessions.set(key, client)
31
+ }
32
+ return client
33
+ }
34
+
35
+ async function cleanupClientSession(clientId) {
36
+ const client = clientSessions.get(clientId)
37
+ if (!client) return
38
+ clientSessions.delete(clientId)
39
+ // 释放仅被该会话 pin/bind 且无其他会话引用、也非当前跟随页的调试 session
40
+ const candidateTabIds = new Set([client.pinnedTabId, ...client.namedTargets.values()])
41
+ for (const tabId of candidateTabIds) {
42
+ if (tabId === null || tabId === undefined) continue
43
+ if (isTabReferenced(tabId)) continue
44
+ if (tabId === attachedTabId) continue
45
+ const session = getSession(tabId, { create: false })
46
+ if (session) {
47
+ await detachSession(session)
48
+ sessionsByTabId.delete(tabId)
49
+ }
50
+ }
51
+ log(`已清理会话 "${clientId}" 的 target 命名空间`)
52
+ }
53
+
54
+ let state = { enabled: false, connected: false, port: null, currentPort: null, connectionStatus: 'disconnected', connectionError: '', serverInfo: null }
23
55
 
24
56
  // 待处理的请求(等待 offscreen 响应)
25
57
  const pendingRequests = new Map()
26
58
 
59
+ function createSession(tabId) {
60
+ return {
61
+ tabId,
62
+ attached: false,
63
+ scriptMap: new Map(),
64
+ scriptSourceCache: new Map(),
65
+ lastErrors: [],
66
+ lastErrorLocation: null,
67
+ requestMap: new Map(),
68
+ networkRequests: [],
69
+ }
70
+ }
71
+
72
+ function getSession(tabId, { create = true } = {}) {
73
+ if (tabId === undefined || tabId === null) return null
74
+ const numericTabId = Number(tabId)
75
+ if (!Number.isInteger(numericTabId)) return null
76
+ let session = sessionsByTabId.get(numericTabId)
77
+ if (!session && create) {
78
+ session = createSession(numericTabId)
79
+ sessionsByTabId.set(numericTabId, session)
80
+ }
81
+ return session
82
+ }
83
+
84
+ function resetDebuggerState(session) {
85
+ if (!session) return
86
+ session.scriptMap = new Map()
87
+ session.scriptSourceCache = new Map()
88
+ session.networkRequests = []
89
+ session.requestMap = new Map()
90
+ }
91
+
27
92
  function setBadgeState(status) {
28
93
  const map = {
29
94
  connecting: { text: "…", color: "#999" },
@@ -88,14 +153,77 @@ async function closeOffscreenDocument() {
88
153
  }
89
154
  }
90
155
 
156
+ async function startBridgeConnection({ persist = false } = {}) {
157
+ state.enabled = true
158
+ state.connected = false
159
+ state.port = null
160
+ state.currentPort = CONFIG.basePort
161
+ state.connectionStatus = 'connecting'
162
+ state.connectionError = ''
163
+ state.serverInfo = null
164
+ setBadgeState('connecting')
165
+
166
+ if (persist) {
167
+ await chrome.storage.local.set({ bridgeEnabled: true, basePort: CONFIG.basePort })
168
+ }
169
+
170
+ await setupOffscreenDocument()
171
+ await chrome.runtime.sendMessage({
172
+ type: 'connect',
173
+ basePort: CONFIG.basePort,
174
+ token: CONFIG.token,
175
+ }).catch(() => {})
176
+ broadcastStatus()
177
+ }
178
+
179
+ async function stopBridgeConnection({ persist = false } = {}) {
180
+ state.enabled = false
181
+ state.connected = false
182
+ state.port = null
183
+ state.currentPort = null
184
+ state.connectionStatus = 'disconnected'
185
+ state.connectionError = ''
186
+ state.serverInfo = null
187
+ focusedTabId = null
188
+ clientSessions.clear()
189
+ setBadgeState('off')
190
+
191
+ if (persist) {
192
+ await chrome.storage.local.set({ bridgeEnabled: false })
193
+ }
194
+
195
+ await detachAllTargets().catch(() => {})
196
+ await chrome.runtime.sendMessage({ type: 'disconnect' }).catch(() => {})
197
+ await closeOffscreenDocument().catch(() => {})
198
+ broadcastStatus()
199
+ }
200
+
201
+ async function restoreBridgeConnection() {
202
+ try {
203
+ const result = await chrome.storage.local.get(['basePort', 'bridgeEnabled'])
204
+ if (result.basePort) {
205
+ CONFIG.basePort = result.basePort
206
+ }
207
+ if (result.bridgeEnabled) {
208
+ await startBridgeConnection()
209
+ } else {
210
+ setBadgeState('off')
211
+ }
212
+ } catch (e) {
213
+ log(`恢复连接状态失败:${e.message}`)
214
+ setBadgeState('off')
215
+ }
216
+ }
217
+
91
218
  // ========== Chrome Debugger 事件处理 ==========
92
219
 
93
220
  chrome.debugger.onEvent.addListener((source, method, params) => {
94
- if (source.tabId !== attachedTabId) return
221
+ const session = getSession(source.tabId, { create: false })
222
+ if (!session) return
95
223
  if (!state.enabled) return
96
224
 
97
225
  if (method === "Debugger.scriptParsed") {
98
- scriptMap.set(params.scriptId, { url: params.url || "(inline)" })
226
+ session.scriptMap.set(params.scriptId, { url: params.url || "(inline)" })
99
227
  }
100
228
 
101
229
  if (method === "Runtime.exceptionThrown") {
@@ -112,18 +240,18 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
112
240
  stack: compactStack(detail.stackTrace),
113
241
  timestamp: Date.now(),
114
242
  }
115
- lastErrorLocation = {
243
+ session.lastErrorLocation = {
116
244
  url: entry.url,
117
245
  line: entry.line,
118
246
  column: entry.column,
119
247
  scriptId: entry.scriptId,
120
248
  }
121
- pushError(entry)
249
+ pushError(session, entry)
122
250
  }
123
251
 
124
252
  if (method === "Log.entryAdded") {
125
253
  const entry = params?.entry || {}
126
- pushError({
254
+ pushError(session, {
127
255
  type: entry.level || "log",
128
256
  severity: entry.level === "warning" ? "warn" : entry.level === "error" ? "error" : "info",
129
257
  url: entry.source || entry.url,
@@ -136,7 +264,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
136
264
 
137
265
  if (method === "Runtime.consoleAPICalled") {
138
266
  const args = (params.args || []).map((a) => a.description || a.value).filter(Boolean)
139
- pushError({
267
+ pushError(session, {
140
268
  type: params.type || "console",
141
269
  severity: params.type === "error" ? "error" : params.type === "warning" ? "warn" : "info",
142
270
  url: params.stackTrace?.callFrames?.[0]?.url,
@@ -151,6 +279,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
151
279
  if (method === "Network.requestWillBeSent") {
152
280
  const req = params.request || {}
153
281
  const entry = {
282
+ tabId: source.tabId,
154
283
  requestId: params.requestId,
155
284
  url: req.url,
156
285
  method: req.method || "GET",
@@ -162,13 +291,13 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
162
291
  timestamp: Date.now(),
163
292
  status: "pending",
164
293
  }
165
- requestMap.set(params.requestId, entry)
166
- trimPendingRequests()
294
+ session.requestMap.set(params.requestId, entry)
295
+ trimPendingRequests(session)
167
296
  }
168
297
 
169
298
  if (method === "Network.responseReceived") {
170
299
  const res = params.response || {}
171
- const entry = requestMap.get(params.requestId)
300
+ const entry = session.requestMap.get(params.requestId)
172
301
  if (entry) {
173
302
  entry.status = res.status >= 400 ? "error" : "success"
174
303
  entry.statusCode = res.status
@@ -181,7 +310,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
181
310
  entry.timing = res.timing
182
311
  entry.encodedDataLength = params.encodedDataLength
183
312
  if (res.status >= 400) {
184
- pushError({
313
+ pushError(session, {
185
314
  type: "network",
186
315
  severity: "error",
187
316
  url: res.url || entry.url,
@@ -197,7 +326,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
197
326
  }
198
327
 
199
328
  if (method === "Network.loadingFinished") {
200
- const entry = requestMap.get(params.requestId)
329
+ const entry = session.requestMap.get(params.requestId)
201
330
  if (entry) {
202
331
  entry.endTime = params.timestamp
203
332
  entry.encodedDataLength = params.encodedDataLength
@@ -205,19 +334,19 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
205
334
  ? Math.round((entry.endTime - entry.startTime) * 1000)
206
335
  : null
207
336
  if (entry.status === "pending") entry.status = "success"
208
- pushNetworkRequest(entry)
209
- requestMap.delete(params.requestId)
337
+ pushNetworkRequest(session, entry)
338
+ session.requestMap.delete(params.requestId)
210
339
  }
211
340
  }
212
341
 
213
342
  if (method === "Network.loadingFailed") {
214
- const entry = requestMap.get(params.requestId)
343
+ const entry = session.requestMap.get(params.requestId)
215
344
  if (entry) {
216
345
  entry.status = "failed"
217
346
  entry.errorText = params.errorText
218
347
  entry.canceled = params.canceled
219
348
  entry.blockedReason = params.blockedReason
220
- pushError({
349
+ pushError(session, {
221
350
  type: "network",
222
351
  severity: "error",
223
352
  url: entry.url,
@@ -226,32 +355,31 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
226
355
  text: params.errorText,
227
356
  timestamp: Date.now(),
228
357
  })
229
- pushNetworkRequest(entry)
230
- requestMap.delete(params.requestId)
358
+ pushNetworkRequest(session, entry)
359
+ session.requestMap.delete(params.requestId)
231
360
  }
232
361
  }
233
362
  })
234
363
 
235
- function pushNetworkRequest(entry) {
236
- networkRequests.unshift(entry)
237
- trimNetworkRequests()
364
+ function pushNetworkRequest(session, entry) {
365
+ session.networkRequests.unshift(entry)
366
+ trimNetworkRequests(session)
238
367
  }
239
368
 
240
- function trimNetworkRequests() {
241
- GhostBridgeNetwork.trimTrackedRequests(networkRequests, CONFIG.maxRequestsTracked)
369
+ function trimNetworkRequests(session) {
370
+ GhostBridgeNetwork.trimTrackedRequests(session.networkRequests, CONFIG.maxRequestsTracked)
242
371
  }
243
372
 
244
- function trimPendingRequests() {
245
- GhostBridgeNetwork.trimPendingRequestMap(requestMap, CONFIG.maxRequestsTracked * 2)
373
+ function trimPendingRequests(session) {
374
+ GhostBridgeNetwork.trimPendingRequestMap(session.requestMap, CONFIG.maxRequestsTracked * 2)
246
375
  }
247
376
 
248
377
  chrome.debugger.onDetach.addListener((source, reason) => {
249
- if (source.tabId && source.tabId === attachedTabId) {
250
- attachedTabId = null
251
- scriptMap = new Map()
252
- scriptSourceCache = new Map()
253
- networkRequests = []
254
- requestMap = new Map()
378
+ const session = getSession(source.tabId, { create: false })
379
+ if (source.tabId && session) {
380
+ session.attached = false
381
+ if (source.tabId === attachedTabId) attachedTabId = null
382
+ resetDebuggerState(session)
255
383
 
256
384
  if (!state.enabled) return
257
385
  if (reason === "canceled_by_user") {
@@ -271,15 +399,15 @@ chrome.debugger.onDetach.addListener((source, reason) => {
271
399
  }
272
400
  })
273
401
 
274
- function pushError(entry) {
275
- lastErrors.unshift(entry)
276
- if (lastErrors.length > CONFIG.maxErrors) {
277
- const dropIdx = lastErrors
402
+ function pushError(session, entry) {
403
+ session.lastErrors.unshift(entry)
404
+ if (session.lastErrors.length > CONFIG.maxErrors) {
405
+ const dropIdx = session.lastErrors
278
406
  .map((e, i) => ({ sev: e.severity || "info", i }))
279
407
  .reverse()
280
408
  .find((e) => e.sev !== "error")?.i
281
- if (dropIdx !== undefined) lastErrors.splice(dropIdx, 1)
282
- else lastErrors.pop()
409
+ if (dropIdx !== undefined) session.lastErrors.splice(dropIdx, 1)
410
+ else session.lastErrors.pop()
283
411
  }
284
412
  }
285
413
 
@@ -295,27 +423,129 @@ function compactStack(stackTrace) {
295
423
 
296
424
  // ========== Debugger 操作 ==========
297
425
 
426
+ function summarizeTab(tab) {
427
+ if (!tab) return null
428
+ return {
429
+ id: tab.id,
430
+ windowId: tab.windowId,
431
+ index: tab.index,
432
+ active: !!tab.active,
433
+ title: tab.title || '',
434
+ url: tab.url || '',
435
+ }
436
+ }
437
+
438
+ async function getFocusedTabOrThrow() {
439
+ const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
440
+ if (!tab || tab.id === undefined) throw new Error("没有激活的标签页")
441
+ return tab
442
+ }
443
+
444
+ async function getTabOrThrow(tabId) {
445
+ if (!Number.isInteger(tabId) || tabId < 0) throw new Error("需要提供有效的 tabId")
446
+ try {
447
+ const tab = await chrome.tabs.get(tabId)
448
+ if (!tab || tab.id === undefined) throw new Error("标签页不可用")
449
+ return tab
450
+ } catch (e) {
451
+ throw new Error(`标签页 ${tabId} 不可用:${e.message}`)
452
+ }
453
+ }
454
+
455
+ function normalizeTargetName(name, field = 'target') {
456
+ const value = String(name || '').trim()
457
+ if (!value) throw new Error(`需要提供 ${field}`)
458
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(value)) {
459
+ throw new Error(`${field} 只能包含字母、数字、下划线和连字符,长度 1-64`)
460
+ }
461
+ return value
462
+ }
463
+
464
+ function getNamesForTab(tabId) {
465
+ const names = []
466
+ for (const client of clientSessions.values()) {
467
+ for (const [name, boundTabId] of client.namedTargets.entries()) {
468
+ if (boundTabId === tabId) names.push(name)
469
+ }
470
+ }
471
+ return names
472
+ }
473
+
474
+ function isTabReferenced(tabId) {
475
+ for (const client of clientSessions.values()) {
476
+ if (client.pinnedTabId === tabId) return true
477
+ for (const boundTabId of client.namedTargets.values()) {
478
+ if (boundTabId === tabId) return true
479
+ }
480
+ }
481
+ return false
482
+ }
483
+
484
+ async function findTabByParams(params = {}) {
485
+ if (params.tabId !== undefined) {
486
+ return getTabOrThrow(Number(params.tabId))
487
+ }
488
+ const urlContains = String(params.urlContains || '')
489
+ const titleContains = String(params.titleContains || '')
490
+ if (!urlContains && !titleContains) {
491
+ throw new Error("需要提供 tabId、urlContains 或 titleContains")
492
+ }
493
+
494
+ const tabs = await chrome.tabs.query({})
495
+ const matches = tabs.filter((tab) => {
496
+ const urlOk = !urlContains || (tab.url || '').includes(urlContains)
497
+ const titleOk = !titleContains || (tab.title || '').includes(titleContains)
498
+ return tab.id !== undefined && urlOk && titleOk
499
+ })
500
+ if (matches.length === 0) throw new Error("没有找到匹配的标签页")
501
+
502
+ matches.sort((a, b) => Number(b.active) - Number(a.active) || (a.windowId - b.windowId) || (a.index - b.index))
503
+ return matches[0]
504
+ }
505
+
506
+ async function resolveTargetTab(params = {}) {
507
+ const client = getClientSession(params.clientId)
508
+ if (params.target !== undefined && params.target !== null && String(params.target).trim() !== '') {
509
+ const targetName = normalizeTargetName(params.target)
510
+ const tabId = client.namedTargets.get(targetName)
511
+ if (tabId === undefined) throw new Error(`未绑定 target "${targetName}",请先调用 bind_tab`)
512
+ return getTabOrThrow(tabId)
513
+ }
514
+ if (params.tabId !== undefined) {
515
+ return getTabOrThrow(Number(params.tabId))
516
+ }
517
+ if (client.targetMode === 'pinned') {
518
+ if (client.pinnedTabId === null) throw new Error("锁定的标签页不可用,请重新 pin")
519
+ return getTabOrThrow(client.pinnedTabId)
520
+ }
521
+ return getFocusedTabOrThrow()
522
+ }
523
+
298
524
  // attach 互斥锁:防止并发调用 ensureAttached 导致重复 attach / 状态竞态
299
525
  let _attachLock = Promise.resolve()
300
526
 
301
- async function ensureAttached() {
527
+ async function ensureAttachedSession(params = {}) {
302
528
  let _release
303
529
  const _prev = _attachLock
304
530
  _attachLock = new Promise(r => _release = r)
305
531
  await _prev
306
532
  try {
307
533
  if (!state.enabled) throw new Error("扩展已暂停,点击图标开启后再试")
308
- const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
309
- if (!tab) throw new Error("没有激活的标签页")
310
- if (attachedTabId !== tab.id) {
311
- if (attachedTabId) {
312
- try { await chrome.debugger.detach({ tabId: attachedTabId }) } catch (e) {}
313
- }
534
+ const tab = await resolveTargetTab(params)
535
+ const client = getClientSession(params.clientId)
536
+ const isFocusedDefaultTarget = !params.target && params.tabId === undefined && client.targetMode === 'focused'
537
+ if (isFocusedDefaultTarget && focusedTabId !== null && focusedTabId !== tab.id && !isTabReferenced(focusedTabId)) {
538
+ const previousFocusedSession = getSession(focusedTabId, { create: false })
539
+ await detachSession(previousFocusedSession)
540
+ sessionsByTabId.delete(focusedTabId)
541
+ }
542
+ const session = getSession(tab.id)
543
+ if (!session.attached) {
314
544
  try {
315
545
  await chrome.debugger.attach({ tabId: tab.id }, "1.3")
316
546
  setBadgeState("on")
317
547
  } catch (e) {
318
- attachedTabId = null
548
+ if (attachedTabId === tab.id) attachedTabId = null
319
549
  if (state.connected) {
320
550
  setBadgeState("on")
321
551
  } else {
@@ -323,40 +553,57 @@ async function ensureAttached() {
323
553
  }
324
554
  throw e
325
555
  }
326
- attachedTabId = tab.id
327
- scriptMap = new Map()
328
- scriptSourceCache = new Map()
329
- networkRequests = []
330
- requestMap = new Map()
331
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Runtime.enable")
332
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Log.enable")
333
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Console.enable").catch(() => {})
334
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Debugger.enable")
335
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Profiler.enable")
336
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Network.enable").catch(() => {})
556
+ session.attached = true
557
+ resetDebuggerState(session)
558
+ await chrome.debugger.sendCommand({ tabId: session.tabId }, "Runtime.enable")
559
+ await chrome.debugger.sendCommand({ tabId: session.tabId }, "Log.enable")
560
+ await chrome.debugger.sendCommand({ tabId: session.tabId }, "Console.enable").catch(() => {})
561
+ await chrome.debugger.sendCommand({ tabId: session.tabId }, "Debugger.enable")
562
+ await chrome.debugger.sendCommand({ tabId: session.tabId }, "Profiler.enable")
563
+ await chrome.debugger.sendCommand({ tabId: session.tabId }, "Network.enable").catch(() => {})
337
564
 
338
565
  // Enable auto-attach to sub-targets (iframes, workers) for comprehensive capture
339
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Target.setAutoAttach", {
566
+ await chrome.debugger.sendCommand({ tabId: session.tabId }, "Target.setAutoAttach", {
340
567
  autoAttach: true,
341
568
  waitForDebuggerOnStart: false,
342
569
  flatten: true,
343
570
  }).catch(() => {})
344
571
  }
345
- return { tabId: attachedTabId }
572
+ attachedTabId = session.tabId
573
+ if (isFocusedDefaultTarget) focusedTabId = session.tabId
574
+ return { target: { tabId: session.tabId }, session, tab }
346
575
  } finally {
347
576
  _release()
348
577
  }
349
578
  }
350
579
 
580
+ async function ensureAttached(params = {}) {
581
+ const attached = await ensureAttachedSession(params)
582
+ return attached.target
583
+ }
584
+
585
+ async function detachSession(session) {
586
+ if (!session) return
587
+ try {
588
+ if (session.attached) await chrome.debugger.detach({ tabId: session.tabId })
589
+ } catch (e) {
590
+ log(`detach 失败:${e.message}`)
591
+ } finally {
592
+ session.attached = false
593
+ if (attachedTabId === session.tabId) attachedTabId = null
594
+ resetDebuggerState(session)
595
+ }
596
+ }
597
+
351
598
  async function maybeDetach(force = false) {
352
- if ((CONFIG.autoDetach || force) && attachedTabId) {
353
- try {
354
- await chrome.debugger.detach({ tabId: attachedTabId })
355
- } catch (e) {
356
- log(`detach 失败:${e.message}`)
357
- } finally {
358
- attachedTabId = null
599
+ if (force) {
600
+ for (const session of sessionsByTabId.values()) {
601
+ await detachSession(session)
359
602
  }
603
+ return
604
+ }
605
+ if (CONFIG.autoDetach && attachedTabId !== null) {
606
+ await detachSession(getSession(attachedTabId, { create: false }))
360
607
  }
361
608
  }
362
609
 
@@ -382,15 +629,185 @@ async function detachAllTargets() {
382
629
  }
383
630
  } catch {}
384
631
  attachedTabId = null
632
+ focusedTabId = null
633
+ sessionsByTabId.clear()
385
634
  }
386
635
 
387
636
  // ========== 命令处理 ==========
388
637
 
638
+ async function buildTargetInfo(clientId) {
639
+ const client = getClientSession(clientId)
640
+ const viewingTab = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
641
+ .then(([tab]) => tab || null)
642
+ .catch(() => null)
643
+
644
+ let targetTab = null
645
+ let targetError = ''
646
+ const targetTabId = client.targetMode === 'pinned' ? client.pinnedTabId : (viewingTab?.id ?? attachedTabId)
647
+
648
+ if (targetTabId !== null && targetTabId !== undefined) {
649
+ try {
650
+ targetTab = await chrome.tabs.get(targetTabId)
651
+ } catch (e) {
652
+ targetError = e.message
653
+ }
654
+ } else if (client.targetMode === 'focused') {
655
+ targetTab = viewingTab
656
+ }
657
+
658
+ return {
659
+ clientId: clientId || DEFAULT_CLIENT_ID,
660
+ targetMode: client.targetMode,
661
+ pinnedTabId: client.pinnedTabId,
662
+ focusedTabId,
663
+ attachedTabId,
664
+ attachedTabIds: [...sessionsByTabId.values()].filter((session) => session.attached).map((session) => session.tabId),
665
+ targetTab: summarizeTab(targetTab),
666
+ viewingTab: summarizeTab(viewingTab),
667
+ targets: await summarizeNamedTargets(),
668
+ targetError,
669
+ }
670
+ }
671
+
672
+ function describeCommandTarget(params, session) {
673
+ if (params?.target) return String(params.target)
674
+ const names = getNamesForTab(session.tabId)
675
+ if (names.length) return names[0]
676
+ const client = getClientSession(params?.clientId)
677
+ if (client.targetMode === 'pinned' && client.pinnedTabId === session.tabId) return 'pinned'
678
+ return 'focused'
679
+ }
680
+
681
+ async function summarizeNamedTargets() {
682
+ const results = []
683
+ for (const [ownerId, client] of clientSessions.entries()) {
684
+ for (const [name, tabId] of client.namedTargets.entries()) {
685
+ const session = getSession(tabId, { create: false })
686
+ try {
687
+ const tab = await chrome.tabs.get(tabId)
688
+ results.push({
689
+ owner: ownerId,
690
+ name,
691
+ tabId,
692
+ tab: summarizeTab(tab),
693
+ attached: !!session?.attached,
694
+ errorCount: session?.lastErrors.filter((e) => e.severity === 'error').length || 0,
695
+ networkCount: (session?.networkRequests.length || 0) + (session?.requestMap.size || 0),
696
+ })
697
+ } catch (e) {
698
+ results.push({ owner: ownerId, name, tabId, attached: false, error: e.message })
699
+ }
700
+ }
701
+ }
702
+ return results
703
+ }
704
+
705
+ async function handleListTabs(params = {}) {
706
+ const tabs = await chrome.tabs.query({})
707
+ const targetInfo = await buildTargetInfo(params.clientId)
708
+ return {
709
+ ...targetInfo,
710
+ tabs: tabs
711
+ .filter((tab) => tab.id !== undefined)
712
+ .sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
713
+ .map(summarizeTab),
714
+ }
715
+ }
716
+
717
+ async function handleListTargets(params = {}) {
718
+ return buildTargetInfo(params.clientId)
719
+ }
720
+
721
+ async function bindTarget(name, tab, clientId) {
722
+ const client = getClientSession(clientId)
723
+ const previousTabId = client.namedTargets.get(name)
724
+ client.namedTargets.set(name, tab.id)
725
+ try {
726
+ await ensureAttachedSession({ target: name, clientId })
727
+ } catch (e) {
728
+ if (previousTabId !== undefined) client.namedTargets.set(name, previousTabId)
729
+ else client.namedTargets.delete(name)
730
+ throw e
731
+ }
732
+ if (previousTabId !== undefined && previousTabId !== tab.id && !isTabReferenced(previousTabId)) {
733
+ const previousSession = getSession(previousTabId, { create: false })
734
+ await detachSession(previousSession)
735
+ sessionsByTabId.delete(previousTabId)
736
+ }
737
+ broadcastStatus()
738
+ return {
739
+ bound: { name, tabId: tab.id, tab: summarizeTab(tab) },
740
+ ...(await buildTargetInfo(clientId)),
741
+ }
742
+ }
743
+
744
+ async function handleBindTab(params = {}) {
745
+ const name = normalizeTargetName(params.name, 'name')
746
+ const tab = await findTabByParams(params)
747
+ return bindTarget(name, tab, params.clientId)
748
+ }
749
+
750
+ async function handleUnbindTab(params = {}) {
751
+ const client = getClientSession(params.clientId)
752
+ const name = normalizeTargetName(params.name || params.target, 'name')
753
+ const tabId = client.namedTargets.get(name)
754
+ if (tabId === undefined) throw new Error(`未绑定 target "${name}"`)
755
+ client.namedTargets.delete(name)
756
+
757
+ const session = getSession(tabId, { create: false })
758
+ if (session && !isTabReferenced(tabId)) {
759
+ await detachSession(session)
760
+ sessionsByTabId.delete(tabId)
761
+ }
762
+
763
+ broadcastStatus()
764
+ return {
765
+ unbound: { name, tabId },
766
+ ...(await buildTargetInfo(params.clientId)),
767
+ }
768
+ }
769
+
770
+ async function pinTab(tab, clientId) {
771
+ const client = getClientSession(clientId)
772
+ const previousMode = client.targetMode
773
+ const previousPinnedTabId = client.pinnedTabId
774
+ client.targetMode = 'pinned'
775
+ client.pinnedTabId = tab.id
776
+ try {
777
+ await ensureAttachedSession({ tabId: tab.id, clientId })
778
+ } catch (e) {
779
+ client.targetMode = previousMode
780
+ client.pinnedTabId = previousPinnedTabId
781
+ throw e
782
+ }
783
+ broadcastStatus()
784
+ return buildTargetInfo(clientId)
785
+ }
786
+
787
+ async function handlePinCurrentTab(params = {}) {
788
+ return pinTab(await getFocusedTabOrThrow(), params.clientId)
789
+ }
790
+
791
+ async function handlePinTab(params = {}) {
792
+ return pinTab(await findTabByParams(params), params.clientId)
793
+ }
794
+
795
+ async function handleUnpinTab(params = {}) {
796
+ const client = getClientSession(params.clientId)
797
+ client.targetMode = 'focused'
798
+ client.pinnedTabId = null
799
+ if (state.enabled && state.connected) {
800
+ await ensureAttached({ clientId: params.clientId })
801
+ }
802
+ broadcastStatus()
803
+ return buildTargetInfo(params.clientId)
804
+ }
805
+
389
806
  async function handleGetLastError(params = {}) {
390
- await ensureAttached()
807
+ const { session } = await ensureAttachedSession(params)
391
808
  const severity = params.severity || "error"
392
809
  const limit = Math.max(1, Math.min(params.limit || 20, CONFIG.maxErrors))
393
- const allEvents = lastErrors.slice(0, CONFIG.maxErrors)
810
+ const allEvents = session.lastErrors.slice(0, CONFIG.maxErrors)
394
811
  const filteredEvents = severity === "all"
395
812
  ? allEvents
396
813
  : allEvents.filter((event) => (event.severity || "info") === severity)
@@ -404,7 +821,7 @@ async function handleGetLastError(params = {}) {
404
821
  )
405
822
  const events = filteredEvents.slice(0, limit)
406
823
  return {
407
- lastErrorLocation,
824
+ lastErrorLocation: session.lastErrorLocation,
408
825
  summary: {
409
826
  count: events.length,
410
827
  cachedCount: allEvents.length,
@@ -418,17 +835,17 @@ async function handleGetLastError(params = {}) {
418
835
  }
419
836
  }
420
837
 
421
- async function pickScriptId(preferUrlContains) {
838
+ async function pickScriptId(session, preferUrlContains) {
422
839
  if (preferUrlContains) {
423
- for (const [id, meta] of scriptMap.entries()) {
840
+ for (const [id, meta] of session.scriptMap.entries()) {
424
841
  if (meta.url && meta.url.includes(preferUrlContains)) return { id, url: meta.url }
425
842
  }
426
843
  }
427
- if (lastErrorLocation?.scriptId && scriptMap.has(lastErrorLocation.scriptId)) {
428
- const meta = scriptMap.get(lastErrorLocation.scriptId)
429
- return { id: lastErrorLocation.scriptId, url: meta.url }
844
+ if (session.lastErrorLocation?.scriptId && session.scriptMap.has(session.lastErrorLocation.scriptId)) {
845
+ const meta = session.scriptMap.get(session.lastErrorLocation.scriptId)
846
+ return { id: session.lastErrorLocation.scriptId, url: meta.url }
430
847
  }
431
- const first = scriptMap.entries().next().value
848
+ const first = session.scriptMap.entries().next().value
432
849
  if (first) {
433
850
  return { id: first[0], url: first[1].url }
434
851
  }
@@ -436,15 +853,15 @@ async function pickScriptId(preferUrlContains) {
436
853
  }
437
854
 
438
855
  async function handleGetScriptSource(params = {}) {
439
- const target = await ensureAttached()
440
- const chosen = await pickScriptId(params.scriptUrlContains)
856
+ const { target, session } = await ensureAttachedSession(params)
857
+ const chosen = await pickScriptId(session, params.scriptUrlContains)
441
858
  const { scriptSource } = await chrome.debugger.sendCommand(target, "Debugger.getScriptSource", {
442
859
  scriptId: chosen.id,
443
860
  })
444
- scriptSourceCache.set(chosen.id, scriptSource)
861
+ session.scriptSourceCache.set(chosen.id, scriptSource)
445
862
  const location = {
446
- line: params.line ?? lastErrorLocation?.line ?? null,
447
- column: params.column ?? lastErrorLocation?.column ?? null,
863
+ line: params.line ?? session.lastErrorLocation?.line ?? null,
864
+ column: params.column ?? session.lastErrorLocation?.column ?? null,
448
865
  }
449
866
  return {
450
867
  url: chosen.url,
@@ -456,7 +873,7 @@ async function handleGetScriptSource(params = {}) {
456
873
  }
457
874
 
458
875
  async function handleCoverageSnapshot(params = {}) {
459
- const target = await ensureAttached()
876
+ const target = await ensureAttached(params)
460
877
  const durationMs = params.durationMs || 1500
461
878
  await chrome.debugger.sendCommand(target, "Profiler.startPreciseCoverage", {
462
879
  callCount: true,
@@ -492,20 +909,20 @@ function findContexts(source, query, maxMatches) {
492
909
  }
493
910
 
494
911
  async function handleFindByString(params = {}) {
495
- const target = await ensureAttached()
912
+ const { target, session } = await ensureAttachedSession(params)
496
913
  const query = params.query
497
914
  const maxMatches = params.maxMatches || 5
498
915
  const preferred = params.scriptUrlContains
499
916
 
500
917
  const results = []
501
- const entries = [...scriptMap.entries()]
918
+ const entries = [...session.scriptMap.entries()]
502
919
  for (const [id, meta] of entries) {
503
920
  if (preferred && (!meta.url || !meta.url.includes(preferred))) continue
504
- if (!scriptSourceCache.has(id)) {
921
+ if (!session.scriptSourceCache.has(id)) {
505
922
  const { scriptSource } = await chrome.debugger.sendCommand(target, "Debugger.getScriptSource", { scriptId: id })
506
- scriptSourceCache.set(id, scriptSource)
923
+ session.scriptSourceCache.set(id, scriptSource)
507
924
  }
508
- const source = scriptSourceCache.get(id)
925
+ const source = session.scriptSourceCache.get(id)
509
926
  const matches = findContexts(source, query, maxMatches - results.length)
510
927
  if (matches.length) {
511
928
  results.push({ url: meta.url, scriptId: id, matches })
@@ -516,8 +933,8 @@ async function handleFindByString(params = {}) {
516
933
  return { query, results }
517
934
  }
518
935
 
519
- async function handleSymbolicHints() {
520
- const target = await ensureAttached()
936
+ async function handleSymbolicHints(params = {}) {
937
+ const target = await ensureAttached(params)
521
938
  const expression = `(function(){
522
939
  try {
523
940
  const resources = performance.getEntriesByType('resource').slice(-20).map(e => ({
@@ -540,7 +957,7 @@ async function handleSymbolicHints() {
540
957
  }
541
958
 
542
959
  async function handleEval(params = {}) {
543
- const target = await ensureAttached()
960
+ const target = await ensureAttached(params)
544
961
  const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
545
962
  expression: params.code,
546
963
  returnByValue: true,
@@ -549,11 +966,11 @@ async function handleEval(params = {}) {
549
966
  }
550
967
 
551
968
  async function handleListNetworkRequests(params = {}) {
552
- await ensureAttached()
969
+ const { session } = await ensureAttachedSession(params)
553
970
  const { filter, method, status, resourceType, limit = 50, priorityMode = 'debug' } = params
554
971
 
555
- let results = [...networkRequests]
556
- const pending = [...requestMap.values()].map(r => ({ ...r, status: "pending" }))
972
+ let results = [...session.networkRequests]
973
+ const pending = [...session.requestMap.values()].map(r => ({ ...r, status: "pending" }))
557
974
  results = [...pending, ...results]
558
975
 
559
976
  if (filter) {
@@ -571,7 +988,7 @@ async function handleListNetworkRequests(params = {}) {
571
988
  results = results.slice(0, limit)
572
989
 
573
990
  return {
574
- total: networkRequests.length + requestMap.size,
991
+ total: session.networkRequests.length + session.requestMap.size,
575
992
  filtered: results.length,
576
993
  priorityMode,
577
994
  requests: results.map((entry) => GhostBridgeNetwork.buildNetworkRequestSummary(entry)),
@@ -579,12 +996,12 @@ async function handleListNetworkRequests(params = {}) {
579
996
  }
580
997
 
581
998
  async function handleGetNetworkDetail(params = {}) {
582
- const target = await ensureAttached()
999
+ const { target, session } = await ensureAttachedSession(params)
583
1000
  const { requestId, includeBody = false } = params
584
1001
  if (!requestId) throw new Error("需要提供 requestId")
585
1002
 
586
- let entry = requestMap.get(requestId)
587
- if (!entry) entry = networkRequests.find(r => r.requestId === requestId)
1003
+ let entry = session.requestMap.get(requestId)
1004
+ if (!entry) entry = session.networkRequests.find(r => r.requestId === requestId)
588
1005
  if (!entry) throw new Error(`未找到请求: ${requestId}`)
589
1006
 
590
1007
  const urlMeta = GhostBridgeNetwork.summarizeNetworkUrl(entry.url)
@@ -627,15 +1044,15 @@ async function handleGetNetworkDetail(params = {}) {
627
1044
  return result
628
1045
  }
629
1046
 
630
- async function handleClearNetworkRequests() {
631
- await ensureAttached()
632
- const count = networkRequests.length
633
- networkRequests = []
1047
+ async function handleClearNetworkRequests(params = {}) {
1048
+ const { session } = await ensureAttachedSession(params)
1049
+ const count = session.networkRequests.length
1050
+ session.networkRequests = []
634
1051
  return { cleared: count }
635
1052
  }
636
1053
 
637
1054
  async function handlePerfMetrics(params = {}) {
638
- const target = await ensureAttached()
1055
+ const target = await ensureAttached(params)
639
1056
  const { includeResources = true, includeTimings = true } = params
640
1057
 
641
1058
  // 1. CDP Performance.getMetrics — 底层引擎指标
@@ -786,7 +1203,7 @@ function roundMs(seconds) {
786
1203
  }
787
1204
 
788
1205
  async function handleCaptureScreenshot(params = {}) {
789
- const target = await ensureAttached()
1206
+ const target = await ensureAttached(params)
790
1207
  const { format: requestedFormat, quality: requestedQuality, fullPage = false, clip } = params
791
1208
  const format = requestedFormat || 'jpeg'
792
1209
  const quality = format === 'jpeg'
@@ -866,7 +1283,7 @@ async function handleCaptureScreenshot(params = {}) {
866
1283
  }
867
1284
 
868
1285
  async function handleInspectPageSnapshot(params = {}) {
869
- const target = await ensureAttached()
1286
+ const { target, session } = await ensureAttachedSession(params)
870
1287
  const { selector, includeInteractive = true, maxElements = 30 } = params
871
1288
  const expression = GhostBridgeDom.buildInspectPageExpression({ selector, includeInteractive, maxElements })
872
1289
 
@@ -876,11 +1293,16 @@ async function handleInspectPageSnapshot(params = {}) {
876
1293
  })
877
1294
 
878
1295
  if (result?.value?.error) throw new Error(result.value.error)
879
- return result?.value
1296
+ const value = result?.value
1297
+ if (value) {
1298
+ value.target = describeCommandTarget(params, session)
1299
+ value.tabId = session.tabId
1300
+ }
1301
+ return value
880
1302
  }
881
1303
 
882
1304
  async function handleGetPageContent(params = {}) {
883
- const target = await ensureAttached()
1305
+ const target = await ensureAttached(params)
884
1306
  const { mode = "text", selector, maxLength = 50000, includeMetadata = true } = params
885
1307
  const expression = GhostBridgeDom.buildPageContentExpression({ mode, selector, maxLength, includeMetadata })
886
1308
 
@@ -896,7 +1318,7 @@ async function handleGetPageContent(params = {}) {
896
1318
  // ========== DOM 交互:可交互元素快照 ==========
897
1319
 
898
1320
  async function handleGetInteractiveSnapshot(params = {}) {
899
- const target = await ensureAttached()
1321
+ const { target, session } = await ensureAttachedSession(params)
900
1322
  const { selector, includeText = true, maxElements = 100 } = params
901
1323
  const expression = GhostBridgeDom.buildInteractiveSnapshotExpression({ selector, includeText, maxElements })
902
1324
 
@@ -906,13 +1328,22 @@ async function handleGetInteractiveSnapshot(params = {}) {
906
1328
  })
907
1329
 
908
1330
  if (result?.value?.error) throw new Error(result.value.error)
909
- return result?.value
1331
+ const value = result?.value
1332
+ if (value) {
1333
+ value.target = describeCommandTarget(params, session)
1334
+ value.tabId = session.tabId
1335
+ }
1336
+ return value
910
1337
  }
911
1338
 
912
1339
  // ========== DOM 交互:动作分发器 ==========
913
1340
 
914
1341
  async function handleDispatchAction(params = {}) {
915
- const target = await ensureAttached()
1342
+ const anyNamedTargets = [...clientSessions.values()].some((client) => client.namedTargets.size > 0)
1343
+ if (anyNamedTargets && !params.target && params.tabId === undefined) {
1344
+ throw new Error("已绑定命名 target 时,dispatch_action 必须提供 target,避免跨页面误用 ref")
1345
+ }
1346
+ const target = await ensureAttached(params)
916
1347
  const { ref, action, value, key, deltaX, deltaY, waitMs = 500 } = params
917
1348
 
918
1349
  if (!ref) throw new Error("需要提供 ref(元素标识,如 'e1')")
@@ -1080,8 +1511,15 @@ async function handleDispatchAction(params = {}) {
1080
1511
 
1081
1512
  // 处理来自服务器的命令
1082
1513
  async function handleCommand(message) {
1083
- const { id, command, params, token } = message
1514
+ const { id, command, token } = message
1084
1515
  if (!id || !command) return
1516
+
1517
+ // 内部命令:服务器通知某个 MCP 会话断开,清理其 target 命名空间(不要求扩展已启用)
1518
+ if (command === "_clientDisconnected") {
1519
+ cleanupClientSession(message.params?.clientId)
1520
+ sendToServer({ id, result: { cleaned: true } })
1521
+ return
1522
+ }
1085
1523
  if (!state.enabled) {
1086
1524
  sendToServer({ id, error: "扩展已暂停,点击图标重新开启" })
1087
1525
  return
@@ -1090,17 +1528,28 @@ async function handleCommand(message) {
1090
1528
  sendToServer({ id, error: "token 校验失败" })
1091
1529
  return
1092
1530
  }
1531
+ // 命令所属的 MCP 会话:pin/bind 的命名空间按它隔离
1532
+ const clientId = message.clientId
1533
+ const params = { ...(message.params || {}), clientId }
1093
1534
  try {
1094
1535
  let result
1095
- if (command === "getLastError") result = await handleGetLastError(params)
1536
+ if (command === "listTabs") result = await handleListTabs(params)
1537
+ else if (command === "getTargetTab") result = await buildTargetInfo(clientId)
1538
+ else if (command === "listTargets") result = await handleListTargets(params)
1539
+ else if (command === "bindTab") result = await handleBindTab(params)
1540
+ else if (command === "unbindTab") result = await handleUnbindTab(params)
1541
+ else if (command === "pinCurrentTab") result = await handlePinCurrentTab(params)
1542
+ else if (command === "pinTab") result = await handlePinTab(params)
1543
+ else if (command === "unpinTab") result = await handleUnpinTab(params)
1544
+ else if (command === "getLastError") result = await handleGetLastError(params)
1096
1545
  else if (command === "getScriptSource") result = await handleGetScriptSource(params)
1097
1546
  else if (command === "coverageSnapshot") result = await handleCoverageSnapshot(params)
1098
1547
  else if (command === "findByString") result = await handleFindByString(params)
1099
- else if (command === "symbolicHints") result = await handleSymbolicHints()
1548
+ else if (command === "symbolicHints") result = await handleSymbolicHints(params)
1100
1549
  else if (command === "eval") result = await handleEval(params)
1101
1550
  else if (command === "listNetworkRequests") result = await handleListNetworkRequests(params)
1102
1551
  else if (command === "getNetworkDetail") result = await handleGetNetworkDetail(params)
1103
- else if (command === "clearNetworkRequests") result = await handleClearNetworkRequests()
1552
+ else if (command === "clearNetworkRequests") result = await handleClearNetworkRequests(params)
1104
1553
  else if (command === "perfMetrics") result = await handlePerfMetrics(params)
1105
1554
  else if (command === "captureScreenshot") result = await handleCaptureScreenshot(params)
1106
1555
  else if (command === "inspectPageSnapshot") result = await handleInspectPageSnapshot(params)
@@ -1124,8 +1573,7 @@ function sendToServer(data) {
1124
1573
 
1125
1574
  // ========== 状态广播 ==========
1126
1575
 
1127
- // 主动推送状态给 popup
1128
- function broadcastStatus() {
1576
+ function getConnectionStatus() {
1129
1577
  let status
1130
1578
  if (!state.enabled) {
1131
1579
  status = 'disconnected'
@@ -1134,43 +1582,53 @@ function broadcastStatus() {
1134
1582
  } else {
1135
1583
  status = state.connectionStatus || 'connecting'
1136
1584
  }
1585
+ return status
1586
+ }
1137
1587
 
1138
- let tabUrl = ''
1139
- let tabTitle = ''
1140
-
1141
- if (attachedTabId) {
1142
- chrome.tabs.get(attachedTabId).then(t => {
1143
- tabUrl = t.url
1144
- tabTitle = t.title
1145
- doBroadcast()
1146
- }).catch(() => doBroadcast())
1147
- } else {
1148
- doBroadcast()
1588
+ async function buildPopupState() {
1589
+ const status = getConnectionStatus()
1590
+ const targetInfo = await buildTargetInfo()
1591
+ const targetTab = targetInfo.targetTab
1592
+ const viewingTab = targetInfo.viewingTab
1593
+ return {
1594
+ status,
1595
+ enabled: state.enabled,
1596
+ port: state.port,
1597
+ currentPort: state.currentPort,
1598
+ basePort: CONFIG.basePort,
1599
+ connectionError: state.connectionError,
1600
+ serverInfo: state.serverInfo,
1601
+ targetMode: targetInfo.targetMode,
1602
+ pinnedTabId: targetInfo.pinnedTabId,
1603
+ focusedTabId: targetInfo.focusedTabId,
1604
+ attachedTabId: targetInfo.attachedTabId,
1605
+ attachedTabIds: targetInfo.attachedTabIds,
1606
+ targets: targetInfo.targets,
1607
+ targetTab,
1608
+ viewingTab,
1609
+ targetError: targetInfo.targetError,
1610
+ tabTitle: targetTab?.title || '',
1611
+ tabUrl: targetTab?.url || '',
1612
+ viewingTitle: viewingTab?.title || '',
1613
+ viewingUrl: viewingTab?.url || '',
1149
1614
  }
1615
+ }
1150
1616
 
1151
- function doBroadcast() {
1152
- const actualErrors = lastErrors.filter(e => e.severity === 'error')
1617
+ // 主动推送状态给 popup
1618
+ async function broadcastStatus() {
1619
+ try {
1153
1620
  chrome.runtime.sendMessage({
1154
1621
  type: 'statusUpdate',
1155
- state: {
1156
- status,
1157
- enabled: state.enabled,
1158
- port: state.port,
1159
- currentPort: state.currentPort,
1160
- basePort: CONFIG.basePort,
1161
- connectionError: state.connectionError,
1162
- errorCount: actualErrors.length,
1163
- recentErrors: actualErrors.slice(0, 5),
1164
- tabTitle,
1165
- tabUrl,
1166
- }
1622
+ state: await buildPopupState()
1167
1623
  }).catch(() => {}) // popup 可能未打开,忽略错误
1624
+ } catch (e) {
1625
+ log(`状态广播失败:${e.message}`)
1168
1626
  }
1169
1627
  }
1170
1628
 
1171
1629
  // 监听被调试页面的导航变化,实时推送到 popup
1172
1630
  chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
1173
- if (tabId === attachedTabId && (changeInfo.title || changeInfo.url)) {
1631
+ if ((sessionsByTabId.has(tabId) || tab.active) && (changeInfo.title || changeInfo.url)) {
1174
1632
  if (state.connected) broadcastStatus()
1175
1633
  }
1176
1634
  })
@@ -1179,8 +1637,12 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
1179
1637
  chrome.tabs.onActivated.addListener(async (activeInfo) => {
1180
1638
  if (state.enabled && state.connected) {
1181
1639
  try {
1182
- // 这里的 ensureAttached() 会自动处理从旧 Tab detach attach 到新 Tab
1183
- await ensureAttached()
1640
+ // focused 模式保持旧行为:切换 Tab 时自动跟随(focused 是共享语义,任一会话处于 focused 即跟随)
1641
+ const anyFocused = clientSessions.size === 0
1642
+ || [...clientSessions.values()].some((client) => client.targetMode === 'focused')
1643
+ if (anyFocused) {
1644
+ await ensureAttached()
1645
+ }
1184
1646
  broadcastStatus()
1185
1647
  } catch (e) {
1186
1648
  log(`自动跟随切换 Tab 失败:${e.message}`)
@@ -1188,6 +1650,26 @@ chrome.tabs.onActivated.addListener(async (activeInfo) => {
1188
1650
  }
1189
1651
  })
1190
1652
 
1653
+ chrome.tabs.onRemoved.addListener((tabId) => {
1654
+ for (const client of clientSessions.values()) {
1655
+ if (client.pinnedTabId === tabId) {
1656
+ client.targetMode = 'focused'
1657
+ client.pinnedTabId = null
1658
+ }
1659
+ for (const [name, boundTabId] of client.namedTargets.entries()) {
1660
+ if (boundTabId === tabId) client.namedTargets.delete(name)
1661
+ }
1662
+ }
1663
+ if (tabId === attachedTabId) {
1664
+ attachedTabId = null
1665
+ }
1666
+ if (tabId === focusedTabId) {
1667
+ focusedTabId = null
1668
+ }
1669
+ sessionsByTabId.delete(tabId)
1670
+ if (state.connected) broadcastStatus()
1671
+ })
1672
+
1191
1673
 
1192
1674
  // ========== 消息监听 ==========
1193
1675
 
@@ -1210,29 +1692,38 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1210
1692
  state.currentPort = message.port
1211
1693
  state.connectionStatus = 'connected'
1212
1694
  state.connectionError = ''
1695
+ state.serverInfo = message.serverInfo || null
1213
1696
  setBadgeState('on')
1697
+ // 服务器可能已重启,clientId 分配从零开始,旧会话桶全部作废
1698
+ clientSessions.clear()
1214
1699
  log(`✅ 已连接到 ghost-bridge 服务 (端口 ${message.port})`)
1215
- ensureAttached().catch((e) => log(`attach 失败:${e.message}`))
1700
+ ensureAttached()
1701
+ .then(() => broadcastStatus())
1702
+ .catch((e) => log(`attach 失败:${e.message}`))
1216
1703
  } else if (message.status === 'disconnected') {
1217
1704
  state.connected = false
1218
1705
  state.port = null
1219
1706
  state.connectionStatus = 'connecting'
1220
1707
  state.connectionError = ''
1708
+ state.serverInfo = null
1221
1709
  if (state.enabled) setBadgeState('connecting')
1222
1710
  } else if (message.status === 'connecting') {
1223
1711
  state.currentPort = message.currentPort
1224
1712
  state.connectionStatus = 'connecting'
1225
1713
  state.connectionError = ''
1714
+ state.serverInfo = null
1226
1715
  setBadgeState('connecting')
1227
1716
  } else if (message.status === 'error') {
1228
1717
  state.currentPort = message.currentPort
1229
1718
  state.connectionStatus = 'error'
1230
1719
  state.connectionError = message.errorMessage || ''
1720
+ state.serverInfo = null
1231
1721
  setBadgeState('err')
1232
1722
  } else if (message.status === 'not_found') {
1233
1723
  state.currentPort = message.currentPort
1234
1724
  state.connectionStatus = 'not_found'
1235
- state.connectionError = ''
1725
+ state.connectionError = message.errorMessage || ''
1726
+ state.serverInfo = null
1236
1727
  setBadgeState('connecting')
1237
1728
  }
1238
1729
  broadcastStatus() // 状态变化时主动推送
@@ -1258,44 +1749,25 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1258
1749
 
1259
1750
  // 来自 popup 的状态查询
1260
1751
  if (message.type === 'getStatus') {
1261
- let status
1262
- if (!state.enabled) {
1263
- status = 'disconnected'
1264
- } else if (state.connected) {
1265
- status = 'connected'
1266
- } else {
1267
- status = state.connectionStatus || 'connecting'
1268
- }
1752
+ buildPopupState().then(sendResponse).catch((e) => sendResponse({ status: 'error', connectionError: e.message }))
1753
+ return true
1754
+ }
1269
1755
 
1270
- let tabUrl = ''
1271
- let tabTitle = ''
1272
- if (attachedTabId) {
1273
- chrome.tabs.get(attachedTabId).then(t => {
1274
- tabUrl = t.url
1275
- tabTitle = t.title
1276
- sendStatusResponse()
1277
- }).catch(() => {
1278
- sendStatusResponse()
1279
- })
1280
- } else {
1281
- sendStatusResponse()
1756
+ if (message.type === 'pinCurrentTab') {
1757
+ if (!state.enabled || !state.connected) {
1758
+ sendResponse({ ok: false, error: "Ghost Bridge 尚未连接" })
1759
+ return true
1282
1760
  }
1761
+ handlePinCurrentTab().then((result) => sendResponse({ ok: true, result })).catch((e) => sendResponse({ ok: false, error: e.message }))
1762
+ return true
1763
+ }
1283
1764
 
1284
- function sendStatusResponse() {
1285
- const actualErrors = lastErrors.filter(e => e.severity === 'error')
1286
- sendResponse({
1287
- status,
1288
- enabled: state.enabled,
1289
- port: state.port,
1290
- currentPort: state.currentPort,
1291
- basePort: CONFIG.basePort,
1292
- connectionError: state.connectionError,
1293
- errorCount: actualErrors.length,
1294
- recentErrors: actualErrors.slice(0, 5),
1295
- tabTitle,
1296
- tabUrl,
1297
- })
1765
+ if (message.type === 'unpinTab') {
1766
+ if (!state.enabled || !state.connected) {
1767
+ sendResponse({ ok: false, error: "Ghost Bridge 尚未连接" })
1768
+ return true
1298
1769
  }
1770
+ handleUnpinTab().then((result) => sendResponse({ ok: true, result })).catch((e) => sendResponse({ ok: false, error: e.message }))
1299
1771
  return true
1300
1772
  }
1301
1773
 
@@ -1303,60 +1775,56 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1303
1775
  if (message.type === 'connect') {
1304
1776
  if (message.port) {
1305
1777
  CONFIG.basePort = message.port
1306
- chrome.storage.local.set({ basePort: message.port })
1307
1778
  }
1308
- state.enabled = true
1309
- state.connected = false
1310
- state.port = null
1311
- state.currentPort = CONFIG.basePort
1312
- state.connectionStatus = 'connecting'
1313
- state.connectionError = ''
1314
- setBadgeState('connecting')
1315
-
1316
- // 启动 offscreen 并开始连接
1317
- setupOffscreenDocument().then(() => {
1318
- chrome.runtime.sendMessage({
1319
- type: 'connect',
1320
- basePort: CONFIG.basePort,
1321
- token: CONFIG.token,
1322
- }).catch(() => {})
1323
- })
1324
1779
 
1325
- sendResponse({ ok: true })
1780
+ startBridgeConnection({ persist: true })
1781
+ .then(() => sendResponse({ ok: true }))
1782
+ .catch((e) => sendResponse({ ok: false, error: e.message }))
1326
1783
  return true
1327
1784
  }
1328
1785
 
1329
1786
  // 来自 popup 的断开请求
1330
1787
  if (message.type === 'disconnect') {
1331
- state.enabled = false
1332
- state.connected = false
1333
- state.port = null
1334
- state.currentPort = null
1335
- state.connectionStatus = 'disconnected'
1336
- state.connectionError = ''
1337
- setBadgeState('off')
1338
- detachAllTargets().catch(() => {})
1339
-
1340
- // 通知 offscreen 断开 (WebSocket 清除)
1341
- chrome.runtime.sendMessage({ type: 'disconnect' }).catch(() => {})
1342
-
1343
- // 关键修复:显式销毁 offscreen document 防止内存泄漏
1344
- closeOffscreenDocument().catch(() => {})
1345
-
1346
- sendResponse({ ok: true })
1788
+ stopBridgeConnection({ persist: true })
1789
+ .then(() => sendResponse({ ok: true }))
1790
+ .catch((e) => sendResponse({ ok: false, error: e.message }))
1347
1791
  return true
1348
1792
  }
1349
1793
 
1350
1794
  return false
1351
1795
  })
1352
1796
 
1353
- // 启动时从 storage 加载端口配置
1354
- chrome.storage.local.get(['basePort'], (result) => {
1355
- if (result.basePort) {
1356
- CONFIG.basePort = result.basePort
1797
+ // ========== 唤醒探活钩子 ==========
1798
+ // 系统锁屏/睡眠唤醒后,WebSocket 可能处于半开状态(onclose 不触发、徽章仍显示已连接),
1799
+ // 通知 offscreen 立即发一次心跳:无响应则关闭死链并马上重连,不等 15 秒周期心跳超时
1800
+ chrome.idle.onStateChanged.addListener(async (idleState) => {
1801
+ if (idleState !== 'active') return
1802
+ if (!state.enabled) return
1803
+ log('系统唤醒,触发连接探活...')
1804
+ try {
1805
+ await setupOffscreenDocument()
1806
+ const alive = await chrome.runtime.sendMessage({ type: 'healthCheck' }).catch(() => null)
1807
+ // alive === false 说明 offscreen 已自行触发重连,无需干预;
1808
+ // 仅当 offscreen 完全无响应(可能被回收)时才走完整连接流程兜底
1809
+ if (alive !== true && alive !== false) {
1810
+ const status = await chrome.runtime.sendMessage({ type: 'getOffscreenStatus' }).catch(() => null)
1811
+ if (!status || !status.connected) {
1812
+ log('唤醒探活无响应,重新建立连接...')
1813
+ await startBridgeConnection()
1814
+ }
1815
+ }
1816
+ } catch (e) {
1817
+ log(`唤醒探活失败:${e.message}`)
1357
1818
  }
1358
1819
  })
1359
1820
 
1360
- // 默认暂停
1361
- setBadgeState("off")
1821
+ chrome.runtime.onStartup.addListener(() => {
1822
+ restoreBridgeConnection()
1823
+ })
1824
+
1825
+ chrome.runtime.onInstalled.addListener(() => {
1826
+ restoreBridgeConnection()
1827
+ })
1828
+
1829
+ restoreBridgeConnection()
1362
1830
  log("Ghost Bridge background 已加载")