ghost-bridge 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@
4
4
  let ws = null
5
5
  let reconnectTimer = null
6
6
  let manualDisconnect = false // 用户主动断开标志,防止 onclose 触发重连
7
+ let connectionGeneration = 0
7
8
  let config = {
8
9
  basePort: 33333,
9
10
  token: '',
@@ -17,12 +18,42 @@ function log(msg) {
17
18
 
18
19
  const DEFAULT_TOKEN = 'ghost-bridge-local'
19
20
 
21
+ function clearReconnectTimer() {
22
+ if (reconnectTimer) {
23
+ clearTimeout(reconnectTimer)
24
+ reconnectTimer = null
25
+ }
26
+ }
27
+
28
+ function isCurrentConnection(generation, socket) {
29
+ return generation === connectionGeneration && socket === ws
30
+ }
31
+
32
+ function scheduleReconnect(generation, delay) {
33
+ if (generation !== connectionGeneration) return
34
+ clearReconnectTimer()
35
+ reconnectTimer = setTimeout(() => {
36
+ if (generation === connectionGeneration) connect()
37
+ }, delay)
38
+ }
39
+
40
+ function closeCurrentSocket() {
41
+ if (!ws) return
42
+ try {
43
+ ws.close()
44
+ } catch {}
45
+ ws = null
46
+ }
47
+
20
48
  // 连接到服务器
21
49
  function connect() {
22
50
  // 如果已手动断开,不再尝试连接
23
51
  if (manualDisconnect) return
52
+ clearReconnectTimer()
53
+ closeCurrentSocket()
24
54
 
25
55
  const port = config.basePort
56
+ const generation = ++connectionGeneration
26
57
  const url = new URL(`ws://localhost:${port}`)
27
58
  url.searchParams.set('token', config.token)
28
59
  log(`尝试连接固定端口 ${port}...`)
@@ -32,12 +63,13 @@ function connect() {
32
63
  currentPort: port,
33
64
  }).catch(() => {})
34
65
 
35
- ws = new WebSocket(url.toString())
36
- ws.binaryType = 'blob' // 明确设置
66
+ const socket = new WebSocket(url.toString())
67
+ ws = socket
68
+ socket.binaryType = 'blob' // 明确设置
37
69
 
38
70
  const connectionTimeout = setTimeout(() => {
39
- if (ws && ws.readyState === WebSocket.CONNECTING) {
40
- ws.close()
71
+ if (isCurrentConnection(generation, socket) && socket.readyState === WebSocket.CONNECTING) {
72
+ socket.close()
41
73
  }
42
74
  }, 2000) // 增加到 2 秒
43
75
 
@@ -45,19 +77,22 @@ function connect() {
45
77
  let socketOpened = false
46
78
  let terminalErrorMessage = ''
47
79
 
48
- ws.onopen = () => {
80
+ socket.onopen = () => {
81
+ if (!isCurrentConnection(generation, socket)) return
49
82
  socketOpened = true
50
83
  clearTimeout(connectionTimeout)
51
84
  log(`WebSocket 已连接端口 ${port},等待身份验证...`)
52
85
  }
53
86
 
54
- ws.onmessage = async (event) => {
87
+ socket.onmessage = async (event) => {
55
88
  try {
89
+ if (!isCurrentConnection(generation, socket)) return
56
90
  // 处理 Blob 类型的消息
57
91
  let data = event.data
58
92
  if (data instanceof Blob) {
59
93
  data = await data.text()
60
94
  }
95
+ if (!isCurrentConnection(generation, socket)) return
61
96
  const msg = JSON.parse(data)
62
97
 
63
98
  if (msg.type === 'identity') {
@@ -68,6 +103,13 @@ function connect() {
68
103
  type: 'status',
69
104
  status: 'connected',
70
105
  port: port,
106
+ serverInfo: {
107
+ version: msg.version,
108
+ pid: msg.pid,
109
+ port: msg.port,
110
+ startedAt: msg.startedAt,
111
+ serverPath: msg.serverPath,
112
+ },
71
113
  }).catch(() => {})
72
114
  } else {
73
115
  terminalErrorMessage = msg.service === 'ghost-bridge'
@@ -94,7 +136,8 @@ function connect() {
94
136
  }
95
137
  }
96
138
 
97
- ws.onclose = (event) => {
139
+ socket.onclose = (event) => {
140
+ if (!isCurrentConnection(generation, socket)) return
98
141
  clearTimeout(connectionTimeout)
99
142
 
100
143
  // 用户主动断开,不重连
@@ -110,22 +153,28 @@ function connect() {
110
153
  currentPort: port,
111
154
  errorMessage,
112
155
  }).catch(() => {})
113
- reconnectTimer = setTimeout(() => connect(), 2000)
156
+ scheduleReconnect(generation, 2000)
114
157
  return
115
158
  }
116
159
  log(`固定端口 ${port} 未发现可用服务,2秒后重试...`)
117
- chrome.runtime.sendMessage({ type: 'status', status: 'not_found', currentPort: port }).catch(() => {})
118
- reconnectTimer = setTimeout(() => connect(), 2000)
160
+ chrome.runtime.sendMessage({
161
+ type: 'status',
162
+ status: 'not_found',
163
+ currentPort: port,
164
+ errorMessage: `No ghost-bridge WebSocket service was found on port ${port}.`,
165
+ }).catch(() => {})
166
+ scheduleReconnect(generation, 2000)
119
167
  return
120
168
  }
121
169
 
122
170
  // 连接断开,重试
123
171
  log(`端口 ${port} 连接断开,尝试重连...`)
124
172
  chrome.runtime.sendMessage({ type: 'status', status: 'disconnected' }).catch(() => {})
125
- reconnectTimer = setTimeout(() => connect(), 1000)
173
+ scheduleReconnect(generation, 1000)
126
174
  }
127
175
 
128
- ws.onerror = () => {
176
+ socket.onerror = () => {
177
+ if (!isCurrentConnection(generation, socket)) return
129
178
  clearTimeout(connectionTimeout)
130
179
  }
131
180
  }
@@ -142,14 +191,9 @@ function sendToServer(data) {
142
191
  // 断开连接
143
192
  function disconnect() {
144
193
  manualDisconnect = true // 标记为手动断开,阻止 onclose 重连
145
- if (reconnectTimer) {
146
- clearTimeout(reconnectTimer)
147
- reconnectTimer = null
148
- }
149
- if (ws) {
150
- ws.close()
151
- ws = null
152
- }
194
+ connectionGeneration++
195
+ clearReconnectTimer()
196
+ closeCurrentSocket()
153
197
  log('已断开连接')
154
198
  }
155
199
 
@@ -158,7 +202,6 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
158
202
  if (message.type === 'connect') {
159
203
  config.basePort = message.basePort || 33333
160
204
  config.token = message.token || DEFAULT_TOKEN
161
- disconnect()
162
205
  manualDisconnect = false // 用户重新连接,清除断开标志
163
206
  connect()
164
207
  sendResponse({ ok: true })
@@ -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">
@@ -7,11 +7,13 @@ const detailContainer = document.getElementById('detailContainer')
7
7
  const portVal = document.getElementById('portVal')
8
8
  const tabRow = document.getElementById('tabRow')
9
9
  const tabVal = document.getElementById('tabVal')
10
- const errorRow = document.getElementById('errorRow')
11
- const errorVal = document.getElementById('errorVal')
12
- const errorList = document.getElementById('errorList')
10
+ const viewingRow = document.getElementById('viewingRow')
11
+ const viewingVal = document.getElementById('viewingVal')
13
12
  const headerGhost = document.getElementById('headerGhost')
14
13
 
14
+ const targetBtn = document.getElementById('targetBtn')
15
+ const targetBtnText = document.getElementById('targetBtnText')
16
+ const targetIcon = document.getElementById('targetIcon')
15
17
  const connectBtn = document.getElementById('connectBtn')
16
18
  const disconnectBtn = document.getElementById('disconnectBtn')
17
19
  const scanInfo = document.getElementById('scanInfo')
@@ -19,6 +21,7 @@ const scanInfo = document.getElementById('scanInfo')
19
21
  let lastStableStatus = null
20
22
  let pendingStatus = null
21
23
  let statusChangeTimer = null
24
+ let latestState = null
22
25
  const STATUS_DEBOUNCE_MS = 300
23
26
 
24
27
  const STATUS_MAP = {
@@ -53,8 +56,24 @@ const STATUS_MAP = {
53
56
  }
54
57
 
55
58
  function renderUI(state) {
56
- const { status, port, enabled, currentPort, basePort, connectionError, errorCount, recentErrors, tabTitle } = state
59
+ latestState = state
60
+ const {
61
+ status,
62
+ port,
63
+ enabled,
64
+ currentPort,
65
+ basePort,
66
+ connectionError,
67
+ targetMode = 'focused',
68
+ targetTab,
69
+ viewingTab,
70
+ tabTitle,
71
+ } = state
72
+ const isPinned = targetMode === 'pinned'
57
73
  const config = STATUS_MAP[status] || STATUS_MAP.disconnected
74
+ const statusLabel = status === 'connected'
75
+ ? isPinned ? 'ON / Pinned' : 'ON / Following'
76
+ : config.text
58
77
 
59
78
  // Update classes for color & animations
60
79
  dotWrapper.className = `status-dot-wrapper ${config.statusClass}`
@@ -75,10 +94,10 @@ function renderUI(state) {
75
94
  }
76
95
 
77
96
  // Animate text change
78
- if (statusText.textContent !== config.text) {
97
+ if (statusText.textContent !== statusLabel) {
79
98
  statusText.style.opacity = '0'
80
99
  setTimeout(() => {
81
- statusText.textContent = config.text
100
+ statusText.textContent = statusLabel
82
101
  statusText.style.opacity = '1'
83
102
  }, 150)
84
103
  }
@@ -88,34 +107,21 @@ function renderUI(state) {
88
107
  portVal.textContent = port
89
108
  portVal.className = 'detail-value highlight'
90
109
 
91
- if (tabTitle) {
110
+ const targetTitle = targetTab?.title || tabTitle
111
+ if (targetTitle) {
92
112
  tabRow.classList.remove('hidden')
93
- tabVal.textContent = tabTitle
94
- tabVal.title = tabTitle
113
+ tabVal.textContent = targetTitle
114
+ tabVal.title = targetTab?.url || targetTitle
95
115
  } else {
96
116
  tabRow.classList.add('hidden')
97
117
  }
98
118
 
99
- errorRow.classList.remove('hidden')
100
- errorVal.textContent = errorCount || 0
101
- errorVal.className = errorCount > 0 ? 'detail-value warning' : 'detail-value'
102
-
103
- // Render error list
104
- if (recentErrors && recentErrors.length > 0) {
105
- errorList.innerHTML = recentErrors.map(err => {
106
- const text = err.text ? err.text.substring(0, 100) : 'Unknown Error'
107
- const file = err.url ? err.url.split('/').pop() : 'inline'
108
- const loc = err.line ? `${file}:${err.line}` : file
109
- return `
110
- <div class="error-item" title="${err.text || ''}">
111
- <div class="err-msg">${text}${err.text && err.text.length > 100 ? '...' : ''}</div>
112
- <div class="err-loc">${loc}</div>
113
- </div>
114
- `
115
- }).join('')
119
+ if (isPinned && viewingTab && targetTab && viewingTab.id !== targetTab.id) {
120
+ viewingRow.classList.remove('hidden')
121
+ viewingVal.textContent = viewingTab.title || 'Untitled'
122
+ viewingVal.title = viewingTab.url || viewingTab.title || ''
116
123
  } else {
117
- errorList.innerHTML = '<div class="error-item" style="text-align:center;color:#64748b;border:none;">No recent errors recorded.</div>'
118
- errorList.classList.add('collapsed')
124
+ viewingRow.classList.add('hidden')
119
125
  }
120
126
 
121
127
  detailContainer.classList.remove('collapsed')
@@ -123,21 +129,21 @@ function renderUI(state) {
123
129
  portVal.textContent = `Connecting: ${currentPort}`
124
130
  portVal.className = 'detail-value highlight'
125
131
  tabRow.classList.add('hidden')
126
- errorRow.classList.add('hidden')
132
+ viewingRow.classList.add('hidden')
127
133
  detailContainer.classList.remove('collapsed')
128
134
  } else if (status === 'disconnected') {
129
135
  detailContainer.classList.add('collapsed')
130
136
  } else if (status === 'not_found') {
131
- portVal.textContent = 'Launch Claude Code'
137
+ portVal.textContent = 'No Bridge'
132
138
  portVal.className = 'detail-value warning'
133
139
  tabRow.classList.add('hidden')
134
- errorRow.classList.add('hidden')
140
+ viewingRow.classList.add('hidden')
135
141
  detailContainer.classList.remove('collapsed')
136
142
  } else if (status === 'error') {
137
143
  portVal.textContent = `Port ${currentPort || basePort || '-'} blocked`
138
144
  portVal.className = 'detail-value warning'
139
145
  tabRow.classList.add('hidden')
140
- errorRow.classList.add('hidden')
146
+ viewingRow.classList.add('hidden')
141
147
  detailContainer.classList.remove('collapsed')
142
148
  } else {
143
149
  detailContainer.classList.add('collapsed')
@@ -152,12 +158,24 @@ function renderUI(state) {
152
158
  connectBtn.disabled = false
153
159
  }
154
160
 
161
+ targetBtn.classList.toggle('pinned', isPinned)
162
+ targetBtnText.textContent = isPinned ? 'Locked' : 'Pin'
163
+ targetIcon.textContent = isPinned ? '●' : '○'
164
+ targetBtn.title = isPinned
165
+ ? 'Unpin target tab and follow the focused tab'
166
+ : 'Pin current tab as the Ghost Bridge target'
167
+ targetBtn.setAttribute('aria-label', targetBtn.title)
168
+ targetBtn.disabled = status !== 'connected' || !port
169
+
155
170
  // Scan info text
156
171
  if (status === 'error' && connectionError) {
157
172
  scanInfo.textContent = connectionError
158
173
  scanInfo.classList.remove('collapsed')
174
+ } else if (status === 'connected' && state.targetError) {
175
+ scanInfo.textContent = state.targetError
176
+ scanInfo.classList.remove('collapsed')
159
177
  } else if (status === 'not_found' && basePort) {
160
- scanInfo.textContent = `Port ${basePort} unavailable. Is your MCP server running on this port?`
178
+ scanInfo.textContent = connectionError || `No ghost-bridge WebSocket service was found on port ${basePort}. Start a configured MCP client, then reconnect.`
161
179
  scanInfo.classList.remove('collapsed')
162
180
  } else {
163
181
  scanInfo.classList.add('collapsed')
@@ -212,6 +230,27 @@ async function fetchStatus() {
212
230
  }
213
231
  }
214
232
 
233
+ targetBtn.addEventListener('click', async () => {
234
+ try {
235
+ const isPinned = latestState?.targetMode === 'pinned'
236
+ targetBtnText.textContent = isPinned ? 'Unlocking' : 'Pinning'
237
+ targetBtn.disabled = true
238
+ const response = await chrome.runtime.sendMessage({
239
+ type: isPinned ? 'unpinTab' : 'pinCurrentTab',
240
+ })
241
+ if (response && !response.ok) {
242
+ scanInfo.textContent = response.error || 'Target action failed'
243
+ scanInfo.classList.remove('collapsed')
244
+ }
245
+ } catch (e) {
246
+ console.error('Target action failed:', e)
247
+ scanInfo.textContent = e.message
248
+ scanInfo.classList.remove('collapsed')
249
+ } finally {
250
+ setTimeout(fetchStatus, 100)
251
+ }
252
+ })
253
+
215
254
  connectBtn.addEventListener('click', async () => {
216
255
  try {
217
256
  // Add visual click feedback
@@ -240,12 +279,3 @@ chrome.runtime.onMessage.addListener((message) => {
240
279
  updateUI(message.state)
241
280
  }
242
281
  })
243
-
244
- // Error List Toggle Logic
245
- errorRow.addEventListener('click', () => {
246
- if (errorList.classList.contains('collapsed')) {
247
- errorList.classList.remove('collapsed')
248
- } else {
249
- errorList.classList.add('collapsed')
250
- }
251
- })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghost-bridge",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Ghost Bridge: Zero-restart Chrome debugger bridge for Claude MCP. Includes CLI for easy setup.",
Binary file