yoke-mcp 0.1.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/LICENSE +21 -0
- package/MOTIVATION.md +73 -0
- package/README.md +207 -0
- package/ROADMAP.md +111 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +93 -0
- package/dist/cli.js.map +1 -0
- package/dist/doctor.d.ts +10 -0
- package/dist/doctor.js +178 -0
- package/dist/doctor.js.map +1 -0
- package/dist/install.d.ts +47 -0
- package/dist/install.js +165 -0
- package/dist/install.js.map +1 -0
- package/dist/mcp-server.d.ts +370 -0
- package/dist/mcp-server.js +606 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/native-host.d.ts +2 -0
- package/dist/native-host.js +170 -0
- package/dist/native-host.js.map +1 -0
- package/dist/protocol.d.ts +339 -0
- package/dist/protocol.js +9 -0
- package/dist/protocol.js.map +1 -0
- package/dist/socket-client.d.ts +16 -0
- package/dist/socket-client.js +89 -0
- package/dist/socket-client.js.map +1 -0
- package/dist/socket-path.d.ts +5 -0
- package/dist/socket-path.js +19 -0
- package/dist/socket-path.js.map +1 -0
- package/extension/browser/background.js +371 -0
- package/extension/browser/cdp.js +259 -0
- package/extension/browser/snapshot.js +154 -0
- package/extension/icons/128.png +0 -0
- package/extension/icons/16.png +0 -0
- package/extension/icons/32.png +0 -0
- package/extension/icons/48.png +0 -0
- package/extension/icons/icon.svg +8 -0
- package/extension/manifest.json +28 -0
- package/extension/protocol.js +8 -0
- package/package.json +53 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { attach, clickAt, consoleFor, detach, evaluate, insertText, networkFor, pressKey as dispatchKey, screenshot as capture, scrollBy, } from './cdp.js';
|
|
2
|
+
import { collectSnapshot, locateRef } from './snapshot.js';
|
|
3
|
+
// Must match HOST_NAME in ../install.ts exactly: Chrome matches the string
|
|
4
|
+
// against the manifest filename it was registered under.
|
|
5
|
+
const HOST = 'io.github.hamzahamidi.yoke';
|
|
6
|
+
/** How long to wait for a navigation to report complete before giving up on it. */
|
|
7
|
+
const NAVIGATE_TIMEOUT_MS = 20_000;
|
|
8
|
+
let port;
|
|
9
|
+
/** chrome.tabs ids are SessionID::id(), the same numbers Chrome's session file records. */
|
|
10
|
+
const describeTab = (tab) => ({
|
|
11
|
+
id: tab.id ?? -1,
|
|
12
|
+
windowId: tab.windowId,
|
|
13
|
+
groupId: tab.groupId ?? -1,
|
|
14
|
+
title: tab.title ?? '',
|
|
15
|
+
url: tab.url ?? tab.pendingUrl ?? '',
|
|
16
|
+
});
|
|
17
|
+
const describeGroup = (group) => ({
|
|
18
|
+
id: group.id,
|
|
19
|
+
title: group.title ?? '',
|
|
20
|
+
color: group.color,
|
|
21
|
+
windowId: group.windowId,
|
|
22
|
+
collapsed: group.collapsed,
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* Waits for a tab to finish loading.
|
|
26
|
+
*
|
|
27
|
+
* Resolving on `status === 'complete'` rather than on the navigate call
|
|
28
|
+
* returning, because chrome.tabs.update resolves as soon as the navigation is
|
|
29
|
+
* *started*. Returning then would have every caller racing the page it just
|
|
30
|
+
* asked for.
|
|
31
|
+
*/
|
|
32
|
+
function waitForLoad(tabId, timeoutMs) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let settled = false;
|
|
35
|
+
const finish = (outcome) => {
|
|
36
|
+
if (settled) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
settled = true;
|
|
40
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve(outcome);
|
|
43
|
+
};
|
|
44
|
+
const listener = (changedId, change) => {
|
|
45
|
+
if (changedId === tabId && change.status === 'complete') {
|
|
46
|
+
finish('complete');
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const timer = setTimeout(() => finish('timeout'), timeoutMs);
|
|
50
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
51
|
+
// The load may already have finished between the update and this listener.
|
|
52
|
+
void chrome.tabs.get(tabId).then((tab) => {
|
|
53
|
+
if (tab.status === 'complete') {
|
|
54
|
+
finish('complete');
|
|
55
|
+
}
|
|
56
|
+
}).catch(() => finish('timeout'));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function navigate(args) {
|
|
60
|
+
const { tabId, url } = args;
|
|
61
|
+
await chrome.tabs.update(tabId, { url });
|
|
62
|
+
const status = await waitForLoad(tabId, args.timeoutMs ?? NAVIGATE_TIMEOUT_MS);
|
|
63
|
+
const tab = await chrome.tabs.get(tabId);
|
|
64
|
+
return { tabId, url: tab.url ?? url, title: tab.title ?? '', status };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The label on the group holding tabs yoke opened.
|
|
68
|
+
*
|
|
69
|
+
* The tool's own name rather than a generic word: the pill exists to tell the
|
|
70
|
+
* person which thing is working in their browser, and "agent" answers that with
|
|
71
|
+
* nothing. Overridable per call, because someone running two of these at once
|
|
72
|
+
* needs to tell them apart.
|
|
73
|
+
*/
|
|
74
|
+
const DEFAULT_GROUP_TITLE = 'yoke';
|
|
75
|
+
/**
|
|
76
|
+
* The group with this title, creating it only if none exists.
|
|
77
|
+
*
|
|
78
|
+
* Reuse is keyed on the title within one window, which is the whole point: a
|
|
79
|
+
* fresh group per call is exactly how the bridge this replaces left four
|
|
80
|
+
* identical `Claude (MCP)` pills in the tab strip with no way to tell them
|
|
81
|
+
* apart. Safe to do freely because nothing addresses a tab through its group.
|
|
82
|
+
*/
|
|
83
|
+
async function ensureGroup(tabIds, title, windowId, color) {
|
|
84
|
+
// Scoped to one window because a group lives in one: a TabGroup carries a
|
|
85
|
+
// single windowId. A query by title alone can match a group in a different
|
|
86
|
+
// window, and joining that one would haul the tab out of the window it was
|
|
87
|
+
// opened in, which is the rearranging this is meant to avoid.
|
|
88
|
+
const existing = await chrome.tabGroups.query({ title, windowId });
|
|
89
|
+
// Matched exactly rather than taken from the query, because query treats the
|
|
90
|
+
// title as a pattern: a caller passing "*" would otherwise adopt and rename
|
|
91
|
+
// whichever group it happened to match, which is the opposite of only ever
|
|
92
|
+
// touching what we were pointed at.
|
|
93
|
+
const found = existing.find((group) => group.title === title);
|
|
94
|
+
const groupId = found === undefined
|
|
95
|
+
? await chrome.tabs.group({ tabIds })
|
|
96
|
+
: await chrome.tabs.group({ tabIds, groupId: found.id });
|
|
97
|
+
await chrome.tabGroups.update(groupId, {
|
|
98
|
+
title,
|
|
99
|
+
...(color === undefined ? {} : { color }),
|
|
100
|
+
});
|
|
101
|
+
const group = await chrome.tabGroups.get(groupId);
|
|
102
|
+
return { groupId, title: group.title ?? title };
|
|
103
|
+
}
|
|
104
|
+
async function openTab(args) {
|
|
105
|
+
const created = await chrome.tabs.create({
|
|
106
|
+
...(args.url === undefined ? {} : { url: args.url }),
|
|
107
|
+
// Defaults to a background tab: opening something in a script should not
|
|
108
|
+
// yank focus away from whatever the person is doing.
|
|
109
|
+
active: args.active ?? false,
|
|
110
|
+
...(args.windowId === undefined ? {} : { windowId: args.windowId }),
|
|
111
|
+
});
|
|
112
|
+
if (created.id !== undefined && args.url !== undefined) {
|
|
113
|
+
await waitForLoad(created.id, NAVIGATE_TIMEOUT_MS);
|
|
114
|
+
}
|
|
115
|
+
const title = args.groupTitle ?? DEFAULT_GROUP_TITLE;
|
|
116
|
+
let groupId = -1;
|
|
117
|
+
let groupTitle = title;
|
|
118
|
+
if (created.id !== undefined) {
|
|
119
|
+
// Grouping is cosmetic, so a failure here must not fail the open: the tab
|
|
120
|
+
// exists and is usable whether or not the strip shows a pill.
|
|
121
|
+
try {
|
|
122
|
+
const group = await ensureGroup([created.id], title, created.windowId, 'cyan');
|
|
123
|
+
groupId = group.groupId;
|
|
124
|
+
groupTitle = group.title;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
groupId = -1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const tab = created.id === undefined ? created : await chrome.tabs.get(created.id);
|
|
131
|
+
return { tab: describeTab(tab), groupId, groupTitle };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The page's visible text.
|
|
135
|
+
*
|
|
136
|
+
* innerText rather than textContent, because textContent includes script and
|
|
137
|
+
* style bodies and ignores layout, so it returns something no reader would
|
|
138
|
+
* recognise as the page.
|
|
139
|
+
*/
|
|
140
|
+
async function getPageText(args) {
|
|
141
|
+
const { tabId } = args;
|
|
142
|
+
const max = args.maxChars ?? 200_000;
|
|
143
|
+
const [injected] = await chrome.scripting.executeScript({
|
|
144
|
+
target: { tabId },
|
|
145
|
+
func: () => ({
|
|
146
|
+
text: document.body?.innerText ?? '',
|
|
147
|
+
title: document.title,
|
|
148
|
+
url: location.href,
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
const value = injected?.result;
|
|
152
|
+
if (!value) {
|
|
153
|
+
throw new Error(`nothing came back from tab ${tabId}; a chrome:// or Web Store page cannot be read`);
|
|
154
|
+
}
|
|
155
|
+
const truncated = value.text.length > max;
|
|
156
|
+
return {
|
|
157
|
+
tabId,
|
|
158
|
+
url: value.url,
|
|
159
|
+
title: value.title,
|
|
160
|
+
text: truncated ? value.text.slice(0, max) : value.text,
|
|
161
|
+
truncated,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Where a reference is, right now.
|
|
166
|
+
*
|
|
167
|
+
* Resolved in the page at the moment of use rather than taken from the snapshot,
|
|
168
|
+
* because a page that has scrolled since would otherwise be clicked in the wrong
|
|
169
|
+
* place, and the caller would have no way to tell.
|
|
170
|
+
*/
|
|
171
|
+
async function pointFor(tabId, ref) {
|
|
172
|
+
const [found] = await chrome.scripting.executeScript({
|
|
173
|
+
target: { tabId },
|
|
174
|
+
func: locateRef,
|
|
175
|
+
args: [ref],
|
|
176
|
+
});
|
|
177
|
+
const point = found?.result;
|
|
178
|
+
if (!point?.found) {
|
|
179
|
+
throw new Error(`${ref} is not on this page any more. Call read_page again: a navigation or a `
|
|
180
|
+
+ 're-render invalidates every reference from the previous snapshot.');
|
|
181
|
+
}
|
|
182
|
+
return point;
|
|
183
|
+
}
|
|
184
|
+
async function readPage(args) {
|
|
185
|
+
const { tabId } = args;
|
|
186
|
+
const max = args.maxElements ?? 200;
|
|
187
|
+
const [injected] = await chrome.scripting.executeScript({
|
|
188
|
+
target: { tabId },
|
|
189
|
+
func: collectSnapshot,
|
|
190
|
+
args: [max],
|
|
191
|
+
});
|
|
192
|
+
const snapshot = injected?.result;
|
|
193
|
+
if (!snapshot) {
|
|
194
|
+
throw new Error(`nothing came back from tab ${tabId}; Chrome's own pages cannot be read`);
|
|
195
|
+
}
|
|
196
|
+
// Coordinates stay inside the extension. A caller acts by reference.
|
|
197
|
+
const elements = snapshot.elements.map(({ x: _x, y: _y, ...rest }) => rest);
|
|
198
|
+
return {
|
|
199
|
+
tabId,
|
|
200
|
+
url: snapshot.url,
|
|
201
|
+
title: snapshot.title,
|
|
202
|
+
elements,
|
|
203
|
+
truncated: elements.length >= max,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function handle(message) {
|
|
207
|
+
switch (message.op) {
|
|
208
|
+
case 'ping':
|
|
209
|
+
return { extension: chrome.runtime.getManifest().version };
|
|
210
|
+
case 'listTabs':
|
|
211
|
+
return { tabs: (await chrome.tabs.query({})).map(describeTab) };
|
|
212
|
+
case 'listGroups':
|
|
213
|
+
return { groups: (await chrome.tabGroups.query({})).map(describeGroup) };
|
|
214
|
+
case 'navigate':
|
|
215
|
+
return navigate(message.args);
|
|
216
|
+
case 'openTab':
|
|
217
|
+
return openTab((message.args ?? {}));
|
|
218
|
+
case 'closeTab': {
|
|
219
|
+
const { tabId } = message.args;
|
|
220
|
+
await chrome.tabs.remove(tabId);
|
|
221
|
+
return { closed: tabId };
|
|
222
|
+
}
|
|
223
|
+
case 'getPageText':
|
|
224
|
+
return getPageText(message.args);
|
|
225
|
+
case 'readPage':
|
|
226
|
+
return readPage(message.args);
|
|
227
|
+
case 'evaluate': {
|
|
228
|
+
const { tabId, expression } = message.args;
|
|
229
|
+
const outcome = await evaluate(tabId, expression);
|
|
230
|
+
return { tabId, ...outcome };
|
|
231
|
+
}
|
|
232
|
+
case 'screenshot': {
|
|
233
|
+
const args = message.args;
|
|
234
|
+
const format = args.format ?? 'png';
|
|
235
|
+
const shot = await capture(tabId2(args.tabId), format, args.quality);
|
|
236
|
+
return {
|
|
237
|
+
tabId: args.tabId,
|
|
238
|
+
format: shot.format,
|
|
239
|
+
base64: shot.base64,
|
|
240
|
+
bytes: Math.floor((shot.base64.length * 3) / 4),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
case 'click': {
|
|
244
|
+
const args = message.args;
|
|
245
|
+
const point = await pointFor(args.tabId, args.ref);
|
|
246
|
+
await clickAt(args.tabId, point, args.button ?? 'left', args.clickCount ?? 1);
|
|
247
|
+
// What was on top is reported rather than swallowed. CDP says an event was
|
|
248
|
+
// dispatched and nothing about what received it, so this is the only part
|
|
249
|
+
// of the answer that is a claim about the page.
|
|
250
|
+
return {
|
|
251
|
+
tabId: args.tabId,
|
|
252
|
+
ref: args.ref,
|
|
253
|
+
dispatched: true,
|
|
254
|
+
hit: point.hit ?? 'nothing',
|
|
255
|
+
...(point.topmost === undefined ? {} : { topmost: point.topmost }),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
case 'typeText': {
|
|
259
|
+
const args = message.args;
|
|
260
|
+
if (args.ref !== undefined) {
|
|
261
|
+
const point = await pointFor(args.tabId, args.ref);
|
|
262
|
+
await clickAt(args.tabId, point, 'left', 1);
|
|
263
|
+
}
|
|
264
|
+
await insertText(args.tabId, args.text);
|
|
265
|
+
if (args.pressEnter === true) {
|
|
266
|
+
await dispatchKey(args.tabId, 'Enter');
|
|
267
|
+
}
|
|
268
|
+
return { tabId: args.tabId, typed: args.text.length };
|
|
269
|
+
}
|
|
270
|
+
case 'pressKey': {
|
|
271
|
+
const args = message.args;
|
|
272
|
+
if (args.ref !== undefined) {
|
|
273
|
+
const point = await pointFor(args.tabId, args.ref);
|
|
274
|
+
await clickAt(args.tabId, point, 'left', 1);
|
|
275
|
+
}
|
|
276
|
+
await dispatchKey(args.tabId, args.key);
|
|
277
|
+
return { tabId: args.tabId, key: args.key };
|
|
278
|
+
}
|
|
279
|
+
case 'scroll': {
|
|
280
|
+
const args = message.args;
|
|
281
|
+
const point = args.ref === undefined
|
|
282
|
+
? { x: 200, y: 300 }
|
|
283
|
+
: await pointFor(args.tabId, args.ref);
|
|
284
|
+
const dx = args.dx ?? 0;
|
|
285
|
+
const dy = args.dy ?? 400;
|
|
286
|
+
await scrollBy(args.tabId, point, dx, dy);
|
|
287
|
+
return { tabId: args.tabId, dx, dy };
|
|
288
|
+
}
|
|
289
|
+
case 'consoleMessages': {
|
|
290
|
+
const args = message.args;
|
|
291
|
+
const { attachedNow } = await attach(args.tabId);
|
|
292
|
+
return { tabId: args.tabId, messages: consoleFor(args.tabId, args.limit ?? 100), attachedNow };
|
|
293
|
+
}
|
|
294
|
+
case 'networkRequests': {
|
|
295
|
+
const args = message.args;
|
|
296
|
+
const { attachedNow } = await attach(args.tabId);
|
|
297
|
+
return { tabId: args.tabId, requests: networkFor(args.tabId, args.limit ?? 100), attachedNow };
|
|
298
|
+
}
|
|
299
|
+
case 'groupTabs': {
|
|
300
|
+
const args = message.args;
|
|
301
|
+
const members = await Promise.all(args.tabIds.map((id) => chrome.tabs.get(id)));
|
|
302
|
+
const first = members[0];
|
|
303
|
+
if (first === undefined) {
|
|
304
|
+
throw new Error('groupTabs needs at least one tab id');
|
|
305
|
+
}
|
|
306
|
+
// Refused rather than resolved by picking a window, because a group holds
|
|
307
|
+
// tabs from one window and the only way to satisfy this request would be
|
|
308
|
+
// to move the others there.
|
|
309
|
+
const windows = new Set(members.map((tab) => tab.windowId));
|
|
310
|
+
if (windows.size > 1) {
|
|
311
|
+
throw new Error(`those ${args.tabIds.length} tabs are spread across ${windows.size} windows, and a group `
|
|
312
|
+
+ 'holds tabs from one window. Group them a window at a time.');
|
|
313
|
+
}
|
|
314
|
+
const group = await ensureGroup(args.tabIds, args.title ?? DEFAULT_GROUP_TITLE, first.windowId, args.color);
|
|
315
|
+
return { groupId: group.groupId, title: group.title, tabIds: args.tabIds };
|
|
316
|
+
}
|
|
317
|
+
case 'ungroupTabs': {
|
|
318
|
+
const args = message.args;
|
|
319
|
+
await chrome.tabs.ungroup(args.tabIds);
|
|
320
|
+
return { tabIds: args.tabIds };
|
|
321
|
+
}
|
|
322
|
+
case 'release': {
|
|
323
|
+
const args = message.args;
|
|
324
|
+
return { tabId: args.tabId, released: await detach(args.tabId) };
|
|
325
|
+
}
|
|
326
|
+
default:
|
|
327
|
+
throw new Error(`unknown op ${String(message.op)}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** Narrows a tab id that has already been validated by the server. */
|
|
331
|
+
const tabId2 = (value) => value;
|
|
332
|
+
function connect() {
|
|
333
|
+
if (port) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
port = chrome.runtime.connectNative(HOST);
|
|
338
|
+
}
|
|
339
|
+
catch (failure) {
|
|
340
|
+
console.log('yoke: native host unavailable', failure);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
port.onMessage.addListener((message) => {
|
|
344
|
+
if (message?.id === undefined) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
void handle(message)
|
|
348
|
+
.then((data) => {
|
|
349
|
+
const reply = { id: message.id, ok: true, data };
|
|
350
|
+
port?.postMessage(reply);
|
|
351
|
+
})
|
|
352
|
+
.catch((thrown) => {
|
|
353
|
+
const reply = {
|
|
354
|
+
id: message.id,
|
|
355
|
+
ok: false,
|
|
356
|
+
error: thrown instanceof Error ? thrown.message : String(thrown),
|
|
357
|
+
};
|
|
358
|
+
port?.postMessage(reply);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
// An open port keeps the service worker alive, and a dropped port means the
|
|
362
|
+
// host went away. Reconnecting on a delay is what lets `install` followed by a
|
|
363
|
+
// first call work without reloading the extension by hand.
|
|
364
|
+
port.onDisconnect.addListener(() => {
|
|
365
|
+
port = undefined;
|
|
366
|
+
setTimeout(connect, 1_000);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
chrome.runtime.onStartup.addListener(connect);
|
|
370
|
+
chrome.runtime.onInstalled.addListener(connect);
|
|
371
|
+
connect();
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/** How much history to keep per tab, so a long-lived attachment cannot grow without bound. */
|
|
2
|
+
const BUFFER_LIMIT = 500;
|
|
3
|
+
const state = new Map();
|
|
4
|
+
const stateFor = (tabId) => {
|
|
5
|
+
let existing = state.get(tabId);
|
|
6
|
+
if (!existing) {
|
|
7
|
+
existing = { console: [], network: [], attached: false, painting: false };
|
|
8
|
+
state.set(tabId, existing);
|
|
9
|
+
}
|
|
10
|
+
return existing;
|
|
11
|
+
};
|
|
12
|
+
const push = (buffer, entry) => {
|
|
13
|
+
buffer.push(entry);
|
|
14
|
+
if (buffer.length > BUFFER_LIMIT) {
|
|
15
|
+
buffer.shift();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Records console and network events for tabs we are attached to.
|
|
20
|
+
*
|
|
21
|
+
* Registered once for the whole extension rather than per tab: chrome.debugger
|
|
22
|
+
* delivers every attached target through this one listener, and adding it per
|
|
23
|
+
* attachment would leak a listener per tab.
|
|
24
|
+
*/
|
|
25
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
26
|
+
const tabId = source.tabId;
|
|
27
|
+
if (tabId === undefined) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const tab = state.get(tabId);
|
|
31
|
+
if (!tab) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (method === 'Runtime.consoleAPICalled') {
|
|
35
|
+
const event = params;
|
|
36
|
+
const text = (event.args ?? [])
|
|
37
|
+
.map((arg) => (arg.value !== undefined ? String(arg.value) : arg.description ?? ''))
|
|
38
|
+
.join(' ');
|
|
39
|
+
push(tab.console, { level: event.type ?? 'log', text, at: Date.now() });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (method === 'Log.entryAdded') {
|
|
43
|
+
const entry = params.entry;
|
|
44
|
+
if (entry) {
|
|
45
|
+
push(tab.console, {
|
|
46
|
+
level: entry.level ?? 'info',
|
|
47
|
+
text: entry.text ?? '',
|
|
48
|
+
...(entry.url === undefined ? {} : { url: entry.url }),
|
|
49
|
+
...(entry.lineNumber === undefined ? {} : { line: entry.lineNumber }),
|
|
50
|
+
at: Date.now(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (method === 'Runtime.exceptionThrown') {
|
|
56
|
+
const details = params
|
|
57
|
+
.exceptionDetails;
|
|
58
|
+
push(tab.console, {
|
|
59
|
+
level: 'error',
|
|
60
|
+
text: details?.exception?.description ?? details?.text ?? 'uncaught exception',
|
|
61
|
+
at: Date.now(),
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (method === 'Network.requestWillBeSent') {
|
|
66
|
+
const event = params;
|
|
67
|
+
push(tab.network, {
|
|
68
|
+
method: event.request?.method ?? 'GET',
|
|
69
|
+
url: event.request?.url ?? '',
|
|
70
|
+
...(event.type === undefined ? {} : { type: event.type }),
|
|
71
|
+
at: Date.now(),
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (method === 'Network.responseReceived') {
|
|
76
|
+
const event = params;
|
|
77
|
+
// Fills in the status on the most recent matching request rather than
|
|
78
|
+
// recording a second row for the same exchange.
|
|
79
|
+
const match = [...tab.network].reverse().find((entry) => entry.url === event.response?.url);
|
|
80
|
+
if (match && event.response?.status !== undefined) {
|
|
81
|
+
match.status = event.response.status;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
// Chrome detaches when a tab closes, DevTools opens, or the user dismisses the
|
|
86
|
+
// infobar. Forgetting the state keeps a later attach from looking already-done.
|
|
87
|
+
chrome.debugger.onDetach.addListener((source) => {
|
|
88
|
+
if (source.tabId !== undefined) {
|
|
89
|
+
state.delete(source.tabId);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
chrome.tabs.onRemoved.addListener((tabId) => { state.delete(tabId); });
|
|
93
|
+
const send = (tabId, method, params) => chrome.debugger.sendCommand({ tabId }, method, params);
|
|
94
|
+
/**
|
|
95
|
+
* Attaches to a tab if it is not already attached, and enables the domains whose
|
|
96
|
+
* events we buffer.
|
|
97
|
+
*
|
|
98
|
+
* Idempotent, because every operation calls it: a caller should not have to know
|
|
99
|
+
* whether some earlier call already attached.
|
|
100
|
+
*/
|
|
101
|
+
export async function attach(tabId) {
|
|
102
|
+
const tab = stateFor(tabId);
|
|
103
|
+
if (tab.attached) {
|
|
104
|
+
return { attachedNow: false };
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await chrome.debugger.attach({ tabId }, '1.3');
|
|
108
|
+
}
|
|
109
|
+
catch (failure) {
|
|
110
|
+
const message = failure instanceof Error ? failure.message : String(failure);
|
|
111
|
+
// Chrome's own wording when DevTools holds the target. Worth translating,
|
|
112
|
+
// because "another debugger" is not obviously "close your DevTools".
|
|
113
|
+
if (/already attached/i.test(message)) {
|
|
114
|
+
throw new Error(`tab ${tabId} already has a debugger attached, which is usually DevTools being open on it. `
|
|
115
|
+
+ 'Chrome allows one debugger client per tab, so close DevTools there and retry.');
|
|
116
|
+
}
|
|
117
|
+
throw failure;
|
|
118
|
+
}
|
|
119
|
+
tab.attached = true;
|
|
120
|
+
// Enabled together so console and network history accumulate from the moment
|
|
121
|
+
// the tab is first driven, rather than only after someone asks for them.
|
|
122
|
+
await Promise.all([
|
|
123
|
+
send(tabId, 'Runtime.enable'),
|
|
124
|
+
send(tabId, 'Log.enable'),
|
|
125
|
+
send(tabId, 'Network.enable'),
|
|
126
|
+
send(tabId, 'Page.enable'),
|
|
127
|
+
]);
|
|
128
|
+
tab.painting = await keepPainting(tabId);
|
|
129
|
+
return { attachedNow: true };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Makes a tab produce frames even while it is not the selected tab.
|
|
133
|
+
*
|
|
134
|
+
* Without this, input dispatched to a tab that has never rendered goes nowhere
|
|
135
|
+
* and reports success. Nothing rejects the event: RenderWidgetHostImpl's input
|
|
136
|
+
* filter has no visibility check at all. The renderer simply never runs the
|
|
137
|
+
* frame that would process it, because the compositor stops asking for frames
|
|
138
|
+
* when the widget is not visible.
|
|
139
|
+
*
|
|
140
|
+
* setFocusEmulationEnabled takes a visible capturer handle on the WebContents,
|
|
141
|
+
* which releases that deferral while leaving the tab unselected in the strip and
|
|
142
|
+
* the window untouched. It is what Playwright sends once per page for the same
|
|
143
|
+
* reason, and it survives navigation, so once per attachment is enough.
|
|
144
|
+
*
|
|
145
|
+
* The command is experimental, so a Chrome that refuses it must still work:
|
|
146
|
+
* a screenshot takes a weaker (hidden) capturer handle that also releases the
|
|
147
|
+
* deferral, and is the measured fallback.
|
|
148
|
+
*/
|
|
149
|
+
async function keepPainting(tabId) {
|
|
150
|
+
try {
|
|
151
|
+
await send(tabId, 'Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
try {
|
|
156
|
+
await send(tabId, 'Page.captureScreenshot', { format: 'jpeg', quality: 1 });
|
|
157
|
+
}
|
|
158
|
+
catch { /* Nothing else to try: input on this tab may not land. */ }
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export async function detach(tabId) {
|
|
163
|
+
const tab = state.get(tabId);
|
|
164
|
+
state.delete(tabId);
|
|
165
|
+
if (!tab?.attached) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
await chrome.debugger.detach({ tabId });
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export const consoleFor = (tabId, limit) => (state.get(tabId)?.console ?? []).slice(-limit);
|
|
177
|
+
export const networkFor = (tabId, limit) => (state.get(tabId)?.network ?? []).slice(-limit);
|
|
178
|
+
export async function evaluate(tabId, expression) {
|
|
179
|
+
await attach(tabId);
|
|
180
|
+
const reply = await send(tabId, 'Runtime.evaluate', {
|
|
181
|
+
expression,
|
|
182
|
+
returnByValue: true,
|
|
183
|
+
awaitPromise: true,
|
|
184
|
+
// The page's own world, so it sees the page's variables. An isolated world
|
|
185
|
+
// would answer questions about a context nobody asked about.
|
|
186
|
+
userGesture: true,
|
|
187
|
+
});
|
|
188
|
+
if (reply.exceptionDetails) {
|
|
189
|
+
return {
|
|
190
|
+
value: reply.exceptionDetails.exception?.description ?? reply.exceptionDetails.text ?? 'threw',
|
|
191
|
+
type: 'error',
|
|
192
|
+
threw: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const result = reply.result ?? {};
|
|
196
|
+
const value = result.value === undefined
|
|
197
|
+
? result.description ?? 'undefined'
|
|
198
|
+
: typeof result.value === 'string' ? result.value : JSON.stringify(result.value);
|
|
199
|
+
return { value, type: result.type ?? 'undefined', threw: false };
|
|
200
|
+
}
|
|
201
|
+
export async function screenshot(tabId, format, quality) {
|
|
202
|
+
await attach(tabId);
|
|
203
|
+
// Page.captureScreenshot rather than chrome.tabs.captureVisibleTab, which can
|
|
204
|
+
// only photograph the active tab of a window. Going through CDP is what makes
|
|
205
|
+
// a background tab capturable at all.
|
|
206
|
+
const reply = await send(tabId, 'Page.captureScreenshot', {
|
|
207
|
+
format,
|
|
208
|
+
...(format === 'jpeg' && quality !== undefined ? { quality } : {}),
|
|
209
|
+
captureBeyondViewport: false,
|
|
210
|
+
});
|
|
211
|
+
return { base64: reply.data, format };
|
|
212
|
+
}
|
|
213
|
+
export async function clickAt(tabId, point, button, clickCount) {
|
|
214
|
+
await attach(tabId);
|
|
215
|
+
const base = { x: point.x, y: point.y, button, clickCount };
|
|
216
|
+
await send(tabId, 'Input.dispatchMouseEvent', { ...base, type: 'mouseMoved', button: 'none' });
|
|
217
|
+
await send(tabId, 'Input.dispatchMouseEvent', { ...base, type: 'mousePressed' });
|
|
218
|
+
await send(tabId, 'Input.dispatchMouseEvent', { ...base, type: 'mouseReleased' });
|
|
219
|
+
}
|
|
220
|
+
export async function insertText(tabId, text) {
|
|
221
|
+
await attach(tabId);
|
|
222
|
+
// Input.insertText rather than a keydown per character: it is one round trip
|
|
223
|
+
// instead of hundreds, and it handles characters no single key produces.
|
|
224
|
+
await send(tabId, 'Input.insertText', { text });
|
|
225
|
+
}
|
|
226
|
+
export async function pressKey(tabId, key) {
|
|
227
|
+
await attach(tabId);
|
|
228
|
+
const named = {
|
|
229
|
+
Enter: { code: 'Enter', keyCode: 13, text: '\r' },
|
|
230
|
+
Tab: { code: 'Tab', keyCode: 9 },
|
|
231
|
+
Escape: { code: 'Escape', keyCode: 27 },
|
|
232
|
+
Backspace: { code: 'Backspace', keyCode: 8 },
|
|
233
|
+
ArrowUp: { code: 'ArrowUp', keyCode: 38 },
|
|
234
|
+
ArrowDown: { code: 'ArrowDown', keyCode: 40 },
|
|
235
|
+
ArrowLeft: { code: 'ArrowLeft', keyCode: 37 },
|
|
236
|
+
ArrowRight: { code: 'ArrowRight', keyCode: 39 },
|
|
237
|
+
};
|
|
238
|
+
const spec = named[key];
|
|
239
|
+
if (!spec) {
|
|
240
|
+
throw new Error(`unknown key ${JSON.stringify(key)}. Known: ${Object.keys(named).join(', ')}`);
|
|
241
|
+
}
|
|
242
|
+
const common = { key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode };
|
|
243
|
+
await send(tabId, 'Input.dispatchKeyEvent', {
|
|
244
|
+
...common,
|
|
245
|
+
type: spec.text === undefined ? 'keyDown' : 'keyDown',
|
|
246
|
+
...(spec.text === undefined ? {} : { text: spec.text }),
|
|
247
|
+
});
|
|
248
|
+
await send(tabId, 'Input.dispatchKeyEvent', { ...common, type: 'keyUp' });
|
|
249
|
+
}
|
|
250
|
+
export async function scrollBy(tabId, point, dx, dy) {
|
|
251
|
+
await attach(tabId);
|
|
252
|
+
await send(tabId, 'Input.dispatchMouseEvent', {
|
|
253
|
+
type: 'mouseWheel',
|
|
254
|
+
x: point.x,
|
|
255
|
+
y: point.y,
|
|
256
|
+
deltaX: dx,
|
|
257
|
+
deltaY: dy,
|
|
258
|
+
});
|
|
259
|
+
}
|