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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Ghost Bridge",
4
- "version": "0.7.1",
4
+ "version": "0.9.0",
5
5
  "description": "Zero-restart Chrome debugger bridge for Claude MCP, optimized for no-sourcemap production debugging.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -9,7 +9,8 @@
9
9
  "scripting",
10
10
  "storage",
11
11
  "tabs",
12
- "offscreen"
12
+ "offscreen",
13
+ "idle"
13
14
  ],
14
15
  "host_permissions": [
15
16
  "ws://localhost/*",
@@ -3,26 +3,113 @@
3
3
 
4
4
  let ws = null
5
5
  let reconnectTimer = null
6
+ let heartbeatTimer = null
6
7
  let manualDisconnect = false // 用户主动断开标志,防止 onclose 触发重连
8
+ let connectionGeneration = 0
7
9
  let config = {
8
10
  basePort: 33333,
9
11
  token: '',
10
12
  }
11
13
 
14
+ const HEARTBEAT_INTERVAL_MS = 15000
15
+ const HEARTBEAT_TIMEOUT_MS = 45000
16
+ const DEFAULT_TOKEN = 'ghost-bridge-local'
17
+
12
18
  function log(msg) {
13
19
  console.log(`[ghost-bridge offscreen] ${msg}`)
14
20
  // 转发日志到 service worker
15
21
  chrome.runtime.sendMessage({ type: 'log', msg }).catch(() => {})
16
22
  }
17
23
 
18
- const DEFAULT_TOKEN = 'ghost-bridge-local'
24
+ function clearReconnectTimer() {
25
+ if (reconnectTimer) {
26
+ clearTimeout(reconnectTimer)
27
+ reconnectTimer = null
28
+ }
29
+ }
30
+
31
+ function clearHeartbeatTimer() {
32
+ if (heartbeatTimer) {
33
+ clearInterval(heartbeatTimer)
34
+ heartbeatTimer = null
35
+ }
36
+ }
37
+
38
+ function isCurrentConnection(generation, socket) {
39
+ return generation === connectionGeneration && socket === ws
40
+ }
41
+
42
+ function scheduleReconnect(generation, delay) {
43
+ if (generation !== connectionGeneration) return
44
+ clearReconnectTimer()
45
+ reconnectTimer = setTimeout(() => {
46
+ reconnectTimer = null
47
+ if (!manualDisconnect && generation === connectionGeneration) connect()
48
+ }, delay)
49
+ }
50
+
51
+ function closeCurrentSocket() {
52
+ clearHeartbeatTimer()
53
+ if (!ws) return
54
+ try {
55
+ ws.close()
56
+ } catch {}
57
+ ws = null
58
+ }
59
+
60
+ function describeCloseEvent(event) {
61
+ const reason = event.reason ? ` reason="${event.reason}"` : ''
62
+ return `code=${event.code}${reason} clean=${event.wasClean}`
63
+ }
64
+
65
+ function describeReadyState(socket) {
66
+ const states = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']
67
+ return states[socket.readyState] || String(socket.readyState)
68
+ }
69
+
70
+ function startHeartbeat(generation, socket, port, requireAck) {
71
+ clearHeartbeatTimer()
72
+ let lastAckAt = Date.now()
73
+
74
+ heartbeatTimer = setInterval(() => {
75
+ if (!isCurrentConnection(generation, socket) || socket.readyState !== WebSocket.OPEN) {
76
+ clearHeartbeatTimer()
77
+ return
78
+ }
79
+
80
+ const now = Date.now()
81
+ if (requireAck && now - lastAckAt > HEARTBEAT_TIMEOUT_MS) {
82
+ log(`端口 ${port} 心跳超时,关闭连接后重连...`)
83
+ try {
84
+ socket.close(4000, 'Heartbeat timeout')
85
+ } catch {}
86
+ return
87
+ }
88
+
89
+ try {
90
+ socket.send(JSON.stringify({ type: 'heartbeat', token: config.token, ts: now }))
91
+ } catch (e) {
92
+ log(`端口 ${port} 心跳发送失败:${e.message}`)
93
+ try {
94
+ socket.close(4001, 'Heartbeat send failed')
95
+ } catch {}
96
+ }
97
+ }, HEARTBEAT_INTERVAL_MS)
98
+
99
+ return () => {
100
+ lastAckAt = Date.now()
101
+ }
102
+ }
19
103
 
20
104
  // 连接到服务器
21
105
  function connect() {
22
106
  // 如果已手动断开,不再尝试连接
23
107
  if (manualDisconnect) return
108
+ clearReconnectTimer()
109
+ closeCurrentSocket()
24
110
 
25
111
  const port = config.basePort
112
+ const generation = ++connectionGeneration
26
113
  const url = new URL(`ws://localhost:${port}`)
27
114
  url.searchParams.set('token', config.token)
28
115
  log(`尝试连接固定端口 ${port}...`)
@@ -32,42 +119,60 @@ function connect() {
32
119
  currentPort: port,
33
120
  }).catch(() => {})
34
121
 
35
- ws = new WebSocket(url.toString())
36
- ws.binaryType = 'blob' // 明确设置
122
+ const socket = new WebSocket(url.toString())
123
+ ws = socket
124
+ socket.binaryType = 'blob' // 明确设置
37
125
 
38
126
  const connectionTimeout = setTimeout(() => {
39
- if (ws && ws.readyState === WebSocket.CONNECTING) {
40
- ws.close()
127
+ if (isCurrentConnection(generation, socket) && socket.readyState === WebSocket.CONNECTING) {
128
+ socket.close()
41
129
  }
42
130
  }, 2000) // 增加到 2 秒
43
131
 
44
132
  let identityVerified = false
45
133
  let socketOpened = false
46
134
  let terminalErrorMessage = ''
135
+ let markHeartbeatAck = null
47
136
 
48
- ws.onopen = () => {
137
+ socket.onopen = () => {
138
+ if (!isCurrentConnection(generation, socket)) return
49
139
  socketOpened = true
50
140
  clearTimeout(connectionTimeout)
51
141
  log(`WebSocket 已连接端口 ${port},等待身份验证...`)
52
142
  }
53
143
 
54
- ws.onmessage = async (event) => {
144
+ socket.onmessage = async (event) => {
55
145
  try {
146
+ if (!isCurrentConnection(generation, socket)) return
56
147
  // 处理 Blob 类型的消息
57
148
  let data = event.data
58
149
  if (data instanceof Blob) {
59
150
  data = await data.text()
60
151
  }
152
+ if (!isCurrentConnection(generation, socket)) return
61
153
  const msg = JSON.parse(data)
62
154
 
63
155
  if (msg.type === 'identity') {
64
156
  if (msg.service === 'ghost-bridge' && msg.token === config.token) {
65
157
  identityVerified = true
158
+ markHeartbeatAck = startHeartbeat(
159
+ generation,
160
+ socket,
161
+ port,
162
+ Array.isArray(msg.capabilities) && msg.capabilities.includes('heartbeat')
163
+ )
66
164
  log(`✅ 已连接到 ghost-bridge 服务 (端口 ${port})`)
67
165
  chrome.runtime.sendMessage({
68
166
  type: 'status',
69
167
  status: 'connected',
70
168
  port: port,
169
+ serverInfo: {
170
+ version: msg.version,
171
+ pid: msg.pid,
172
+ port: msg.port,
173
+ startedAt: msg.startedAt,
174
+ serverPath: msg.serverPath,
175
+ },
71
176
  }).catch(() => {})
72
177
  } else {
73
178
  terminalErrorMessage = msg.service === 'ghost-bridge'
@@ -80,11 +185,17 @@ function connect() {
80
185
  currentPort: port,
81
186
  errorMessage: terminalErrorMessage,
82
187
  }).catch(() => {})
83
- ws.close()
188
+ socket.close()
84
189
  }
85
190
  return
86
191
  }
87
192
 
193
+ if (msg.type === 'heartbeat_ack') {
194
+ if (markHeartbeatAck) markHeartbeatAck()
195
+ if (pendingHealthCheck) resolveHealthCheck(true)
196
+ return
197
+ }
198
+
88
199
  // 转发命令到 service worker
89
200
  if (identityVerified && msg.id) {
90
201
  chrome.runtime.sendMessage({ type: 'command', data: msg }).catch(() => {})
@@ -94,62 +205,123 @@ function connect() {
94
205
  }
95
206
  }
96
207
 
97
- ws.onclose = (event) => {
208
+ socket.onclose = (event) => {
209
+ if (!isCurrentConnection(generation, socket)) return
98
210
  clearTimeout(connectionTimeout)
211
+ clearHeartbeatTimer()
212
+ ws = null
213
+ const closeInfo = describeCloseEvent(event)
99
214
 
100
215
  // 用户主动断开,不重连
101
- if (manualDisconnect) return
216
+ if (manualDisconnect) {
217
+ log(`连接已按用户请求关闭 (${closeInfo})`)
218
+ return
219
+ }
102
220
 
103
221
  if (!identityVerified) {
104
222
  if (terminalErrorMessage || socketOpened) {
105
223
  const errorMessage = terminalErrorMessage || `Port ${port} is occupied or responding with a non-ghost-bridge protocol.`
106
- log(`${errorMessage} 2秒后重试...`)
224
+ log(`${errorMessage} (${closeInfo}) 2秒后重试...`)
107
225
  chrome.runtime.sendMessage({
108
226
  type: 'status',
109
227
  status: 'error',
110
228
  currentPort: port,
111
229
  errorMessage,
112
230
  }).catch(() => {})
113
- reconnectTimer = setTimeout(() => connect(), 2000)
231
+ scheduleReconnect(generation, 2000)
114
232
  return
115
233
  }
116
234
  log(`固定端口 ${port} 未发现可用服务,2秒后重试...`)
117
- chrome.runtime.sendMessage({ type: 'status', status: 'not_found', currentPort: port }).catch(() => {})
118
- reconnectTimer = setTimeout(() => connect(), 2000)
235
+ chrome.runtime.sendMessage({
236
+ type: 'status',
237
+ status: 'not_found',
238
+ currentPort: port,
239
+ errorMessage: `No ghost-bridge WebSocket service was found on port ${port}.`,
240
+ }).catch(() => {})
241
+ scheduleReconnect(generation, 2000)
119
242
  return
120
243
  }
121
244
 
122
245
  // 连接断开,重试
123
- log(`端口 ${port} 连接断开,尝试重连...`)
124
- chrome.runtime.sendMessage({ type: 'status', status: 'disconnected' }).catch(() => {})
125
- reconnectTimer = setTimeout(() => connect(), 1000)
246
+ log(`端口 ${port} 连接断开 (${closeInfo}),尝试重连...`)
247
+ chrome.runtime.sendMessage({
248
+ type: 'status',
249
+ status: 'disconnected',
250
+ currentPort: port,
251
+ errorMessage: `Connection closed: ${closeInfo}`,
252
+ }).catch(() => {})
253
+ scheduleReconnect(generation, 1000)
126
254
  }
127
255
 
128
- ws.onerror = () => {
256
+ socket.onerror = () => {
257
+ if (!isCurrentConnection(generation, socket)) return
129
258
  clearTimeout(connectionTimeout)
259
+ log(`端口 ${port} WebSocket 错误,readyState=${describeReadyState(socket)}`)
130
260
  }
131
261
  }
132
262
 
133
263
  // 发送消息到服务器
134
264
  function sendToServer(data) {
135
265
  if (ws && ws.readyState === WebSocket.OPEN) {
136
- ws.send(JSON.stringify(data))
137
- return true
266
+ try {
267
+ ws.send(JSON.stringify(data))
268
+ return true
269
+ } catch (e) {
270
+ log(`发送消息到服务器失败:${e.message}`)
271
+ }
138
272
  }
139
273
  return false
140
274
  }
141
275
 
276
+ // ========== 唤醒探活 ==========
277
+ // 系统睡眠/锁屏唤醒后连接可能处于半开状态且 onclose 不会触发,
278
+ // 由 background 的 idle 钩子通知这里立即发一次心跳,短超时内无响应就主动重连
279
+ let pendingHealthCheck = null
280
+
281
+ function resolveHealthCheck(alive) {
282
+ if (!pendingHealthCheck) return
283
+ clearTimeout(pendingHealthCheck.timer)
284
+ const resolve = pendingHealthCheck.resolve
285
+ pendingHealthCheck = null
286
+ resolve(alive)
287
+ }
288
+
289
+ function requestHealthCheck() {
290
+ return new Promise((resolve) => {
291
+ if (manualDisconnect) return resolve(false)
292
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
293
+ log('唤醒探活:连接未打开,立即重连')
294
+ connect()
295
+ return resolve(false)
296
+ }
297
+ // 收敛上一次未完成的探活
298
+ if (pendingHealthCheck) resolveHealthCheck(false)
299
+
300
+ const timer = setTimeout(() => {
301
+ log('唤醒探活:心跳无响应,判定为死连接,关闭后重连')
302
+ resolveHealthCheck(false)
303
+ try {
304
+ ws.close(4002, 'Health check timeout')
305
+ } catch {}
306
+ }, 5000)
307
+ pendingHealthCheck = { timer, resolve }
308
+
309
+ try {
310
+ ws.send(JSON.stringify({ type: 'heartbeat', token: config.token, ts: Date.now() }))
311
+ } catch (e) {
312
+ log(`唤醒探活:心跳发送失败 (${e.message}),立即重连`)
313
+ resolveHealthCheck(false)
314
+ connect()
315
+ }
316
+ })
317
+ }
318
+
142
319
  // 断开连接
143
320
  function disconnect() {
144
321
  manualDisconnect = true // 标记为手动断开,阻止 onclose 重连
145
- if (reconnectTimer) {
146
- clearTimeout(reconnectTimer)
147
- reconnectTimer = null
148
- }
149
- if (ws) {
150
- ws.close()
151
- ws = null
152
- }
322
+ connectionGeneration++
323
+ clearReconnectTimer()
324
+ closeCurrentSocket()
153
325
  log('已断开连接')
154
326
  }
155
327
 
@@ -158,7 +330,6 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
158
330
  if (message.type === 'connect') {
159
331
  config.basePort = message.basePort || 33333
160
332
  config.token = message.token || DEFAULT_TOKEN
161
- disconnect()
162
333
  manualDisconnect = false // 用户重新连接,清除断开标志
163
334
  connect()
164
335
  sendResponse({ ok: true })
@@ -180,6 +351,17 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
180
351
  if (message.type === 'getOffscreenStatus') {
181
352
  sendResponse({
182
353
  connected: ws && ws.readyState === WebSocket.OPEN,
354
+ readyState: ws ? describeReadyState(ws) : 'CLOSED',
355
+ port: config.basePort,
356
+ })
357
+ return true
358
+ }
359
+
360
+ // 唤醒钩子触发的立即探活(background 的 chrome.idle 监听转发)
361
+ if (message.type === 'healthCheck') {
362
+ requestHealthCheck().then((alive) => {
363
+ if (!alive) log('唤醒探活完成:连接已重建/重连中')
364
+ sendResponse({ alive })
183
365
  })
184
366
  return true
185
367
  }
@@ -163,9 +163,17 @@
163
163
  }
164
164
 
165
165
  .status-row {
166
+ display: flex;
167
+ align-items: center;
168
+ justify-content: space-between;
169
+ gap: 12px;
170
+ }
171
+
172
+ .status-main {
166
173
  display: flex;
167
174
  align-items: center;
168
175
  gap: 12px;
176
+ min-width: 0;
169
177
  }
170
178
 
171
179
  /* Modern Ripple/Pulse Animations */
@@ -200,6 +208,58 @@
200
208
  font-weight: 600;
201
209
  letter-spacing: 0.3px;
202
210
  transition: color 0.3s ease;
211
+ white-space: nowrap;
212
+ }
213
+
214
+ .target-toggle {
215
+ flex: 0 0 auto;
216
+ min-width: 72px;
217
+ height: 28px;
218
+ padding: 0 10px;
219
+ border: 1px solid rgba(0, 210, 255, 0.28);
220
+ border-radius: 999px;
221
+ background: rgba(0, 210, 255, 0.08);
222
+ color: #9deaff;
223
+ font-size: 12px;
224
+ font-weight: 700;
225
+ letter-spacing: 0.2px;
226
+ cursor: pointer;
227
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
228
+ display: inline-flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ gap: 6px;
232
+ }
233
+
234
+ .target-toggle:hover:not(:disabled) {
235
+ background: rgba(0, 210, 255, 0.16);
236
+ border-color: rgba(0, 210, 255, 0.5);
237
+ color: #ffffff;
238
+ box-shadow: 0 0 14px rgba(0, 210, 255, 0.16);
239
+ }
240
+
241
+ .target-toggle:disabled {
242
+ opacity: 0.45;
243
+ cursor: not-allowed;
244
+ box-shadow: none;
245
+ }
246
+
247
+ .target-toggle.pinned {
248
+ background: rgba(255, 159, 10, 0.13);
249
+ border-color: rgba(255, 159, 10, 0.5);
250
+ color: #ffc56b;
251
+ box-shadow: 0 0 16px rgba(255, 159, 10, 0.12);
252
+ }
253
+
254
+ .target-toggle.pinned:hover:not(:disabled) {
255
+ background: rgba(255, 159, 10, 0.2);
256
+ border-color: rgba(255, 159, 10, 0.72);
257
+ color: #fff3d6;
258
+ }
259
+
260
+ .target-toggle .target-icon {
261
+ font-size: 12px;
262
+ line-height: 1;
203
263
  }
204
264
 
205
265
  .status-detail-container {
@@ -262,50 +322,6 @@
262
322
  color: #ff9f0a;
263
323
  }
264
324
 
265
- .error-list {
266
- max-height: 180px;
267
- overflow-y: auto;
268
- margin-top: 12px;
269
- padding: 8px;
270
- background: rgba(0, 0, 0, 0.4);
271
- border-radius: 8px;
272
- border: 1px solid rgba(255, 59, 48, 0.2);
273
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
274
- opacity: 1;
275
- }
276
- .error-list.collapsed {
277
- max-height: 0;
278
- opacity: 0;
279
- margin-top: 0;
280
- padding-top: 0;
281
- padding-bottom: 0;
282
- border-color: transparent;
283
- overflow: hidden;
284
- }
285
-
286
- .error-item {
287
- font-size: 11px;
288
- font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
289
- padding: 6px;
290
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
291
- word-break: break-work;
292
- color: #e2e8f0;
293
- display: flex;
294
- flex-direction: column;
295
- gap: 4px;
296
- }
297
- .error-item:last-child {
298
- border-bottom: none;
299
- }
300
- .error-item .err-msg {
301
- color: #ff8a8a;
302
- white-space: pre-wrap;
303
- }
304
- .error-item .err-loc {
305
- color: #64748b;
306
- font-size: 10px;
307
- }
308
-
309
325
  .btn-row {
310
326
  display: flex;
311
327
  gap: 10px;
@@ -432,10 +448,16 @@
432
448
 
433
449
  <div class="status-card" id="statusCard">
434
450
  <div class="status-row">
435
- <div class="status-dot-wrapper" id="dotWrapper">
436
- <div class="status-dot"></div>
451
+ <div class="status-main">
452
+ <div class="status-dot-wrapper" id="dotWrapper">
453
+ <div class="status-dot"></div>
454
+ </div>
455
+ <span class="status-text" id="statusText">Checking...</span>
437
456
  </div>
438
- <span class="status-text" id="statusText">Checking...</span>
457
+ <button class="target-toggle" id="targetBtn" type="button" title="Pin current tab">
458
+ <span class="target-icon" id="targetIcon">○</span>
459
+ <span id="targetBtnText">Pin</span>
460
+ </button>
439
461
  </div>
440
462
 
441
463
  <div class="status-detail-container" id="detailContainer">
@@ -447,15 +469,11 @@
447
469
  <span class="detail-label">Target Tab</span>
448
470
  <span class="detail-value" id="tabVal" title="">-</span>
449
471
  </div>
450
- <div class="detail-row clickable" id="errorRow" title="Click to view recent errors">
451
- <span class="detail-label">Rec. Errors</span>
452
- <span class="detail-value warning" id="errorVal">0</span>
472
+ <div class="detail-row hidden" id="viewingRow">
473
+ <span class="detail-label">Viewing Now</span>
474
+ <span class="detail-value" id="viewingVal" title="">-</span>
453
475
  </div>
454
476
  </div>
455
-
456
- <div id="errorList" class="error-list collapsed">
457
- <!-- JS dynamically injects errors here -->
458
- </div>
459
477
  </div>
460
478
 
461
479
  <div class="btn-row">