@agent360/browser-mcp 1.13.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 +169 -0
- package/bin/cli.js +90 -0
- package/extension/background.js +1281 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +40 -0
- package/extension/offscreen.html +6 -0
- package/extension/offscreen.js +97 -0
- package/extension/popup.html +65 -0
- package/extension/popup.js +96 -0
- package/index.js +336 -0
- package/package.json +42 -0
- package/tools.js +319 -0
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent360 Browser MCP — Background Service Worker
|
|
3
|
+
*
|
|
4
|
+
* Handles Chrome API calls relayed from the offscreen document.
|
|
5
|
+
* Each MCP session (port) gets its own Chrome Tab Group with color coding.
|
|
6
|
+
* Tabs are isolated per session — no cross-session interference.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ── Session Tab Management ─────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const SESSION_COLORS = ['blue', 'green', 'yellow', 'red', 'pink', 'purple', 'cyan', 'orange'];
|
|
12
|
+
const sessions = new Map(); // port → { tabIds: Set, groupId: number|null, color: string, label: string }
|
|
13
|
+
let sessionsLoaded = false;
|
|
14
|
+
|
|
15
|
+
// Restore sessions from storage (service workers lose in-memory state on suspend)
|
|
16
|
+
async function restoreSessions() {
|
|
17
|
+
if (sessionsLoaded) return;
|
|
18
|
+
sessionsLoaded = true;
|
|
19
|
+
const { sessions: saved } = await chrome.storage.local.get({ sessions: {} });
|
|
20
|
+
for (const [port, data] of Object.entries(saved)) {
|
|
21
|
+
// Verify tabs still exist
|
|
22
|
+
const validTabIds = new Set();
|
|
23
|
+
for (const tabId of (data.tabIds || [])) {
|
|
24
|
+
try {
|
|
25
|
+
await chrome.tabs.get(tabId);
|
|
26
|
+
validTabIds.add(tabId);
|
|
27
|
+
} catch {} // tab no longer exists
|
|
28
|
+
}
|
|
29
|
+
if (validTabIds.size > 0) {
|
|
30
|
+
const activeTabId = data.activeTabId && validTabIds.has(data.activeTabId) ? data.activeTabId : null;
|
|
31
|
+
sessions.set(Number(port), {
|
|
32
|
+
tabIds: validTabIds,
|
|
33
|
+
activeTabId,
|
|
34
|
+
groupId: data.groupId || null,
|
|
35
|
+
color: data.color || SESSION_COLORS[sessions.size % SESSION_COLORS.length],
|
|
36
|
+
label: data.label || `Claude ${sessions.size + 1}`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getSession(port) {
|
|
43
|
+
if (!sessions.has(port)) {
|
|
44
|
+
const idx = sessions.size % SESSION_COLORS.length;
|
|
45
|
+
sessions.set(port, {
|
|
46
|
+
tabIds: new Set(),
|
|
47
|
+
activeTabId: null,
|
|
48
|
+
groupId: null,
|
|
49
|
+
color: SESSION_COLORS[idx],
|
|
50
|
+
label: `Claude ${sessions.size + 1}`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return sessions.get(port);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function addTabToSession(port, tabId) {
|
|
57
|
+
const session = getSession(port);
|
|
58
|
+
session.tabIds.add(tabId);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (session.groupId !== null) {
|
|
62
|
+
try {
|
|
63
|
+
await chrome.tabs.group({ tabIds: [tabId], groupId: session.groupId });
|
|
64
|
+
} catch {
|
|
65
|
+
// Group no longer valid — will create new one below
|
|
66
|
+
session.groupId = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (session.groupId === null) {
|
|
71
|
+
const validTabIds = [...session.tabIds].filter(id => {
|
|
72
|
+
try { return id; } catch { return false; }
|
|
73
|
+
});
|
|
74
|
+
const groupId = await chrome.tabs.group({ tabIds: validTabIds });
|
|
75
|
+
session.groupId = groupId;
|
|
76
|
+
await chrome.tabGroups.update(groupId, {
|
|
77
|
+
title: session.label,
|
|
78
|
+
color: session.color,
|
|
79
|
+
collapsed: false,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.warn('[MCP] Tab group error:', e.message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
persistSessions();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function releaseSession(port) {
|
|
90
|
+
const session = sessions.get(port);
|
|
91
|
+
if (!session) return;
|
|
92
|
+
|
|
93
|
+
// Detach debugger + close all session tabs
|
|
94
|
+
const tabIds = [...session.tabIds];
|
|
95
|
+
for (const tabId of tabIds) {
|
|
96
|
+
debuggerForceDetach(tabId);
|
|
97
|
+
try {
|
|
98
|
+
await chrome.tabs.remove(tabId);
|
|
99
|
+
} catch {} // tab may already be closed
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
sessions.delete(port);
|
|
103
|
+
persistSessions();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function persistSessions() {
|
|
107
|
+
const data = {};
|
|
108
|
+
for (const [port, session] of sessions) {
|
|
109
|
+
data[port] = {
|
|
110
|
+
tabIds: [...session.tabIds],
|
|
111
|
+
activeTabId: session.activeTabId,
|
|
112
|
+
groupId: session.groupId,
|
|
113
|
+
color: session.color,
|
|
114
|
+
label: session.label,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
chrome.storage.local.set({ sessions: data });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Get the active tab for this session (last navigated), or create one.
|
|
121
|
+
// IMPORTANT: Also activates the tab so Chrome APIs (captureVisibleTab,
|
|
122
|
+
// executeScript) operate on the correct tab, not whatever the user is viewing.
|
|
123
|
+
async function getSessionTab(port, activate = true) {
|
|
124
|
+
const session = getSession(port);
|
|
125
|
+
let target = null;
|
|
126
|
+
|
|
127
|
+
// Prefer the active (last navigated) tab
|
|
128
|
+
if (session.activeTabId) {
|
|
129
|
+
try {
|
|
130
|
+
const tab = await chrome.tabs.get(session.activeTabId);
|
|
131
|
+
if (tab && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) {
|
|
132
|
+
target = tab;
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
session.activeTabId = null;
|
|
136
|
+
session.tabIds.delete(session.activeTabId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fallback: any usable session tab
|
|
141
|
+
if (!target) {
|
|
142
|
+
for (const tabId of session.tabIds) {
|
|
143
|
+
try {
|
|
144
|
+
const tab = await chrome.tabs.get(tabId);
|
|
145
|
+
if (tab && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) {
|
|
146
|
+
session.activeTabId = tabId;
|
|
147
|
+
target = tab;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
session.tabIds.delete(tabId);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// No usable tab — create one
|
|
157
|
+
if (!target) {
|
|
158
|
+
target = await chrome.tabs.create({ url: 'about:blank', active: false });
|
|
159
|
+
await addTabToSession(port, target.id);
|
|
160
|
+
return target;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Activate the tab so Chrome APIs target it (not whatever user is viewing)
|
|
164
|
+
if (activate && !target.active) {
|
|
165
|
+
await chrome.tabs.update(target.id, { active: true });
|
|
166
|
+
// Brief wait for Chrome to render the tab
|
|
167
|
+
await new Promise(r => setTimeout(r, 150));
|
|
168
|
+
target = await chrome.tabs.get(target.id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return target;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Chrome Debugger API Helpers (CSP-bypass for Google, Stripe, Slack) ─────
|
|
175
|
+
|
|
176
|
+
// Track which tabs have debugger attached to avoid repeated attach/detach
|
|
177
|
+
const debuggerAttached = new Set();
|
|
178
|
+
|
|
179
|
+
async function debuggerAttach(tabId) {
|
|
180
|
+
if (debuggerAttached.has(tabId)) return;
|
|
181
|
+
try {
|
|
182
|
+
await chrome.debugger.attach({ tabId }, '1.3');
|
|
183
|
+
debuggerAttached.add(tabId);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
if (e.message?.includes('Already attached')) {
|
|
186
|
+
debuggerAttached.add(tabId);
|
|
187
|
+
} else {
|
|
188
|
+
throw e;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function debuggerDetach(tabId) {
|
|
194
|
+
// Don't detach immediately — keep attached for subsequent commands.
|
|
195
|
+
// Will be cleaned up when tab closes or session ends.
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function debuggerForceDetach(tabId) {
|
|
199
|
+
if (!debuggerAttached.has(tabId)) return;
|
|
200
|
+
debuggerAttached.delete(tabId);
|
|
201
|
+
try {
|
|
202
|
+
chrome.debugger.detach({ tabId });
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Clean up debugger + session refs when tabs close
|
|
207
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
208
|
+
debuggerAttached.delete(tabId);
|
|
209
|
+
for (const [, session] of sessions) {
|
|
210
|
+
session.tabIds.delete(tabId);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
async function debuggerType(tabId, text) {
|
|
215
|
+
await debuggerAttach(tabId);
|
|
216
|
+
try {
|
|
217
|
+
for (let i = 0; i < text.length; i++) {
|
|
218
|
+
const char = text[i];
|
|
219
|
+
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
|
|
220
|
+
type: 'keyDown',
|
|
221
|
+
text: char,
|
|
222
|
+
key: char,
|
|
223
|
+
code: `Key${char.toUpperCase()}`,
|
|
224
|
+
unmodifiedText: char,
|
|
225
|
+
});
|
|
226
|
+
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
|
|
227
|
+
type: 'keyUp',
|
|
228
|
+
key: char,
|
|
229
|
+
code: `Key${char.toUpperCase()}`,
|
|
230
|
+
});
|
|
231
|
+
// Human-like typing: random 30-120ms, occasional longer pause
|
|
232
|
+
const pause = (i > 0 && i % (7 + Math.floor(Math.random() * 5)) === 0)
|
|
233
|
+
? 150 + Math.random() * 200 // thinking pause every ~10 chars
|
|
234
|
+
: 30 + Math.random() * 90; // normal keystroke
|
|
235
|
+
await new Promise(r => setTimeout(r, pause));
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
await debuggerDetach(tabId);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function debuggerClick(tabId, x, y) {
|
|
243
|
+
await debuggerAttach(tabId);
|
|
244
|
+
try {
|
|
245
|
+
// 1. mouseMoved first (triggers hover state, required by some frameworks)
|
|
246
|
+
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
|
|
247
|
+
type: 'mouseMoved', x, y,
|
|
248
|
+
});
|
|
249
|
+
await new Promise(r => setTimeout(r, 30));
|
|
250
|
+
// 2. mousePressed + mouseReleased (fires trusted mousedown/mouseup)
|
|
251
|
+
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
|
|
252
|
+
type: 'mousePressed', x, y, button: 'left', clickCount: 1,
|
|
253
|
+
});
|
|
254
|
+
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
|
|
255
|
+
type: 'mouseReleased', x, y, button: 'left', clickCount: 1,
|
|
256
|
+
});
|
|
257
|
+
// 3. CDP doesn't synthesize 'click' event from mousePressed/mouseReleased.
|
|
258
|
+
// React/Angular listen on 'click', not mouseup. Fire it via JS as backup.
|
|
259
|
+
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
|
260
|
+
expression: `document.elementFromPoint(${x}, ${y})?.click()`,
|
|
261
|
+
});
|
|
262
|
+
} finally {
|
|
263
|
+
await debuggerDetach(tabId);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function debuggerFocus(tabId, selector) {
|
|
268
|
+
await debuggerAttach(tabId);
|
|
269
|
+
try {
|
|
270
|
+
const { root } = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument', {});
|
|
271
|
+
const { nodeId } = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', {
|
|
272
|
+
nodeId: root.nodeId, selector,
|
|
273
|
+
});
|
|
274
|
+
if (!nodeId) throw new Error('Element not found: ' + selector);
|
|
275
|
+
await chrome.debugger.sendCommand({ tabId }, 'DOM.focus', { nodeId });
|
|
276
|
+
return nodeId;
|
|
277
|
+
} catch (e) {
|
|
278
|
+
await debuggerDetach(tabId);
|
|
279
|
+
throw e;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function debuggerFill(tabId, selector, value) {
|
|
284
|
+
// Check if element is contenteditable (rich text editors: LinkedIn, Slack)
|
|
285
|
+
const isContentEditable = await debuggerEval(tabId, `
|
|
286
|
+
(function() {
|
|
287
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
288
|
+
return el?.isContentEditable || el?.getAttribute('contenteditable') === 'true';
|
|
289
|
+
})()
|
|
290
|
+
`);
|
|
291
|
+
|
|
292
|
+
if (isContentEditable) {
|
|
293
|
+
// Rich text editors (Quill, ProseMirror, Slate, Draft.js) maintain internal
|
|
294
|
+
// state. Key events get ignored. execCommand('insertText') fires proper
|
|
295
|
+
// InputEvent that these editors handle correctly.
|
|
296
|
+
await debuggerEval(tabId, `
|
|
297
|
+
(function() {
|
|
298
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
299
|
+
el.focus();
|
|
300
|
+
// Select all existing content and delete it
|
|
301
|
+
document.execCommand('selectAll', false, null);
|
|
302
|
+
document.execCommand('delete', false, null);
|
|
303
|
+
// Insert new text — fires InputEvent with inputType='insertText'
|
|
304
|
+
document.execCommand('insertText', false, ${JSON.stringify(value)});
|
|
305
|
+
})()
|
|
306
|
+
`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Standard input/textarea — focus, clear, type
|
|
311
|
+
await debuggerFocus(tabId, selector);
|
|
312
|
+
await debuggerAttach(tabId);
|
|
313
|
+
try {
|
|
314
|
+
// Ctrl+A to select all, then Backspace to clear
|
|
315
|
+
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
|
|
316
|
+
type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
|
|
317
|
+
});
|
|
318
|
+
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
|
|
319
|
+
type: 'keyUp', key: 'a', code: 'KeyA',
|
|
320
|
+
});
|
|
321
|
+
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
|
|
322
|
+
type: 'keyDown', key: 'Backspace', code: 'Backspace',
|
|
323
|
+
});
|
|
324
|
+
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
|
|
325
|
+
type: 'keyUp', key: 'Backspace', code: 'Backspace',
|
|
326
|
+
});
|
|
327
|
+
} finally {
|
|
328
|
+
await debuggerDetach(tabId);
|
|
329
|
+
}
|
|
330
|
+
await debuggerType(tabId, value);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function debuggerEval(tabId, expression) {
|
|
334
|
+
await debuggerAttach(tabId);
|
|
335
|
+
try {
|
|
336
|
+
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
|
337
|
+
expression,
|
|
338
|
+
returnByValue: true,
|
|
339
|
+
});
|
|
340
|
+
if (result.exceptionDetails) {
|
|
341
|
+
throw new Error(result.exceptionDetails.text || 'Script execution failed');
|
|
342
|
+
}
|
|
343
|
+
return result.result?.value;
|
|
344
|
+
} finally {
|
|
345
|
+
await debuggerDetach(tabId);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Try executeScript first, fall back to debugger on CSP error
|
|
350
|
+
async function safeExecuteScript(tabId, func, args = [], world = 'MAIN') {
|
|
351
|
+
try {
|
|
352
|
+
const [result] = await chrome.scripting.executeScript({
|
|
353
|
+
target: { tabId },
|
|
354
|
+
func,
|
|
355
|
+
args,
|
|
356
|
+
...(world === 'MAIN' ? { world: 'MAIN' } : {}),
|
|
357
|
+
});
|
|
358
|
+
return { result: result.result, usedDebugger: false };
|
|
359
|
+
} catch (e) {
|
|
360
|
+
if (e.message?.includes('Content Security Policy') || e.message?.includes('unsafe-eval')) {
|
|
361
|
+
// CSP blocked — this is expected on Google, Stripe, Slack
|
|
362
|
+
return { cspBlocked: true };
|
|
363
|
+
}
|
|
364
|
+
throw e;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Smart Selector Resolution ─────────────────────────────────────────────
|
|
369
|
+
// Supports CSS selectors AND text-based selectors:
|
|
370
|
+
// "button:text(Get started)" → finds button containing "Get started"
|
|
371
|
+
// "#my-id" → standard CSS selector
|
|
372
|
+
// "text=Submit" → any element containing "Submit"
|
|
373
|
+
|
|
374
|
+
function buildTextFinderJS(textPattern, tagFilter) {
|
|
375
|
+
const escaped = JSON.stringify(textPattern);
|
|
376
|
+
const tagCheck = tagFilter ? `&& el.tagName === ${JSON.stringify(tagFilter.toUpperCase())}` : '';
|
|
377
|
+
return `(function() {
|
|
378
|
+
const text = ${escaped};
|
|
379
|
+
// Collect all elements including inside shadow DOM
|
|
380
|
+
function collectAll(root, results) {
|
|
381
|
+
for (const el of root.querySelectorAll('*')) {
|
|
382
|
+
results.push(el);
|
|
383
|
+
if (el.shadowRoot) collectAll(el.shadowRoot, results);
|
|
384
|
+
}
|
|
385
|
+
return results;
|
|
386
|
+
}
|
|
387
|
+
const all = collectAll(document, []);
|
|
388
|
+
// Exact match first (prefer leaf nodes)
|
|
389
|
+
for (const el of all) {
|
|
390
|
+
if (el.children.length > 3) continue;
|
|
391
|
+
const t = el.textContent?.trim();
|
|
392
|
+
if (t === text ${tagCheck}) {
|
|
393
|
+
return el;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Partial match fallback
|
|
397
|
+
for (const el of all) {
|
|
398
|
+
if (el.children.length > 3) continue;
|
|
399
|
+
const t = el.textContent?.trim();
|
|
400
|
+
if (t && t.includes(text) ${tagCheck}) {
|
|
401
|
+
return el;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
})()`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function parseSelector(selector) {
|
|
409
|
+
// "button:text(Get started)" → { tag: 'button', text: 'Get started' }
|
|
410
|
+
const tagTextMatch = selector.match(/^(\w+):text\((.+)\)$/);
|
|
411
|
+
if (tagTextMatch) return { type: 'text', tag: tagTextMatch[1], text: tagTextMatch[2] };
|
|
412
|
+
|
|
413
|
+
// "text=Submit" → { text: 'Submit' }
|
|
414
|
+
if (selector.startsWith('text=')) return { type: 'text', tag: null, text: selector.slice(5) };
|
|
415
|
+
|
|
416
|
+
// Standard CSS selector
|
|
417
|
+
return { type: 'css', selector };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function resolveElement(tabId, selectorStr) {
|
|
421
|
+
const parsed = parseSelector(selectorStr);
|
|
422
|
+
|
|
423
|
+
if (parsed.type === 'css') {
|
|
424
|
+
// Standard CSS with shadow DOM traversal — try executeScript first, debugger fallback
|
|
425
|
+
const deepQueryFn = (sel) => {
|
|
426
|
+
function queryDeep(root, s) {
|
|
427
|
+
const el = root.querySelector(s);
|
|
428
|
+
if (el) return el;
|
|
429
|
+
for (const node of root.querySelectorAll('*')) {
|
|
430
|
+
if (node.shadowRoot) {
|
|
431
|
+
const found = queryDeep(node.shadowRoot, s);
|
|
432
|
+
if (found) return found;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const el = queryDeep(document, sel);
|
|
438
|
+
if (!el) return null;
|
|
439
|
+
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
440
|
+
const r = el.getBoundingClientRect();
|
|
441
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2, tag: el.tagName, found: true };
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const scriptResult = await safeExecuteScript(tabId, deepQueryFn, [parsed.selector]);
|
|
445
|
+
|
|
446
|
+
if (scriptResult.cspBlocked) {
|
|
447
|
+
const sel = JSON.stringify(parsed.selector);
|
|
448
|
+
const result = await debuggerEval(tabId, `
|
|
449
|
+
(function() {
|
|
450
|
+
function queryDeep(root, s) {
|
|
451
|
+
const el = root.querySelector(s);
|
|
452
|
+
if (el) return el;
|
|
453
|
+
for (const node of root.querySelectorAll('*')) {
|
|
454
|
+
if (node.shadowRoot) { const f = queryDeep(node.shadowRoot, s); if (f) return f; }
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
const el = queryDeep(document, ${sel});
|
|
459
|
+
if (!el) return null;
|
|
460
|
+
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
461
|
+
const r = el.getBoundingClientRect();
|
|
462
|
+
return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, found: true };
|
|
463
|
+
})()
|
|
464
|
+
`);
|
|
465
|
+
return result ? { ...result, method: 'debugger' } : null;
|
|
466
|
+
}
|
|
467
|
+
return scriptResult.result;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Text-based selector — always use debugger (more reliable, no CSP issues)
|
|
471
|
+
const finderJS = buildTextFinderJS(parsed.text, parsed.tag);
|
|
472
|
+
const result = await debuggerEval(tabId, `
|
|
473
|
+
(function() {
|
|
474
|
+
const el = ${finderJS};
|
|
475
|
+
if (!el) return null;
|
|
476
|
+
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
477
|
+
const r = el.getBoundingClientRect();
|
|
478
|
+
return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, text: el.textContent?.trim().slice(0, 80), found: true };
|
|
479
|
+
})()
|
|
480
|
+
`);
|
|
481
|
+
return result ? { ...result, method: 'debugger' } : null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Offscreen Document Setup ───────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
async function ensureOffscreen() {
|
|
487
|
+
const existing = await chrome.offscreen.hasDocument();
|
|
488
|
+
if (!existing) {
|
|
489
|
+
await chrome.offscreen.createDocument({
|
|
490
|
+
url: 'offscreen.html',
|
|
491
|
+
reasons: ['WORKERS'],
|
|
492
|
+
justification: 'Maintain persistent WebSocket connection to local MCP server',
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── Action Logging ─────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
const SENSITIVE = new Set(['get_cookies', 'get_local_storage', 'execute_script', 'extract_token']);
|
|
500
|
+
|
|
501
|
+
function logAction(port, method, params) {
|
|
502
|
+
const category = SENSITIVE.has(method) ? 'sensitive' : 'safe';
|
|
503
|
+
const session = sessions.get(port);
|
|
504
|
+
const entry = {
|
|
505
|
+
time: Date.now(),
|
|
506
|
+
method,
|
|
507
|
+
params: JSON.stringify(params).slice(0, 200),
|
|
508
|
+
category,
|
|
509
|
+
session: session?.label || `Port ${port}`,
|
|
510
|
+
color: session?.color || 'grey',
|
|
511
|
+
};
|
|
512
|
+
chrome.storage.local.get({ actionLog: [] }, ({ actionLog }) => {
|
|
513
|
+
actionLog.unshift(entry);
|
|
514
|
+
if (actionLog.length > 50) actionLog.length = 50;
|
|
515
|
+
chrome.storage.local.set({ actionLog });
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── Message Handler — receives commands from offscreen.js ──────────────────
|
|
520
|
+
|
|
521
|
+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
522
|
+
if (msg.type === 'mcp_command') {
|
|
523
|
+
const port = msg.port;
|
|
524
|
+
logAction(port, msg.method, msg.params);
|
|
525
|
+
// Restore sessions from storage (service worker may have restarted)
|
|
526
|
+
restoreSessions().then(() => {
|
|
527
|
+
dispatch(port, msg.method, msg.params)
|
|
528
|
+
.then(result => sendResponse(result))
|
|
529
|
+
.catch(err => sendResponse({ __error: err.message || String(err) }));
|
|
530
|
+
});
|
|
531
|
+
return true; // async response
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (msg.type === 'session_disconnect') {
|
|
535
|
+
releaseSession(msg.port);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (msg.type === 'reconnect') {
|
|
540
|
+
chrome.offscreen.hasDocument().then(exists => {
|
|
541
|
+
if (exists) chrome.offscreen.closeDocument().then(() => ensureOffscreen());
|
|
542
|
+
else ensureOffscreen();
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (msg.type === 'ws_status') {
|
|
548
|
+
const count = msg.count || (msg.connected ? 1 : 0);
|
|
549
|
+
chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' });
|
|
550
|
+
if (count > 0) {
|
|
551
|
+
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
|
|
552
|
+
}
|
|
553
|
+
chrome.storage.local.set({
|
|
554
|
+
mcpConnected: msg.connected,
|
|
555
|
+
mcpCount: count,
|
|
556
|
+
mcpPorts: msg.ports || [],
|
|
557
|
+
});
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// ── OAuth Popup Interception ─────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
const OAUTH_DOMAINS = ['accounts.google.com', 'login.microsoftonline.com', 'github.com/login/oauth', 'slack.com/oauth', 'app.hubspot.com/oauth'];
|
|
565
|
+
|
|
566
|
+
let lastCreatedTabId = null;
|
|
567
|
+
|
|
568
|
+
chrome.tabs.onCreated.addListener(async (tab) => {
|
|
569
|
+
lastCreatedTabId = tab.id;
|
|
570
|
+
|
|
571
|
+
// Auto-claim OAuth popups for the session that opened them
|
|
572
|
+
if (tab.pendingUrl || tab.url) {
|
|
573
|
+
const url = tab.pendingUrl || tab.url;
|
|
574
|
+
const isOAuth = OAUTH_DOMAINS.some(d => url.includes(d));
|
|
575
|
+
if (isOAuth) {
|
|
576
|
+
for (const [port, session] of sessions) {
|
|
577
|
+
if (tab.openerTabId && session.tabIds.has(tab.openerTabId)) {
|
|
578
|
+
await addTabToSession(port, tab.id);
|
|
579
|
+
session.activeTabId = tab.id;
|
|
580
|
+
persistSessions();
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// ── CAPTCHA Detection ────────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
async function detectCaptcha(tabId) {
|
|
591
|
+
try {
|
|
592
|
+
return await debuggerEval(tabId, `
|
|
593
|
+
(function() {
|
|
594
|
+
if (document.querySelector('iframe[src*="hcaptcha.com"]') || document.querySelector('.h-captcha')) return 'hcaptcha';
|
|
595
|
+
if (document.querySelector('iframe[src*="recaptcha"]') || document.querySelector('.g-recaptcha')) return 'recaptcha';
|
|
596
|
+
if (document.querySelector('iframe[src*="challenges.cloudflare.com"]') || document.querySelector('.cf-turnstile')) return 'turnstile';
|
|
597
|
+
if (document.documentElement.innerHTML.includes('challenge-platform')) return 'challenge';
|
|
598
|
+
return null;
|
|
599
|
+
})()
|
|
600
|
+
`);
|
|
601
|
+
} catch { return null; }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ── Deep Shadow DOM Query ────────────────────────────────────────────────────
|
|
605
|
+
// querySelectorDeep: finds elements inside shadow DOMs (Shopify, Salesforce, etc.)
|
|
606
|
+
|
|
607
|
+
function buildDeepQueryJS(selector) {
|
|
608
|
+
return `(function() {
|
|
609
|
+
function queryDeep(root, sel) {
|
|
610
|
+
const el = root.querySelector(sel);
|
|
611
|
+
if (el) return el;
|
|
612
|
+
for (const node of root.querySelectorAll('*')) {
|
|
613
|
+
if (node.shadowRoot) {
|
|
614
|
+
const found = queryDeep(node.shadowRoot, sel);
|
|
615
|
+
if (found) return found;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
return queryDeep(document, ${JSON.stringify(selector)});
|
|
621
|
+
})()`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ── Command Dispatcher ──────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
async function dispatch(port, method, params) {
|
|
627
|
+
switch (method) {
|
|
628
|
+
case 'navigate': {
|
|
629
|
+
const session = getSession(port);
|
|
630
|
+
let tab = await getSessionTab(port);
|
|
631
|
+
|
|
632
|
+
// Always reuse the active tab — navigate in place, don't create new tabs
|
|
633
|
+
// Only create new tab if explicitly requested via new_tab param
|
|
634
|
+
if (params.new_tab) {
|
|
635
|
+
tab = await chrome.tabs.create({ url: params.url, active: true });
|
|
636
|
+
await addTabToSession(port, tab.id);
|
|
637
|
+
} else {
|
|
638
|
+
await chrome.tabs.update(tab.id, { url: params.url });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Wait for load
|
|
642
|
+
await new Promise(resolve => {
|
|
643
|
+
const listener = (tabId, info) => {
|
|
644
|
+
if (tabId === tab.id && info.status === 'complete') {
|
|
645
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
646
|
+
resolve();
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
650
|
+
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 15000);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// Set as active tab for this session
|
|
654
|
+
session.activeTabId = tab.id;
|
|
655
|
+
persistSessions();
|
|
656
|
+
const updated = await chrome.tabs.get(tab.id);
|
|
657
|
+
|
|
658
|
+
// Check for CAPTCHA after navigation
|
|
659
|
+
const captcha = await detectCaptcha(tab.id);
|
|
660
|
+
const result = { title: updated.title, url: updated.url, tab_id: tab.id, session: session.label };
|
|
661
|
+
if (captcha) {
|
|
662
|
+
result.captcha_detected = captcha;
|
|
663
|
+
result.hint = `CAPTCHA (${captcha}) detected. Use browser_ask_user to ask the user to solve it, then retry.`;
|
|
664
|
+
}
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
case 'get_page_content': {
|
|
669
|
+
const tab = await getSessionTab(port);
|
|
670
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
|
|
671
|
+
const format = params.format || 'text';
|
|
672
|
+
const scriptResult = await safeExecuteScript(tab.id, (fmt) => fmt === 'html' ? document.documentElement.outerHTML : document.body.innerText, [format]);
|
|
673
|
+
if (!scriptResult.cspBlocked) {
|
|
674
|
+
return { content: scriptResult.result, url: tab.url, title: tab.title };
|
|
675
|
+
}
|
|
676
|
+
// CSP fallback
|
|
677
|
+
const content = await debuggerEval(tab.id, format === 'html' ? 'document.documentElement.outerHTML' : 'document.body.innerText');
|
|
678
|
+
return { content, url: tab.url, title: tab.title, method: 'debugger' };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
case 'screenshot': {
|
|
682
|
+
const tab = await getSessionTab(port);
|
|
683
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot screenshot chrome:// pages');
|
|
684
|
+
// Use debugger Page.captureScreenshot as PRIMARY method.
|
|
685
|
+
// captureVisibleTab requires active tab in active window — fails when
|
|
686
|
+
// user is in terminal. Debugger works regardless of tab focus.
|
|
687
|
+
try {
|
|
688
|
+
await debuggerAttach(tab.id);
|
|
689
|
+
const { data } = await chrome.debugger.sendCommand({ tabId: tab.id }, 'Page.captureScreenshot', {
|
|
690
|
+
format: 'png',
|
|
691
|
+
});
|
|
692
|
+
return { image: 'data:image/png;base64,' + data };
|
|
693
|
+
} catch {
|
|
694
|
+
// Debugger failed — fall back to captureVisibleTab (needs active tab)
|
|
695
|
+
try {
|
|
696
|
+
await chrome.tabs.update(tab.id, { active: true });
|
|
697
|
+
await new Promise(r => setTimeout(r, 150));
|
|
698
|
+
const dataUrl = await chrome.tabs.captureVisibleTab(null, { format: 'png' });
|
|
699
|
+
return { image: dataUrl };
|
|
700
|
+
} catch (e) {
|
|
701
|
+
throw new Error('Screenshot failed: ' + e.message);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
case 'execute_script': {
|
|
707
|
+
const tab = await getSessionTab(port);
|
|
708
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot execute scripts on chrome:// pages');
|
|
709
|
+
try {
|
|
710
|
+
const [result] = await chrome.scripting.executeScript({
|
|
711
|
+
target: { tabId: tab.id },
|
|
712
|
+
func: new Function('return (' + params.code + ')'),
|
|
713
|
+
world: 'MAIN',
|
|
714
|
+
});
|
|
715
|
+
return { result: result.result };
|
|
716
|
+
} catch (e) {
|
|
717
|
+
if (e.message?.includes('Content Security Policy') || e.message?.includes('unsafe-eval')) {
|
|
718
|
+
// CSP blocked — fall back to debugger Runtime.evaluate
|
|
719
|
+
const value = await debuggerEval(tab.id, params.code);
|
|
720
|
+
return { result: value, method: 'debugger' };
|
|
721
|
+
}
|
|
722
|
+
throw e;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
case 'click': {
|
|
727
|
+
const tab = await getSessionTab(port);
|
|
728
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
729
|
+
|
|
730
|
+
// Resolve element (supports CSS + text selectors, auto-scrolls)
|
|
731
|
+
const el = await resolveElement(tab.id, params.selector);
|
|
732
|
+
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
|
733
|
+
|
|
734
|
+
// Always use debugger mouse events — works on all sites including SPAs
|
|
735
|
+
await debuggerClick(tab.id, el.x, el.y);
|
|
736
|
+
return { ok: true, method: el.method || 'debugger', tag: el.tag, text: el.text };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
case 'fill': {
|
|
740
|
+
const tab = await getSessionTab(port);
|
|
741
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
742
|
+
const parsed = parseSelector(params.selector);
|
|
743
|
+
|
|
744
|
+
// For text-based selectors, click the element first then type
|
|
745
|
+
if (parsed.type === 'text') {
|
|
746
|
+
const el = await resolveElement(tab.id, params.selector);
|
|
747
|
+
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
|
748
|
+
await debuggerClick(tab.id, el.x, el.y);
|
|
749
|
+
await new Promise(r => setTimeout(r, 100));
|
|
750
|
+
await debuggerType(tab.id, params.value);
|
|
751
|
+
return { ok: true, method: 'debugger' };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Always use debugger for input/textarea — React/Angular/Vue need real keyboard events
|
|
755
|
+
try {
|
|
756
|
+
await debuggerFill(tab.id, parsed.selector, params.value);
|
|
757
|
+
return { ok: true, method: 'debugger' };
|
|
758
|
+
} catch (e) {
|
|
759
|
+
// Fallback to executeScript if debugger fails
|
|
760
|
+
const scriptResult = await safeExecuteScript(tab.id, (sel, val) => {
|
|
761
|
+
const el = document.querySelector(sel);
|
|
762
|
+
if (!el) return { ok: false, error: 'Element not found: ' + sel };
|
|
763
|
+
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
764
|
+
el.focus();
|
|
765
|
+
el.value = val;
|
|
766
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
767
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
768
|
+
return { ok: true };
|
|
769
|
+
}, [parsed.selector, params.value]);
|
|
770
|
+
if (!scriptResult.cspBlocked) return scriptResult.result;
|
|
771
|
+
return { ok: false, error: e.message, method: 'debugger' };
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
case 'wait': {
|
|
776
|
+
const tab = await getSessionTab(port);
|
|
777
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
778
|
+
const timeout = params.timeout || 10000;
|
|
779
|
+
const sel = params.selector;
|
|
780
|
+
const start = Date.now();
|
|
781
|
+
while (Date.now() - start < timeout) {
|
|
782
|
+
// Text-based selectors use debugger directly
|
|
783
|
+
if (sel.startsWith('text=') || sel.match(/^\w+:text\(/)) {
|
|
784
|
+
const el = await resolveElement(tab.id, sel);
|
|
785
|
+
if (el) return { found: true, method: 'debugger' };
|
|
786
|
+
} else {
|
|
787
|
+
const scriptResult = await safeExecuteScript(tab.id, (s) => !!document.querySelector(s), [sel]);
|
|
788
|
+
if (scriptResult.cspBlocked) {
|
|
789
|
+
const found = await debuggerEval(tab.id, `!!document.querySelector(${JSON.stringify(sel)})`);
|
|
790
|
+
if (found) return { found: true, method: 'debugger' };
|
|
791
|
+
} else if (scriptResult.result) {
|
|
792
|
+
return { found: true };
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
await new Promise(r => setTimeout(r, 500));
|
|
796
|
+
}
|
|
797
|
+
return { found: false };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
case 'press_key': {
|
|
801
|
+
const tab = await getSessionTab(port);
|
|
802
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
803
|
+
const key = params.key; // e.g. "Enter", "Tab", "Escape", "ArrowDown"
|
|
804
|
+
const modifiers = (params.ctrl ? 2 : 0) | (params.alt ? 1 : 0) | (params.shift ? 8 : 0) | (params.meta ? 4 : 0);
|
|
805
|
+
|
|
806
|
+
await debuggerAttach(tab.id);
|
|
807
|
+
try {
|
|
808
|
+
await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchKeyEvent', {
|
|
809
|
+
type: 'keyDown',
|
|
810
|
+
key,
|
|
811
|
+
code: params.code || key,
|
|
812
|
+
modifiers,
|
|
813
|
+
text: key.length === 1 ? key : '',
|
|
814
|
+
});
|
|
815
|
+
await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchKeyEvent', {
|
|
816
|
+
type: 'keyUp',
|
|
817
|
+
key,
|
|
818
|
+
code: params.code || key,
|
|
819
|
+
modifiers,
|
|
820
|
+
});
|
|
821
|
+
} finally {
|
|
822
|
+
await debuggerDetach(tab.id);
|
|
823
|
+
}
|
|
824
|
+
return { ok: true, key };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
case 'scroll': {
|
|
828
|
+
const tab = await getSessionTab(port);
|
|
829
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
830
|
+
// Scroll to element or by pixels
|
|
831
|
+
if (params.selector) {
|
|
832
|
+
const el = await resolveElement(tab.id, params.selector);
|
|
833
|
+
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
|
834
|
+
return { ok: true, scrolled_to: params.selector };
|
|
835
|
+
}
|
|
836
|
+
// Scroll by pixels
|
|
837
|
+
const dx = params.x || 0;
|
|
838
|
+
const dy = params.y || 0;
|
|
839
|
+
await debuggerEval(tab.id, `window.scrollBy(${dx}, ${dy})`);
|
|
840
|
+
return { ok: true, scrolled: { x: dx, y: dy } };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
case 'hover': {
|
|
844
|
+
const tab = await getSessionTab(port);
|
|
845
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
846
|
+
const el = await resolveElement(tab.id, params.selector);
|
|
847
|
+
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
|
848
|
+
await debuggerAttach(tab.id);
|
|
849
|
+
try {
|
|
850
|
+
await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchMouseEvent', {
|
|
851
|
+
type: 'mouseMoved', x: el.x, y: el.y,
|
|
852
|
+
});
|
|
853
|
+
// Hold hover for duration (default 500ms) so menus/tooltips appear
|
|
854
|
+
await new Promise(r => setTimeout(r, params.duration || 500));
|
|
855
|
+
} finally {
|
|
856
|
+
await debuggerDetach(tab.id);
|
|
857
|
+
}
|
|
858
|
+
return { ok: true, tag: el.tag, text: el.text };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
case 'select_option': {
|
|
862
|
+
const tab = await getSessionTab(port);
|
|
863
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
864
|
+
|
|
865
|
+
// Strategy: handle native <select> and custom dropdowns differently
|
|
866
|
+
const isNativeSelect = await debuggerEval(tab.id, `
|
|
867
|
+
(function() {
|
|
868
|
+
const el = document.querySelector(${JSON.stringify(params.selector)});
|
|
869
|
+
return el?.tagName === 'SELECT';
|
|
870
|
+
})()
|
|
871
|
+
`);
|
|
872
|
+
|
|
873
|
+
if (isNativeSelect) {
|
|
874
|
+
// Native <select> — set value directly
|
|
875
|
+
await debuggerEval(tab.id, `
|
|
876
|
+
(function() {
|
|
877
|
+
const sel = document.querySelector(${JSON.stringify(params.selector)});
|
|
878
|
+
const opt = Array.from(sel.options).find(o => o.text.includes(${JSON.stringify(params.option)}) || o.value === ${JSON.stringify(params.option)});
|
|
879
|
+
if (opt) {
|
|
880
|
+
sel.value = opt.value;
|
|
881
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
882
|
+
sel.dispatchEvent(new Event('input', { bubbles: true }));
|
|
883
|
+
}
|
|
884
|
+
return !!opt;
|
|
885
|
+
})()
|
|
886
|
+
`);
|
|
887
|
+
return { ok: true, type: 'native_select' };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Custom dropdown (Angular Material, React Select, etc.)
|
|
891
|
+
// Step 1: Click the trigger to open
|
|
892
|
+
const trigger = await resolveElement(tab.id, params.selector);
|
|
893
|
+
if (!trigger) return { ok: false, error: 'Dropdown trigger not found: ' + params.selector };
|
|
894
|
+
await debuggerClick(tab.id, trigger.x, trigger.y);
|
|
895
|
+
|
|
896
|
+
// Step 2: Wait for options to appear
|
|
897
|
+
await new Promise(r => setTimeout(r, params.wait || 300));
|
|
898
|
+
|
|
899
|
+
// Step 3: Find and click the option by text
|
|
900
|
+
const option = await resolveElement(tab.id, `text=${params.option}`);
|
|
901
|
+
if (!option) return { ok: false, error: 'Option not found: ' + params.option };
|
|
902
|
+
await debuggerClick(tab.id, option.x, option.y);
|
|
903
|
+
|
|
904
|
+
return { ok: true, type: 'custom_dropdown', selected: params.option };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
case 'handle_dialog': {
|
|
908
|
+
// Auto-handle JS alert/confirm/prompt dialogs
|
|
909
|
+
// Must be set up BEFORE the dialog appears
|
|
910
|
+
const tab = await getSessionTab(port);
|
|
911
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
912
|
+
const action = params.action || 'accept'; // accept, dismiss
|
|
913
|
+
const promptText = params.text || '';
|
|
914
|
+
|
|
915
|
+
await debuggerAttach(tab.id);
|
|
916
|
+
try {
|
|
917
|
+
// Enable page events to catch dialogs
|
|
918
|
+
await chrome.debugger.sendCommand({ tabId: tab.id }, 'Page.enable', {});
|
|
919
|
+
|
|
920
|
+
// Wait for dialog to appear (or handle existing one)
|
|
921
|
+
const result = await new Promise((resolve) => {
|
|
922
|
+
const timeout = setTimeout(() => {
|
|
923
|
+
chrome.debugger.onEvent.removeListener(listener);
|
|
924
|
+
resolve({ ok: false, error: 'No dialog appeared within timeout' });
|
|
925
|
+
}, params.timeout || 10000);
|
|
926
|
+
|
|
927
|
+
const listener = (source, method, eventParams) => {
|
|
928
|
+
if (source.tabId !== tab.id || method !== 'Page.javascriptDialogOpening') return;
|
|
929
|
+
chrome.debugger.onEvent.removeListener(listener);
|
|
930
|
+
clearTimeout(timeout);
|
|
931
|
+
|
|
932
|
+
chrome.debugger.sendCommand({ tabId: tab.id }, 'Page.handleJavaScriptDialog', {
|
|
933
|
+
accept: action === 'accept',
|
|
934
|
+
promptText: promptText,
|
|
935
|
+
}).then(() => {
|
|
936
|
+
resolve({
|
|
937
|
+
ok: true,
|
|
938
|
+
dialog_type: eventParams.type,
|
|
939
|
+
message: eventParams.message,
|
|
940
|
+
action,
|
|
941
|
+
});
|
|
942
|
+
}).catch(e => resolve({ ok: false, error: e.message }));
|
|
943
|
+
};
|
|
944
|
+
chrome.debugger.onEvent.addListener(listener);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
return result;
|
|
948
|
+
} finally {
|
|
949
|
+
await debuggerDetach(tab.id);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
case 'wait_for_network': {
|
|
954
|
+
const tab = await getSessionTab(port);
|
|
955
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
956
|
+
const urlPattern = params.url_pattern || '';
|
|
957
|
+
const timeout = params.timeout || 15000;
|
|
958
|
+
|
|
959
|
+
await debuggerAttach(tab.id);
|
|
960
|
+
try {
|
|
961
|
+
await chrome.debugger.sendCommand({ tabId: tab.id }, 'Network.enable', {});
|
|
962
|
+
|
|
963
|
+
const result = await new Promise((resolve) => {
|
|
964
|
+
const timer = setTimeout(() => {
|
|
965
|
+
chrome.debugger.onEvent.removeListener(listener);
|
|
966
|
+
resolve({ ok: false, error: 'No matching request within timeout' });
|
|
967
|
+
}, timeout);
|
|
968
|
+
|
|
969
|
+
const listener = (source, method, eventParams) => {
|
|
970
|
+
if (source.tabId !== tab.id) return;
|
|
971
|
+
|
|
972
|
+
if (method === 'Network.responseReceived') {
|
|
973
|
+
const url = eventParams.response?.url || '';
|
|
974
|
+
const status = eventParams.response?.status;
|
|
975
|
+
// Match by pattern (substring match) or return any if no pattern
|
|
976
|
+
if (!urlPattern || url.includes(urlPattern)) {
|
|
977
|
+
chrome.debugger.onEvent.removeListener(listener);
|
|
978
|
+
clearTimeout(timer);
|
|
979
|
+
// Try to get response body
|
|
980
|
+
chrome.debugger.sendCommand({ tabId: tab.id }, 'Network.getResponseBody', {
|
|
981
|
+
requestId: eventParams.requestId,
|
|
982
|
+
}).then(bodyResult => {
|
|
983
|
+
resolve({
|
|
984
|
+
ok: true,
|
|
985
|
+
url,
|
|
986
|
+
status,
|
|
987
|
+
method: eventParams.response?.requestHeaders?.[':method'] || 'GET',
|
|
988
|
+
body: bodyResult?.body?.substring(0, 5000) || null,
|
|
989
|
+
});
|
|
990
|
+
}).catch(() => {
|
|
991
|
+
resolve({
|
|
992
|
+
ok: true,
|
|
993
|
+
url,
|
|
994
|
+
status,
|
|
995
|
+
method: eventParams.response?.requestHeaders?.[':method'] || 'GET',
|
|
996
|
+
body: null,
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
chrome.debugger.onEvent.addListener(listener);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
await chrome.debugger.sendCommand({ tabId: tab.id }, 'Network.disable', {});
|
|
1006
|
+
return result;
|
|
1007
|
+
} finally {
|
|
1008
|
+
await debuggerDetach(tab.id);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
case 'fetch': {
|
|
1013
|
+
// HTTP requests from background — NOT subject to CORS
|
|
1014
|
+
const options = {
|
|
1015
|
+
method: params.method || 'GET',
|
|
1016
|
+
headers: params.headers || {},
|
|
1017
|
+
};
|
|
1018
|
+
if (params.body) options.body = typeof params.body === 'string' ? params.body : JSON.stringify(params.body);
|
|
1019
|
+
try {
|
|
1020
|
+
const resp = await fetch(params.url, options);
|
|
1021
|
+
const text = await resp.text();
|
|
1022
|
+
let json = null;
|
|
1023
|
+
try { json = JSON.parse(text); } catch {}
|
|
1024
|
+
return { ok: resp.ok, status: resp.status, body: json || text };
|
|
1025
|
+
} catch (e) {
|
|
1026
|
+
return { ok: false, error: e.message };
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
case 'list_tabs': {
|
|
1031
|
+
// Return only this session's tabs
|
|
1032
|
+
const session = getSession(port);
|
|
1033
|
+
const tabs = [];
|
|
1034
|
+
for (const tabId of session.tabIds) {
|
|
1035
|
+
try {
|
|
1036
|
+
const tab = await chrome.tabs.get(tabId);
|
|
1037
|
+
tabs.push({ id: tab.id, url: tab.url, title: tab.title, active: tab.active });
|
|
1038
|
+
} catch {
|
|
1039
|
+
session.tabIds.delete(tabId);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return { tabs, session: session.label, color: session.color };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
case 'get_cookies': {
|
|
1046
|
+
const cookies = await chrome.cookies.getAll({ domain: params.domain });
|
|
1047
|
+
return { cookies: cookies.map(c => ({ name: c.name, value: c.value, domain: c.domain, path: c.path })) };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
case 'get_local_storage': {
|
|
1051
|
+
const tab = await getSessionTab(port);
|
|
1052
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
|
|
1053
|
+
const scriptResult = await safeExecuteScript(tab.id, (key) => key ? localStorage.getItem(key) : JSON.stringify(Object.fromEntries(Object.entries(localStorage))), [params.key || null]);
|
|
1054
|
+
if (!scriptResult.cspBlocked) {
|
|
1055
|
+
return { value: scriptResult.result };
|
|
1056
|
+
}
|
|
1057
|
+
const expr = params.key
|
|
1058
|
+
? `localStorage.getItem(${JSON.stringify(params.key)})`
|
|
1059
|
+
: `JSON.stringify(Object.fromEntries(Object.entries(localStorage)))`;
|
|
1060
|
+
const value = await debuggerEval(tab.id, expr);
|
|
1061
|
+
return { value, method: 'debugger' };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
case 'ask_user': {
|
|
1065
|
+
const tab = await getSessionTab(port);
|
|
1066
|
+
const timeout = params.timeout || 120000;
|
|
1067
|
+
const fields = params.fields || [];
|
|
1068
|
+
const hasFields = fields.length > 0;
|
|
1069
|
+
const session = getSession(port);
|
|
1070
|
+
|
|
1071
|
+
// Activate tab + alert badge
|
|
1072
|
+
await chrome.tabs.update(tab.id, { active: true });
|
|
1073
|
+
chrome.action.setBadgeText({ text: '!' });
|
|
1074
|
+
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
|
|
1075
|
+
const notifId = 'mcp-ask-' + Date.now();
|
|
1076
|
+
chrome.notifications.create(notifId, {
|
|
1077
|
+
type: 'basic',
|
|
1078
|
+
iconUrl: 'icons/icon-128.png',
|
|
1079
|
+
title: `${session.label} — Action Required`,
|
|
1080
|
+
message: params.message,
|
|
1081
|
+
requireInteraction: true,
|
|
1082
|
+
silent: false,
|
|
1083
|
+
priority: 2,
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
const [result] = await chrome.scripting.executeScript({
|
|
1087
|
+
target: { tabId: tab.id },
|
|
1088
|
+
func: (message, title, fields, hasFields, timeout, sessionLabel) => {
|
|
1089
|
+
return new Promise((resolve) => {
|
|
1090
|
+
document.getElementById('a360-overlay')?.remove();
|
|
1091
|
+
|
|
1092
|
+
// Notification sound — short pleasant chime
|
|
1093
|
+
try {
|
|
1094
|
+
const ctx = new AudioContext();
|
|
1095
|
+
const osc = ctx.createOscillator();
|
|
1096
|
+
const gain = ctx.createGain();
|
|
1097
|
+
osc.connect(gain);
|
|
1098
|
+
gain.connect(ctx.destination);
|
|
1099
|
+
osc.frequency.value = 880;
|
|
1100
|
+
osc.type = 'sine';
|
|
1101
|
+
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
|
1102
|
+
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
|
|
1103
|
+
osc.start(ctx.currentTime);
|
|
1104
|
+
osc.stop(ctx.currentTime + 0.4);
|
|
1105
|
+
// Second tone (higher, pleasant ding-dong)
|
|
1106
|
+
setTimeout(() => {
|
|
1107
|
+
const osc2 = ctx.createOscillator();
|
|
1108
|
+
const gain2 = ctx.createGain();
|
|
1109
|
+
osc2.connect(gain2);
|
|
1110
|
+
gain2.connect(ctx.destination);
|
|
1111
|
+
osc2.frequency.value = 1320;
|
|
1112
|
+
osc2.type = 'sine';
|
|
1113
|
+
gain2.gain.setValueAtTime(0.2, ctx.currentTime);
|
|
1114
|
+
gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
|
1115
|
+
osc2.start(ctx.currentTime);
|
|
1116
|
+
osc2.stop(ctx.currentTime + 0.3);
|
|
1117
|
+
}, 150);
|
|
1118
|
+
} catch {}
|
|
1119
|
+
|
|
1120
|
+
// Inject animation keyframes
|
|
1121
|
+
if (!document.getElementById('a360-styles')) {
|
|
1122
|
+
const style = document.createElement('style');
|
|
1123
|
+
style.id = 'a360-styles';
|
|
1124
|
+
style.textContent = `
|
|
1125
|
+
@keyframes a360-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
1126
|
+
@keyframes a360-slide-up { from { opacity: 0; transform: translateY(30px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
|
1127
|
+
`;
|
|
1128
|
+
document.head.appendChild(style);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const overlay = document.createElement('div');
|
|
1132
|
+
overlay.id = 'a360-overlay';
|
|
1133
|
+
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:2147483647;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,sans-serif;animation:a360-fade-in 0.3s ease-out';
|
|
1134
|
+
|
|
1135
|
+
const card = document.createElement('div');
|
|
1136
|
+
card.style.cssText = 'background:#1e293b;border-radius:12px;padding:24px;max-width:420px;width:90%;color:#e2e8f0;box-shadow:0 20px 60px rgba(0,0,0,0.5);animation:a360-slide-up 0.4s ease-out';
|
|
1137
|
+
|
|
1138
|
+
const h = document.createElement('div');
|
|
1139
|
+
h.style.cssText = 'font-size:14px;font-weight:600;color:#3b82f6;margin-bottom:4px';
|
|
1140
|
+
h.textContent = title || 'Agent360 — Action Required';
|
|
1141
|
+
card.appendChild(h);
|
|
1142
|
+
const badge = document.createElement('div');
|
|
1143
|
+
badge.style.cssText = 'font-size:10px;color:#94a3b8;margin-bottom:12px';
|
|
1144
|
+
badge.textContent = sessionLabel;
|
|
1145
|
+
card.appendChild(badge);
|
|
1146
|
+
const msg = document.createElement('div');
|
|
1147
|
+
msg.style.cssText = 'font-size:13px;color:#cbd5e1;margin-bottom:16px;line-height:1.5';
|
|
1148
|
+
msg.textContent = message;
|
|
1149
|
+
card.appendChild(msg);
|
|
1150
|
+
const inputs = {};
|
|
1151
|
+
if (hasFields) {
|
|
1152
|
+
fields.forEach(f => {
|
|
1153
|
+
const label = document.createElement('label');
|
|
1154
|
+
label.style.cssText = 'display:block;font-size:11px;color:#94a3b8;margin-bottom:4px;margin-top:8px';
|
|
1155
|
+
label.textContent = f.label || f.name;
|
|
1156
|
+
card.appendChild(label);
|
|
1157
|
+
const input = document.createElement('input');
|
|
1158
|
+
input.type = f.type || 'text';
|
|
1159
|
+
input.placeholder = f.label || f.name;
|
|
1160
|
+
input.style.cssText = 'width:100%;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none;box-sizing:border-box';
|
|
1161
|
+
input.addEventListener('focus', () => input.style.borderColor = '#3b82f6');
|
|
1162
|
+
input.addEventListener('blur', () => input.style.borderColor = '#334155');
|
|
1163
|
+
card.appendChild(input);
|
|
1164
|
+
inputs[f.name] = input;
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
const btnRow = document.createElement('div');
|
|
1168
|
+
btnRow.style.cssText = 'display:flex;gap:8px;margin-top:16px';
|
|
1169
|
+
const doneBtn = document.createElement('button');
|
|
1170
|
+
doneBtn.textContent = hasFields ? 'Submit' : '✓ Done';
|
|
1171
|
+
doneBtn.style.cssText = 'flex:1;padding:10px;background:#3b82f6;color:white;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500';
|
|
1172
|
+
doneBtn.addEventListener('click', () => {
|
|
1173
|
+
const values = {};
|
|
1174
|
+
Object.entries(inputs).forEach(([k, el]) => values[k] = el.value);
|
|
1175
|
+
overlay.remove();
|
|
1176
|
+
resolve({ acknowledged: true, action: 'done', values });
|
|
1177
|
+
});
|
|
1178
|
+
const skipBtn = document.createElement('button');
|
|
1179
|
+
skipBtn.textContent = '✗ Skip';
|
|
1180
|
+
skipBtn.style.cssText = 'flex:1;padding:10px;background:#334155;color:#94a3b8;border:none;border-radius:6px;font-size:13px;cursor:pointer';
|
|
1181
|
+
skipBtn.addEventListener('click', () => { overlay.remove(); resolve({ acknowledged: true, action: 'skip', values: {} }); });
|
|
1182
|
+
btnRow.appendChild(doneBtn);
|
|
1183
|
+
btnRow.appendChild(skipBtn);
|
|
1184
|
+
card.appendChild(btnRow);
|
|
1185
|
+
overlay.appendChild(card);
|
|
1186
|
+
document.body.appendChild(overlay);
|
|
1187
|
+
const firstInput = Object.values(inputs)[0];
|
|
1188
|
+
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
|
1189
|
+
card.addEventListener('keydown', (e) => { if (e.key === 'Enter') doneBtn.click(); });
|
|
1190
|
+
setTimeout(() => { if (document.getElementById('a360-overlay')) { overlay.remove(); resolve({ acknowledged: false, action: 'timeout', values: {} }); } }, timeout);
|
|
1191
|
+
});
|
|
1192
|
+
},
|
|
1193
|
+
args: [params.message, params.title, fields, hasFields, timeout, session.label],
|
|
1194
|
+
world: 'MAIN',
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
// Restore badge
|
|
1198
|
+
const count = sessions.size;
|
|
1199
|
+
chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' });
|
|
1200
|
+
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
|
|
1201
|
+
chrome.notifications.clear(notifId);
|
|
1202
|
+
return result.result;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
case 'select_frame': {
|
|
1206
|
+
const tab = await getSessionTab(port);
|
|
1207
|
+
if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
|
|
1208
|
+
const frameIndex = params.frame_index ?? 0;
|
|
1209
|
+
const frames = await chrome.webNavigation.getAllFrames({ tabId: tab.id });
|
|
1210
|
+
if (!frames || frameIndex >= frames.length) {
|
|
1211
|
+
return { error: `Frame ${frameIndex} not found. Available: ${frames?.length || 0} frames`, frames: frames?.map((f, i) => ({ index: i, url: f.url })) };
|
|
1212
|
+
}
|
|
1213
|
+
const frameId = frames[frameIndex].frameId;
|
|
1214
|
+
const code = params.code || 'document.body.innerText.slice(0, 5000)';
|
|
1215
|
+
const [result] = await chrome.scripting.executeScript({
|
|
1216
|
+
target: { tabId: tab.id, frameIds: [frameId] },
|
|
1217
|
+
func: new Function('return (' + code + ')'),
|
|
1218
|
+
world: 'MAIN',
|
|
1219
|
+
});
|
|
1220
|
+
return { result: result.result, frame_url: frames[frameIndex].url };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
case 'list_frames': {
|
|
1224
|
+
const tab = await getSessionTab(port);
|
|
1225
|
+
const frames = await chrome.webNavigation.getAllFrames({ tabId: tab.id });
|
|
1226
|
+
return { frames: frames?.map((f, i) => ({ index: i, url: f.url, frame_id: f.frameId, parent_frame_id: f.parentFrameId })) || [] };
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
case 'get_new_tab': {
|
|
1230
|
+
if (!lastCreatedTabId) return { error: 'No new tab detected' };
|
|
1231
|
+
try {
|
|
1232
|
+
const tab = await chrome.tabs.get(lastCreatedTabId);
|
|
1233
|
+
// Claim the new tab for this session
|
|
1234
|
+
await addTabToSession(port, tab.id);
|
|
1235
|
+
return { id: tab.id, url: tab.url, title: tab.title };
|
|
1236
|
+
} catch {
|
|
1237
|
+
return { error: 'Tab no longer exists' };
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
case 'switch_tab': {
|
|
1242
|
+
const session = getSession(port);
|
|
1243
|
+
if (!session.tabIds.has(params.tab_id)) {
|
|
1244
|
+
throw new Error(`Tab ${params.tab_id} does not belong to this session (${session.label})`);
|
|
1245
|
+
}
|
|
1246
|
+
const tab = await chrome.tabs.update(params.tab_id, { active: true });
|
|
1247
|
+
session.activeTabId = tab.id;
|
|
1248
|
+
persistSessions();
|
|
1249
|
+
return { id: tab.id, url: tab.url, title: tab.title };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
case 'close_tab': {
|
|
1253
|
+
const session = getSession(port);
|
|
1254
|
+
const tabId = params.tab_id;
|
|
1255
|
+
if (!session.tabIds.has(tabId)) {
|
|
1256
|
+
throw new Error(`Tab ${tabId} does not belong to this session (${session.label})`);
|
|
1257
|
+
}
|
|
1258
|
+
await chrome.tabs.remove(tabId);
|
|
1259
|
+
session.tabIds.delete(tabId);
|
|
1260
|
+
if (session.activeTabId === tabId) session.activeTabId = null;
|
|
1261
|
+
persistSessions();
|
|
1262
|
+
return { ok: true, remaining: session.tabIds.size };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
default:
|
|
1266
|
+
throw new Error('Unknown method: ' + method);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// ── Start ───────────────────────────────────────────────────────────────────
|
|
1271
|
+
ensureOffscreen().catch(console.error);
|
|
1272
|
+
|
|
1273
|
+
chrome.runtime.onStartup.addListener(() => ensureOffscreen().catch(console.error));
|
|
1274
|
+
chrome.runtime.onInstalled.addListener(() => ensureOffscreen().catch(console.error));
|
|
1275
|
+
|
|
1276
|
+
chrome.alarms.create('ensure-offscreen', { periodInMinutes: 1 });
|
|
1277
|
+
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
1278
|
+
if (alarm.name === 'ensure-offscreen') {
|
|
1279
|
+
ensureOffscreen().catch(console.error);
|
|
1280
|
+
}
|
|
1281
|
+
});
|