@shiplightai/mcp 0.1.44 → 0.1.46
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/chrome-extension/README.md +103 -0
- package/chrome-extension/background-utils.js +24 -0
- package/chrome-extension/background.js +774 -0
- package/chrome-extension/chrome-extension/README.md +103 -0
- package/chrome-extension/chrome-extension/background-utils.js +24 -0
- package/chrome-extension/chrome-extension/background.js +774 -0
- package/chrome-extension/chrome-extension/icons/icon128.png +0 -0
- package/chrome-extension/chrome-extension/icons/icon16.png +0 -0
- package/chrome-extension/chrome-extension/icons/icon32.png +0 -0
- package/chrome-extension/chrome-extension/icons/icon48.png +0 -0
- package/chrome-extension/chrome-extension/manifest.json +25 -0
- package/chrome-extension/chrome-extension/options.html +103 -0
- package/chrome-extension/chrome-extension/options.js +38 -0
- package/chrome-extension/icons/icon128.png +0 -0
- package/chrome-extension/icons/icon16.png +0 -0
- package/chrome-extension/icons/icon32.png +0 -0
- package/chrome-extension/icons/icon48.png +0 -0
- package/chrome-extension/manifest.json +25 -0
- package/chrome-extension/options.html +103 -0
- package/chrome-extension/options.js +38 -0
- package/dist/index.js +489 -754
- package/package.json +4 -3
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
|
|
2
|
+
|
|
3
|
+
const BADGE = {
|
|
4
|
+
on: { text: 'ON', color: '#FF5A36' },
|
|
5
|
+
off: { text: '', color: '#000000' },
|
|
6
|
+
connecting: { text: '…', color: '#F59E0B' },
|
|
7
|
+
error: { text: '!', color: '#B91C1C' },
|
|
8
|
+
unconfigured: { text: '?', color: '#9CA3AF' },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @type {WebSocket|null} */
|
|
12
|
+
let relayWs = null
|
|
13
|
+
/** @type {Promise<void>|null} */
|
|
14
|
+
let relayConnectPromise = null
|
|
15
|
+
let nextSession = 1
|
|
16
|
+
|
|
17
|
+
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string}>} */
|
|
18
|
+
const tabs = new Map()
|
|
19
|
+
/** @type {Map<string, number>} */
|
|
20
|
+
const tabBySession = new Map()
|
|
21
|
+
|
|
22
|
+
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
|
|
23
|
+
const pending = new Map()
|
|
24
|
+
|
|
25
|
+
// Per-tab operation locks prevent double-attach races.
|
|
26
|
+
/** @type {Set<number>} */
|
|
27
|
+
const tabOperationLocks = new Set()
|
|
28
|
+
|
|
29
|
+
// Tabs currently in a detach/re-attach cycle after navigation.
|
|
30
|
+
/** @type {Set<number>} */
|
|
31
|
+
const reattachPending = new Set()
|
|
32
|
+
|
|
33
|
+
// Reconnect state for exponential backoff.
|
|
34
|
+
let reconnectAttempt = 0
|
|
35
|
+
let reconnectTimer = null
|
|
36
|
+
|
|
37
|
+
/** @returns {Promise<number|null>} configured port or null if not set */
|
|
38
|
+
async function getRelayPort() {
|
|
39
|
+
const stored = await chrome.storage.local.get(['relayPort'])
|
|
40
|
+
const raw = stored.relayPort
|
|
41
|
+
const n = Number.parseInt(String(raw || ''), 10)
|
|
42
|
+
if (!Number.isFinite(n) || n <= 0 || n > 65535) return null
|
|
43
|
+
return n
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function setBadge(tabId, kind) {
|
|
47
|
+
const cfg = BADGE[kind]
|
|
48
|
+
void chrome.action.setBadgeText({ tabId, text: cfg.text })
|
|
49
|
+
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
|
|
50
|
+
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Badge kind reflecting current relay connection state. */
|
|
54
|
+
function relayBadgeKind() {
|
|
55
|
+
return relayWs?.readyState === WebSocket.OPEN ? 'on' : 'connecting'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Persist attached tab state to survive MV3 service worker restarts.
|
|
59
|
+
async function persistState() {
|
|
60
|
+
try {
|
|
61
|
+
const tabEntries = []
|
|
62
|
+
for (const [tabId, tab] of tabs.entries()) {
|
|
63
|
+
if (tab.state === 'connected' && tab.sessionId && tab.targetId) {
|
|
64
|
+
tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await chrome.storage.session.set({
|
|
68
|
+
persistedTabs: tabEntries,
|
|
69
|
+
nextSession,
|
|
70
|
+
})
|
|
71
|
+
} catch {
|
|
72
|
+
// chrome.storage.session may not be available in all contexts.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Rehydrate tab state on service worker startup.
|
|
77
|
+
async function rehydrateState() {
|
|
78
|
+
try {
|
|
79
|
+
const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession'])
|
|
80
|
+
if (stored.nextSession) {
|
|
81
|
+
nextSession = Math.max(nextSession, stored.nextSession)
|
|
82
|
+
}
|
|
83
|
+
const entries = stored.persistedTabs || []
|
|
84
|
+
// Phase 1: optimistically restore state and badges.
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
tabs.set(entry.tabId, {
|
|
87
|
+
state: 'connected',
|
|
88
|
+
sessionId: entry.sessionId,
|
|
89
|
+
targetId: entry.targetId,
|
|
90
|
+
})
|
|
91
|
+
tabBySession.set(entry.sessionId, entry.tabId)
|
|
92
|
+
setBadge(entry.tabId, 'on')
|
|
93
|
+
}
|
|
94
|
+
// Phase 2: validate asynchronously, remove dead tabs.
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
try {
|
|
97
|
+
await chrome.tabs.get(entry.tabId)
|
|
98
|
+
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
|
|
99
|
+
expression: '1',
|
|
100
|
+
returnByValue: true,
|
|
101
|
+
})
|
|
102
|
+
} catch {
|
|
103
|
+
tabs.delete(entry.tabId)
|
|
104
|
+
tabBySession.delete(entry.sessionId)
|
|
105
|
+
setBadge(entry.tabId, 'off')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Ignore rehydration errors.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function ensureRelayConnection() {
|
|
114
|
+
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
|
|
115
|
+
if (relayConnectPromise) return await relayConnectPromise
|
|
116
|
+
|
|
117
|
+
relayConnectPromise = (async () => {
|
|
118
|
+
const port = await getRelayPort()
|
|
119
|
+
if (!port) throw new Error('No relay port configured')
|
|
120
|
+
const httpBase = `http://127.0.0.1:${port}`
|
|
121
|
+
const wsUrl = buildRelayWsUrl(port)
|
|
122
|
+
|
|
123
|
+
// Fast preflight: is the relay server up?
|
|
124
|
+
try {
|
|
125
|
+
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ws = new WebSocket(wsUrl)
|
|
131
|
+
relayWs = ws
|
|
132
|
+
// Bind message handler before open so an immediate first frame cannot be missed.
|
|
133
|
+
ws.onmessage = (event) => {
|
|
134
|
+
if (ws !== relayWs) return
|
|
135
|
+
void whenReady(() => onRelayMessage(String(event.data || '')))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await new Promise((resolve, reject) => {
|
|
139
|
+
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
|
|
140
|
+
ws.onopen = () => {
|
|
141
|
+
clearTimeout(t)
|
|
142
|
+
resolve()
|
|
143
|
+
}
|
|
144
|
+
ws.onerror = () => {
|
|
145
|
+
clearTimeout(t)
|
|
146
|
+
reject(new Error('WebSocket connect failed'))
|
|
147
|
+
}
|
|
148
|
+
ws.onclose = (ev) => {
|
|
149
|
+
clearTimeout(t)
|
|
150
|
+
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Bind permanent handlers.
|
|
155
|
+
ws.onclose = () => {
|
|
156
|
+
if (ws !== relayWs) return
|
|
157
|
+
onRelayClosed('closed')
|
|
158
|
+
}
|
|
159
|
+
ws.onerror = () => {
|
|
160
|
+
if (ws !== relayWs) return
|
|
161
|
+
onRelayClosed('error')
|
|
162
|
+
}
|
|
163
|
+
})()
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
await relayConnectPromise
|
|
167
|
+
reconnectAttempt = 0
|
|
168
|
+
} finally {
|
|
169
|
+
relayConnectPromise = null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Relay closed — update badges, reject pending requests, auto-reconnect.
|
|
174
|
+
function onRelayClosed(reason) {
|
|
175
|
+
relayWs = null
|
|
176
|
+
|
|
177
|
+
for (const [id, p] of pending.entries()) {
|
|
178
|
+
pending.delete(id)
|
|
179
|
+
p.reject(new Error(`Relay disconnected (${reason})`))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
reattachPending.clear()
|
|
183
|
+
|
|
184
|
+
for (const [tabId, tab] of tabs.entries()) {
|
|
185
|
+
if (tab.state === 'connected') {
|
|
186
|
+
setBadge(tabId, 'connecting')
|
|
187
|
+
void chrome.action.setTitle({
|
|
188
|
+
tabId,
|
|
189
|
+
title: 'Shiplight AI: relay reconnecting…',
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
scheduleReconnect()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function scheduleReconnect() {
|
|
198
|
+
if (reconnectTimer) {
|
|
199
|
+
clearTimeout(reconnectTimer)
|
|
200
|
+
reconnectTimer = null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const delay = reconnectDelayMs(reconnectAttempt)
|
|
204
|
+
reconnectAttempt++
|
|
205
|
+
|
|
206
|
+
console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`)
|
|
207
|
+
|
|
208
|
+
reconnectTimer = setTimeout(async () => {
|
|
209
|
+
reconnectTimer = null
|
|
210
|
+
try {
|
|
211
|
+
await ensureRelayConnection()
|
|
212
|
+
reconnectAttempt = 0
|
|
213
|
+
console.log('Reconnected successfully')
|
|
214
|
+
await reannounceAttachedTabs()
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
217
|
+
console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`)
|
|
218
|
+
if (!isRetryableReconnectError(err)) {
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
scheduleReconnect()
|
|
222
|
+
}
|
|
223
|
+
}, delay)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function cancelReconnect() {
|
|
227
|
+
if (reconnectTimer) {
|
|
228
|
+
clearTimeout(reconnectTimer)
|
|
229
|
+
reconnectTimer = null
|
|
230
|
+
}
|
|
231
|
+
reconnectAttempt = 0
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Re-announce all attached tabs to the relay after reconnect.
|
|
235
|
+
async function reannounceAttachedTabs() {
|
|
236
|
+
for (const [tabId, tab] of tabs.entries()) {
|
|
237
|
+
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
|
|
238
|
+
|
|
239
|
+
// Verify debugger is still attached.
|
|
240
|
+
try {
|
|
241
|
+
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
|
242
|
+
expression: '1',
|
|
243
|
+
returnByValue: true,
|
|
244
|
+
})
|
|
245
|
+
} catch {
|
|
246
|
+
tabs.delete(tabId)
|
|
247
|
+
if (tab.sessionId) tabBySession.delete(tab.sessionId)
|
|
248
|
+
setBadge(tabId, 'off')
|
|
249
|
+
void chrome.action.setTitle({
|
|
250
|
+
tabId,
|
|
251
|
+
title: 'Shiplight AI (click to attach/detach)',
|
|
252
|
+
})
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Send fresh attach event to relay.
|
|
257
|
+
try {
|
|
258
|
+
const info = /** @type {any} */ (
|
|
259
|
+
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
|
|
260
|
+
)
|
|
261
|
+
const targetInfo = info?.targetInfo
|
|
262
|
+
|
|
263
|
+
sendToRelay({
|
|
264
|
+
method: 'forwardCDPEvent',
|
|
265
|
+
params: {
|
|
266
|
+
method: 'Target.attachedToTarget',
|
|
267
|
+
params: {
|
|
268
|
+
sessionId: tab.sessionId,
|
|
269
|
+
targetInfo: { ...targetInfo, tabId, attached: true },
|
|
270
|
+
waitingForDebugger: false,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
setBadge(tabId, 'on')
|
|
276
|
+
void chrome.action.setTitle({
|
|
277
|
+
tabId,
|
|
278
|
+
title: 'Shiplight AI: attached (click to detach)',
|
|
279
|
+
})
|
|
280
|
+
} catch {
|
|
281
|
+
setBadge(tabId, 'on')
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await persistState()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function sendToRelay(payload) {
|
|
289
|
+
const ws = relayWs
|
|
290
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
291
|
+
throw new Error('Relay not connected')
|
|
292
|
+
}
|
|
293
|
+
ws.send(JSON.stringify(payload))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function requestFromRelay(command) {
|
|
297
|
+
const id = command.id
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
pending.delete(id)
|
|
301
|
+
reject(new Error('Relay request timeout (30s)'))
|
|
302
|
+
}, 30000)
|
|
303
|
+
pending.set(id, {
|
|
304
|
+
resolve: (v) => { clearTimeout(timer); resolve(v) },
|
|
305
|
+
reject: (e) => { clearTimeout(timer); reject(e) },
|
|
306
|
+
})
|
|
307
|
+
try {
|
|
308
|
+
sendToRelay(command)
|
|
309
|
+
} catch (err) {
|
|
310
|
+
clearTimeout(timer)
|
|
311
|
+
pending.delete(id)
|
|
312
|
+
reject(err instanceof Error ? err : new Error(String(err)))
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function onRelayMessage(text) {
|
|
318
|
+
/** @type {any} */
|
|
319
|
+
let msg
|
|
320
|
+
try {
|
|
321
|
+
msg = JSON.parse(text)
|
|
322
|
+
} catch {
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Handle CDP command from relay
|
|
327
|
+
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
|
|
328
|
+
const { method, params, sessionId } = msg.params || {}
|
|
329
|
+
const tabId = tabBySession.get(sessionId)
|
|
330
|
+
if (!tabId) {
|
|
331
|
+
// No tab found for this session
|
|
332
|
+
sendToRelay({
|
|
333
|
+
id: msg.id,
|
|
334
|
+
error: { message: `No tab found for session ${sessionId}` },
|
|
335
|
+
})
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
// Add timeout wrapper - chrome.debugger.sendCommand can hang indefinitely
|
|
341
|
+
const result = await Promise.race([
|
|
342
|
+
chrome.debugger.sendCommand({ tabId }, method, params),
|
|
343
|
+
new Promise((_, reject) =>
|
|
344
|
+
setTimeout(() => reject(new Error(`CDP command timeout: ${method}`)), 25000)
|
|
345
|
+
),
|
|
346
|
+
])
|
|
347
|
+
sendToRelay({
|
|
348
|
+
id: msg.id,
|
|
349
|
+
result,
|
|
350
|
+
})
|
|
351
|
+
} catch (err) {
|
|
352
|
+
const errorMsg = err instanceof Error ? err.message : String(err)
|
|
353
|
+
|
|
354
|
+
// Handle unsupported Browser.* commands with fake responses
|
|
355
|
+
if (method === 'Browser.getVersion') {
|
|
356
|
+
sendToRelay({
|
|
357
|
+
id: msg.id,
|
|
358
|
+
result: {
|
|
359
|
+
protocolVersion: '1.3',
|
|
360
|
+
product: 'Chrome',
|
|
361
|
+
revision: '@000000',
|
|
362
|
+
userAgent: navigator.userAgent,
|
|
363
|
+
jsVersion: '0.0.0',
|
|
364
|
+
},
|
|
365
|
+
})
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (method === 'Browser.setDownloadBehavior') {
|
|
370
|
+
// Just return success - downloads won't work but we can ignore this
|
|
371
|
+
sendToRelay({
|
|
372
|
+
id: msg.id,
|
|
373
|
+
result: {},
|
|
374
|
+
})
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
sendToRelay({
|
|
379
|
+
id: msg.id,
|
|
380
|
+
error: { message: errorMsg },
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Handle getActiveTab request from relay
|
|
387
|
+
if (msg && typeof msg.id === 'number' && msg.method === 'getActiveTab') {
|
|
388
|
+
try {
|
|
389
|
+
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true })
|
|
390
|
+
const tabId = activeTab?.id
|
|
391
|
+
|
|
392
|
+
if (!tabId) {
|
|
393
|
+
sendToRelay({
|
|
394
|
+
id: msg.id,
|
|
395
|
+
result: { sessionId: null, tabId: null },
|
|
396
|
+
})
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const attachedTab = tabs.get(tabId)
|
|
401
|
+
|
|
402
|
+
if (attachedTab && attachedTab.state === 'connected') {
|
|
403
|
+
// Active tab is already attached
|
|
404
|
+
sendToRelay({
|
|
405
|
+
id: msg.id,
|
|
406
|
+
result: {
|
|
407
|
+
sessionId: attachedTab.sessionId,
|
|
408
|
+
tabId: tabId,
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
} else {
|
|
412
|
+
// Active tab is not attached
|
|
413
|
+
sendToRelay({
|
|
414
|
+
id: msg.id,
|
|
415
|
+
result: { sessionId: null, tabId: null },
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
sendToRelay({
|
|
420
|
+
id: msg.id,
|
|
421
|
+
error: { message: err instanceof Error ? err.message : String(err) },
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Handle response to our CDP commands
|
|
428
|
+
if (msg && typeof msg.id === 'number') {
|
|
429
|
+
const p = pending.get(msg.id)
|
|
430
|
+
if (p) {
|
|
431
|
+
pending.delete(msg.id)
|
|
432
|
+
if (msg.error) {
|
|
433
|
+
p.reject(new Error(msg.error.message || 'Command failed'))
|
|
434
|
+
} else {
|
|
435
|
+
p.resolve(msg.result)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Attach/detach flow
|
|
442
|
+
async function toggleAttach(tabId) {
|
|
443
|
+
if (tabOperationLocks.has(tabId)) {
|
|
444
|
+
console.warn(`Tab ${tabId} is locked (concurrent attach/detach)`)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
tabOperationLocks.add(tabId)
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const tab = tabs.get(tabId)
|
|
451
|
+
if (tab && tab.state === 'connected') {
|
|
452
|
+
await doDetach(tabId)
|
|
453
|
+
} else {
|
|
454
|
+
await doAttach(tabId)
|
|
455
|
+
}
|
|
456
|
+
} catch (err) {
|
|
457
|
+
console.error(`Toggle attach failed for tab ${tabId}:`, err)
|
|
458
|
+
setBadge(tabId, 'error')
|
|
459
|
+
void chrome.action.setTitle({
|
|
460
|
+
tabId,
|
|
461
|
+
title: `Shiplight AI: error (${err instanceof Error ? err.message : String(err)})`,
|
|
462
|
+
})
|
|
463
|
+
} finally {
|
|
464
|
+
tabOperationLocks.delete(tabId)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function doAttach(tabId) {
|
|
469
|
+
// Ensure relay connection
|
|
470
|
+
try {
|
|
471
|
+
await ensureRelayConnection()
|
|
472
|
+
} catch (err) {
|
|
473
|
+
throw new Error(`Cannot connect to relay: ${err instanceof Error ? err.message : String(err)}`)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Attach debugger
|
|
477
|
+
try {
|
|
478
|
+
await chrome.debugger.attach({ tabId }, '1.3')
|
|
479
|
+
} catch (err) {
|
|
480
|
+
throw new Error(`Cannot attach debugger: ${err instanceof Error ? err.message : String(err)}`)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Get target info
|
|
484
|
+
const info = /** @type {any} */ (
|
|
485
|
+
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
|
|
486
|
+
)
|
|
487
|
+
const targetInfo = info?.targetInfo
|
|
488
|
+
if (!targetInfo) {
|
|
489
|
+
await chrome.debugger.detach({ tabId })
|
|
490
|
+
throw new Error('No target info')
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Generate session ID
|
|
494
|
+
const sessionId = `sl-tab-${nextSession++}`
|
|
495
|
+
|
|
496
|
+
// Store tab state
|
|
497
|
+
tabs.set(tabId, {
|
|
498
|
+
state: 'connected',
|
|
499
|
+
sessionId,
|
|
500
|
+
targetId: targetInfo.targetId,
|
|
501
|
+
})
|
|
502
|
+
tabBySession.set(sessionId, tabId)
|
|
503
|
+
|
|
504
|
+
// Notify relay
|
|
505
|
+
sendToRelay({
|
|
506
|
+
method: 'forwardCDPEvent',
|
|
507
|
+
params: {
|
|
508
|
+
method: 'Target.attachedToTarget',
|
|
509
|
+
params: {
|
|
510
|
+
sessionId,
|
|
511
|
+
targetInfo: { ...targetInfo, tabId, attached: true },
|
|
512
|
+
waitingForDebugger: false,
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
setBadge(tabId, 'on')
|
|
518
|
+
void chrome.action.setTitle({
|
|
519
|
+
tabId,
|
|
520
|
+
title: 'Shiplight AI: attached (click to detach)',
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
await persistState()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function doDetach(tabId) {
|
|
527
|
+
const tab = tabs.get(tabId)
|
|
528
|
+
if (!tab) return
|
|
529
|
+
|
|
530
|
+
// Notify relay
|
|
531
|
+
if (tab.sessionId) {
|
|
532
|
+
try {
|
|
533
|
+
sendToRelay({
|
|
534
|
+
method: 'forwardCDPEvent',
|
|
535
|
+
params: {
|
|
536
|
+
method: 'Target.detachedFromTarget',
|
|
537
|
+
params: {
|
|
538
|
+
sessionId: tab.sessionId,
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
})
|
|
542
|
+
} catch {
|
|
543
|
+
// Ignore relay errors on detach
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Detach debugger
|
|
548
|
+
try {
|
|
549
|
+
await chrome.debugger.detach({ tabId })
|
|
550
|
+
} catch {
|
|
551
|
+
// Ignore detach errors
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Clean up state
|
|
555
|
+
if (tab.sessionId) {
|
|
556
|
+
tabBySession.delete(tab.sessionId)
|
|
557
|
+
}
|
|
558
|
+
tabs.delete(tabId)
|
|
559
|
+
|
|
560
|
+
setBadge(tabId, 'off')
|
|
561
|
+
void chrome.action.setTitle({
|
|
562
|
+
tabId,
|
|
563
|
+
title: 'Shiplight AI (click to attach/detach)',
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
await persistState()
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// CDP event forwarding
|
|
570
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
571
|
+
const tabId = source.tabId
|
|
572
|
+
if (!tabId) return
|
|
573
|
+
|
|
574
|
+
const tab = tabs.get(tabId)
|
|
575
|
+
if (!tab || tab.state !== 'connected' || !tab.sessionId) return
|
|
576
|
+
|
|
577
|
+
// Forward CDP events to relay
|
|
578
|
+
try {
|
|
579
|
+
sendToRelay({
|
|
580
|
+
method: 'forwardCDPEvent',
|
|
581
|
+
params: {
|
|
582
|
+
method,
|
|
583
|
+
params,
|
|
584
|
+
sessionId: tab.sessionId,
|
|
585
|
+
},
|
|
586
|
+
})
|
|
587
|
+
} catch {
|
|
588
|
+
// Ignore relay errors
|
|
589
|
+
}
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
// Debugger detach handler
|
|
593
|
+
chrome.debugger.onDetach.addListener(async (source, reason) => {
|
|
594
|
+
const tabId = source.tabId
|
|
595
|
+
if (!tabId) return
|
|
596
|
+
|
|
597
|
+
const tab = tabs.get(tabId)
|
|
598
|
+
if (!tab) return
|
|
599
|
+
|
|
600
|
+
console.log(`Debugger detached from tab ${tabId}: ${reason}`)
|
|
601
|
+
|
|
602
|
+
// Clean up
|
|
603
|
+
if (tab.sessionId) {
|
|
604
|
+
tabBySession.delete(tab.sessionId)
|
|
605
|
+
}
|
|
606
|
+
tabs.delete(tabId)
|
|
607
|
+
|
|
608
|
+
setBadge(tabId, 'off')
|
|
609
|
+
void chrome.action.setTitle({
|
|
610
|
+
tabId,
|
|
611
|
+
title: 'Shiplight AI (click to attach/detach)',
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
await persistState()
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
// Navigation handling: auto-reattach
|
|
618
|
+
chrome.webNavigation.onCommitted.addListener((details) => {
|
|
619
|
+
const tabId = details.tabId
|
|
620
|
+
if (!tabId || details.frameId !== 0) return
|
|
621
|
+
|
|
622
|
+
const tab = tabs.get(tabId)
|
|
623
|
+
if (!tab || tab.state !== 'connected') return
|
|
624
|
+
|
|
625
|
+
// Mark for reattach and preserve badge during navigation
|
|
626
|
+
reattachPending.add(tabId)
|
|
627
|
+
setBadge(tabId, relayBadgeKind())
|
|
628
|
+
|
|
629
|
+
// Reattach after navigation completes (self-removing listener to avoid accumulation)
|
|
630
|
+
const onNavCompleted = async (nav) => {
|
|
631
|
+
if (nav.tabId !== tabId || nav.frameId !== 0) return
|
|
632
|
+
chrome.webNavigation.onCompleted.removeListener(onNavCompleted)
|
|
633
|
+
|
|
634
|
+
reattachPending.delete(tabId)
|
|
635
|
+
|
|
636
|
+
// Re-announce to relay and restore badge
|
|
637
|
+
try {
|
|
638
|
+
const info = /** @type {any} */ (
|
|
639
|
+
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
|
|
640
|
+
)
|
|
641
|
+
const targetInfo = info?.targetInfo
|
|
642
|
+
|
|
643
|
+
sendToRelay({
|
|
644
|
+
method: 'forwardCDPEvent',
|
|
645
|
+
params: {
|
|
646
|
+
method: 'Target.attachedToTarget',
|
|
647
|
+
params: {
|
|
648
|
+
sessionId: tab.sessionId,
|
|
649
|
+
targetInfo: { ...targetInfo, tabId, attached: true },
|
|
650
|
+
waitingForDebugger: false,
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
// Restore badge — Chrome may clear per-tab badge state on navigation
|
|
656
|
+
setBadge(tabId, relayBadgeKind())
|
|
657
|
+
} catch {
|
|
658
|
+
// Ignore reattach errors
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
chrome.webNavigation.onCompleted.addListener(onNavCompleted, { url: [{ urlMatches: '.*' }] })
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
// Extension toolbar click
|
|
665
|
+
chrome.action.onClicked.addListener(async (tab) => {
|
|
666
|
+
const port = await getRelayPort()
|
|
667
|
+
if (!port) {
|
|
668
|
+
// No port configured — open options page
|
|
669
|
+
chrome.runtime.openOptionsPage()
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
if (tab.id) {
|
|
673
|
+
await toggleAttach(tab.id)
|
|
674
|
+
}
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
// Tab activation handler — notify relay when user switches to a registered tab
|
|
678
|
+
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
679
|
+
const tabId = activeInfo.tabId
|
|
680
|
+
const tab = tabs.get(tabId)
|
|
681
|
+
if (!tab || tab.state !== 'connected' || !tab.sessionId) return
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
sendToRelay({
|
|
685
|
+
method: 'forwardCDPEvent',
|
|
686
|
+
params: {
|
|
687
|
+
method: 'Target.activateTarget',
|
|
688
|
+
params: {
|
|
689
|
+
targetId: tab.targetId,
|
|
690
|
+
sessionId: tab.sessionId,
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
})
|
|
694
|
+
} catch {
|
|
695
|
+
// Ignore relay errors — tab activation is best-effort
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
// Tab close handler
|
|
700
|
+
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
|
701
|
+
const tab = tabs.get(tabId)
|
|
702
|
+
if (tab) {
|
|
703
|
+
await doDetach(tabId)
|
|
704
|
+
}
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
// Tab replaced handler (when Chrome replaces a tab with a new ID)
|
|
708
|
+
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
|
|
709
|
+
const tab = tabs.get(removedTabId)
|
|
710
|
+
if (!tab) return
|
|
711
|
+
|
|
712
|
+
// Move tab state to new ID
|
|
713
|
+
tabs.delete(removedTabId)
|
|
714
|
+
tabs.set(addedTabId, tab)
|
|
715
|
+
if (tab.sessionId) {
|
|
716
|
+
tabBySession.set(tab.sessionId, addedTabId)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Update badge on new tab
|
|
720
|
+
setBadge(addedTabId, relayBadgeKind())
|
|
721
|
+
|
|
722
|
+
void persistState()
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
// Keepalive alarm
|
|
726
|
+
chrome.alarms.create('keepalive', { periodInMinutes: 0.5 })
|
|
727
|
+
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
728
|
+
if (alarm.name === 'keepalive') {
|
|
729
|
+
// Restore badge state for all connected tabs (service worker restart may clear badges)
|
|
730
|
+
console.debug('Keepalive: restoring badges for', tabs.size, 'tab(s)')
|
|
731
|
+
for (const [tabId, tab] of tabs.entries()) {
|
|
732
|
+
if (tab.state === 'connected') {
|
|
733
|
+
setBadge(tabId, relayBadgeKind())
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
// Service worker startup
|
|
740
|
+
let whenReadyQueue = []
|
|
741
|
+
let serviceReady = false
|
|
742
|
+
|
|
743
|
+
async function init() {
|
|
744
|
+
await rehydrateState()
|
|
745
|
+
|
|
746
|
+
// Show unconfigured state if no port is set
|
|
747
|
+
const port = await getRelayPort()
|
|
748
|
+
if (!port) {
|
|
749
|
+
void chrome.action.setBadgeText({ text: '?' })
|
|
750
|
+
void chrome.action.setBadgeBackgroundColor({ color: '#9CA3AF' })
|
|
751
|
+
void chrome.action.setBadgeTextColor({ color: '#FFFFFF' }).catch(() => {})
|
|
752
|
+
void chrome.action.setTitle({ title: 'Shiplight AI: no relay port configured (click to configure)' })
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
serviceReady = true
|
|
756
|
+
for (const fn of whenReadyQueue) {
|
|
757
|
+
try {
|
|
758
|
+
await fn()
|
|
759
|
+
} catch (err) {
|
|
760
|
+
console.error('whenReady callback error:', err)
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
whenReadyQueue = []
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function whenReady(fn) {
|
|
767
|
+
if (serviceReady) {
|
|
768
|
+
void fn()
|
|
769
|
+
} else {
|
|
770
|
+
whenReadyQueue.push(fn)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
void init()
|