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.
- package/README.md +32 -1
- package/dist/cli.js +266 -140
- package/dist/server.js +288 -69
- package/extension/background.js +692 -224
- package/extension/manifest.json +3 -2
- package/extension/offscreen.js +211 -29
- package/extension/popup.html +72 -54
- package/extension/popup.js +72 -42
- package/package.json +4 -4
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Ghost Bridge",
|
|
4
|
-
"version": "0.
|
|
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/*",
|
package/extension/offscreen.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
-
ws
|
|
122
|
+
const socket = new WebSocket(url.toString())
|
|
123
|
+
ws = socket
|
|
124
|
+
socket.binaryType = 'blob' // 明确设置
|
|
37
125
|
|
|
38
126
|
const connectionTimeout = setTimeout(() => {
|
|
39
|
-
if (
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
231
|
+
scheduleReconnect(generation, 2000)
|
|
114
232
|
return
|
|
115
233
|
}
|
|
116
234
|
log(`固定端口 ${port} 未发现可用服务,2秒后重试...`)
|
|
117
|
-
chrome.runtime.sendMessage({
|
|
118
|
-
|
|
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({
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|
package/extension/popup.html
CHANGED
|
@@ -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-
|
|
436
|
-
<div class="status-dot"
|
|
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
|
-
<
|
|
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
|
|
451
|
-
<span class="detail-label">
|
|
452
|
-
<span class="detail-value
|
|
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">
|