ghost-bridge 0.8.0 → 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,20 +12,83 @@ const CONFIG = {
12
12
  maxRequestBodySize: 500000, // 提升至 500KB,容纳较大的 API 请求
13
13
  }
14
14
 
15
- let attachedTabId = null
16
- let targetMode = 'focused' // focused | pinned
17
- let pinnedTabId = null
18
- let scriptMap = new Map()
19
- let scriptSourceCache = new Map()
20
- let lastErrors = []
21
- let lastErrorLocation = null
22
- let requestMap = new Map()
23
- let networkRequests = []
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
+
24
54
  let state = { enabled: false, connected: false, port: null, currentPort: null, connectionStatus: 'disconnected', connectionError: '', serverInfo: null }
25
55
 
26
56
  // 待处理的请求(等待 offscreen 响应)
27
57
  const pendingRequests = new Map()
28
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
+
29
92
  function setBadgeState(status) {
30
93
  const map = {
31
94
  connecting: { text: "…", color: "#999" },
@@ -90,14 +153,77 @@ async function closeOffscreenDocument() {
90
153
  }
91
154
  }
92
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
+
93
218
  // ========== Chrome Debugger 事件处理 ==========
94
219
 
95
220
  chrome.debugger.onEvent.addListener((source, method, params) => {
96
- if (source.tabId !== attachedTabId) return
221
+ const session = getSession(source.tabId, { create: false })
222
+ if (!session) return
97
223
  if (!state.enabled) return
98
224
 
99
225
  if (method === "Debugger.scriptParsed") {
100
- scriptMap.set(params.scriptId, { url: params.url || "(inline)" })
226
+ session.scriptMap.set(params.scriptId, { url: params.url || "(inline)" })
101
227
  }
102
228
 
103
229
  if (method === "Runtime.exceptionThrown") {
@@ -114,18 +240,18 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
114
240
  stack: compactStack(detail.stackTrace),
115
241
  timestamp: Date.now(),
116
242
  }
117
- lastErrorLocation = {
243
+ session.lastErrorLocation = {
118
244
  url: entry.url,
119
245
  line: entry.line,
120
246
  column: entry.column,
121
247
  scriptId: entry.scriptId,
122
248
  }
123
- pushError(entry)
249
+ pushError(session, entry)
124
250
  }
125
251
 
126
252
  if (method === "Log.entryAdded") {
127
253
  const entry = params?.entry || {}
128
- pushError({
254
+ pushError(session, {
129
255
  type: entry.level || "log",
130
256
  severity: entry.level === "warning" ? "warn" : entry.level === "error" ? "error" : "info",
131
257
  url: entry.source || entry.url,
@@ -138,7 +264,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
138
264
 
139
265
  if (method === "Runtime.consoleAPICalled") {
140
266
  const args = (params.args || []).map((a) => a.description || a.value).filter(Boolean)
141
- pushError({
267
+ pushError(session, {
142
268
  type: params.type || "console",
143
269
  severity: params.type === "error" ? "error" : params.type === "warning" ? "warn" : "info",
144
270
  url: params.stackTrace?.callFrames?.[0]?.url,
@@ -153,6 +279,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
153
279
  if (method === "Network.requestWillBeSent") {
154
280
  const req = params.request || {}
155
281
  const entry = {
282
+ tabId: source.tabId,
156
283
  requestId: params.requestId,
157
284
  url: req.url,
158
285
  method: req.method || "GET",
@@ -164,13 +291,13 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
164
291
  timestamp: Date.now(),
165
292
  status: "pending",
166
293
  }
167
- requestMap.set(params.requestId, entry)
168
- trimPendingRequests()
294
+ session.requestMap.set(params.requestId, entry)
295
+ trimPendingRequests(session)
169
296
  }
170
297
 
171
298
  if (method === "Network.responseReceived") {
172
299
  const res = params.response || {}
173
- const entry = requestMap.get(params.requestId)
300
+ const entry = session.requestMap.get(params.requestId)
174
301
  if (entry) {
175
302
  entry.status = res.status >= 400 ? "error" : "success"
176
303
  entry.statusCode = res.status
@@ -183,7 +310,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
183
310
  entry.timing = res.timing
184
311
  entry.encodedDataLength = params.encodedDataLength
185
312
  if (res.status >= 400) {
186
- pushError({
313
+ pushError(session, {
187
314
  type: "network",
188
315
  severity: "error",
189
316
  url: res.url || entry.url,
@@ -199,7 +326,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
199
326
  }
200
327
 
201
328
  if (method === "Network.loadingFinished") {
202
- const entry = requestMap.get(params.requestId)
329
+ const entry = session.requestMap.get(params.requestId)
203
330
  if (entry) {
204
331
  entry.endTime = params.timestamp
205
332
  entry.encodedDataLength = params.encodedDataLength
@@ -207,19 +334,19 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
207
334
  ? Math.round((entry.endTime - entry.startTime) * 1000)
208
335
  : null
209
336
  if (entry.status === "pending") entry.status = "success"
210
- pushNetworkRequest(entry)
211
- requestMap.delete(params.requestId)
337
+ pushNetworkRequest(session, entry)
338
+ session.requestMap.delete(params.requestId)
212
339
  }
213
340
  }
214
341
 
215
342
  if (method === "Network.loadingFailed") {
216
- const entry = requestMap.get(params.requestId)
343
+ const entry = session.requestMap.get(params.requestId)
217
344
  if (entry) {
218
345
  entry.status = "failed"
219
346
  entry.errorText = params.errorText
220
347
  entry.canceled = params.canceled
221
348
  entry.blockedReason = params.blockedReason
222
- pushError({
349
+ pushError(session, {
223
350
  type: "network",
224
351
  severity: "error",
225
352
  url: entry.url,
@@ -228,29 +355,31 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
228
355
  text: params.errorText,
229
356
  timestamp: Date.now(),
230
357
  })
231
- pushNetworkRequest(entry)
232
- requestMap.delete(params.requestId)
358
+ pushNetworkRequest(session, entry)
359
+ session.requestMap.delete(params.requestId)
233
360
  }
234
361
  }
235
362
  })
236
363
 
237
- function pushNetworkRequest(entry) {
238
- networkRequests.unshift(entry)
239
- trimNetworkRequests()
364
+ function pushNetworkRequest(session, entry) {
365
+ session.networkRequests.unshift(entry)
366
+ trimNetworkRequests(session)
240
367
  }
241
368
 
242
- function trimNetworkRequests() {
243
- GhostBridgeNetwork.trimTrackedRequests(networkRequests, CONFIG.maxRequestsTracked)
369
+ function trimNetworkRequests(session) {
370
+ GhostBridgeNetwork.trimTrackedRequests(session.networkRequests, CONFIG.maxRequestsTracked)
244
371
  }
245
372
 
246
- function trimPendingRequests() {
247
- GhostBridgeNetwork.trimPendingRequestMap(requestMap, CONFIG.maxRequestsTracked * 2)
373
+ function trimPendingRequests(session) {
374
+ GhostBridgeNetwork.trimPendingRequestMap(session.requestMap, CONFIG.maxRequestsTracked * 2)
248
375
  }
249
376
 
250
377
  chrome.debugger.onDetach.addListener((source, reason) => {
251
- if (source.tabId && source.tabId === attachedTabId) {
252
- attachedTabId = null
253
- resetDebuggerState()
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)
254
383
 
255
384
  if (!state.enabled) return
256
385
  if (reason === "canceled_by_user") {
@@ -270,15 +399,15 @@ chrome.debugger.onDetach.addListener((source, reason) => {
270
399
  }
271
400
  })
272
401
 
273
- function pushError(entry) {
274
- lastErrors.unshift(entry)
275
- if (lastErrors.length > CONFIG.maxErrors) {
276
- 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
277
406
  .map((e, i) => ({ sev: e.severity || "info", i }))
278
407
  .reverse()
279
408
  .find((e) => e.sev !== "error")?.i
280
- if (dropIdx !== undefined) lastErrors.splice(dropIdx, 1)
281
- else lastErrors.pop()
409
+ if (dropIdx !== undefined) session.lastErrors.splice(dropIdx, 1)
410
+ else session.lastErrors.pop()
282
411
  }
283
412
  }
284
413
 
@@ -294,13 +423,6 @@ function compactStack(stackTrace) {
294
423
 
295
424
  // ========== Debugger 操作 ==========
296
425
 
297
- function resetDebuggerState() {
298
- scriptMap = new Map()
299
- scriptSourceCache = new Map()
300
- networkRequests = []
301
- requestMap = new Map()
302
- }
303
-
304
426
  function summarizeTab(tab) {
305
427
  if (!tab) return null
306
428
  return {
@@ -330,13 +452,71 @@ async function getTabOrThrow(tabId) {
330
452
  }
331
453
  }
332
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
+
333
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
+ }
334
514
  if (params.tabId !== undefined) {
335
515
  return getTabOrThrow(Number(params.tabId))
336
516
  }
337
- if (targetMode === 'pinned') {
338
- if (pinnedTabId === null) throw new Error("锁定的标签页不可用,请重新 pin")
339
- return getTabOrThrow(pinnedTabId)
517
+ if (client.targetMode === 'pinned') {
518
+ if (client.pinnedTabId === null) throw new Error("锁定的标签页不可用,请重新 pin")
519
+ return getTabOrThrow(client.pinnedTabId)
340
520
  }
341
521
  return getFocusedTabOrThrow()
342
522
  }
@@ -344,7 +524,7 @@ async function resolveTargetTab(params = {}) {
344
524
  // attach 互斥锁:防止并发调用 ensureAttached 导致重复 attach / 状态竞态
345
525
  let _attachLock = Promise.resolve()
346
526
 
347
- async function ensureAttached(params = {}) {
527
+ async function ensureAttachedSession(params = {}) {
348
528
  let _release
349
529
  const _prev = _attachLock
350
530
  _attachLock = new Promise(r => _release = r)
@@ -352,15 +532,20 @@ async function ensureAttached(params = {}) {
352
532
  try {
353
533
  if (!state.enabled) throw new Error("扩展已暂停,点击图标开启后再试")
354
534
  const tab = await resolveTargetTab(params)
355
- if (attachedTabId !== tab.id) {
356
- if (attachedTabId !== null) {
357
- try { await chrome.debugger.detach({ tabId: attachedTabId }) } catch (e) {}
358
- }
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) {
359
544
  try {
360
545
  await chrome.debugger.attach({ tabId: tab.id }, "1.3")
361
546
  setBadgeState("on")
362
547
  } catch (e) {
363
- attachedTabId = null
548
+ if (attachedTabId === tab.id) attachedTabId = null
364
549
  if (state.connected) {
365
550
  setBadgeState("on")
366
551
  } else {
@@ -368,38 +553,57 @@ async function ensureAttached(params = {}) {
368
553
  }
369
554
  throw e
370
555
  }
371
- attachedTabId = tab.id
372
- resetDebuggerState()
373
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Runtime.enable")
374
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Log.enable")
375
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Console.enable").catch(() => {})
376
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Debugger.enable")
377
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Profiler.enable")
378
- 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(() => {})
379
564
 
380
565
  // Enable auto-attach to sub-targets (iframes, workers) for comprehensive capture
381
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Target.setAutoAttach", {
566
+ await chrome.debugger.sendCommand({ tabId: session.tabId }, "Target.setAutoAttach", {
382
567
  autoAttach: true,
383
568
  waitForDebuggerOnStart: false,
384
569
  flatten: true,
385
570
  }).catch(() => {})
386
571
  }
387
- return { tabId: attachedTabId }
572
+ attachedTabId = session.tabId
573
+ if (isFocusedDefaultTarget) focusedTabId = session.tabId
574
+ return { target: { tabId: session.tabId }, session, tab }
388
575
  } finally {
389
576
  _release()
390
577
  }
391
578
  }
392
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
+
393
598
  async function maybeDetach(force = false) {
394
- if ((CONFIG.autoDetach || force) && attachedTabId !== null) {
395
- try {
396
- await chrome.debugger.detach({ tabId: attachedTabId })
397
- } catch (e) {
398
- log(`detach 失败:${e.message}`)
399
- } finally {
400
- attachedTabId = null
401
- resetDebuggerState()
599
+ if (force) {
600
+ for (const session of sessionsByTabId.values()) {
601
+ await detachSession(session)
402
602
  }
603
+ return
604
+ }
605
+ if (CONFIG.autoDetach && attachedTabId !== null) {
606
+ await detachSession(getSession(attachedTabId, { create: false }))
403
607
  }
404
608
  }
405
609
 
@@ -425,19 +629,21 @@ async function detachAllTargets() {
425
629
  }
426
630
  } catch {}
427
631
  attachedTabId = null
428
- resetDebuggerState()
632
+ focusedTabId = null
633
+ sessionsByTabId.clear()
429
634
  }
430
635
 
431
636
  // ========== 命令处理 ==========
432
637
 
433
- async function buildTargetInfo() {
638
+ async function buildTargetInfo(clientId) {
639
+ const client = getClientSession(clientId)
434
640
  const viewingTab = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
435
641
  .then(([tab]) => tab || null)
436
642
  .catch(() => null)
437
643
 
438
644
  let targetTab = null
439
645
  let targetError = ''
440
- const targetTabId = targetMode === 'pinned' ? pinnedTabId : attachedTabId
646
+ const targetTabId = client.targetMode === 'pinned' ? client.pinnedTabId : (viewingTab?.id ?? attachedTabId)
441
647
 
442
648
  if (targetTabId !== null && targetTabId !== undefined) {
443
649
  try {
@@ -445,23 +651,60 @@ async function buildTargetInfo() {
445
651
  } catch (e) {
446
652
  targetError = e.message
447
653
  }
448
- } else if (targetMode === 'focused') {
654
+ } else if (client.targetMode === 'focused') {
449
655
  targetTab = viewingTab
450
656
  }
451
657
 
452
658
  return {
453
- targetMode,
454
- pinnedTabId,
659
+ clientId: clientId || DEFAULT_CLIENT_ID,
660
+ targetMode: client.targetMode,
661
+ pinnedTabId: client.pinnedTabId,
662
+ focusedTabId,
455
663
  attachedTabId,
664
+ attachedTabIds: [...sessionsByTabId.values()].filter((session) => session.attached).map((session) => session.tabId),
456
665
  targetTab: summarizeTab(targetTab),
457
666
  viewingTab: summarizeTab(viewingTab),
667
+ targets: await summarizeNamedTargets(),
458
668
  targetError,
459
669
  }
460
670
  }
461
671
 
462
- async function handleListTabs() {
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 = {}) {
463
706
  const tabs = await chrome.tabs.query({})
464
- const targetInfo = await buildTargetInfo()
707
+ const targetInfo = await buildTargetInfo(params.clientId)
465
708
  return {
466
709
  ...targetInfo,
467
710
  tabs: tabs
@@ -471,64 +714,100 @@ async function handleListTabs() {
471
714
  }
472
715
  }
473
716
 
474
- async function pinTab(tab) {
475
- const previousMode = targetMode
476
- const previousPinnedTabId = pinnedTabId
477
- targetMode = 'pinned'
478
- pinnedTabId = tab.id
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)
479
725
  try {
480
- await ensureAttached({ tabId: tab.id })
726
+ await ensureAttachedSession({ target: name, clientId })
481
727
  } catch (e) {
482
- targetMode = previousMode
483
- pinnedTabId = previousPinnedTabId
728
+ if (previousTabId !== undefined) client.namedTargets.set(name, previousTabId)
729
+ else client.namedTargets.delete(name)
484
730
  throw e
485
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
+ }
486
737
  broadcastStatus()
487
- return buildTargetInfo()
738
+ return {
739
+ bound: { name, tabId: tab.id, tab: summarizeTab(tab) },
740
+ ...(await buildTargetInfo(clientId)),
741
+ }
488
742
  }
489
743
 
490
- async function handlePinCurrentTab() {
491
- return pinTab(await getFocusedTabOrThrow())
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)
492
748
  }
493
749
 
494
- async function handlePinTab(params = {}) {
495
- if (params.tabId !== undefined) {
496
- return pinTab(await getTabOrThrow(Number(params.tabId)))
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)
497
761
  }
498
762
 
499
- const urlContains = String(params.urlContains || '')
500
- const titleContains = String(params.titleContains || '')
501
- if (!urlContains && !titleContains) {
502
- throw new Error("需要提供 tabId、urlContains 或 titleContains")
763
+ broadcastStatus()
764
+ return {
765
+ unbound: { name, tabId },
766
+ ...(await buildTargetInfo(params.clientId)),
503
767
  }
768
+ }
504
769
 
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("没有找到匹配的标签页")
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
+ }
512
786
 
513
- matches.sort((a, b) => Number(b.active) - Number(a.active) || (a.windowId - b.windowId) || (a.index - b.index))
514
- return pinTab(matches[0])
787
+ async function handlePinCurrentTab(params = {}) {
788
+ return pinTab(await getFocusedTabOrThrow(), params.clientId)
515
789
  }
516
790
 
517
- async function handleUnpinTab() {
518
- targetMode = 'focused'
519
- pinnedTabId = null
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
520
799
  if (state.enabled && state.connected) {
521
- await ensureAttached()
800
+ await ensureAttached({ clientId: params.clientId })
522
801
  }
523
802
  broadcastStatus()
524
- return buildTargetInfo()
803
+ return buildTargetInfo(params.clientId)
525
804
  }
526
805
 
527
806
  async function handleGetLastError(params = {}) {
528
- await ensureAttached()
807
+ const { session } = await ensureAttachedSession(params)
529
808
  const severity = params.severity || "error"
530
809
  const limit = Math.max(1, Math.min(params.limit || 20, CONFIG.maxErrors))
531
- const allEvents = lastErrors.slice(0, CONFIG.maxErrors)
810
+ const allEvents = session.lastErrors.slice(0, CONFIG.maxErrors)
532
811
  const filteredEvents = severity === "all"
533
812
  ? allEvents
534
813
  : allEvents.filter((event) => (event.severity || "info") === severity)
@@ -542,7 +821,7 @@ async function handleGetLastError(params = {}) {
542
821
  )
543
822
  const events = filteredEvents.slice(0, limit)
544
823
  return {
545
- lastErrorLocation,
824
+ lastErrorLocation: session.lastErrorLocation,
546
825
  summary: {
547
826
  count: events.length,
548
827
  cachedCount: allEvents.length,
@@ -556,17 +835,17 @@ async function handleGetLastError(params = {}) {
556
835
  }
557
836
  }
558
837
 
559
- async function pickScriptId(preferUrlContains) {
838
+ async function pickScriptId(session, preferUrlContains) {
560
839
  if (preferUrlContains) {
561
- for (const [id, meta] of scriptMap.entries()) {
840
+ for (const [id, meta] of session.scriptMap.entries()) {
562
841
  if (meta.url && meta.url.includes(preferUrlContains)) return { id, url: meta.url }
563
842
  }
564
843
  }
565
- if (lastErrorLocation?.scriptId && scriptMap.has(lastErrorLocation.scriptId)) {
566
- const meta = scriptMap.get(lastErrorLocation.scriptId)
567
- 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 }
568
847
  }
569
- const first = scriptMap.entries().next().value
848
+ const first = session.scriptMap.entries().next().value
570
849
  if (first) {
571
850
  return { id: first[0], url: first[1].url }
572
851
  }
@@ -574,15 +853,15 @@ async function pickScriptId(preferUrlContains) {
574
853
  }
575
854
 
576
855
  async function handleGetScriptSource(params = {}) {
577
- const target = await ensureAttached()
578
- const chosen = await pickScriptId(params.scriptUrlContains)
856
+ const { target, session } = await ensureAttachedSession(params)
857
+ const chosen = await pickScriptId(session, params.scriptUrlContains)
579
858
  const { scriptSource } = await chrome.debugger.sendCommand(target, "Debugger.getScriptSource", {
580
859
  scriptId: chosen.id,
581
860
  })
582
- scriptSourceCache.set(chosen.id, scriptSource)
861
+ session.scriptSourceCache.set(chosen.id, scriptSource)
583
862
  const location = {
584
- line: params.line ?? lastErrorLocation?.line ?? null,
585
- column: params.column ?? lastErrorLocation?.column ?? null,
863
+ line: params.line ?? session.lastErrorLocation?.line ?? null,
864
+ column: params.column ?? session.lastErrorLocation?.column ?? null,
586
865
  }
587
866
  return {
588
867
  url: chosen.url,
@@ -594,7 +873,7 @@ async function handleGetScriptSource(params = {}) {
594
873
  }
595
874
 
596
875
  async function handleCoverageSnapshot(params = {}) {
597
- const target = await ensureAttached()
876
+ const target = await ensureAttached(params)
598
877
  const durationMs = params.durationMs || 1500
599
878
  await chrome.debugger.sendCommand(target, "Profiler.startPreciseCoverage", {
600
879
  callCount: true,
@@ -630,20 +909,20 @@ function findContexts(source, query, maxMatches) {
630
909
  }
631
910
 
632
911
  async function handleFindByString(params = {}) {
633
- const target = await ensureAttached()
912
+ const { target, session } = await ensureAttachedSession(params)
634
913
  const query = params.query
635
914
  const maxMatches = params.maxMatches || 5
636
915
  const preferred = params.scriptUrlContains
637
916
 
638
917
  const results = []
639
- const entries = [...scriptMap.entries()]
918
+ const entries = [...session.scriptMap.entries()]
640
919
  for (const [id, meta] of entries) {
641
920
  if (preferred && (!meta.url || !meta.url.includes(preferred))) continue
642
- if (!scriptSourceCache.has(id)) {
921
+ if (!session.scriptSourceCache.has(id)) {
643
922
  const { scriptSource } = await chrome.debugger.sendCommand(target, "Debugger.getScriptSource", { scriptId: id })
644
- scriptSourceCache.set(id, scriptSource)
923
+ session.scriptSourceCache.set(id, scriptSource)
645
924
  }
646
- const source = scriptSourceCache.get(id)
925
+ const source = session.scriptSourceCache.get(id)
647
926
  const matches = findContexts(source, query, maxMatches - results.length)
648
927
  if (matches.length) {
649
928
  results.push({ url: meta.url, scriptId: id, matches })
@@ -654,8 +933,8 @@ async function handleFindByString(params = {}) {
654
933
  return { query, results }
655
934
  }
656
935
 
657
- async function handleSymbolicHints() {
658
- const target = await ensureAttached()
936
+ async function handleSymbolicHints(params = {}) {
937
+ const target = await ensureAttached(params)
659
938
  const expression = `(function(){
660
939
  try {
661
940
  const resources = performance.getEntriesByType('resource').slice(-20).map(e => ({
@@ -678,7 +957,7 @@ async function handleSymbolicHints() {
678
957
  }
679
958
 
680
959
  async function handleEval(params = {}) {
681
- const target = await ensureAttached()
960
+ const target = await ensureAttached(params)
682
961
  const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
683
962
  expression: params.code,
684
963
  returnByValue: true,
@@ -687,11 +966,11 @@ async function handleEval(params = {}) {
687
966
  }
688
967
 
689
968
  async function handleListNetworkRequests(params = {}) {
690
- await ensureAttached()
969
+ const { session } = await ensureAttachedSession(params)
691
970
  const { filter, method, status, resourceType, limit = 50, priorityMode = 'debug' } = params
692
971
 
693
- let results = [...networkRequests]
694
- 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" }))
695
974
  results = [...pending, ...results]
696
975
 
697
976
  if (filter) {
@@ -709,7 +988,7 @@ async function handleListNetworkRequests(params = {}) {
709
988
  results = results.slice(0, limit)
710
989
 
711
990
  return {
712
- total: networkRequests.length + requestMap.size,
991
+ total: session.networkRequests.length + session.requestMap.size,
713
992
  filtered: results.length,
714
993
  priorityMode,
715
994
  requests: results.map((entry) => GhostBridgeNetwork.buildNetworkRequestSummary(entry)),
@@ -717,12 +996,12 @@ async function handleListNetworkRequests(params = {}) {
717
996
  }
718
997
 
719
998
  async function handleGetNetworkDetail(params = {}) {
720
- const target = await ensureAttached()
999
+ const { target, session } = await ensureAttachedSession(params)
721
1000
  const { requestId, includeBody = false } = params
722
1001
  if (!requestId) throw new Error("需要提供 requestId")
723
1002
 
724
- let entry = requestMap.get(requestId)
725
- 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)
726
1005
  if (!entry) throw new Error(`未找到请求: ${requestId}`)
727
1006
 
728
1007
  const urlMeta = GhostBridgeNetwork.summarizeNetworkUrl(entry.url)
@@ -765,15 +1044,15 @@ async function handleGetNetworkDetail(params = {}) {
765
1044
  return result
766
1045
  }
767
1046
 
768
- async function handleClearNetworkRequests() {
769
- await ensureAttached()
770
- const count = networkRequests.length
771
- networkRequests = []
1047
+ async function handleClearNetworkRequests(params = {}) {
1048
+ const { session } = await ensureAttachedSession(params)
1049
+ const count = session.networkRequests.length
1050
+ session.networkRequests = []
772
1051
  return { cleared: count }
773
1052
  }
774
1053
 
775
1054
  async function handlePerfMetrics(params = {}) {
776
- const target = await ensureAttached()
1055
+ const target = await ensureAttached(params)
777
1056
  const { includeResources = true, includeTimings = true } = params
778
1057
 
779
1058
  // 1. CDP Performance.getMetrics — 底层引擎指标
@@ -924,7 +1203,7 @@ function roundMs(seconds) {
924
1203
  }
925
1204
 
926
1205
  async function handleCaptureScreenshot(params = {}) {
927
- const target = await ensureAttached()
1206
+ const target = await ensureAttached(params)
928
1207
  const { format: requestedFormat, quality: requestedQuality, fullPage = false, clip } = params
929
1208
  const format = requestedFormat || 'jpeg'
930
1209
  const quality = format === 'jpeg'
@@ -1004,7 +1283,7 @@ async function handleCaptureScreenshot(params = {}) {
1004
1283
  }
1005
1284
 
1006
1285
  async function handleInspectPageSnapshot(params = {}) {
1007
- const target = await ensureAttached()
1286
+ const { target, session } = await ensureAttachedSession(params)
1008
1287
  const { selector, includeInteractive = true, maxElements = 30 } = params
1009
1288
  const expression = GhostBridgeDom.buildInspectPageExpression({ selector, includeInteractive, maxElements })
1010
1289
 
@@ -1014,11 +1293,16 @@ async function handleInspectPageSnapshot(params = {}) {
1014
1293
  })
1015
1294
 
1016
1295
  if (result?.value?.error) throw new Error(result.value.error)
1017
- 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
1018
1302
  }
1019
1303
 
1020
1304
  async function handleGetPageContent(params = {}) {
1021
- const target = await ensureAttached()
1305
+ const target = await ensureAttached(params)
1022
1306
  const { mode = "text", selector, maxLength = 50000, includeMetadata = true } = params
1023
1307
  const expression = GhostBridgeDom.buildPageContentExpression({ mode, selector, maxLength, includeMetadata })
1024
1308
 
@@ -1034,7 +1318,7 @@ async function handleGetPageContent(params = {}) {
1034
1318
  // ========== DOM 交互:可交互元素快照 ==========
1035
1319
 
1036
1320
  async function handleGetInteractiveSnapshot(params = {}) {
1037
- const target = await ensureAttached()
1321
+ const { target, session } = await ensureAttachedSession(params)
1038
1322
  const { selector, includeText = true, maxElements = 100 } = params
1039
1323
  const expression = GhostBridgeDom.buildInteractiveSnapshotExpression({ selector, includeText, maxElements })
1040
1324
 
@@ -1044,13 +1328,22 @@ async function handleGetInteractiveSnapshot(params = {}) {
1044
1328
  })
1045
1329
 
1046
1330
  if (result?.value?.error) throw new Error(result.value.error)
1047
- 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
1048
1337
  }
1049
1338
 
1050
1339
  // ========== DOM 交互:动作分发器 ==========
1051
1340
 
1052
1341
  async function handleDispatchAction(params = {}) {
1053
- 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)
1054
1347
  const { ref, action, value, key, deltaX, deltaY, waitMs = 500 } = params
1055
1348
 
1056
1349
  if (!ref) throw new Error("需要提供 ref(元素标识,如 'e1')")
@@ -1218,8 +1511,15 @@ async function handleDispatchAction(params = {}) {
1218
1511
 
1219
1512
  // 处理来自服务器的命令
1220
1513
  async function handleCommand(message) {
1221
- const { id, command, params, token } = message
1514
+ const { id, command, token } = message
1222
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
+ }
1223
1523
  if (!state.enabled) {
1224
1524
  sendToServer({ id, error: "扩展已暂停,点击图标重新开启" })
1225
1525
  return
@@ -1228,22 +1528,28 @@ async function handleCommand(message) {
1228
1528
  sendToServer({ id, error: "token 校验失败" })
1229
1529
  return
1230
1530
  }
1531
+ // 命令所属的 MCP 会话:pin/bind 的命名空间按它隔离
1532
+ const clientId = message.clientId
1533
+ const params = { ...(message.params || {}), clientId }
1231
1534
  try {
1232
1535
  let result
1233
- if (command === "listTabs") result = await handleListTabs()
1234
- else if (command === "getTargetTab") result = await buildTargetInfo()
1235
- else if (command === "pinCurrentTab") result = await handlePinCurrentTab()
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)
1236
1542
  else if (command === "pinTab") result = await handlePinTab(params)
1237
- else if (command === "unpinTab") result = await handleUnpinTab()
1543
+ else if (command === "unpinTab") result = await handleUnpinTab(params)
1238
1544
  else if (command === "getLastError") result = await handleGetLastError(params)
1239
1545
  else if (command === "getScriptSource") result = await handleGetScriptSource(params)
1240
1546
  else if (command === "coverageSnapshot") result = await handleCoverageSnapshot(params)
1241
1547
  else if (command === "findByString") result = await handleFindByString(params)
1242
- else if (command === "symbolicHints") result = await handleSymbolicHints()
1548
+ else if (command === "symbolicHints") result = await handleSymbolicHints(params)
1243
1549
  else if (command === "eval") result = await handleEval(params)
1244
1550
  else if (command === "listNetworkRequests") result = await handleListNetworkRequests(params)
1245
1551
  else if (command === "getNetworkDetail") result = await handleGetNetworkDetail(params)
1246
- else if (command === "clearNetworkRequests") result = await handleClearNetworkRequests()
1552
+ else if (command === "clearNetworkRequests") result = await handleClearNetworkRequests(params)
1247
1553
  else if (command === "perfMetrics") result = await handlePerfMetrics(params)
1248
1554
  else if (command === "captureScreenshot") result = await handleCaptureScreenshot(params)
1249
1555
  else if (command === "inspectPageSnapshot") result = await handleInspectPageSnapshot(params)
@@ -1294,7 +1600,10 @@ async function buildPopupState() {
1294
1600
  serverInfo: state.serverInfo,
1295
1601
  targetMode: targetInfo.targetMode,
1296
1602
  pinnedTabId: targetInfo.pinnedTabId,
1603
+ focusedTabId: targetInfo.focusedTabId,
1297
1604
  attachedTabId: targetInfo.attachedTabId,
1605
+ attachedTabIds: targetInfo.attachedTabIds,
1606
+ targets: targetInfo.targets,
1298
1607
  targetTab,
1299
1608
  viewingTab,
1300
1609
  targetError: targetInfo.targetError,
@@ -1319,7 +1628,7 @@ async function broadcastStatus() {
1319
1628
 
1320
1629
  // 监听被调试页面的导航变化,实时推送到 popup
1321
1630
  chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
1322
- if ((tabId === attachedTabId || tab.active) && (changeInfo.title || changeInfo.url)) {
1631
+ if ((sessionsByTabId.has(tabId) || tab.active) && (changeInfo.title || changeInfo.url)) {
1323
1632
  if (state.connected) broadcastStatus()
1324
1633
  }
1325
1634
  })
@@ -1328,8 +1637,10 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
1328
1637
  chrome.tabs.onActivated.addListener(async (activeInfo) => {
1329
1638
  if (state.enabled && state.connected) {
1330
1639
  try {
1331
- if (targetMode === 'focused') {
1332
- // focused 模式保持旧行为:切换 Tab 时自动跟随
1640
+ // focused 模式保持旧行为:切换 Tab 时自动跟随(focused 是共享语义,任一会话处于 focused 即跟随)
1641
+ const anyFocused = clientSessions.size === 0
1642
+ || [...clientSessions.values()].some((client) => client.targetMode === 'focused')
1643
+ if (anyFocused) {
1333
1644
  await ensureAttached()
1334
1645
  }
1335
1646
  broadcastStatus()
@@ -1340,14 +1651,22 @@ chrome.tabs.onActivated.addListener(async (activeInfo) => {
1340
1651
  })
1341
1652
 
1342
1653
  chrome.tabs.onRemoved.addListener((tabId) => {
1343
- if (tabId === pinnedTabId) {
1344
- targetMode = 'focused'
1345
- pinnedTabId = null
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
+ }
1346
1662
  }
1347
1663
  if (tabId === attachedTabId) {
1348
1664
  attachedTabId = null
1349
- resetDebuggerState()
1350
1665
  }
1666
+ if (tabId === focusedTabId) {
1667
+ focusedTabId = null
1668
+ }
1669
+ sessionsByTabId.delete(tabId)
1351
1670
  if (state.connected) broadcastStatus()
1352
1671
  })
1353
1672
 
@@ -1375,6 +1694,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1375
1694
  state.connectionError = ''
1376
1695
  state.serverInfo = message.serverInfo || null
1377
1696
  setBadgeState('on')
1697
+ // 服务器可能已重启,clientId 分配从零开始,旧会话桶全部作废
1698
+ clientSessions.clear()
1378
1699
  log(`✅ 已连接到 ghost-bridge 服务 (端口 ${message.port})`)
1379
1700
  ensureAttached()
1380
1701
  .then(() => broadcastStatus())
@@ -1454,64 +1775,56 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1454
1775
  if (message.type === 'connect') {
1455
1776
  if (message.port) {
1456
1777
  CONFIG.basePort = message.port
1457
- chrome.storage.local.set({ basePort: message.port })
1458
1778
  }
1459
- state.enabled = true
1460
- state.connected = false
1461
- state.port = null
1462
- state.currentPort = CONFIG.basePort
1463
- state.connectionStatus = 'connecting'
1464
- state.connectionError = ''
1465
- state.serverInfo = null
1466
- setBadgeState('connecting')
1467
-
1468
- // 启动 offscreen 并开始连接
1469
- setupOffscreenDocument().then(() => {
1470
- chrome.runtime.sendMessage({
1471
- type: 'connect',
1472
- basePort: CONFIG.basePort,
1473
- token: CONFIG.token,
1474
- }).catch(() => {})
1475
- })
1476
1779
 
1477
- sendResponse({ ok: true })
1780
+ startBridgeConnection({ persist: true })
1781
+ .then(() => sendResponse({ ok: true }))
1782
+ .catch((e) => sendResponse({ ok: false, error: e.message }))
1478
1783
  return true
1479
1784
  }
1480
1785
 
1481
1786
  // 来自 popup 的断开请求
1482
1787
  if (message.type === 'disconnect') {
1483
- state.enabled = false
1484
- state.connected = false
1485
- state.port = null
1486
- state.currentPort = null
1487
- state.connectionStatus = 'disconnected'
1488
- state.connectionError = ''
1489
- state.serverInfo = null
1490
- targetMode = 'focused'
1491
- pinnedTabId = null
1492
- setBadgeState('off')
1493
- detachAllTargets().catch(() => {})
1494
-
1495
- // 通知 offscreen 断开 (WebSocket 清除)
1496
- chrome.runtime.sendMessage({ type: 'disconnect' }).catch(() => {})
1497
-
1498
- // 关键修复:显式销毁 offscreen document 防止内存泄漏
1499
- closeOffscreenDocument().catch(() => {})
1500
-
1501
- sendResponse({ ok: true })
1788
+ stopBridgeConnection({ persist: true })
1789
+ .then(() => sendResponse({ ok: true }))
1790
+ .catch((e) => sendResponse({ ok: false, error: e.message }))
1502
1791
  return true
1503
1792
  }
1504
1793
 
1505
1794
  return false
1506
1795
  })
1507
1796
 
1508
- // 启动时从 storage 加载端口配置
1509
- chrome.storage.local.get(['basePort'], (result) => {
1510
- if (result.basePort) {
1511
- 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}`)
1512
1818
  }
1513
1819
  })
1514
1820
 
1515
- // 默认暂停
1516
- setBadgeState("off")
1821
+ chrome.runtime.onStartup.addListener(() => {
1822
+ restoreBridgeConnection()
1823
+ })
1824
+
1825
+ chrome.runtime.onInstalled.addListener(() => {
1826
+ restoreBridgeConnection()
1827
+ })
1828
+
1829
+ restoreBridgeConnection()
1517
1830
  log("Ghost Bridge background 已加载")