chrome-ai-bridge 2.3.9 → 2.3.10
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/build/extension/README.md +181 -0
- package/build/extension/background.mjs +1263 -0
- package/build/extension/debug-logger.mjs +148 -0
- package/build/extension/icons/icon-128.png +0 -0
- package/build/extension/icons/icon-16.png +0 -0
- package/build/extension/icons/icon-32.png +0 -0
- package/build/extension/icons/icon-48.png +0 -0
- package/build/extension/icons/icon.svg +19 -0
- package/build/extension/manifest.json +28 -0
- package/build/extension/relay-server.ts +505 -0
- package/build/extension/ui/connect.html +429 -0
- package/build/extension/ui/connect.js +491 -0
- package/package.json +2 -1
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chrome-ai-bridge Extension Background Service Worker
|
|
3
|
+
* Playwright extension2-style flow:
|
|
4
|
+
* - connectToRelay -> establishes WS only
|
|
5
|
+
* - connectToTab -> binds a tab to that relay
|
|
6
|
+
* - attachToTab / forwardCDPCommand for CDP passthrough
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ============================================
|
|
10
|
+
// Logging System
|
|
11
|
+
// ============================================
|
|
12
|
+
const LOG_LEVEL = {DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3};
|
|
13
|
+
let currentLogLevel = LOG_LEVEL.DEBUG;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Enhanced logger with level, category, and Storage persistence
|
|
17
|
+
*/
|
|
18
|
+
function log(level, category, message, data = {}) {
|
|
19
|
+
const timestamp = new Date().toISOString();
|
|
20
|
+
const levelName = Object.keys(LOG_LEVEL).find(k => LOG_LEVEL[k] === level) || 'INFO';
|
|
21
|
+
const entry = {timestamp, level: levelName, category, message, data};
|
|
22
|
+
|
|
23
|
+
// Console output
|
|
24
|
+
const prefix = `[${timestamp}] [${levelName}] [${category}]`;
|
|
25
|
+
if (level >= currentLogLevel) {
|
|
26
|
+
const dataStr = Object.keys(data).length > 0 ? JSON.stringify(data) : '';
|
|
27
|
+
console.log(prefix, message, dataStr);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Save to Storage (async, fire-and-forget)
|
|
31
|
+
saveLogEntry(entry);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function saveLogEntry(entry) {
|
|
35
|
+
try {
|
|
36
|
+
const result = await chrome.storage.local.get('logs');
|
|
37
|
+
const logs = result.logs || [];
|
|
38
|
+
logs.push(entry);
|
|
39
|
+
// Keep only last 100 entries
|
|
40
|
+
while (logs.length > 100) {
|
|
41
|
+
logs.shift();
|
|
42
|
+
}
|
|
43
|
+
await chrome.storage.local.set({logs});
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore storage errors
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Convenience functions
|
|
50
|
+
function logDebug(category, message, data) {
|
|
51
|
+
log(LOG_LEVEL.DEBUG, category, message, data);
|
|
52
|
+
}
|
|
53
|
+
function logInfo(category, message, data) {
|
|
54
|
+
log(LOG_LEVEL.INFO, category, message, data);
|
|
55
|
+
}
|
|
56
|
+
function logWarn(category, message, data) {
|
|
57
|
+
log(LOG_LEVEL.WARN, category, message, data);
|
|
58
|
+
}
|
|
59
|
+
function logError(category, message, data) {
|
|
60
|
+
log(LOG_LEVEL.ERROR, category, message, data);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Legacy debug log (for compatibility)
|
|
64
|
+
function debugLog(...args) {
|
|
65
|
+
logDebug('general', args.join(' '));
|
|
66
|
+
console.log('[Extension]', ...args);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class RelayConnection {
|
|
70
|
+
constructor(ws) {
|
|
71
|
+
this._debuggee = {};
|
|
72
|
+
this._ws = ws;
|
|
73
|
+
this._closed = false;
|
|
74
|
+
this._tabPromise = new Promise(resolve => (this._tabPromiseResolve = resolve));
|
|
75
|
+
this._eventListener = this._onDebuggerEvent.bind(this);
|
|
76
|
+
this._detachListener = this._onDebuggerDetach.bind(this);
|
|
77
|
+
this._ws.onmessage = this._onMessage.bind(this);
|
|
78
|
+
this._ws.onclose = () => this._onClose();
|
|
79
|
+
chrome.debugger.onEvent.addListener(this._eventListener);
|
|
80
|
+
chrome.debugger.onDetach.addListener(this._detachListener);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setTabId(tabId) {
|
|
84
|
+
this._debuggee = {tabId};
|
|
85
|
+
this._tabPromiseResolve();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
sendReady(tabId) {
|
|
89
|
+
this._sendMessage({
|
|
90
|
+
type: 'ready',
|
|
91
|
+
tabId,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
close(message) {
|
|
96
|
+
if (
|
|
97
|
+
this._ws.readyState === WebSocket.OPEN ||
|
|
98
|
+
this._ws.readyState === WebSocket.CONNECTING
|
|
99
|
+
) {
|
|
100
|
+
this._ws.close(1000, message);
|
|
101
|
+
}
|
|
102
|
+
this._onClose();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_onClose() {
|
|
106
|
+
if (this._closed) return;
|
|
107
|
+
this._closed = true;
|
|
108
|
+
chrome.debugger.onEvent.removeListener(this._eventListener);
|
|
109
|
+
chrome.debugger.onDetach.removeListener(this._detachListener);
|
|
110
|
+
chrome.debugger.detach(this._debuggee).catch(() => {});
|
|
111
|
+
if (this.onclose) this.onclose();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_onDebuggerEvent(source, method, params) {
|
|
115
|
+
if (source.tabId !== this._debuggee.tabId) return;
|
|
116
|
+
const sessionId = source.sessionId;
|
|
117
|
+
this._sendMessage({
|
|
118
|
+
method: 'forwardCDPEvent',
|
|
119
|
+
params: {
|
|
120
|
+
sessionId,
|
|
121
|
+
method,
|
|
122
|
+
params,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_onDebuggerDetach(source, reason) {
|
|
128
|
+
if (source.tabId !== this._debuggee.tabId) return;
|
|
129
|
+
this.close(`Debugger detached: ${reason}`);
|
|
130
|
+
this._debuggee = {};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_onMessage(event) {
|
|
134
|
+
this._onMessageAsync(event).catch(err =>
|
|
135
|
+
debugLog('Error handling message:', err),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async _onMessageAsync(event) {
|
|
140
|
+
let message;
|
|
141
|
+
try {
|
|
142
|
+
message = JSON.parse(event.data);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
this._sendMessage({
|
|
145
|
+
error: {code: -32700, message: `Error parsing message: ${error.message}`},
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Handle keep-alive ping from relay server
|
|
151
|
+
if (message.type === 'ping') {
|
|
152
|
+
this._sendMessage({ type: 'pong' });
|
|
153
|
+
logDebug('keepalive', 'Received ping, sent pong');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const response = {id: message.id};
|
|
158
|
+
try {
|
|
159
|
+
response.result = await this._handleCommand(message);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
response.error = error.message;
|
|
162
|
+
}
|
|
163
|
+
this._sendMessage(response);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async _handleCommand(message) {
|
|
167
|
+
if (message.method === 'getVersion') {
|
|
168
|
+
const manifest = chrome.runtime.getManifest();
|
|
169
|
+
return { version: manifest.version, name: manifest.name };
|
|
170
|
+
}
|
|
171
|
+
if (message.method === 'reloadExtension') {
|
|
172
|
+
logInfo('reload', 'reloadExtension command received');
|
|
173
|
+
// Delay reload to allow response to be sent first
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
logInfo('reload', 'Calling chrome.runtime.reload()');
|
|
176
|
+
chrome.runtime.reload();
|
|
177
|
+
}, 100);
|
|
178
|
+
return { success: true, message: 'Extension reload initiated' };
|
|
179
|
+
}
|
|
180
|
+
if (message.method === 'attachToTab') {
|
|
181
|
+
await this._tabPromise;
|
|
182
|
+
debugLog('Attaching debugger to tab:', this._debuggee);
|
|
183
|
+
|
|
184
|
+
// デバッグ: アタッチ前にタブの状態を確認
|
|
185
|
+
try {
|
|
186
|
+
const tabInfo = await chrome.tabs.get(this._debuggee.tabId);
|
|
187
|
+
logInfo('attach', 'Tab info before attach', {
|
|
188
|
+
tabId: tabInfo.id,
|
|
189
|
+
url: tabInfo.url,
|
|
190
|
+
title: tabInfo.title,
|
|
191
|
+
status: tabInfo.status,
|
|
192
|
+
active: tabInfo.active,
|
|
193
|
+
});
|
|
194
|
+
} catch (e) {
|
|
195
|
+
logError('attach', 'Failed to get tab info', {error: e.message});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await chrome.debugger.attach(this._debuggee, '1.3');
|
|
199
|
+
const result = await chrome.debugger.sendCommand(
|
|
200
|
+
this._debuggee,
|
|
201
|
+
'Target.getTargetInfo',
|
|
202
|
+
);
|
|
203
|
+
logInfo('attach', 'Target info after attach', {targetInfo: result?.targetInfo});
|
|
204
|
+
return {targetInfo: result?.targetInfo};
|
|
205
|
+
}
|
|
206
|
+
if (!this._debuggee.tabId) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'No tab is connected. Please select a tab in the extension UI.',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (message.method === 'forwardCDPCommand') {
|
|
212
|
+
const {sessionId, method, params} = message.params;
|
|
213
|
+
const debuggerSession = {...this._debuggee, sessionId};
|
|
214
|
+
|
|
215
|
+
logDebug('cdp', `Sending ${method}`, {
|
|
216
|
+
tabId: this._debuggee.tabId,
|
|
217
|
+
sessionId,
|
|
218
|
+
});
|
|
219
|
+
if (method === 'Runtime.evaluate') {
|
|
220
|
+
logDebug('cdp', `Runtime.evaluate expression`, {
|
|
221
|
+
expression: params?.expression?.slice(0, 120),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const result = await chrome.debugger.sendCommand(
|
|
226
|
+
debuggerSession,
|
|
227
|
+
method,
|
|
228
|
+
params,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
logDebug('cdp', `Result of ${method}`, {
|
|
232
|
+
hasResult: result !== undefined,
|
|
233
|
+
});
|
|
234
|
+
if (method === 'Runtime.evaluate') {
|
|
235
|
+
logDebug('cdp', 'Runtime.evaluate value', {
|
|
236
|
+
value: result?.result?.value,
|
|
237
|
+
type: result?.result?.type,
|
|
238
|
+
subtype: result?.result?.subtype,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_sendMessage(message) {
|
|
248
|
+
if (this._ws.readyState === WebSocket.OPEN) {
|
|
249
|
+
this._ws.send(JSON.stringify(message));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
class TabShareExtension {
|
|
255
|
+
constructor() {
|
|
256
|
+
this._activeConnections = new Map();
|
|
257
|
+
this._pendingTabSelection = new Map();
|
|
258
|
+
this._tabSessionOwners = new Map();
|
|
259
|
+
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
|
260
|
+
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
|
|
261
|
+
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
|
|
262
|
+
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_onMessage(message, sender, sendResponse) {
|
|
266
|
+
switch (message.type) {
|
|
267
|
+
case 'connectToRelay':
|
|
268
|
+
this._connectToRelay(
|
|
269
|
+
sender.tab?.id,
|
|
270
|
+
message.mcpRelayUrl,
|
|
271
|
+
message.sessionId,
|
|
272
|
+
).then(
|
|
273
|
+
() => sendResponse({success: true}),
|
|
274
|
+
error => sendResponse({success: false, error: error.message}),
|
|
275
|
+
);
|
|
276
|
+
return true;
|
|
277
|
+
case 'getTabs':
|
|
278
|
+
this._getTabs().then(
|
|
279
|
+
tabs =>
|
|
280
|
+
sendResponse({
|
|
281
|
+
success: true,
|
|
282
|
+
tabs,
|
|
283
|
+
currentTabId: sender.tab?.id,
|
|
284
|
+
}),
|
|
285
|
+
error => sendResponse({success: false, error: error.message}),
|
|
286
|
+
);
|
|
287
|
+
return true;
|
|
288
|
+
case 'connectToTab':
|
|
289
|
+
this._connectTab(
|
|
290
|
+
sender.tab?.id,
|
|
291
|
+
message.tabId || sender.tab?.id,
|
|
292
|
+
message.windowId || sender.tab?.windowId,
|
|
293
|
+
message.mcpRelayUrl,
|
|
294
|
+
message.tabUrl,
|
|
295
|
+
message.newTab,
|
|
296
|
+
message.sessionId,
|
|
297
|
+
Boolean(message.allowTabTakeover),
|
|
298
|
+
).then(
|
|
299
|
+
() => sendResponse({success: true}),
|
|
300
|
+
error => sendResponse({success: false, error: error.message}),
|
|
301
|
+
);
|
|
302
|
+
return true;
|
|
303
|
+
case 'disconnect':
|
|
304
|
+
this._disconnect(message.tabId).then(
|
|
305
|
+
() => sendResponse({success: true}),
|
|
306
|
+
error => sendResponse({success: false, error: error.message}),
|
|
307
|
+
);
|
|
308
|
+
return true;
|
|
309
|
+
case 'getDebugLogs':
|
|
310
|
+
this._getDebugLogs(message.filter, message.limit || 100).then(
|
|
311
|
+
payload => sendResponse({success: true, ...payload}),
|
|
312
|
+
error => sendResponse({success: false, error: error.message}),
|
|
313
|
+
);
|
|
314
|
+
return true;
|
|
315
|
+
case 'clearDebugLogs':
|
|
316
|
+
this._clearDebugLogs().then(
|
|
317
|
+
() => sendResponse({success: true}),
|
|
318
|
+
error => sendResponse({success: false, error: error.message}),
|
|
319
|
+
);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
_getPendingKey(selectorTabId, sessionId) {
|
|
326
|
+
if (sessionId) {
|
|
327
|
+
return `session:${sessionId}`;
|
|
328
|
+
}
|
|
329
|
+
return `selector:${selectorTabId}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async _connectToRelay(selectorTabId, mcpRelayUrl, sessionId) {
|
|
333
|
+
if (!mcpRelayUrl) {
|
|
334
|
+
logError('relay', 'Missing relay URL');
|
|
335
|
+
throw new Error('Missing relay URL');
|
|
336
|
+
}
|
|
337
|
+
const pendingKey = this._getPendingKey(selectorTabId, sessionId);
|
|
338
|
+
const existingPending = this._pendingTabSelection.get(pendingKey);
|
|
339
|
+
if (existingPending?.connection) {
|
|
340
|
+
logInfo('relay', 'Replacing stale pending connection', {pendingKey, sessionId, selectorTabId});
|
|
341
|
+
existingPending.connection.close('Pending connection replaced');
|
|
342
|
+
this._pendingTabSelection.delete(pendingKey);
|
|
343
|
+
ensureKeepAliveAlarm('replace-stale-pending');
|
|
344
|
+
}
|
|
345
|
+
logInfo('relay', 'Connecting to relay', {mcpRelayUrl, selectorTabId, sessionId, pendingKey});
|
|
346
|
+
|
|
347
|
+
const openSocket = async attempt => {
|
|
348
|
+
const socket = new WebSocket(mcpRelayUrl);
|
|
349
|
+
await new Promise((resolve, reject) => {
|
|
350
|
+
let settled = false;
|
|
351
|
+
const finish = (handler) => {
|
|
352
|
+
if (settled) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
settled = true;
|
|
356
|
+
clearTimeout(timeoutId);
|
|
357
|
+
handler();
|
|
358
|
+
};
|
|
359
|
+
const timeoutId = setTimeout(() => {
|
|
360
|
+
finish(() => {
|
|
361
|
+
try {
|
|
362
|
+
socket.close();
|
|
363
|
+
} catch {
|
|
364
|
+
// ignore
|
|
365
|
+
}
|
|
366
|
+
reject(new Error('WS_OPEN_TIMEOUT: Connection timeout'));
|
|
367
|
+
});
|
|
368
|
+
}, 5000);
|
|
369
|
+
socket.onopen = () => {
|
|
370
|
+
finish(resolve);
|
|
371
|
+
};
|
|
372
|
+
socket.onerror = () => {
|
|
373
|
+
finish(() => {
|
|
374
|
+
try {
|
|
375
|
+
socket.close();
|
|
376
|
+
} catch {
|
|
377
|
+
// ignore
|
|
378
|
+
}
|
|
379
|
+
reject(new Error(`WS_OPEN_ERROR: WebSocket error (attempt=${attempt + 1})`));
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
socket.onclose = () => {
|
|
383
|
+
finish(() => {
|
|
384
|
+
reject(new Error(`WS_OPEN_CLOSED: Socket closed before open (attempt=${attempt + 1})`));
|
|
385
|
+
});
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
return socket;
|
|
389
|
+
};
|
|
390
|
+
let socket;
|
|
391
|
+
let lastError;
|
|
392
|
+
const maxAttempts = 5;
|
|
393
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
394
|
+
logDebug('relay', `WebSocket attempt ${attempt + 1}/${maxAttempts}`, {mcpRelayUrl});
|
|
395
|
+
try {
|
|
396
|
+
socket = await openSocket(attempt);
|
|
397
|
+
logInfo('relay', 'WebSocket connected', {attempt: attempt + 1});
|
|
398
|
+
break;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
lastError = error;
|
|
401
|
+
logWarn('relay', `WebSocket attempt ${attempt + 1} failed`, {error: error.message});
|
|
402
|
+
if (attempt < maxAttempts - 1) {
|
|
403
|
+
const baseDelay = Math.min(300 * (2 ** attempt), 3000);
|
|
404
|
+
const jitter = Math.floor(Math.random() * 200);
|
|
405
|
+
const waitMs = baseDelay + jitter;
|
|
406
|
+
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (!socket) {
|
|
411
|
+
logError('relay', 'All WebSocket attempts failed', {lastError: lastError?.message});
|
|
412
|
+
throw lastError || new Error('WebSocket error');
|
|
413
|
+
}
|
|
414
|
+
const connection = new RelayConnection(socket);
|
|
415
|
+
connection.onclose = () => {
|
|
416
|
+
logInfo('relay', 'Connection closed', {selectorTabId, sessionId, pendingKey});
|
|
417
|
+
this._pendingTabSelection.delete(pendingKey);
|
|
418
|
+
ensureKeepAliveAlarm('relay-connection-closed');
|
|
419
|
+
};
|
|
420
|
+
this._pendingTabSelection.set(pendingKey, {connection, sessionId, selectorTabId});
|
|
421
|
+
logInfo('relay', 'Relay connection established', {selectorTabId, sessionId, pendingKey});
|
|
422
|
+
ensureKeepAliveAlarm('relay-connected');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async _connectTab(
|
|
426
|
+
selectorTabId,
|
|
427
|
+
tabId,
|
|
428
|
+
windowId,
|
|
429
|
+
mcpRelayUrl,
|
|
430
|
+
tabUrl,
|
|
431
|
+
newTab,
|
|
432
|
+
sessionId,
|
|
433
|
+
allowTabTakeover = false,
|
|
434
|
+
) {
|
|
435
|
+
const pendingKey = this._getPendingKey(selectorTabId, sessionId);
|
|
436
|
+
logInfo('connect', '_connectTab called', {
|
|
437
|
+
selectorTabId,
|
|
438
|
+
tabId,
|
|
439
|
+
tabUrl,
|
|
440
|
+
newTab,
|
|
441
|
+
sessionId,
|
|
442
|
+
allowTabTakeover,
|
|
443
|
+
pendingKey,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
if (!tabId && tabUrl) {
|
|
447
|
+
logDebug('connect', 'Resolving tabId from URL', {tabUrl, newTab});
|
|
448
|
+
tabId = await this._resolveTabId(tabUrl, undefined, newTab);
|
|
449
|
+
}
|
|
450
|
+
if (!tabId) {
|
|
451
|
+
logError('connect', 'No tab selected');
|
|
452
|
+
throw new Error('No tab selected');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const ownerSessionId = this._tabSessionOwners.get(tabId);
|
|
456
|
+
if (
|
|
457
|
+
ownerSessionId &&
|
|
458
|
+
sessionId &&
|
|
459
|
+
ownerSessionId !== sessionId &&
|
|
460
|
+
!allowTabTakeover
|
|
461
|
+
) {
|
|
462
|
+
logWarn('connect', 'Tab already owned by another session', {
|
|
463
|
+
tabId,
|
|
464
|
+
ownerSessionId,
|
|
465
|
+
requestedSessionId: sessionId,
|
|
466
|
+
});
|
|
467
|
+
throw new Error(
|
|
468
|
+
`TAB_LOCKED_BY_OTHER_SESSION: tabId=${tabId} ownerSessionId=${ownerSessionId}`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const existingConnection = this._activeConnections.get(tabId);
|
|
473
|
+
if (existingConnection) {
|
|
474
|
+
logInfo('connect', 'Replacing existing connection', {tabId, sessionId});
|
|
475
|
+
existingConnection.close('Connection replaced for the same tab');
|
|
476
|
+
this._activeConnections.delete(tabId);
|
|
477
|
+
this._tabSessionOwners.delete(tabId);
|
|
478
|
+
await this._setConnectedTab(tabId, false);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const pending = this._pendingTabSelection.get(pendingKey);
|
|
482
|
+
if (!pending) {
|
|
483
|
+
logDebug('connect', 'No pending connection, creating relay', {selectorTabId, sessionId, mcpRelayUrl});
|
|
484
|
+
// If no pending connection, create one now.
|
|
485
|
+
await this._connectToRelay(selectorTabId, mcpRelayUrl, sessionId);
|
|
486
|
+
}
|
|
487
|
+
const newPending = this._pendingTabSelection.get(pendingKey);
|
|
488
|
+
if (!newPending) {
|
|
489
|
+
logError('connect', 'No active MCP relay connection');
|
|
490
|
+
throw new Error('No active MCP relay connection');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (
|
|
494
|
+
sessionId &&
|
|
495
|
+
newPending.sessionId &&
|
|
496
|
+
newPending.sessionId !== sessionId
|
|
497
|
+
) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
`SESSION_MISMATCH_RELAY: expected=${sessionId} actual=${newPending.sessionId}`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
this._pendingTabSelection.delete(pendingKey);
|
|
504
|
+
ensureKeepAliveAlarm('pending-to-active-handoff');
|
|
505
|
+
const connection = newPending.connection;
|
|
506
|
+
connection.setTabId(tabId);
|
|
507
|
+
connection.sendReady(tabId);
|
|
508
|
+
connection.onclose = () => {
|
|
509
|
+
logInfo('connect', 'Tab connection closed', {tabId, sessionId});
|
|
510
|
+
this._activeConnections.delete(tabId);
|
|
511
|
+
const owner = this._tabSessionOwners.get(tabId);
|
|
512
|
+
if (!owner || owner === sessionId) {
|
|
513
|
+
this._tabSessionOwners.delete(tabId);
|
|
514
|
+
}
|
|
515
|
+
void this._setConnectedTab(tabId, false);
|
|
516
|
+
ensureKeepAliveAlarm('active-connection-closed');
|
|
517
|
+
};
|
|
518
|
+
this._activeConnections.set(tabId, connection);
|
|
519
|
+
this._tabSessionOwners.set(tabId, sessionId || `selector:${selectorTabId}`);
|
|
520
|
+
logInfo('connect', 'Tab connected successfully', {tabId, windowId, sessionId});
|
|
521
|
+
ensureKeepAliveAlarm('tab-connected');
|
|
522
|
+
// バッジのみ設定(フォーカスはMCPサーバー側が必要に応じて制御)
|
|
523
|
+
await this._setConnectedTab(tabId, true);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async _resolveTabId(tabUrl, tabId, newTab, active = true) {
|
|
527
|
+
logDebug('resolve', '_resolveTabId called', {tabUrl, tabId, newTab, active});
|
|
528
|
+
|
|
529
|
+
// デバッグ: 全タブの一覧を取得
|
|
530
|
+
const allTabs = await chrome.tabs.query({});
|
|
531
|
+
const tabSummary = allTabs.map(t => ({id: t.id, url: t.url?.slice(0, 60), active: t.active}));
|
|
532
|
+
logInfo('resolve', 'All tabs', {count: allTabs.length, tabs: tabSummary.slice(0, 10)});
|
|
533
|
+
|
|
534
|
+
// Priority 1: If tabId is provided, try to use it directly
|
|
535
|
+
// Note: newTab flag is ignored - always prefer existing tabs to prevent tab spam
|
|
536
|
+
if (tabId) {
|
|
537
|
+
try {
|
|
538
|
+
const tab = await chrome.tabs.get(tabId);
|
|
539
|
+
if (tab && tabUrl) {
|
|
540
|
+
const urlObj = new URL(tabUrl);
|
|
541
|
+
// Check if the tab's URL matches the expected hostname
|
|
542
|
+
if (tab.url && tab.url.includes(urlObj.hostname)) {
|
|
543
|
+
logInfo('resolve', 'Reusing tab by tabId', {tabId, url: tab.url});
|
|
544
|
+
return tabId;
|
|
545
|
+
}
|
|
546
|
+
logDebug('resolve', 'Tab URL mismatch, continuing search', {
|
|
547
|
+
tabId,
|
|
548
|
+
expectedHost: urlObj.hostname,
|
|
549
|
+
actualUrl: tab.url
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
} catch (error) {
|
|
553
|
+
logDebug('resolve', 'Tab not found by tabId (may be closed)', {tabId, error: error.message});
|
|
554
|
+
// Tab may have been closed, continue with URL-based search
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Priority 2: Search by URL pattern
|
|
559
|
+
try {
|
|
560
|
+
const urlObj = new URL(tabUrl);
|
|
561
|
+
const pattern = `*://${urlObj.hostname}${urlObj.pathname}*`;
|
|
562
|
+
const tabs = await chrome.tabs.query({url: pattern});
|
|
563
|
+
logDebug('resolve', `Found ${tabs.length} matching tabs`, {pattern, tabCount: tabs.length});
|
|
564
|
+
// Note: newTab flag is ignored - always prefer existing tabs to prevent tab spam
|
|
565
|
+
if (tabs.length) {
|
|
566
|
+
// Prefer active tab, then the most recently accessed
|
|
567
|
+
const activeTab = tabs.find(tab => tab.active);
|
|
568
|
+
const selectedTab = activeTab || tabs[0];
|
|
569
|
+
logInfo('resolve', 'Reusing existing tab by URL', {tabId: selectedTab.id, url: selectedTab.url});
|
|
570
|
+
return selectedTab.id;
|
|
571
|
+
}
|
|
572
|
+
} catch (error) {
|
|
573
|
+
logWarn('resolve', 'Error querying tabs', {error: error.message});
|
|
574
|
+
// ignore
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Priority 3: Create new tab
|
|
578
|
+
if (!tabUrl) {
|
|
579
|
+
logWarn('resolve', 'No tabUrl provided');
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
logInfo('resolve', 'Creating new tab', {url: tabUrl, active});
|
|
583
|
+
const created = await chrome.tabs.create({url: tabUrl, active});
|
|
584
|
+
logInfo('resolve', 'New tab created', {tabId: created.id, active});
|
|
585
|
+
return created.id;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async _getTabs() {
|
|
589
|
+
const tabs = await chrome.tabs.query({});
|
|
590
|
+
return tabs.filter(
|
|
591
|
+
tab =>
|
|
592
|
+
tab.url &&
|
|
593
|
+
!['chrome:', 'edge:', 'devtools:'].some(scheme =>
|
|
594
|
+
tab.url.startsWith(scheme),
|
|
595
|
+
),
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async _getDebugLogs(filter, limit) {
|
|
600
|
+
const result = await chrome.storage.local.get('logs');
|
|
601
|
+
const rawLogs = Array.isArray(result.logs) ? result.logs : [];
|
|
602
|
+
const normalized = rawLogs.map(logEntry => ({
|
|
603
|
+
ts: logEntry.timestamp || logEntry.ts || new Date().toISOString(),
|
|
604
|
+
category: logEntry.category || 'unknown',
|
|
605
|
+
message: logEntry.message || '',
|
|
606
|
+
data: logEntry.data ?? null,
|
|
607
|
+
level: logEntry.level || 'INFO',
|
|
608
|
+
}));
|
|
609
|
+
|
|
610
|
+
const filtered = filter
|
|
611
|
+
? normalized.filter(logEntry => logEntry.category === filter)
|
|
612
|
+
: normalized;
|
|
613
|
+
|
|
614
|
+
const byCategory = {};
|
|
615
|
+
for (const logEntry of normalized) {
|
|
616
|
+
byCategory[logEntry.category] = (byCategory[logEntry.category] || 0) + 1;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
logs: filtered.slice(-limit),
|
|
621
|
+
stats: {
|
|
622
|
+
total: normalized.length,
|
|
623
|
+
byCategory,
|
|
624
|
+
},
|
|
625
|
+
state: {
|
|
626
|
+
activeConnections: Array.from(this._activeConnections.keys()),
|
|
627
|
+
pendingTabSelection: Array.from(this._pendingTabSelection.keys()),
|
|
628
|
+
tabSessionOwners: Object.fromEntries(this._tabSessionOwners.entries()),
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async _clearDebugLogs() {
|
|
634
|
+
await chrome.storage.local.set({logs: []});
|
|
635
|
+
logInfo('debug', 'Debug logs cleared');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async _setConnectedTab(tabId, connected) {
|
|
639
|
+
if (!tabId) return;
|
|
640
|
+
try {
|
|
641
|
+
if (connected) {
|
|
642
|
+
await chrome.action.setBadgeText({tabId, text: '✓'});
|
|
643
|
+
await chrome.action.setBadgeBackgroundColor({
|
|
644
|
+
tabId,
|
|
645
|
+
color: '#4CAF50',
|
|
646
|
+
});
|
|
647
|
+
} else {
|
|
648
|
+
await chrome.action.setBadgeText({tabId, text: ''});
|
|
649
|
+
}
|
|
650
|
+
} catch {
|
|
651
|
+
// Tab no longer exists, ignore
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async _disconnect(tabId) {
|
|
656
|
+
if (tabId) {
|
|
657
|
+
const connection = this._activeConnections.get(tabId);
|
|
658
|
+
if (connection) connection.close('User disconnected');
|
|
659
|
+
this._activeConnections.delete(tabId);
|
|
660
|
+
this._tabSessionOwners.delete(tabId);
|
|
661
|
+
await this._setConnectedTab(tabId, false);
|
|
662
|
+
ensureKeepAliveAlarm('disconnect-single-tab');
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
for (const [connectedTabId, connection] of this._activeConnections) {
|
|
666
|
+
connection.close('User disconnected');
|
|
667
|
+
await this._setConnectedTab(connectedTabId, false);
|
|
668
|
+
this._tabSessionOwners.delete(connectedTabId);
|
|
669
|
+
}
|
|
670
|
+
this._activeConnections.clear();
|
|
671
|
+
this._pendingTabSelection.clear();
|
|
672
|
+
this._tabSessionOwners.clear();
|
|
673
|
+
ensureKeepAliveAlarm('disconnect-all');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
_onTabRemoved(tabId) {
|
|
677
|
+
for (const [pendingKey, pending] of this._pendingTabSelection) {
|
|
678
|
+
if (pending.selectorTabId === tabId) {
|
|
679
|
+
this._pendingTabSelection.delete(pendingKey);
|
|
680
|
+
pending.connection.close('Browser tab closed');
|
|
681
|
+
ensureKeepAliveAlarm('pending-tab-removed');
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const active = this._activeConnections.get(tabId);
|
|
685
|
+
if (active) {
|
|
686
|
+
active.close('Browser tab closed');
|
|
687
|
+
this._activeConnections.delete(tabId);
|
|
688
|
+
}
|
|
689
|
+
this._tabSessionOwners.delete(tabId);
|
|
690
|
+
ensureKeepAliveAlarm('active-tab-removed');
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
_onTabActivated(activeInfo) {
|
|
694
|
+
for (const [pendingKey, pending] of this._pendingTabSelection) {
|
|
695
|
+
if (typeof pending.selectorTabId !== 'number') continue;
|
|
696
|
+
if (pending.selectorTabId === activeInfo.tabId) continue;
|
|
697
|
+
if (!pending.timerId) {
|
|
698
|
+
pending.timerId = setTimeout(() => {
|
|
699
|
+
const existed = this._pendingTabSelection.delete(pendingKey);
|
|
700
|
+
if (existed) {
|
|
701
|
+
pending.connection.close('Tab inactive for 30 seconds');
|
|
702
|
+
chrome.tabs.sendMessage(pending.selectorTabId, {type: 'connectionTimeout'});
|
|
703
|
+
}
|
|
704
|
+
}, 30000);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
_onTabUpdated(tabId) {
|
|
710
|
+
if (this._activeConnections.has(tabId)) {
|
|
711
|
+
void this._setConnectedTab(tabId, true);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const tabShareExtension = new TabShareExtension();
|
|
717
|
+
|
|
718
|
+
const DISCOVERY_ALARM = 'mcp-relay-discovery';
|
|
719
|
+
const KEEPALIVE_ALARM = 'keepAlive';
|
|
720
|
+
const KEEPALIVE_PERIOD_MINUTES = 0.5;
|
|
721
|
+
const DISCOVERY_PORTS = [38765, 38766, 38767, 38768, 38769, 38770, 38771, 38772, 38773, 38774, 38775];
|
|
722
|
+
const DISCOVERY_MODE = {
|
|
723
|
+
FAST: 'fast',
|
|
724
|
+
NORMAL: 'normal',
|
|
725
|
+
IDLE: 'idle',
|
|
726
|
+
};
|
|
727
|
+
const DISCOVERY_INTERVAL_MS = {
|
|
728
|
+
[DISCOVERY_MODE.FAST]: 500,
|
|
729
|
+
[DISCOVERY_MODE.NORMAL]: 3000,
|
|
730
|
+
[DISCOVERY_MODE.IDLE]: 15000,
|
|
731
|
+
};
|
|
732
|
+
const FAST_TO_NORMAL_EMPTY_STREAK = 5;
|
|
733
|
+
const NORMAL_TO_IDLE_EMPTY_STREAK = 20;
|
|
734
|
+
const ACTIVE_TO_IDLE_EMPTY_STREAK = 3;
|
|
735
|
+
let lastSuccessfulPort = null;
|
|
736
|
+
const lastRelayByPort = new Map();
|
|
737
|
+
|
|
738
|
+
// Interval管理: 重複防止
|
|
739
|
+
let discoveryIntervalId = null;
|
|
740
|
+
|
|
741
|
+
// 並列実行防止: autoOpenConnectUiが実行中かどうか
|
|
742
|
+
let isDiscoveryRunning = false;
|
|
743
|
+
let discoveryMode = DISCOVERY_MODE.FAST;
|
|
744
|
+
let emptyDiscoveryStreak = 0;
|
|
745
|
+
let keepAliveActive = false;
|
|
746
|
+
|
|
747
|
+
// リロード時クールダウン: 5秒間は「新しいrelay」検出をスキップ
|
|
748
|
+
const extensionStartTime = Date.now();
|
|
749
|
+
const COOLDOWN_MS = 5000;
|
|
750
|
+
|
|
751
|
+
// ユーザー操作によるDiscoveryかどうかのフラグ
|
|
752
|
+
// Chrome起動時やService Worker再起動時はfalse、アイコンクリック時のみtrue
|
|
753
|
+
let userTriggeredDiscovery = false;
|
|
754
|
+
let userTriggeredDiscoveryUntil = 0;
|
|
755
|
+
|
|
756
|
+
function isUserTriggeredDiscoveryActive() {
|
|
757
|
+
if (!userTriggeredDiscovery) {
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
if (Date.now() > userTriggeredDiscoveryUntil) {
|
|
761
|
+
userTriggeredDiscovery = false;
|
|
762
|
+
userTriggeredDiscoveryUntil = 0;
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function getConnectionCounts() {
|
|
769
|
+
return {
|
|
770
|
+
activeCount: tabShareExtension._activeConnections.size,
|
|
771
|
+
pendingCount: tabShareExtension._pendingTabSelection.size,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function shouldKeepAlive() {
|
|
776
|
+
const {activeCount, pendingCount} = getConnectionCounts();
|
|
777
|
+
return (
|
|
778
|
+
activeCount > 0 ||
|
|
779
|
+
pendingCount > 0 ||
|
|
780
|
+
discoveryIntervalId !== null ||
|
|
781
|
+
isDiscoveryRunning
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function ensureKeepAliveAlarm(reason = 'state-change') {
|
|
786
|
+
const needed = shouldKeepAlive();
|
|
787
|
+
if (needed === keepAliveActive) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const {activeCount, pendingCount} = getConnectionCounts();
|
|
791
|
+
if (needed) {
|
|
792
|
+
chrome.alarms.create(KEEPALIVE_ALARM, {periodInMinutes: KEEPALIVE_PERIOD_MINUTES});
|
|
793
|
+
keepAliveActive = true;
|
|
794
|
+
logInfo('keepalive', 'Enabled keepAlive alarm', {reason, activeCount, pendingCount});
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
chrome.alarms.clear(KEEPALIVE_ALARM).catch(() => {
|
|
798
|
+
// Ignore errors - alarm may not exist.
|
|
799
|
+
});
|
|
800
|
+
keepAliveActive = false;
|
|
801
|
+
logInfo('keepalive', 'Disabled keepAlive alarm', {reason, activeCount, pendingCount});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function getDiscoveryIntervalMs(mode = discoveryMode) {
|
|
805
|
+
return DISCOVERY_INTERVAL_MS[mode] || DISCOVERY_INTERVAL_MS[DISCOVERY_MODE.FAST];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function setDiscoveryMode(nextMode, reason) {
|
|
809
|
+
if (discoveryMode === nextMode) {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const previousMode = discoveryMode;
|
|
813
|
+
discoveryMode = nextMode;
|
|
814
|
+
logInfo('discovery', 'Discovery mode changed', {
|
|
815
|
+
from: previousMode,
|
|
816
|
+
to: nextMode,
|
|
817
|
+
reason,
|
|
818
|
+
intervalMs: getDiscoveryIntervalMs(nextMode),
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function getDiscoveryPortsByPriority() {
|
|
823
|
+
if (!lastSuccessfulPort || !DISCOVERY_PORTS.includes(lastSuccessfulPort)) {
|
|
824
|
+
return DISCOVERY_PORTS;
|
|
825
|
+
}
|
|
826
|
+
return [
|
|
827
|
+
lastSuccessfulPort,
|
|
828
|
+
...DISCOVERY_PORTS.filter(port => port !== lastSuccessfulPort),
|
|
829
|
+
];
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
function buildConnectUrl(
|
|
834
|
+
wsUrl,
|
|
835
|
+
tabUrl,
|
|
836
|
+
newTab,
|
|
837
|
+
autoMode = false,
|
|
838
|
+
sessionId,
|
|
839
|
+
allowTabTakeover = false,
|
|
840
|
+
) {
|
|
841
|
+
const params = new URLSearchParams({mcpRelayUrl: wsUrl});
|
|
842
|
+
if (tabUrl) params.set('tabUrl', tabUrl);
|
|
843
|
+
if (newTab) params.set('newTab', 'true');
|
|
844
|
+
if (autoMode) params.set('auto', 'true');
|
|
845
|
+
if (sessionId) params.set('sessionId', sessionId);
|
|
846
|
+
if (allowTabTakeover) params.set('allowTabTakeover', 'true');
|
|
847
|
+
return chrome.runtime.getURL(`ui/connect.html?${params.toString()}`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function focusTab(tabId, windowId) {
|
|
851
|
+
try {
|
|
852
|
+
if (windowId) {
|
|
853
|
+
await chrome.windows.update(windowId, {focused: true});
|
|
854
|
+
}
|
|
855
|
+
await chrome.tabs.update(tabId, {active: true});
|
|
856
|
+
} catch {
|
|
857
|
+
// Ignore transient tab editing errors (e.g. user dragging tabs).
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function getExistingConnectTab() {
|
|
862
|
+
const connectBase = chrome.runtime.getURL('ui/connect.html');
|
|
863
|
+
const tabs = await chrome.tabs.query({url: `${connectBase}*`});
|
|
864
|
+
if (!tabs.length) return false;
|
|
865
|
+
const tab = tabs[0];
|
|
866
|
+
if (!tab?.id) return false;
|
|
867
|
+
return tab;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function ensureConnectUiTab(
|
|
871
|
+
wsUrl,
|
|
872
|
+
tabUrl,
|
|
873
|
+
newTab,
|
|
874
|
+
autoMode = false,
|
|
875
|
+
sessionId,
|
|
876
|
+
allowTabTakeover = false,
|
|
877
|
+
) {
|
|
878
|
+
const existing = await getExistingConnectTab();
|
|
879
|
+
if (existing?.id) {
|
|
880
|
+
await focusTab(existing.id, existing.windowId);
|
|
881
|
+
return existing;
|
|
882
|
+
}
|
|
883
|
+
const url = buildConnectUrl(
|
|
884
|
+
wsUrl,
|
|
885
|
+
tabUrl,
|
|
886
|
+
newTab,
|
|
887
|
+
autoMode,
|
|
888
|
+
sessionId,
|
|
889
|
+
allowTabTakeover,
|
|
890
|
+
);
|
|
891
|
+
const created = await chrome.tabs.create({url, active: true});
|
|
892
|
+
if (created?.id) {
|
|
893
|
+
await focusTab(created.id, created.windowId);
|
|
894
|
+
}
|
|
895
|
+
return created;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function fetchRelayInfo(port, timeoutMs = 800) {
|
|
899
|
+
const discoveryUrl = `http://127.0.0.1:${port}/relay-info`;
|
|
900
|
+
let timer = null;
|
|
901
|
+
try {
|
|
902
|
+
const controller = new AbortController();
|
|
903
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
904
|
+
const res = await fetch(discoveryUrl, {signal: controller.signal});
|
|
905
|
+
if (!res.ok) return null;
|
|
906
|
+
const data = await res.json();
|
|
907
|
+
if (!data?.wsUrl) return null;
|
|
908
|
+
lastSuccessfulPort = port;
|
|
909
|
+
return data;
|
|
910
|
+
} catch {
|
|
911
|
+
return null;
|
|
912
|
+
} finally {
|
|
913
|
+
if (timer) {
|
|
914
|
+
clearTimeout(timer);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function autoConnectRelay(best) {
|
|
920
|
+
const tabUrl = best?.data?.tabUrl;
|
|
921
|
+
const preferredTabId = best?.data?.tabId;
|
|
922
|
+
logDebug('auto-connect', 'autoConnectRelay called', {port: best?.port, tabUrl, tabId: preferredTabId, newTab: best?.data?.newTab});
|
|
923
|
+
|
|
924
|
+
if (!tabUrl) {
|
|
925
|
+
logDebug('auto-connect', 'No tabUrl, skipping');
|
|
926
|
+
return false; // tabUrl がなければ失敗
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (best?.port) {
|
|
930
|
+
const refreshed = await fetchRelayInfo(best.port, 400);
|
|
931
|
+
if (refreshed?.wsUrl) {
|
|
932
|
+
best.data = refreshed;
|
|
933
|
+
lastSuccessfulPort = best.port;
|
|
934
|
+
logDebug('auto-connect', 'Refreshed relay info', {wsUrl: refreshed.wsUrl, tabId: refreshed.tabId});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// tabUrl があれば、connect.html を開かずに直接接続
|
|
939
|
+
// preferredTabId があれば優先的に使用
|
|
940
|
+
let targetTabId;
|
|
941
|
+
try {
|
|
942
|
+
// autoConnectRelay経由の場合はフォーカスしない(active: false)
|
|
943
|
+
// リロード時に勝手にタブがフォーカスされる問題を防ぐ
|
|
944
|
+
// newTab: false に固定 - 自動接続では既存タブを優先してタブスパムを防止
|
|
945
|
+
targetTabId = await tabShareExtension._resolveTabId(
|
|
946
|
+
tabUrl,
|
|
947
|
+
preferredTabId,
|
|
948
|
+
false, // newTab: false - always prefer existing tabs in auto-connect
|
|
949
|
+
false, // active: false - 自動接続時はタブをフォーカスしない
|
|
950
|
+
);
|
|
951
|
+
} catch (error) {
|
|
952
|
+
logError('auto-connect', 'Failed to resolve tab', {tabUrl, tabId: preferredTabId, error: error.message});
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
if (!targetTabId) {
|
|
956
|
+
logWarn('auto-connect', 'No targetTabId resolved');
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
if (tabShareExtension._activeConnections?.has(targetTabId)) {
|
|
960
|
+
logInfo('auto-connect', 'Tab already connected', {targetTabId});
|
|
961
|
+
return true; // 既に接続済み
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const targetTab = await chrome.tabs.get(targetTabId).catch(() => null);
|
|
965
|
+
|
|
966
|
+
const sessionId = best?.data?.sessionId || null;
|
|
967
|
+
// selectorId は後方互換のため保持。sessionId がある場合は session 軸を優先する。
|
|
968
|
+
const selectorId = `auto:${best.data.wsUrl}`;
|
|
969
|
+
logInfo('auto-connect', 'Attempting auto-connect', {
|
|
970
|
+
selectorId,
|
|
971
|
+
sessionId,
|
|
972
|
+
targetTabId,
|
|
973
|
+
wsUrl: best.data.wsUrl,
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
try {
|
|
977
|
+
await tabShareExtension._connectToRelay(selectorId, best.data.wsUrl, sessionId);
|
|
978
|
+
await tabShareExtension._connectTab(
|
|
979
|
+
selectorId,
|
|
980
|
+
targetTabId,
|
|
981
|
+
targetTab?.windowId,
|
|
982
|
+
best.data.wsUrl,
|
|
983
|
+
tabUrl,
|
|
984
|
+
Boolean(best.data.newTab),
|
|
985
|
+
sessionId,
|
|
986
|
+
Boolean(best.data.allowTabTakeover),
|
|
987
|
+
);
|
|
988
|
+
logInfo('auto-connect', 'Auto-connect successful', {targetTabId, tabUrl});
|
|
989
|
+
if (best?.port) {
|
|
990
|
+
lastSuccessfulPort = best.port;
|
|
991
|
+
}
|
|
992
|
+
} catch (err) {
|
|
993
|
+
logError('auto-connect', 'autoConnectRelay failed', {error: err.message, tabUrl});
|
|
994
|
+
debugLog('autoConnectRelay failed:', err);
|
|
995
|
+
if (best?.port) {
|
|
996
|
+
lastRelayByPort.delete(best.port);
|
|
997
|
+
}
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async function autoOpenConnectUi() {
|
|
1004
|
+
const result = {
|
|
1005
|
+
skippedCooldown: false,
|
|
1006
|
+
newRelayCount: 0,
|
|
1007
|
+
successCount: 0,
|
|
1008
|
+
failureCount: 0,
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
// リロード直後はタブを開かない(既存MCPサーバーとの再接続を防ぐ)
|
|
1012
|
+
const elapsed = Date.now() - extensionStartTime;
|
|
1013
|
+
if (elapsed < COOLDOWN_MS) {
|
|
1014
|
+
logDebug('discovery', `Cooldown active (${elapsed}ms < ${COOLDOWN_MS}ms), skipping`);
|
|
1015
|
+
result.skippedCooldown = true;
|
|
1016
|
+
return result;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// 複数の relay を同時にサポート(ChatGPT + Gemini)
|
|
1020
|
+
const newRelays = [];
|
|
1021
|
+
const portsToCheck = getDiscoveryPortsByPriority();
|
|
1022
|
+
for (const port of portsToCheck) {
|
|
1023
|
+
const timeoutMs = port === lastSuccessfulPort ? 250 : 800;
|
|
1024
|
+
const data = await fetchRelayInfo(port, timeoutMs);
|
|
1025
|
+
if (!data?.wsUrl) {
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const last = lastRelayByPort.get(port);
|
|
1030
|
+
const startedAt = data.startedAt || 0;
|
|
1031
|
+
const instanceId = data.instanceId || '';
|
|
1032
|
+
if (
|
|
1033
|
+
last &&
|
|
1034
|
+
last.wsUrl === data.wsUrl &&
|
|
1035
|
+
last.startedAt === startedAt &&
|
|
1036
|
+
last.instanceId === instanceId
|
|
1037
|
+
) {
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
logInfo('discovery', 'New relay detected', {port, tabUrl: data.tabUrl, wsUrl: data.wsUrl});
|
|
1042
|
+
lastRelayByPort.set(port, {
|
|
1043
|
+
wsUrl: data.wsUrl,
|
|
1044
|
+
startedAt,
|
|
1045
|
+
instanceId,
|
|
1046
|
+
});
|
|
1047
|
+
newRelays.push({port, data});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
result.newRelayCount = newRelays.length;
|
|
1051
|
+
|
|
1052
|
+
if (newRelays.length > 0) {
|
|
1053
|
+
logInfo('discovery', `Processing ${newRelays.length} new relay(s)`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// 全ての新しい relay を処理(並列ではなく順次)
|
|
1057
|
+
for (const relay of newRelays) {
|
|
1058
|
+
logInfo('discovery', 'Processing relay', {port: relay.port, tabUrl: relay.data.tabUrl});
|
|
1059
|
+
debugLog('Processing new relay:', relay.port, relay.data.tabUrl);
|
|
1060
|
+
let ok = false;
|
|
1061
|
+
try {
|
|
1062
|
+
ok = await autoConnectRelay(relay);
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
logError('discovery', 'autoConnectRelay error', {error: err.message, port: relay.port});
|
|
1065
|
+
debugLog('autoConnectRelay error:', err);
|
|
1066
|
+
ok = false;
|
|
1067
|
+
}
|
|
1068
|
+
if (!ok) {
|
|
1069
|
+
result.failureCount += 1;
|
|
1070
|
+
const userTriggered = isUserTriggeredDiscoveryActive();
|
|
1071
|
+
// Only open connect.html when user explicitly clicked the extension icon
|
|
1072
|
+
// This prevents tab spam on Chrome restart, Service Worker restart, etc.
|
|
1073
|
+
if (userTriggered) {
|
|
1074
|
+
logInfo('discovery', 'Opening connect UI', {
|
|
1075
|
+
port: relay.port,
|
|
1076
|
+
tabUrl: relay.data.tabUrl
|
|
1077
|
+
});
|
|
1078
|
+
await ensureConnectUiTab(
|
|
1079
|
+
relay.data.wsUrl,
|
|
1080
|
+
relay.data.tabUrl || undefined,
|
|
1081
|
+
Boolean(relay.data.newTab),
|
|
1082
|
+
false,
|
|
1083
|
+
relay.data.sessionId || undefined,
|
|
1084
|
+
Boolean(relay.data.allowTabTakeover),
|
|
1085
|
+
);
|
|
1086
|
+
userTriggeredDiscovery = false; // Reset after opening
|
|
1087
|
+
userTriggeredDiscoveryUntil = 0;
|
|
1088
|
+
} else {
|
|
1089
|
+
logDebug('discovery', 'Skipping connect UI (auto mode)', {
|
|
1090
|
+
port: relay.port,
|
|
1091
|
+
tabUrl: relay.data.tabUrl
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
result.successCount += 1;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (newRelays.length > 0) {
|
|
1100
|
+
userTriggeredDiscovery = false;
|
|
1101
|
+
userTriggeredDiscoveryUntil = 0;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return result;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Discovery is now passive - only triggered by MCP server requests
|
|
1108
|
+
// The extension no longer auto-opens tabs on install/startup
|
|
1109
|
+
// MCPサーバーからの明示的な接続要求時のみ動作する
|
|
1110
|
+
|
|
1111
|
+
// Clear any existing discovery alarms from previous sessions
|
|
1112
|
+
// This prevents leftover alarms from auto-opening tabs
|
|
1113
|
+
chrome.alarms.clear(DISCOVERY_ALARM).then(() => {
|
|
1114
|
+
logInfo('background', 'Cleared existing discovery alarm (if any)');
|
|
1115
|
+
}).catch(() => {
|
|
1116
|
+
// Ignore errors - alarm may not exist
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
function updateDiscoveryMode(result) {
|
|
1120
|
+
const {activeCount, pendingCount} = getConnectionCounts();
|
|
1121
|
+
const hasRelayActivity =
|
|
1122
|
+
result.newRelayCount > 0 ||
|
|
1123
|
+
result.successCount > 0 ||
|
|
1124
|
+
result.failureCount > 0;
|
|
1125
|
+
|
|
1126
|
+
if (pendingCount > 0 || hasRelayActivity) {
|
|
1127
|
+
emptyDiscoveryStreak = 0;
|
|
1128
|
+
setDiscoveryMode(
|
|
1129
|
+
DISCOVERY_MODE.FAST,
|
|
1130
|
+
pendingCount > 0 ? 'pending-connections' : 'relay-activity',
|
|
1131
|
+
);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (result.skippedCooldown) {
|
|
1136
|
+
setDiscoveryMode(DISCOVERY_MODE.FAST, 'cooldown');
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
emptyDiscoveryStreak += 1;
|
|
1141
|
+
|
|
1142
|
+
if (activeCount > 0 && emptyDiscoveryStreak >= ACTIVE_TO_IDLE_EMPTY_STREAK) {
|
|
1143
|
+
setDiscoveryMode(DISCOVERY_MODE.IDLE, 'stable-active-connections');
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
if (emptyDiscoveryStreak >= NORMAL_TO_IDLE_EMPTY_STREAK) {
|
|
1148
|
+
setDiscoveryMode(DISCOVERY_MODE.IDLE, 'long-idle');
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (emptyDiscoveryStreak >= FAST_TO_NORMAL_EMPTY_STREAK) {
|
|
1153
|
+
setDiscoveryMode(DISCOVERY_MODE.NORMAL, 'no-new-relays');
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
setDiscoveryMode(DISCOVERY_MODE.FAST, 'probing');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function scheduleDiscoveryTick(delayMs) {
|
|
1161
|
+
if (discoveryIntervalId !== null) {
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
discoveryIntervalId = setTimeout(async () => {
|
|
1165
|
+
discoveryIntervalId = null;
|
|
1166
|
+
|
|
1167
|
+
if (isDiscoveryRunning) {
|
|
1168
|
+
scheduleDiscoveryTick(getDiscoveryIntervalMs());
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
isDiscoveryRunning = true;
|
|
1173
|
+
try {
|
|
1174
|
+
const result = await autoOpenConnectUi();
|
|
1175
|
+
updateDiscoveryMode(result);
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
logWarn('discovery', 'Discovery cycle failed', {
|
|
1178
|
+
error: error?.message || String(error),
|
|
1179
|
+
});
|
|
1180
|
+
emptyDiscoveryStreak = 0;
|
|
1181
|
+
setDiscoveryMode(DISCOVERY_MODE.FAST, 'cycle-error');
|
|
1182
|
+
} finally {
|
|
1183
|
+
isDiscoveryRunning = false;
|
|
1184
|
+
ensureKeepAliveAlarm('discovery-cycle');
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
scheduleDiscoveryTick(getDiscoveryIntervalMs());
|
|
1188
|
+
}, Math.max(0, delayMs));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function scheduleDiscovery() {
|
|
1192
|
+
if (discoveryIntervalId !== null || isDiscoveryRunning) {
|
|
1193
|
+
logDebug('discovery', 'Discovery already scheduled', {
|
|
1194
|
+
mode: discoveryMode,
|
|
1195
|
+
intervalMs: getDiscoveryIntervalMs(),
|
|
1196
|
+
});
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
logInfo('discovery', 'Starting discovery scheduler', {
|
|
1201
|
+
mode: discoveryMode,
|
|
1202
|
+
intervalMs: getDiscoveryIntervalMs(),
|
|
1203
|
+
});
|
|
1204
|
+
scheduleDiscoveryTick(0);
|
|
1205
|
+
ensureKeepAliveAlarm('discovery-scheduled');
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function kickDiscovery(reason) {
|
|
1209
|
+
emptyDiscoveryStreak = 0;
|
|
1210
|
+
setDiscoveryMode(DISCOVERY_MODE.FAST, reason);
|
|
1211
|
+
if (discoveryIntervalId !== null) {
|
|
1212
|
+
clearTimeout(discoveryIntervalId);
|
|
1213
|
+
discoveryIntervalId = null;
|
|
1214
|
+
}
|
|
1215
|
+
scheduleDiscovery();
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
1219
|
+
if (alarm.name === KEEPALIVE_ALARM) {
|
|
1220
|
+
const {activeCount, pendingCount} = getConnectionCounts();
|
|
1221
|
+
if (activeCount > 0 || pendingCount > 0) {
|
|
1222
|
+
logDebug('keepalive', 'Alarm triggered', {activeCount, pendingCount});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (!shouldKeepAlive()) {
|
|
1226
|
+
ensureKeepAliveAlarm('alarm-prune');
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (discoveryIntervalId === null && !isDiscoveryRunning) {
|
|
1231
|
+
logInfo('keepalive', 'Re-arming discovery scheduler after wake');
|
|
1232
|
+
scheduleDiscovery();
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// Note: We no longer register an onAlarm listener for DISCOVERY_ALARM
|
|
1238
|
+
// The scheduleDiscovery function is only called on explicit MCP requests
|
|
1239
|
+
|
|
1240
|
+
// Discovery auto-starts on Chrome startup
|
|
1241
|
+
// connect.html only opens when user clicks the extension icon
|
|
1242
|
+
// This prevents tab spam on Chrome restart and Service Worker restart
|
|
1243
|
+
|
|
1244
|
+
// Start discovery when user clicks extension icon
|
|
1245
|
+
chrome.action.onClicked.addListener(() => {
|
|
1246
|
+
logInfo('action', 'Extension icon clicked - starting discovery');
|
|
1247
|
+
userTriggeredDiscovery = true; // ユーザーが明示的にトリガー
|
|
1248
|
+
userTriggeredDiscoveryUntil = Date.now() + 15000;
|
|
1249
|
+
kickDiscovery('user-click');
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
// Auto-start discovery on install/startup
|
|
1253
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
1254
|
+
logInfo('background', 'Extension installed - starting discovery');
|
|
1255
|
+
scheduleDiscovery();
|
|
1256
|
+
});
|
|
1257
|
+
chrome.runtime.onStartup.addListener(() => {
|
|
1258
|
+
logInfo('background', 'Chrome started - starting discovery');
|
|
1259
|
+
scheduleDiscovery();
|
|
1260
|
+
});
|
|
1261
|
+
scheduleDiscovery(); // Start immediately
|
|
1262
|
+
|
|
1263
|
+
logInfo('background', 'Extension loaded (discovery active)');
|