@evan_zhong/browser-attach 0.0.0-stage → 0.11.1
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/CONTRIBUTING.md +27 -0
- package/LICENSE +21 -0
- package/README.md +169 -3
- package/bin/browser-attach.mjs +43 -0
- package/docs/ARCHITECTURE.md +131 -0
- package/docs/NOSIS-INTEGRATION.md +53 -0
- package/docs/TEST-REPORT.md +39 -0
- package/docs/USAGE.md +372 -0
- package/extension/actionability.js +66 -0
- package/extension/background.js +98 -0
- package/extension/batch.js +32 -0
- package/extension/click-visuals.js +82 -0
- package/extension/click.js +308 -0
- package/extension/controller.js +812 -0
- package/extension/diagnostics.js +205 -0
- package/extension/drag.js +163 -0
- package/extension/evaluate.js +131 -0
- package/extension/icons/BrowserAttach.png +0 -0
- package/extension/input.js +318 -0
- package/extension/limits.js +6 -0
- package/extension/locator.js +115 -0
- package/extension/manifest.json +34 -0
- package/extension/navigation.js +65 -0
- package/extension/options.html +6 -0
- package/extension/options.js +15 -0
- package/extension/outcomes.js +15 -0
- package/extension/popup.html +5 -0
- package/extension/popup.js +10 -0
- package/extension/scroll.js +29 -0
- package/extension/sessions.js +100 -0
- package/extension/snapshot.js +75 -0
- package/extension/text.js +37 -0
- package/extension/ui.css +12 -0
- package/extension/visuals.js +93 -0
- package/extension/wait.js +81 -0
- package/mcp-config.json +8 -0
- package/package.json +54 -6
- package/scripts/check-package.mjs +20 -0
- package/src/client.mjs +91 -0
- package/src/config.mjs +36 -0
- package/src/hub.mjs +197 -0
- package/src/mcp.mjs +179 -0
- package/src/setup.mjs +59 -0
- package/src/upload.mjs +15 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { deadline, waitForTarget, delivery } from './actionability.js';
|
|
2
|
+
import { clickOptions } from './click.js';
|
|
3
|
+
|
|
4
|
+
const BUSY = 'Browser UI may be capturing input (a tab drag or native menu). Finish that interaction, then observe the page before deciding whether to try again. Never replay an unknown outcome automatically.';
|
|
5
|
+
|
|
6
|
+
export function inputMode(args) {
|
|
7
|
+
const mode = args.mode ?? 'native';
|
|
8
|
+
if (!['native', 'dom'].includes(mode)) throw new Error('mode must be native or dom');
|
|
9
|
+
return mode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function bounded(operation, message, ms = 5000) {
|
|
13
|
+
let timer;
|
|
14
|
+
try { return await Promise.race([operation, new Promise((resolve, reject) => {
|
|
15
|
+
timer = setTimeout(() => reject(new Error(`${message} Outcome is unknown. ${BUSY}`)), ms);
|
|
16
|
+
})]); }
|
|
17
|
+
finally { clearTimeout(timer); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createInput(cdp, value, sessions) {
|
|
21
|
+
const pending = new Map();
|
|
22
|
+
function event(tabId, method, params, sessionId) {
|
|
23
|
+
const operation = pending.get(tabId);
|
|
24
|
+
if (!operation) return;
|
|
25
|
+
if (method === 'Page.javascriptDialogOpening') {
|
|
26
|
+
operation.reject(new Error('The key action opened a JavaScript dialog. It may already have executed; use browser_snapshot and browser_handle_dialog before continuing.'));
|
|
27
|
+
}
|
|
28
|
+
if (method !== 'Runtime.bindingCalled' || params.name !== operation.name || !operation.contexts.has(JSON.stringify([sessionId, params.executionContextId]))) return;
|
|
29
|
+
try { if (JSON.parse(params.payload).delivered === true) operation.resolve(); }
|
|
30
|
+
catch (error) { operation.reject(error); }
|
|
31
|
+
}
|
|
32
|
+
function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during keyboard input; outcome is unknown. Observe before retrying.')); }
|
|
33
|
+
|
|
34
|
+
async function pressKey(tabId, chord, args = {}) {
|
|
35
|
+
const budget = args.budget || deadline(args.timeout_ms);
|
|
36
|
+
const send = (...params) => budget.run(() => cdp(...params));
|
|
37
|
+
const { params, text } = args.character != null ? characterParameters(args.character) : keyParameters(chord);
|
|
38
|
+
const name = `__attachKey_${crypto.randomUUID().replaceAll('-', '')}`;
|
|
39
|
+
const contexts = new Set(), guards = [], omittedFrames = [];
|
|
40
|
+
const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject }));
|
|
41
|
+
receipt.catch(() => {});
|
|
42
|
+
try {
|
|
43
|
+
const frames = await budget.run(() => sessions.frames(tabId, send));
|
|
44
|
+
const worlds = new Map();
|
|
45
|
+
for (const frame of frames) {
|
|
46
|
+
const target = sessions.target(tabId, frame.id);
|
|
47
|
+
let contextId;
|
|
48
|
+
try {
|
|
49
|
+
const { executionContextId } = await send(target, 'Page.createIsolatedWorld', { frameId: frame.id, worldName: 'browser-attach-input' });
|
|
50
|
+
if (!executionContextId) throw new Error('Could not create keyboard verification context; no input was dispatched.');
|
|
51
|
+
contextId = executionContextId;
|
|
52
|
+
worlds.set(frame.id, { target, contextId, frameId: frame.id });
|
|
53
|
+
contexts.add(JSON.stringify([target.sessionId, executionContextId]));
|
|
54
|
+
await send(target, 'Runtime.addBinding', { name, executionContextId });
|
|
55
|
+
const installed = await send(target, 'Runtime.evaluate', {
|
|
56
|
+
contextId: executionContextId, expression: `(${installKeyReceipt.toString()})(${JSON.stringify(name)},${JSON.stringify(params)},${budget.remaining() + 500})`,
|
|
57
|
+
objectGroup: 'browser-attach',
|
|
58
|
+
});
|
|
59
|
+
if (installed.exceptionDetails) value(installed);
|
|
60
|
+
if (!installed.result?.objectId) throw new Error('Could not install keyboard verification; no input was dispatched.');
|
|
61
|
+
guards.push({ target, objectId: installed.result.objectId });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (frame.id === frames[0].id) throw error;
|
|
64
|
+
contexts.delete(JSON.stringify([target.sessionId, contextId]));
|
|
65
|
+
omittedFrames.push({ frame_id: frame.id, reason: error.message });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let focusTarget = worlds.get(frames[0].id), focusedNode;
|
|
69
|
+
const visited = new Set();
|
|
70
|
+
while (focusTarget) {
|
|
71
|
+
const { target, contextId } = focusTarget;
|
|
72
|
+
if (visited.has(contextId + ':' + target.sessionId)) throw new Error('Focus frame cycle; no input was dispatched.');
|
|
73
|
+
visited.add(contextId + ':' + target.sessionId);
|
|
74
|
+
const active = await send(target, 'Runtime.evaluate', { contextId,
|
|
75
|
+
expression: `(${activeElement.toString()})()`, objectGroup: 'browser-attach' });
|
|
76
|
+
if (!active.result?.objectId) throw new Error('No focused page element is available; no input was dispatched.');
|
|
77
|
+
const { node } = await send(target, 'DOM.describeNode', { objectId: active.result.objectId });
|
|
78
|
+
if (node.frameId) {
|
|
79
|
+
focusTarget = worlds.get(node.frameId);
|
|
80
|
+
if (!focusTarget) throw new Error('Focused frame is unavailable; no input was dispatched.');
|
|
81
|
+
} else { focusedNode = { objectId: active.result.objectId }; break; }
|
|
82
|
+
}
|
|
83
|
+
await waitForTarget(cdp, value, focusTarget.target, focusedNode, budget);
|
|
84
|
+
if (args.expectedNode) {
|
|
85
|
+
const expected = args.expectedNode;
|
|
86
|
+
const focused = focusTarget.frameId === expected.frameId && value(await send(expected.target, 'Runtime.callFunctionOn', { objectId: expected.objectId,
|
|
87
|
+
functionDeclaration: 'function() { const a=this.getRootNode().activeElement; return this.isConnected && (a===this || this.contains(a)); }', returnByValue: true }));
|
|
88
|
+
if (!focused) throw new Error('Typing target lost focus during key preparation; no further key was dispatched');
|
|
89
|
+
}
|
|
90
|
+
budget.dispatch();
|
|
91
|
+
// Queue the complete chord before waiting for acknowledgements. Always
|
|
92
|
+
// enqueue keyUp, even if keyDown fails or opens a modal.
|
|
93
|
+
const commands = [send(focusTarget.target, 'Input.dispatchKeyEvent', { type: args.character != null ? 'keyDown' : 'rawKeyDown', ...(args.character != null ? { text, unmodifiedText: text } : {}), ...params })];
|
|
94
|
+
if (text != null && args.character == null) commands.push(send(focusTarget.target, 'Input.dispatchKeyEvent', { type: 'char', text, unmodifiedText: text, ...params }));
|
|
95
|
+
commands.push(send(focusTarget.target, 'Input.dispatchKeyEvent', { type: 'keyUp', ...params }));
|
|
96
|
+
await budget.run(() => delivery(Promise.all([Promise.all(commands), receipt])));
|
|
97
|
+
return { sent: chord, method: 'native', delivery_verified: true, application_result: 'not_checked',
|
|
98
|
+
...(omittedFrames.length ? { verification_omitted_frames: omittedFrames } : {}), observe_again: true };
|
|
99
|
+
} finally {
|
|
100
|
+
pending.delete(tabId);
|
|
101
|
+
const cleanup = guards.flatMap(({ target, objectId }) => [cdp(target, 'Runtime.removeBinding', { name }),
|
|
102
|
+
cdp(target, 'Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { this.stop(); }' })]);
|
|
103
|
+
await bounded(Promise.allSettled(cleanup), 'Keyboard verification cleanup timed out.', 250).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function fill(tabId, node, args) {
|
|
108
|
+
if (!node || typeof args.text !== 'string') throw new Error('fill requires ref or selector, and text');
|
|
109
|
+
const mode = inputMode(args);
|
|
110
|
+
const budget = args.budget || deadline(args.timeout_ms), target = node.target;
|
|
111
|
+
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
112
|
+
await waitForTarget(cdp, value, target, node, budget, { editable: true });
|
|
113
|
+
budget.dispatch();
|
|
114
|
+
value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
|
|
115
|
+
functionDeclaration: prepareFill.toString(), arguments: [{ value: mode }, { value: args.text }] }));
|
|
116
|
+
if (mode === 'native') await send('Input.insertText', { text: args.text });
|
|
117
|
+
const matches = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
|
|
118
|
+
functionDeclaration: verifyFill.toString(), arguments: [{ value: args.text }] }));
|
|
119
|
+
if (matches !== true) throw new Error(`Fill did not retain the requested text. Input may have been swallowed, focus may have moved, or the page may have reformatted it. Outcome is unknown. ${BUSY}`);
|
|
120
|
+
return { filled: true, method: mode, value_verified: true, ...(mode === 'dom' ? { events_trusted: false } : {}), application_result: 'not_checked', observe_again: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function domClick(tabId, node, args) {
|
|
124
|
+
if (!node || args.x != null || args.y != null) throw new Error('DOM click requires ref or selector; coordinates are unsupported.');
|
|
125
|
+
clickOptions(args);
|
|
126
|
+
const budget = args.budget || deadline(args.timeout_ms), target = node.target;
|
|
127
|
+
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
128
|
+
await waitForTarget(cdp, value, target, node, budget);
|
|
129
|
+
budget.dispatch();
|
|
130
|
+
const result = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
|
|
131
|
+
functionDeclaration: clickElement.toString() }));
|
|
132
|
+
return { ...result, method: 'dom', events_trusted: false, application_result: 'not_checked', observe_again: true };
|
|
133
|
+
}
|
|
134
|
+
async function selectOption(tabId, node, args) {
|
|
135
|
+
selectOptions(args);
|
|
136
|
+
const budget = args.budget || deadline(args.timeout_ms);
|
|
137
|
+
await waitForTarget(cdp, value, node.target, node, budget);
|
|
138
|
+
budget.dispatch();
|
|
139
|
+
const result = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', {
|
|
140
|
+
objectId: node.objectId, functionDeclaration: selectElement.toString(),
|
|
141
|
+
arguments: [{ value: { value: args.value, label: args.label, index: args.index } }], returnByValue: true,
|
|
142
|
+
})));
|
|
143
|
+
return { ...result, method: 'dom', events_trusted: false, application_result: 'not_checked', observe_again: true };
|
|
144
|
+
}
|
|
145
|
+
async function typeText(tabId, node, args) {
|
|
146
|
+
typeOptions(args);
|
|
147
|
+
const budget = args.budget || deadline(args.timeout_ms);
|
|
148
|
+
await waitForTarget(cdp, value, node.target, node, budget, { editable: true });
|
|
149
|
+
budget.dispatch();
|
|
150
|
+
value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
|
|
151
|
+
functionDeclaration: 'function() { this.focus(); if (this.getRootNode().activeElement !== this && !this.contains(this.getRootNode().activeElement)) throw new Error("Field did not receive focus"); }', returnByValue: true })));
|
|
152
|
+
let typed = 0; const length = Array.from(args.text).length;
|
|
153
|
+
for (const character of args.text) {
|
|
154
|
+
const focused = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
|
|
155
|
+
functionDeclaration: 'function() { const a=this.getRootNode().activeElement; return this.isConnected && (a===this || this.contains(a)); }', returnByValue: true })));
|
|
156
|
+
if (!focused) throw new Error(`Typing stopped after ${typed} characters: original field detached or lost focus. Outcome is unknown; do not replay.`);
|
|
157
|
+
try { await pressKey(tabId, character, { budget, character, expectedNode: node }); }
|
|
158
|
+
catch (error) { error.message = `Typing stopped after ${typed} verified characters; the current character may already have been delivered. ${error.message}`; throw error; }
|
|
159
|
+
typed++;
|
|
160
|
+
if (args.delay_ms && typed < length) await budget.run(() => new Promise(resolve => setTimeout(resolve, args.delay_ms)));
|
|
161
|
+
}
|
|
162
|
+
return { typed, method: 'native', events_trusted: true, delivery_verified: true, application_result: 'not_checked', observe_again: true };
|
|
163
|
+
}
|
|
164
|
+
return { pressKey, fill, typeText, domClick, selectOption, event, cancel };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function installKeyReceipt(name, params, expiryMs) {
|
|
168
|
+
const report = globalThis[name];
|
|
169
|
+
function listener(event) {
|
|
170
|
+
const modifiers = (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
|
|
171
|
+
if (event.isTrusted && event.code === params.code && event.key === params.key && modifiers === params.modifiers) {
|
|
172
|
+
report(JSON.stringify({ delivered: true })); stop();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
let expiry;
|
|
176
|
+
function stop() { clearTimeout(expiry); globalThis.removeEventListener('keydown', listener, true); delete globalThis[name]; }
|
|
177
|
+
globalThis.addEventListener('keydown', listener, true);
|
|
178
|
+
expiry = setTimeout(stop, expiryMs);
|
|
179
|
+
return { stop };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const NAMED_KEYS = {
|
|
183
|
+
Enter: [13, 'Enter'], Tab: [9, 'Tab'], Escape: [27, 'Escape'], Backspace: [8, 'Backspace'],
|
|
184
|
+
Insert: [45, 'Insert'], Delete: [46, 'Delete'], ArrowLeft: [37, 'ArrowLeft'], ArrowRight: [39, 'ArrowRight'],
|
|
185
|
+
ArrowUp: [38, 'ArrowUp'], ArrowDown: [40, 'ArrowDown'], Home: [36, 'Home'], End: [35, 'End'],
|
|
186
|
+
PageUp: [33, 'PageUp'], PageDown: [34, 'PageDown'],
|
|
187
|
+
};
|
|
188
|
+
// Deterministic US keyboard mapping: [plain, shifted, virtual key, physical code].
|
|
189
|
+
const PRINTABLE_KEYS = [
|
|
190
|
+
[' ', ' ', 32, 'Space'], ['0', ')', 48, 'Digit0'], ['1', '!', 49, 'Digit1'], ['2', '@', 50, 'Digit2'],
|
|
191
|
+
['3', '#', 51, 'Digit3'], ['4', '$', 52, 'Digit4'], ['5', '%', 53, 'Digit5'], ['6', '^', 54, 'Digit6'],
|
|
192
|
+
['7', '&', 55, 'Digit7'], ['8', '*', 56, 'Digit8'], ['9', '(', 57, 'Digit9'],
|
|
193
|
+
[';', ':', 186, 'Semicolon'], ['=', '+', 187, 'Equal'], [',', '<', 188, 'Comma'], ['-', '_', 189, 'Minus'],
|
|
194
|
+
['.', '>', 190, 'Period'], ['/', '?', 191, 'Slash'], ['`', '~', 192, 'Backquote'],
|
|
195
|
+
['[', '{', 219, 'BracketLeft'], ['\\', '|', 220, 'Backslash'], [']', '}', 221, 'BracketRight'], ["'", '"', 222, 'Quote'],
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
export function keyParameters(chord) {
|
|
199
|
+
if (typeof chord !== 'string') throw new Error('key must be a string');
|
|
200
|
+
const modifiers = { Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Shift: 8 };
|
|
201
|
+
const parts = chord.split('+');
|
|
202
|
+
let key = parts.pop();
|
|
203
|
+
// A literal plus may appear alone or at the end of a chord: +, Control++.
|
|
204
|
+
if (key === '' && parts.at(-1) === '') { key = '+'; parts.pop(); }
|
|
205
|
+
let mask = 0;
|
|
206
|
+
for (const part of parts) {
|
|
207
|
+
if (!Object.hasOwn(modifiers, part)) throw new Error(`Unknown modifier: ${part}`);
|
|
208
|
+
mask |= modifiers[part];
|
|
209
|
+
}
|
|
210
|
+
let entry = Object.hasOwn(NAMED_KEYS, key) ? NAMED_KEYS[key] : null, text;
|
|
211
|
+
if (!entry && /^F([1-9]|1[0-2])$/.test(key)) entry = [111 + Number(key.slice(1)), key];
|
|
212
|
+
if (!entry && /^[a-zA-Z]$/.test(key)) {
|
|
213
|
+
entry = [key.toUpperCase().charCodeAt(0), `Key${key.toUpperCase()}`];
|
|
214
|
+
key = mask & 8 ? key.toUpperCase() : key.toLowerCase();
|
|
215
|
+
if ((mask & ~8) === 0) text = key;
|
|
216
|
+
}
|
|
217
|
+
if (!entry) {
|
|
218
|
+
const row = PRINTABLE_KEYS.find(([plain, shifted, , code]) => key === plain || key === shifted || key === code || (key === 'Plus' && shifted === '+'));
|
|
219
|
+
if (row) {
|
|
220
|
+
const [plain, shifted, vk, code] = row;
|
|
221
|
+
if (plain !== shifted && (key === shifted || key === 'Plus')) mask |= 8;
|
|
222
|
+
key = mask & 8 ? shifted : plain;
|
|
223
|
+
entry = [vk, code];
|
|
224
|
+
if ((mask & ~8) === 0) text = key;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!entry) throw new Error('Unsupported key. Use F1-F12, a named navigation/editing key, ASCII letter/digit or US punctuation.');
|
|
228
|
+
if ((mask & ~8) === 0 && key === 'Enter') text = '\r';
|
|
229
|
+
if (mask === 0 && key === 'Tab') text = '\t';
|
|
230
|
+
return { params: { key, code: entry[1], windowsVirtualKeyCode: entry[0], nativeVirtualKeyCode: entry[0], modifiers: mask }, text };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function prepareFill(mode, text) {
|
|
234
|
+
const tag = this.tagName?.toLowerCase();
|
|
235
|
+
if (!this.isConnected) throw new Error('Field is detached');
|
|
236
|
+
if (this.disabled || this.readOnly || this.closest('[inert]')) throw new Error('Field is disabled, read-only or inert');
|
|
237
|
+
if (!['input', 'textarea'].includes(tag) && !this.isContentEditable) throw new Error('Ref is not an editable field');
|
|
238
|
+
if (tag === 'input' && !['text', 'search', 'email', 'password', 'tel', 'url', ''].includes(this.type)) throw new Error('Input type cannot be filled with text');
|
|
239
|
+
if (mode === 'dom') {
|
|
240
|
+
const win = this.ownerDocument.defaultView;
|
|
241
|
+
if (this.isContentEditable) this.textContent = text;
|
|
242
|
+
else {
|
|
243
|
+
const prototype = tag === 'textarea' ? win.HTMLTextAreaElement.prototype : win.HTMLInputElement.prototype;
|
|
244
|
+
Object.getOwnPropertyDescriptor(prototype, 'value').set.call(this, text);
|
|
245
|
+
}
|
|
246
|
+
this.dispatchEvent(new win.InputEvent('input', { bubbles: true, composed: true, inputType: 'insertReplacementText', data: text }));
|
|
247
|
+
this.dispatchEvent(new win.Event('change', { bubbles: true }));
|
|
248
|
+
} else {
|
|
249
|
+
this.focus();
|
|
250
|
+
const active = this.getRootNode().activeElement;
|
|
251
|
+
if (active !== this && !this.contains(active)) throw new Error('Field did not receive focus');
|
|
252
|
+
if (this.isContentEditable) {
|
|
253
|
+
const range = this.ownerDocument.createRange(); range.selectNodeContents(this);
|
|
254
|
+
const selection = this.ownerDocument.getSelection(); selection.removeAllRanges(); selection.addRange(range);
|
|
255
|
+
} else this.select();
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function verifyFill(text) {
|
|
261
|
+
// Native editing may represent line breaks using nested blocks or <br>.
|
|
262
|
+
return this.isConnected && (this.isContentEditable ? this.textContent === text || this.innerText === text : this.value === text);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function clickElement() {
|
|
266
|
+
const element = this.nodeType === 3 ? this.parentElement : this;
|
|
267
|
+
if (!element?.isConnected) throw new Error('Element is detached');
|
|
268
|
+
if (element.matches(':disabled') || element.closest('[inert]')) throw new Error('Element is disabled or inert');
|
|
269
|
+
const win = element.ownerDocument.defaultView, rect = element.getBoundingClientRect(), style = win.getComputedStyle(element);
|
|
270
|
+
if (style.visibility !== 'visible' || !rect.width || !rect.height) throw new Error('Element is not visible');
|
|
271
|
+
if (!(element instanceof win.HTMLElement)) throw new Error('DOM click supports HTML elements only; use native input for this ref.');
|
|
272
|
+
win.HTMLElement.prototype.click.call(element);
|
|
273
|
+
return { clicked: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function activeElement() {
|
|
277
|
+
let element = document.activeElement;
|
|
278
|
+
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
|
|
279
|
+
return element;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function selectOptions(args) {
|
|
283
|
+
if (['value', 'label', 'index'].filter(key => args[key] != null).length !== 1) throw new Error('Provide exactly one of value, label or index');
|
|
284
|
+
if (args.index != null && (!Number.isSafeInteger(args.index) || args.index < 0)) throw new Error('index must be a nonnegative safe integer');
|
|
285
|
+
for (const key of ['value', 'label']) if (args[key] != null && typeof args[key] !== 'string') throw new Error(`${key} must be a string`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function selectElement(choice) {
|
|
289
|
+
if (this.tagName !== 'SELECT' || !this.isConnected) throw new Error('Target must be a connected HTML select');
|
|
290
|
+
if (this.matches(':disabled') || this.closest('[inert]')) throw new Error('Select is disabled or inert');
|
|
291
|
+
const options = Array.from(this.options);
|
|
292
|
+
const matches = options.filter((option, index) => choice.index != null ? index === choice.index : choice.value != null ? option.value === choice.value : option.label === choice.label);
|
|
293
|
+
if (matches.length !== 1) throw new Error(`Option must match exactly once; matched ${matches.length}. Use an explicit index for duplicate labels/values. No value was set.`);
|
|
294
|
+
const option = matches[0];
|
|
295
|
+
if (option.disabled || option.parentElement?.tagName === 'OPTGROUP' && option.parentElement.disabled) throw new Error('Option is disabled; no value was set');
|
|
296
|
+
const win = this.ownerDocument.defaultView;
|
|
297
|
+
const index = options.indexOf(option);
|
|
298
|
+
Object.getOwnPropertyDescriptor(win.HTMLSelectElement.prototype, 'selectedIndex').set.call(this, index);
|
|
299
|
+
this.dispatchEvent(new win.Event('input', { bubbles: true, composed: true }));
|
|
300
|
+
this.dispatchEvent(new win.Event('change', { bubbles: true }));
|
|
301
|
+
if (!this.isConnected || this.selectedIndex !== index || this.options[index] !== option || !option.selected) throw new Error('Select did not retain the requested option. Outcome is unknown; observe before continuing.');
|
|
302
|
+
return { selected: true, index, value: option.value, label: option.label, value_verified: true };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function typeOptions(args) {
|
|
306
|
+
if (typeof args.text !== 'string' || args.text.length > 10000 || /[\r\n\t\x00-\x1f]/.test(args.text)) throw new Error('type text must contain at most 10000 characters');
|
|
307
|
+
if (!Number.isInteger(args.delay_ms ?? 0) || (args.delay_ms ?? 0) < 0 || (args.delay_ms ?? 0) > 1000) throw new Error('delay_ms must be 0..1000');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function characterParameters(character) {
|
|
311
|
+
if (typeof character !== 'string' || Array.from(character).length !== 1 || /[\r\n\t\x00-\x1f]/.test(character)) throw new Error('type accepts printable characters; use press_key for control keys');
|
|
312
|
+
try {
|
|
313
|
+
const result = keyParameters(/^[A-Z]$/.test(character) ? `Shift+${character}` : character);
|
|
314
|
+
return { params: { ...result.params, key: character }, text: character };
|
|
315
|
+
} catch {
|
|
316
|
+
return { params: { key: character, code: '', windowsVirtualKeyCode: 0, nativeVirtualKeyCode: 0, modifiers: 0 }, text: character };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Navigation can use its full wait budget plus a bounded observation phase.
|
|
2
|
+
// Leave transport headroom so this never releases an otherwise healthy claim.
|
|
3
|
+
export const NAVIGATION_MAX_TIMEOUT_MS = 40000;
|
|
4
|
+
export const NAVIGATION_OBSERVATION_TIMEOUT_MS = 10000;
|
|
5
|
+
export const HUB_OPERATION_TIMEOUT_MS = 60000;
|
|
6
|
+
export const CLIENT_RESPONSE_TIMEOUT_MS = 65000;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Selectors are reusable queries, never identities inferred from old AX text.
|
|
2
|
+
export function targetOptions(args, { coordinates = false } = {}) {
|
|
3
|
+
const hasRef = args.ref != null, hasSelector = args.selector != null;
|
|
4
|
+
const hasPoint = args.x != null || args.y != null;
|
|
5
|
+
if (Number(hasRef) + Number(hasSelector) + Number(hasPoint) !== 1 ||
|
|
6
|
+
(hasPoint && (!coordinates || !Number.isFinite(args.x) || !Number.isFinite(args.y)))) {
|
|
7
|
+
throw new Error(coordinates ? 'Provide exactly one of ref, selector, or both finite x and y' : 'Provide exactly one of ref or selector');
|
|
8
|
+
}
|
|
9
|
+
if (hasRef && (typeof args.ref !== 'string' || !args.ref)) throw new Error('ref must be nonempty');
|
|
10
|
+
if (hasSelector && (typeof args.selector !== 'string' || !args.selector.trim() || args.selector.length > 10000)) throw new Error('selector must contain 1..10000 characters');
|
|
11
|
+
if (args.selector_type != null && !['css', 'xpath'].includes(args.selector_type)) throw new Error('selector_type must be css or xpath');
|
|
12
|
+
frameOptions(args);
|
|
13
|
+
if (!hasSelector && (args.frame_id != null || args.frame != null || args.selector_type != null)) throw new Error('frame_id and selector_type require selector');
|
|
14
|
+
if (args.nth != null && (!hasSelector || !Number.isSafeInteger(args.nth) || args.nth < 0)) throw new Error('nth requires selector and must be a nonnegative safe integer (zero-based)');
|
|
15
|
+
if (args.coordinate_space != null && (!hasPoint || !['viewport', 'document'].includes(args.coordinate_space))) throw new Error('coordinate_space must be viewport or document and applies to coordinate clicks only');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function queryElement(selector, type = 'css', nth, waitForMatch = false) {
|
|
19
|
+
if (nth != null && (!Number.isSafeInteger(nth) || nth < 0)) throw new Error('nth must be a nonnegative safe integer (zero-based)');
|
|
20
|
+
let element, count;
|
|
21
|
+
if (type === 'xpath') {
|
|
22
|
+
const result = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
23
|
+
count = result.snapshotLength; element = result.snapshotItem(nth ?? 0);
|
|
24
|
+
} else {
|
|
25
|
+
const elements = document.querySelectorAll(selector);
|
|
26
|
+
count = elements.length; element = elements[nth ?? 0];
|
|
27
|
+
}
|
|
28
|
+
if (waitForMatch && (count === 0 || (nth != null && nth >= count))) return null;
|
|
29
|
+
if (nth == null && count !== 1) throw new Error(`Selector must match exactly one element; matched ${count}. Scope the selector or provide an explicit zero-based nth.`);
|
|
30
|
+
if (nth != null && nth >= count) throw new Error(`Selector nth=${nth} is out of range; matched ${count}`);
|
|
31
|
+
if (element.nodeType !== 1) throw new Error('Selector must resolve to an element');
|
|
32
|
+
return element;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function resolveSelector(cdp, value, sessions, tabId, args, budget) {
|
|
36
|
+
const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget);
|
|
37
|
+
const target = sessions.target(tabId, frameId);
|
|
38
|
+
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
39
|
+
const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
|
|
40
|
+
let result;
|
|
41
|
+
while (true) {
|
|
42
|
+
result = await send('Runtime.evaluate', { contextId: executionContextId, objectGroup: 'browser-attach',
|
|
43
|
+
expression: `(${queryElement.toString()})(${JSON.stringify(args.selector)},${JSON.stringify(args.selector_type ?? 'css')},${args.nth ?? 'undefined'},true)` });
|
|
44
|
+
value(result);
|
|
45
|
+
if (result.result?.subtype !== 'null') break;
|
|
46
|
+
budget.describe?.(`selector ${JSON.stringify(args.selector)}${args.nth == null ? '' : ` nth=${args.nth}`} to appear; current=no matching element`);
|
|
47
|
+
await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
|
|
48
|
+
}
|
|
49
|
+
if (!result.result?.objectId) throw new Error('Selector did not resolve to an element');
|
|
50
|
+
const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
|
|
51
|
+
// Hit-test refs resolve in the default world too. CDP forbids passing object
|
|
52
|
+
// arguments between worlds, even when both wrappers refer to the same node.
|
|
53
|
+
const resolved = await send('DOM.resolveNode', { backendNodeId: node.backendNodeId, objectGroup: 'browser-attach' });
|
|
54
|
+
if (!resolved.object?.objectId) throw new Error('Selector target detached before resolution; no input was dispatched');
|
|
55
|
+
return { target, frameId, objectId: resolved.object.objectId, backendNodeId: node.backendNodeId };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function frameOptions(args) {
|
|
59
|
+
if (args.frame_id != null && args.frame != null) throw new Error('Provide frame_id or frame, never both');
|
|
60
|
+
const frame = args.frame;
|
|
61
|
+
if (frame == null) return;
|
|
62
|
+
if (typeof frame !== 'object' || Array.isArray(frame) || !Object.keys(frame).length || Object.keys(frame).some(key => !['url', 'title', 'path'].includes(key))) throw new Error('frame requires url, title or a CSS iframe path');
|
|
63
|
+
for (const key of ['url', 'title']) if (frame[key] != null && (typeof frame[key] !== 'string' || !frame[key])) throw new Error(`frame.${key} must be nonempty`);
|
|
64
|
+
if (frame.path != null && (!Array.isArray(frame.path) || frame.path.length < 1 || frame.path.length > 16 || frame.path.some(s => typeof s !== 'string' || !s.trim() || s.length > 10000))) throw new Error('frame.path must contain 1..16 CSS iframe selectors');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveFrameOnce(cdp, value, sessions, tabId, args, budget) {
|
|
68
|
+
frameOptions(args);
|
|
69
|
+
const frames = await budget.run(() => sessions.frames(tabId));
|
|
70
|
+
if (args.frame_id != null) {
|
|
71
|
+
if (!frames.some(f => f.id === args.frame_id)) throw new Error('frame_id is unknown; take a new snapshot');
|
|
72
|
+
return args.frame_id;
|
|
73
|
+
}
|
|
74
|
+
if (!args.frame) return frames[0].id;
|
|
75
|
+
const scope = args.frame;
|
|
76
|
+
let frameId = frames[0].id;
|
|
77
|
+
for (const selector of scope.path || []) {
|
|
78
|
+
const target = sessions.target(tabId, frameId);
|
|
79
|
+
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
80
|
+
const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
|
|
81
|
+
const result = await send('Runtime.evaluate', { contextId: executionContextId, expression: `(${queryElement.toString()})(${JSON.stringify(selector)},"css")`, objectGroup: 'browser-attach-frame' });
|
|
82
|
+
value(result);
|
|
83
|
+
try {
|
|
84
|
+
const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
|
|
85
|
+
if (!['IFRAME', 'FRAME'].includes(node.nodeName)) throw new Error('frame.path must resolve to iframe/frame elements');
|
|
86
|
+
frameId = node.frameId || node.contentDocument?.frameId;
|
|
87
|
+
if (!frameId) throw new Error('Frame document is not available yet');
|
|
88
|
+
} finally { await send('Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-frame' }); }
|
|
89
|
+
}
|
|
90
|
+
const candidates = scope.path ? frames.filter(f => f.id === frameId) : frames.filter(f => f.id !== frames[0].id);
|
|
91
|
+
const matches = [];
|
|
92
|
+
for (const frame of candidates) {
|
|
93
|
+
if (scope.url != null && frame.url !== scope.url) continue;
|
|
94
|
+
if (scope.title != null) {
|
|
95
|
+
const target = sessions.target(tabId, frame.id);
|
|
96
|
+
const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId: frame.id, worldName: 'browser-attach-locator' }));
|
|
97
|
+
const title = value(await budget.run(() => cdp(target, 'Runtime.evaluate', { contextId: executionContextId, expression: 'document.title', returnByValue: true })));
|
|
98
|
+
if (title !== scope.title) continue;
|
|
99
|
+
}
|
|
100
|
+
matches.push(frame.id);
|
|
101
|
+
}
|
|
102
|
+
if (matches.length !== 1) throw new Error(`Frame locator must match exactly once; matched ${matches.length}`);
|
|
103
|
+
return matches[0];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function resolveFrame(cdp, value, sessions, tabId, args, budget) {
|
|
107
|
+
while (true) {
|
|
108
|
+
try { return await resolveFrameOnce(cdp, value, sessions, tabId, args, budget); }
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!/Frame locator.*matched 0|Frame document.*not available|Selector.*matched 0|context.*destroyed|cannot find context/i.test(error.message)) throw error;
|
|
111
|
+
budget.describe?.(`frame ${JSON.stringify(args.frame)}; current=${error.message}`);
|
|
112
|
+
await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "BrowserAttach",
|
|
4
|
+
"version": "0.11.1",
|
|
5
|
+
"description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
|
|
6
|
+
"minimum_chrome_version": "125",
|
|
7
|
+
"permissions": [
|
|
8
|
+
"debugger",
|
|
9
|
+
"tabs",
|
|
10
|
+
"tabGroups",
|
|
11
|
+
"storage",
|
|
12
|
+
"alarms"
|
|
13
|
+
],
|
|
14
|
+
"background": {
|
|
15
|
+
"service_worker": "background.js",
|
|
16
|
+
"type": "module"
|
|
17
|
+
},
|
|
18
|
+
"action": {
|
|
19
|
+
"default_popup": "popup.html",
|
|
20
|
+
"default_title": "BrowserAttach",
|
|
21
|
+
"default_icon": {
|
|
22
|
+
"16": "icons/BrowserAttach.png",
|
|
23
|
+
"32": "icons/BrowserAttach.png"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"options_page": "options.html",
|
|
27
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs+qDiHd5PWudXHWlZavUGxYrk/oVDs8NXbgzdjGKQvJr0QUhM9OiurLQvAkNWfabdvWAT0iCl0TwZqeoe86cypci5H+mgBuSsW7LbEH5Le7eLRlgxOGIERZO+4e6ztyvsVe4QgYfNgS+uPZtovEVfbDh+VCr0EyUzXeqDR0DRdzz+3lkYtLutgBzna7XUYmKXH9tkPBL32A7Diu1RT2E3I40ydurreLTJ71TH9D8LJF4zGbG1/7VUqkxMGcIZEEvJod2achB12TQvo8tCCNeMijt4UdRudl2G1+axjjRJwyWDjes9tH4AjHyhmX3Ynn42Kf5LmkvyAsKx6yEApRF6wIDAQAB",
|
|
28
|
+
"content_security_policy": {
|
|
29
|
+
"extension_pages": "script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:47831"
|
|
30
|
+
},
|
|
31
|
+
"icons": {
|
|
32
|
+
"128": "icons/BrowserAttach.png"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { NAVIGATION_MAX_TIMEOUT_MS } from './limits.js';
|
|
2
|
+
|
|
3
|
+
// Lifecycle events can precede the Page.navigate reply. Keep them until its loader is known.
|
|
4
|
+
export function createNavigation(cdp) {
|
|
5
|
+
const pending = new Map();
|
|
6
|
+
function event(tabId, method, params) { pending.get(tabId)?.event(method, params); }
|
|
7
|
+
function cancel(tabId, reason) { pending.get(tabId)?.cancel(reason); }
|
|
8
|
+
async function navigate(tabId, url, { wait_until = 'domcontentloaded', timeout_ms = 15000, direction } = {}) {
|
|
9
|
+
if (!['commit', 'domcontentloaded', 'load'].includes(wait_until)) throw new Error('wait_until must be commit, domcontentloaded or load');
|
|
10
|
+
if (!Number.isInteger(timeout_ms) || timeout_ms < 100 || timeout_ms > NAVIGATION_MAX_TIMEOUT_MS) throw new Error('timeout_ms must be 100..40000');
|
|
11
|
+
const end = Date.now() + timeout_ms;
|
|
12
|
+
async function prepare(method) {
|
|
13
|
+
const remaining = end - Date.now(); let timer;
|
|
14
|
+
try {
|
|
15
|
+
if (remaining <= 0) throw new Error('Navigation preparation timed out; no navigation was dispatched');
|
|
16
|
+
return await Promise.race([cdp(tabId, method), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('Navigation preparation timed out; no navigation was dispatched')), remaining); })]);
|
|
17
|
+
} finally { clearTimeout(timer); }
|
|
18
|
+
}
|
|
19
|
+
let entry, mainId;
|
|
20
|
+
if (direction != null) {
|
|
21
|
+
if (!['back', 'forward'].includes(direction) || url != null) throw new Error('Provide URL or direction=back/forward, never both');
|
|
22
|
+
const history = await prepare('Page.getNavigationHistory');
|
|
23
|
+
entry = history.entries[history.currentIndex + (direction === 'back' ? -1 : 1)];
|
|
24
|
+
if (!entry) throw new Error(`No ${direction} history entry; no navigation was dispatched`);
|
|
25
|
+
const destination = new URL(entry.url);
|
|
26
|
+
if (!['http:', 'https:'].includes(destination.protocol) || destination.username || destination.password) throw new Error('History entry is outside supported HTTP(S) scope; no navigation was dispatched');
|
|
27
|
+
mainId = (await prepare('Page.getFrameTree')).frameTree.frame.id;
|
|
28
|
+
}
|
|
29
|
+
let result, resolveDone, rejectDone, timer;
|
|
30
|
+
const events = [];
|
|
31
|
+
const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
|
|
32
|
+
function check() {
|
|
33
|
+
if (!result) return;
|
|
34
|
+
if (direction) {
|
|
35
|
+
const commit = events.find(e => e.method === 'Page.frameNavigated' && e.frame.id === mainId && !e.frame.parentId || e.method === 'Page.navigatedWithinDocument' && e.frameId === mainId);
|
|
36
|
+
if (!commit) return;
|
|
37
|
+
const same = commit.method === 'Page.navigatedWithinDocument';
|
|
38
|
+
if (same || wait_until === 'commit' || commit.type === 'BackForwardCacheRestore' || events.some(e => e.method === 'Page.lifecycleEvent' && e.frameId === mainId && e.loaderId === commit.frame?.loaderId && e.name === (wait_until === 'load' ? 'load' : 'DOMContentLoaded'))) {
|
|
39
|
+
result = { frameId: mainId, ...(same ? {} : { loaderId: commit.frame.loaderId || 'history' }) }; resolveDone(result);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// A successful reply without loaderId denotes a same-document navigation.
|
|
44
|
+
if (wait_until === 'commit' || !result.loaderId || events.some(event =>
|
|
45
|
+
event.frameId === result.frameId && event.loaderId === result.loaderId &&
|
|
46
|
+
event.name === (wait_until === 'load' ? 'load' : 'DOMContentLoaded'))) resolveDone(result);
|
|
47
|
+
}
|
|
48
|
+
pending.set(tabId, {
|
|
49
|
+
event(method, params) { if (['Page.lifecycleEvent', 'Page.frameNavigated', 'Page.navigatedWithinDocument'].includes(method)) { events.push({ method, ...params }); check(); } },
|
|
50
|
+
cancel(reason) { rejectDone(reason || new Error('Tab detached during navigation; outcome is unknown.')); },
|
|
51
|
+
});
|
|
52
|
+
timer = setTimeout(() => rejectDone(new Error(`Navigation did not reach ${wait_until} within ${timeout_ms} ms. It may still be loading; observe the tab before another action.`)), Math.max(0, end - Date.now()));
|
|
53
|
+
try {
|
|
54
|
+
// Attach rejection handlers immediately, including when the deadline wins first.
|
|
55
|
+
const command = cdp(tabId, direction ? 'Page.navigateToHistoryEntry' : 'Page.navigate', direction ? { entryId: entry.id } : { url }).then(reply => {
|
|
56
|
+
if (reply.errorText) throw new Error(`Navigation failed: ${reply.errorText}`);
|
|
57
|
+
if (reply.isDownload) throw new Error('Navigation started a download instead of a document.');
|
|
58
|
+
result = direction ? {} : reply; check(); return done;
|
|
59
|
+
});
|
|
60
|
+
await Promise.race([command, done]);
|
|
61
|
+
return { navigated_to: entry?.url || url, ...(direction ? { direction } : {}), wait_until, same_document: !result.loaderId };
|
|
62
|
+
} finally { clearTimeout(timer); pending.delete(tabId); }
|
|
63
|
+
}
|
|
64
|
+
return { event, cancel, navigate };
|
|
65
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><meta charset="utf-8"><link rel="stylesheet" href="ui.css">
|
|
3
|
+
<body><main><div class="brand"><img src="icons/BrowserAttach.png" alt="" width="36" height="36"><span>BrowserAttach</span></div><h1>浏览器配置实例</h1><p>为当前浏览器配置设置一个名称,便于 MCP 在多个 Chrome / Edge 配置中选择。</p>
|
|
4
|
+
<form><label for="profile">实例名称</label><input id="profile" maxlength="80" placeholder="例如:Edge 工作账号" required><button>保存</button></form><p id="saved" role="status"></p>
|
|
5
|
+
<p class="hint">名称只保存在当前浏览器配置中。连接密钥由本机安装程序生成,无需在网页输入。</p>
|
|
6
|
+
<button id="reload" type="button">重新加载扩展</button><p class="hint">更新本机扩展文件后使用;会释放正在控制的标签页。</p></main><script src="options.js"></script></body></html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// This non-web-accessible page is also a local maintenance entry for setup.
|
|
2
|
+
if (location.hash === '#reload') {
|
|
3
|
+
history.replaceState(null, '', location.pathname);
|
|
4
|
+
chrome.runtime.reload();
|
|
5
|
+
}
|
|
6
|
+
document.querySelector('#reload').onclick = () => chrome.runtime.reload();
|
|
7
|
+
chrome.storage.local.get('profileName').then(result => { document.querySelector('#profile').value = result.profileName || ''; });
|
|
8
|
+
document.querySelector('form').onsubmit = async event => {
|
|
9
|
+
event.preventDefault();
|
|
10
|
+
const profileName = document.querySelector('#profile').value.trim();
|
|
11
|
+
if (!profileName) return;
|
|
12
|
+
await chrome.storage.local.set({ profileName });
|
|
13
|
+
await chrome.runtime.sendMessage({ type: 'reconnect' });
|
|
14
|
+
document.querySelector('#saved').textContent = '已保存,连接正在更新。';
|
|
15
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Observation can confirm a dialog/navigation/mutation, never absence of delivery.
|
|
2
|
+
export async function boundedRead(operation, ms = 300) {
|
|
3
|
+
let timer;
|
|
4
|
+
try { return await Promise.race([Promise.resolve().then(operation), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('observation did not respond')), ms); })]); }
|
|
5
|
+
finally { clearTimeout(timer); }
|
|
6
|
+
}
|
|
7
|
+
export function installOutcomeObserver() {
|
|
8
|
+
let mutations = 0;
|
|
9
|
+
const observer = new MutationObserver(records => { mutations += records.length; });
|
|
10
|
+
observer.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
11
|
+
const startUrl = location.href;
|
|
12
|
+
let expiry = setTimeout(() => observer.disconnect(), 55000);
|
|
13
|
+
return { read() { mutations += observer.takeRecords().length; return { dom_changed: mutations > 0, mutation_records: mutations, start_url: startUrl, current_url: location.href }; },
|
|
14
|
+
stop() { clearTimeout(expiry); observer.disconnect(); } };
|
|
15
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><meta charset="utf-8"><link rel="stylesheet" href="ui.css">
|
|
3
|
+
<body><main><div class="brand"><img src="icons/BrowserAttach.png" alt="" width="36" height="36"><span>BrowserAttach</span></div><p id="status">正在查询连接…</p><p id="profile"></p><p id="claims"></p>
|
|
4
|
+
<button id="release">释放全部标签页</button><button id="reconnect">重新连接</button><button id="settings">配置实例名称</button>
|
|
5
|
+
<p class="hint">只接受本机已配对 MCP 的连接。点击浏览器的停止调试提示也可随时释放控制。</p></main><script src="popup.js"></script></body></html>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
async function refresh() {
|
|
2
|
+
const result = await chrome.runtime.sendMessage({ type: 'status' });
|
|
3
|
+
document.querySelector('#status').textContent = result.status === 'connected' ? '已连接本地 MCP 服务' : '尚未连接:请先启动 MCP';
|
|
4
|
+
document.querySelector('#profile').textContent = `${result.browser.family} · ${result.browser.profile_name}`;
|
|
5
|
+
document.querySelector('#claims').textContent = `正在控制 ${result.claims} 个标签页`;
|
|
6
|
+
}
|
|
7
|
+
document.querySelector('#release').onclick = async () => { await chrome.runtime.sendMessage({ type: 'release_all' }); await refresh(); };
|
|
8
|
+
document.querySelector('#reconnect').onclick = async () => { await chrome.runtime.sendMessage({ type: 'reconnect' }); await refresh(); };
|
|
9
|
+
document.querySelector('#settings').onclick = () => chrome.runtime.openOptionsPage();
|
|
10
|
+
refresh().catch(error => { document.querySelector('#status').textContent = error.message; });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function scrollOptions(args) {
|
|
2
|
+
if (!['dom', 'wheel'].includes(args.mode ?? 'dom')) throw new Error('Scroll mode must be dom or wheel');
|
|
3
|
+
if (!Number.isFinite(args.delta_x ?? 0) || args.delta_y != null && !Number.isFinite(args.delta_y)) throw new Error('Scroll deltas must be finite');
|
|
4
|
+
if (!args.until && !Number.isFinite(args.delta_y)) throw new Error('delta_y is required without until');
|
|
5
|
+
if (args.until) {
|
|
6
|
+
if (args.mode === 'wheel') throw new Error('until search requires explicit DOM scroll mode');
|
|
7
|
+
const { text, selector } = args.until;
|
|
8
|
+
if (typeof text !== 'string' || !text || text.length > 2000 || selector != null && (typeof selector !== 'string' || !selector)) throw new Error('until requires nonempty text and optional CSS selector');
|
|
9
|
+
if (!Number.isInteger(args.max_steps ?? 100) || (args.max_steps ?? 100) < 1 || (args.max_steps ?? 100) > 500) throw new Error('max_steps must be 1..500');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function scrollElement(dx, dy) {
|
|
14
|
+
if (!this.isConnected) throw new Error('Scroll container is detached');
|
|
15
|
+
const before = { x: this.scrollLeft, y: this.scrollTop };
|
|
16
|
+
this.scrollBy({ left: dx, top: dy ?? Math.max(1, this.clientHeight * 0.8), behavior: 'instant' });
|
|
17
|
+
const after = { x: this.scrollLeft, y: this.scrollTop };
|
|
18
|
+
return { scrolled: before.x !== after.x || before.y !== after.y, before, after, method: 'dom_scroll', scope: 'element' };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function findScrollText(text, selector) {
|
|
22
|
+
if (!this.isConnected) throw new Error('Scroll container detached');
|
|
23
|
+
const elements = Array.from(this.querySelectorAll(selector || '*'));
|
|
24
|
+
if (elements.length > 10000) throw new Error('Scroll search exceeds 10000 candidates; scope until.selector');
|
|
25
|
+
const matches = elements.filter(e => (e.textContent || '').includes(text) && e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height > 0 && e.ownerDocument.defaultView.getComputedStyle(e).visibility === 'visible');
|
|
26
|
+
const leaves = matches.filter(e => !matches.some(other => other !== e && e.contains(other)));
|
|
27
|
+
if (leaves.length > 1) throw new Error(`Scroll text must match one element; matched ${leaves.length}. Scope until.selector`);
|
|
28
|
+
return leaves[0] || null;
|
|
29
|
+
}
|