bopbop 0.0.0-stage → 0.1.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/README.md +73 -2
- package/dist/npm/cdp/browser.js +196 -0
- package/dist/npm/cdp/connection.js +123 -0
- package/dist/npm/cdp/keys.js +90 -0
- package/dist/npm/cdp/tab.js +894 -0
- package/dist/npm/controller.js +1084 -0
- package/dist/npm/emulation.js +18 -0
- package/dist/npm/format.js +103 -0
- package/dist/npm/label.js +35 -0
- package/dist/npm/network.js +44 -0
- package/dist/npm/page/agent.js +1359 -0
- package/dist/npm/relay.js +220 -0
- package/dist/npm/server.js +333 -0
- package/dist/npm/session.js +43 -0
- package/package.json +63 -4
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# better-browser-mcp: server reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The MCP server, the page agent and the relay extension. Setup steps for a team: [`INSTALL.md`](INSTALL.md).
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
From npm (the package is `bopbop`; the project is being renamed from better-browser-mcp):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
claude mcp add -s user bbm -- npx -y bopbop
|
|
11
|
+
codex mcp add bbm -- npx -y bopbop
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
From a clone:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun install # or: npm install
|
|
18
|
+
claude mcp add -s user bbm -- bun /abs/path/prototype/src/server.ts
|
|
19
|
+
codex mcp add bbm -- bun /abs/path/prototype/src/server.ts
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Node 24+ also runs the server: replace `bun` with `node`.
|
|
23
|
+
|
|
24
|
+
## Modes (`BBM_MODE`)
|
|
25
|
+
|
|
26
|
+
| Mode | What it does | Settings |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `launch` | Starts a fresh Chromium with a temporary profile. Headless unless `BBM_HEADLESS=0`. | `BBM_CHROME`: the browser binary; `BBM_CHROME_ARGS`: extra flags (e.g. `--no-sandbox` in CI) |
|
|
29
|
+
| `connect` | Attaches to a running browser over CDP. | `BBM_CDP_URL=http://127.0.0.1:9222` |
|
|
30
|
+
| `extension` (default) | Drives **your real Chrome profile** through the relay extension (build and load `dist/extension` unpacked). | `BBM_RELAY_PORT` pins one port; default: the first free port of 9333–9362 (30 sessions) |
|
|
31
|
+
|
|
32
|
+
## Settings
|
|
33
|
+
|
|
34
|
+
| Setting | Effect |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `BBM_GUARD=shop-social` | Blocks Buy now, Place order, checkout pages and Post buttons (step checks, in-page guard, browser-level blocking). Under the guard, JavaScript clicks through `eval` are blocked too. |
|
|
37
|
+
| `BBM_ALLOW=http://127.0.0.1:*,*.example.com` | Origin allowlist. Fails closed. Enforced in the browser (documents, frames, redirects, popups). |
|
|
38
|
+
| `BBM_UPLOAD_ROOTS=/path/a,/path/b` | Upload files only from these folders (default `~/Downloads`, `$TMPDIR/bbm-uploads`). Hidden files are refused. |
|
|
39
|
+
| `BBM_OUTPUT_DIR=/path` | Where HAR files go (default `~/Downloads/bbm`). The agent gives only a file name. |
|
|
40
|
+
| `BBM_SHOW_COOKIES=1` | Show cookie and credential header values in full (default: shortened). |
|
|
41
|
+
| `BBM_WINDOW=agent` | Extension mode: agent tabs in their own unfocused window. |
|
|
42
|
+
| `BBM_KEEP_TABS=1` | Extension mode: keep the agent's tabs open when the session ends. |
|
|
43
|
+
| `BBM_FRONT=1` | Extension mode: bring agent tabs to the front (old behavior). |
|
|
44
|
+
| `BBM_TRACE=1` | Timing traces on stderr. |
|
|
45
|
+
|
|
46
|
+
## Tools
|
|
47
|
+
|
|
48
|
+
| Tool | Use |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `navigate` | Open a URL (or back, forward, reload). Returns the snapshot; `snapshot: interactive\|none` for less. |
|
|
51
|
+
| `act` | Batched steps; returns step results, events (navigation, dialogs, new tabs, errors) and what changed. Refs or locators: `text=Save`, `css=.bar button`, `e12 >> text=Save`; alternatives `css=textarea, text=Ask` (first match wins); an ambiguous `text=` gives a warning. |
|
|
52
|
+
| `snapshot` | Page structure with refs. `search`, `ref`, `viewport`, `mode=interactive`, `scope=page` (default: the open dialog). |
|
|
53
|
+
| `read` | Page text. `format=markdown`: the main content as Markdown. |
|
|
54
|
+
| `extract` | Tables as rows keyed by column, label/value tables and `<dl>` as fields, as JSON. |
|
|
55
|
+
| `wait` | Until text appears or goes, a ref is visible, or the URL matches. |
|
|
56
|
+
| `screenshot` | JPEG of the viewport, a ref/locator, a `clip` rect (+`padding`) or the full page; `marks` draws refs. 1280 px wide at most, `scale: full` for 1:1. Returns the captured area in CSS px. |
|
|
57
|
+
| `eval` | JavaScript for values that the other tools do not show. Awaits a returned Promise (`timeout`). One line if the page changed; `snapshot: diff\|full` shows the change. |
|
|
58
|
+
| `console` | Console messages and page errors; identical messages show once with `×N`. |
|
|
59
|
+
| `network` | Requests with time and size; `id`: phases, headers, bodies; `cookies`; `har` export. |
|
|
60
|
+
| `emulate` | Device presets, viewport, dark mode, reduced motion, network and CPU throttling, `reset`; `window=true` resizes the agent window. |
|
|
61
|
+
| `perf` | Web Vitals with ratings, long tasks, slowest and largest resources, heap and DOM size. |
|
|
62
|
+
| `tabs` | List, open, select (one line), close, duplicate (keeps sessionStorage). Extension mode: `label` names the tab group `Agent:<1-5 letters, digits or emoji>` (default: project initials + slot, e.g. `Agent:CT2`). |
|
|
63
|
+
|
|
64
|
+
`act` steps: `click`, `type` (+`submit`), `press`, `hover`, `select` (native or custom combobox, verified), `check`/`uncheck`, `upload`, `drag` (+`position`), `click_xy`, `scroll` (+`until`), `wait`, `wait_for`, `dialog`, `navigate`. Any ref field also accepts `text=Label`.
|
|
65
|
+
|
|
66
|
+
## Test
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm test # unit + e2e (fixture tasks, guard, modal scope, extract, network, perf, emulate) + MCP stdio
|
|
70
|
+
npm run test:extension # the fixture suite through the extension relay (chrome.debugger)
|
|
71
|
+
npm run bench:runtime # Node vs Bun timings
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Extension-only suites (focus, parallel sessions, other extensions) run in `npm test` with a test Chromium that loads the extension.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Browser-level CDP wrapper: tab list, create/close/activate, attach.
|
|
2
|
+
// Works the same over a direct CDP WebSocket and over the extension relay,
|
|
3
|
+
// because the relay emulates the Target.* domain with chrome.tabs + chrome.debugger.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { tmpdir, homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { CDPConnection, webSocketTransport } from './connection.js';
|
|
10
|
+
export class CDPBrowser {
|
|
11
|
+
conn;
|
|
12
|
+
targets;
|
|
13
|
+
listeners;
|
|
14
|
+
_onClose;
|
|
15
|
+
process;
|
|
16
|
+
extensionId;
|
|
17
|
+
port;
|
|
18
|
+
mode;
|
|
19
|
+
relay; // extension mode: the RelayServer this link came from
|
|
20
|
+
constructor(conn, { onClose } = {}) {
|
|
21
|
+
this.conn = conn;
|
|
22
|
+
this.targets = new Map(); // targetId -> {targetId, url, title, openerId, type}
|
|
23
|
+
this.listeners = { created: new Set(), destroyed: new Set(), changed: new Set() };
|
|
24
|
+
this._onClose = onClose;
|
|
25
|
+
}
|
|
26
|
+
async init() {
|
|
27
|
+
this.conn.on('Target.targetCreated', ({ targetInfo: t }) => {
|
|
28
|
+
if (t.type !== 'page')
|
|
29
|
+
return;
|
|
30
|
+
this.targets.set(t.targetId, t);
|
|
31
|
+
for (const fn of this.listeners.created)
|
|
32
|
+
fn(t);
|
|
33
|
+
});
|
|
34
|
+
this.conn.on('Target.targetInfoChanged', ({ targetInfo: t }) => {
|
|
35
|
+
if (!this.targets.has(t.targetId))
|
|
36
|
+
return;
|
|
37
|
+
this.targets.set(t.targetId, t);
|
|
38
|
+
for (const fn of this.listeners.changed)
|
|
39
|
+
fn(t);
|
|
40
|
+
});
|
|
41
|
+
this.conn.on('Target.targetDestroyed', ({ targetId }) => {
|
|
42
|
+
if (!this.targets.delete(targetId))
|
|
43
|
+
return;
|
|
44
|
+
for (const fn of this.listeners.destroyed)
|
|
45
|
+
fn(targetId);
|
|
46
|
+
});
|
|
47
|
+
await this.conn.send('Target.setDiscoverTargets', { discover: true });
|
|
48
|
+
const { targetInfos } = await this.conn.send('Target.getTargets');
|
|
49
|
+
for (const t of targetInfos)
|
|
50
|
+
if (t.type === 'page')
|
|
51
|
+
this.targets.set(t.targetId, t);
|
|
52
|
+
}
|
|
53
|
+
onTabCreated(fn) {
|
|
54
|
+
this.listeners.created.add(fn);
|
|
55
|
+
}
|
|
56
|
+
onTabDestroyed(fn) {
|
|
57
|
+
this.listeners.destroyed.add(fn);
|
|
58
|
+
}
|
|
59
|
+
onTabChanged(fn) {
|
|
60
|
+
this.listeners.changed.add(fn);
|
|
61
|
+
}
|
|
62
|
+
listTabs() {
|
|
63
|
+
return [...this.targets.values()].filter((t) => !t.url.startsWith('devtools://') && !t.url.startsWith('chrome-extension://'));
|
|
64
|
+
}
|
|
65
|
+
async createTab(url = 'about:blank', { background = false, newWindow = false } = {}) {
|
|
66
|
+
const { targetId } = await this.conn.send('Target.createTarget', { url, background, ...(newWindow ? { newWindow } : {}) });
|
|
67
|
+
return targetId;
|
|
68
|
+
}
|
|
69
|
+
closeTab(targetId) {
|
|
70
|
+
return this.conn.send('Target.closeTarget', { targetId });
|
|
71
|
+
}
|
|
72
|
+
activateTab(targetId) {
|
|
73
|
+
return this.conn.send('Target.activateTarget', { targetId });
|
|
74
|
+
}
|
|
75
|
+
async attach(targetId) {
|
|
76
|
+
const { sessionId } = await this.conn.send('Target.attachToTarget', { targetId, flatten: true });
|
|
77
|
+
return this.conn.session(sessionId);
|
|
78
|
+
}
|
|
79
|
+
async close() {
|
|
80
|
+
try {
|
|
81
|
+
this.conn.close();
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
84
|
+
await this._onClose?.();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function findChromium() {
|
|
88
|
+
if (process.env.BBM_CHROME)
|
|
89
|
+
return process.env.BBM_CHROME;
|
|
90
|
+
// Playwright's Chromium: macOS and Linux caches, old and new folder names.
|
|
91
|
+
for (const pw of [join(homedir(), 'Library/Caches/ms-playwright'), join(homedir(), '.cache/ms-playwright')]) {
|
|
92
|
+
if (!existsSync(pw))
|
|
93
|
+
continue;
|
|
94
|
+
const dirs = readdirSync(pw)
|
|
95
|
+
.filter((d) => /^chromium-\d+$/.test(d))
|
|
96
|
+
.sort()
|
|
97
|
+
.reverse();
|
|
98
|
+
for (const d of dirs) {
|
|
99
|
+
for (const rel of [
|
|
100
|
+
'chrome-mac/Chromium.app/Contents/MacOS/Chromium',
|
|
101
|
+
'chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium',
|
|
102
|
+
'chrome-linux64/chrome',
|
|
103
|
+
'chrome-linux/chrome',
|
|
104
|
+
]) {
|
|
105
|
+
if (existsSync(join(pw, d, rel)))
|
|
106
|
+
return join(pw, d, rel);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const mac = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
111
|
+
if (existsSync(mac))
|
|
112
|
+
return mac;
|
|
113
|
+
throw new Error('No Chromium found; set BBM_CHROME');
|
|
114
|
+
}
|
|
115
|
+
/** Launch a fresh Chromium with a temp profile. */
|
|
116
|
+
const QUIET_PREFS = {
|
|
117
|
+
autofill: { profile_enabled: false, credit_card_enabled: false },
|
|
118
|
+
credentials_enable_service: false,
|
|
119
|
+
profile: { password_manager_enabled: false },
|
|
120
|
+
};
|
|
121
|
+
export async function launchChromium({ headless = true, executable = findChromium(), args = [], userDataDir, } = {}) {
|
|
122
|
+
const dir = userDataDir || (await mkdtemp(join(tmpdir(), 'bbm-profile-')));
|
|
123
|
+
// A fresh profile: no "Save address / card / password?" bubbles when an agent fills forms (they pop over the
|
|
124
|
+
// user's screen and wait for a click). A given userDataDir is the user's: we leave its settings alone.
|
|
125
|
+
if (!userDataDir) {
|
|
126
|
+
await mkdir(join(dir, 'Default'), { recursive: true });
|
|
127
|
+
await writeFile(join(dir, 'Default', 'Preferences'), JSON.stringify(QUIET_PREFS));
|
|
128
|
+
}
|
|
129
|
+
const proc = spawn(executable, [
|
|
130
|
+
'--remote-debugging-port=0',
|
|
131
|
+
`--user-data-dir=${dir}`,
|
|
132
|
+
'--no-first-run',
|
|
133
|
+
'--no-default-browser-check',
|
|
134
|
+
'--disable-search-engine-choice-screen',
|
|
135
|
+
'--disable-features=Translate,MediaRouter,CalculateNativeWinOcclusion',
|
|
136
|
+
'--password-store=basic',
|
|
137
|
+
'--use-mock-keychain',
|
|
138
|
+
// keep background/occluded tabs rendering, else input acks wait seconds for a frame
|
|
139
|
+
'--disable-backgrounding-occluded-windows',
|
|
140
|
+
'--disable-renderer-backgrounding',
|
|
141
|
+
'--disable-background-timer-throttling',
|
|
142
|
+
...(headless ? ['--headless=new'] : []),
|
|
143
|
+
// extra flags, e.g. BBM_CHROME_ARGS=--no-sandbox in CI containers where the sandbox cannot start
|
|
144
|
+
...(process.env.BBM_CHROME_ARGS?.split(/\s+/).filter(Boolean) ?? []),
|
|
145
|
+
...args,
|
|
146
|
+
'about:blank',
|
|
147
|
+
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
148
|
+
let stderr = '';
|
|
149
|
+
proc.stderr.on('data', (d) => {
|
|
150
|
+
stderr += d;
|
|
151
|
+
if (stderr.length > 20000)
|
|
152
|
+
stderr = stderr.slice(-10000);
|
|
153
|
+
});
|
|
154
|
+
const portFile = join(dir, 'DevToolsActivePort');
|
|
155
|
+
const t0 = Date.now();
|
|
156
|
+
let wsUrl;
|
|
157
|
+
while (!wsUrl) {
|
|
158
|
+
if (proc.exitCode !== null)
|
|
159
|
+
throw new Error(`Chromium exited: ${stderr.slice(-2000)}`);
|
|
160
|
+
if (Date.now() - t0 > 20000) {
|
|
161
|
+
proc.kill();
|
|
162
|
+
throw new Error(`Chromium launch timeout: ${stderr.slice(-1500)}`);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const [port, path] = (await readFile(portFile, 'utf8')).trim().split('\n');
|
|
166
|
+
if (port && path)
|
|
167
|
+
wsUrl = `ws://127.0.0.1:${port}${path}`;
|
|
168
|
+
}
|
|
169
|
+
catch { }
|
|
170
|
+
if (!wsUrl)
|
|
171
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
172
|
+
}
|
|
173
|
+
const conn = new CDPConnection(await webSocketTransport(wsUrl));
|
|
174
|
+
const browser = new CDPBrowser(conn, {
|
|
175
|
+
onClose: async () => {
|
|
176
|
+
proc.kill();
|
|
177
|
+
await new Promise((r) => (proc.exitCode !== null ? r() : proc.once('exit', () => r())));
|
|
178
|
+
if (!userDataDir)
|
|
179
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
await browser.init();
|
|
183
|
+
browser.process = proc;
|
|
184
|
+
return browser;
|
|
185
|
+
}
|
|
186
|
+
/** Connect to a running browser: http://host:port or ws://... */
|
|
187
|
+
export async function connectCDP(endpoint) {
|
|
188
|
+
let wsUrl = endpoint;
|
|
189
|
+
if (endpoint.startsWith('http')) {
|
|
190
|
+
const res = await fetch(new URL('/json/version', endpoint));
|
|
191
|
+
wsUrl = (await res.json()).webSocketDebuggerUrl;
|
|
192
|
+
}
|
|
193
|
+
const browser = new CDPBrowser(new CDPConnection(await webSocketTransport(wsUrl)));
|
|
194
|
+
await browser.init();
|
|
195
|
+
return browser;
|
|
196
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Minimal CDP connection over a message transport, with flat sessions.
|
|
2
|
+
// Transport: { send(string), onMessage(cb), onClose(cb), close() }
|
|
3
|
+
class CDPError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
}
|
|
6
|
+
export class CDPConnection {
|
|
7
|
+
#nextId = 1;
|
|
8
|
+
#pending = new Map();
|
|
9
|
+
#listeners = new Map(); // `${sessionId}|${method}` -> Set<fn>
|
|
10
|
+
#closed = false;
|
|
11
|
+
transport;
|
|
12
|
+
constructor(transport) {
|
|
13
|
+
this.transport = transport;
|
|
14
|
+
transport.onMessage((raw) => this.#onMessage(raw));
|
|
15
|
+
transport.onClose(() => {
|
|
16
|
+
this.#closed = true;
|
|
17
|
+
for (const { reject } of this.#pending.values())
|
|
18
|
+
reject(new Error('CDP connection closed'));
|
|
19
|
+
this.#pending.clear();
|
|
20
|
+
this.#emit('', 'close', {});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
get closed() {
|
|
24
|
+
return this.#closed;
|
|
25
|
+
}
|
|
26
|
+
send(method, params = {}, sessionId) {
|
|
27
|
+
if (this.#closed)
|
|
28
|
+
return Promise.reject(new Error('CDP connection closed'));
|
|
29
|
+
const id = this.#nextId++;
|
|
30
|
+
const msg = { id, method, params };
|
|
31
|
+
if (sessionId)
|
|
32
|
+
msg.sessionId = sessionId;
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
this.#pending.set(id, { resolve, reject, method });
|
|
35
|
+
this.transport.send(JSON.stringify(msg));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
on(method, fn, sessionId = '') {
|
|
39
|
+
const key = `${sessionId}|${method}`;
|
|
40
|
+
if (!this.#listeners.has(key))
|
|
41
|
+
this.#listeners.set(key, new Set());
|
|
42
|
+
this.#listeners.get(key).add(fn);
|
|
43
|
+
return () => this.#listeners.get(key)?.delete(fn);
|
|
44
|
+
}
|
|
45
|
+
session(sessionId) {
|
|
46
|
+
return new CDPSession(this, sessionId);
|
|
47
|
+
}
|
|
48
|
+
close() {
|
|
49
|
+
this.transport.close();
|
|
50
|
+
}
|
|
51
|
+
#emit(sessionId, method, params) {
|
|
52
|
+
for (const key of [`${sessionId}|${method}`, `*|${method}`]) {
|
|
53
|
+
const set = this.#listeners.get(key);
|
|
54
|
+
if (!set)
|
|
55
|
+
continue;
|
|
56
|
+
// Copy: a listener may remove itself (or add others) while we iterate.
|
|
57
|
+
// oxlint-disable-next-line unicorn/no-useless-spread
|
|
58
|
+
for (const fn of [...set]) {
|
|
59
|
+
// An async listener's rejection is logged like a thrown error, never left unhandled (that would end the process).
|
|
60
|
+
try {
|
|
61
|
+
Promise.resolve(fn(params, sessionId)).catch((e) => console.error('[cdp listener]', method, e));
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
console.error('[cdp listener]', method, e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
#onMessage(raw) {
|
|
70
|
+
const msg = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
71
|
+
if (msg.id !== undefined) {
|
|
72
|
+
const p = this.#pending.get(msg.id);
|
|
73
|
+
if (!p)
|
|
74
|
+
return;
|
|
75
|
+
this.#pending.delete(msg.id);
|
|
76
|
+
if (msg.error) {
|
|
77
|
+
const err = new CDPError(`${p.method}: ${msg.error.message}`);
|
|
78
|
+
err.code = msg.error.code;
|
|
79
|
+
p.reject(err);
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
p.resolve(msg.result ?? {});
|
|
83
|
+
}
|
|
84
|
+
else if (msg.method) {
|
|
85
|
+
this.#emit(msg.sessionId || '', msg.method, msg.params ?? {});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class CDPSession {
|
|
90
|
+
conn;
|
|
91
|
+
id;
|
|
92
|
+
constructor(conn, id) {
|
|
93
|
+
this.conn = conn;
|
|
94
|
+
this.id = id;
|
|
95
|
+
}
|
|
96
|
+
send(method, params) {
|
|
97
|
+
return this.conn.send(method, params, this.id);
|
|
98
|
+
}
|
|
99
|
+
on(method, fn) {
|
|
100
|
+
return this.conn.on(method, fn, this.id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function webSocketTransport(url) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const ws = new WebSocket(url);
|
|
106
|
+
const msgCbs = [], closeCbs = [];
|
|
107
|
+
ws.addEventListener('open', () => resolve({
|
|
108
|
+
send: (s) => ws.send(s),
|
|
109
|
+
onMessage: (cb) => msgCbs.push(cb),
|
|
110
|
+
onClose: (cb) => closeCbs.push(cb),
|
|
111
|
+
close: () => ws.close(),
|
|
112
|
+
}));
|
|
113
|
+
ws.addEventListener('error', (e) => reject(new Error(`WebSocket error: ${e.message || url}`)));
|
|
114
|
+
ws.addEventListener('message', (e) => {
|
|
115
|
+
for (const cb of msgCbs)
|
|
116
|
+
cb(e.data);
|
|
117
|
+
});
|
|
118
|
+
ws.addEventListener('close', () => {
|
|
119
|
+
for (const cb of closeCbs)
|
|
120
|
+
cb();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const K = {
|
|
2
|
+
Enter: [13, 'Enter', '\r'],
|
|
3
|
+
Tab: [9, 'Tab'],
|
|
4
|
+
Escape: [27, 'Escape'],
|
|
5
|
+
Backspace: [8, 'Backspace'],
|
|
6
|
+
Delete: [46, 'Delete'],
|
|
7
|
+
ArrowUp: [38, 'ArrowUp'],
|
|
8
|
+
ArrowDown: [40, 'ArrowDown'],
|
|
9
|
+
ArrowLeft: [37, 'ArrowLeft'],
|
|
10
|
+
ArrowRight: [39, 'ArrowRight'],
|
|
11
|
+
Home: [36, 'Home'],
|
|
12
|
+
End: [35, 'End'],
|
|
13
|
+
PageUp: [33, 'PageUp'],
|
|
14
|
+
PageDown: [34, 'PageDown'],
|
|
15
|
+
Space: [32, 'Space', ' '],
|
|
16
|
+
Insert: [45, 'Insert'],
|
|
17
|
+
F1: [112, 'F1'],
|
|
18
|
+
F2: [113, 'F2'],
|
|
19
|
+
F3: [114, 'F3'],
|
|
20
|
+
F4: [115, 'F4'],
|
|
21
|
+
F5: [116, 'F5'],
|
|
22
|
+
F12: [123, 'F12'],
|
|
23
|
+
};
|
|
24
|
+
const ALIAS = {
|
|
25
|
+
Esc: 'Escape',
|
|
26
|
+
Return: 'Enter',
|
|
27
|
+
Up: 'ArrowUp',
|
|
28
|
+
Down: 'ArrowDown',
|
|
29
|
+
Left: 'ArrowLeft',
|
|
30
|
+
Right: 'ArrowRight',
|
|
31
|
+
Del: 'Delete',
|
|
32
|
+
' ': 'Space',
|
|
33
|
+
};
|
|
34
|
+
const MODS = { Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Cmd: 4, Command: 4, Shift: 8 };
|
|
35
|
+
const MOD_KEYS = { 1: ['Alt', 18], 2: ['Control', 17], 4: ['Meta', 91], 8: ['Shift', 16] };
|
|
36
|
+
/** Parse "Control+Shift+a" -> {modifiers, key, code, keyCode, text} */
|
|
37
|
+
export function parseKey(combo) {
|
|
38
|
+
const parts = combo === '+' ? ['+'] : combo.split('+').filter(Boolean);
|
|
39
|
+
let modifiers = 0;
|
|
40
|
+
const keyName = parts.pop() ?? '';
|
|
41
|
+
for (const p of parts) {
|
|
42
|
+
const m = MODS[p] ?? MODS[p[0].toUpperCase() + p.slice(1).toLowerCase()];
|
|
43
|
+
if (!m)
|
|
44
|
+
throw new Error(`unknown modifier ${p}`);
|
|
45
|
+
modifiers |= m;
|
|
46
|
+
}
|
|
47
|
+
const name = ALIAS[keyName] || keyName;
|
|
48
|
+
if (K[name]) {
|
|
49
|
+
const [keyCode, code, text] = K[name];
|
|
50
|
+
return { modifiers, key: name === 'Space' ? ' ' : name, code, keyCode, text: modifiers & ~8 ? undefined : text };
|
|
51
|
+
}
|
|
52
|
+
if (name.length === 1) {
|
|
53
|
+
const upper = name.toUpperCase();
|
|
54
|
+
const isLetter = /[A-Z]/.test(upper);
|
|
55
|
+
const isDigit = /[0-9]/.test(name);
|
|
56
|
+
return {
|
|
57
|
+
modifiers,
|
|
58
|
+
key: name,
|
|
59
|
+
keyCode: upper.charCodeAt(0),
|
|
60
|
+
code: isLetter ? `Key${upper}` : isDigit ? `Digit${name}` : undefined,
|
|
61
|
+
text: modifiers & ~8 ? undefined : name,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`unknown key ${keyName}`);
|
|
65
|
+
}
|
|
66
|
+
export async function pressKey(session, combo) {
|
|
67
|
+
const k = parseKey(combo);
|
|
68
|
+
const mods = [1, 2, 4, 8].filter((m) => k.modifiers & m);
|
|
69
|
+
let active = 0;
|
|
70
|
+
for (const m of mods) {
|
|
71
|
+
active |= m;
|
|
72
|
+
const [key, kc] = MOD_KEYS[m];
|
|
73
|
+
await session.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', modifiers: active, key, code: key + 'Left', windowsVirtualKeyCode: kc });
|
|
74
|
+
}
|
|
75
|
+
const base = { modifiers: k.modifiers, key: k.key, code: k.code, windowsVirtualKeyCode: k.keyCode };
|
|
76
|
+
// macOS: editing shortcuts need explicit commands
|
|
77
|
+
const commands = [];
|
|
78
|
+
if (process.platform === 'darwin' && k.modifiers & 4 && !(k.modifiers & 3)) {
|
|
79
|
+
const map = { a: 'selectAll', c: 'copy', v: 'paste', x: 'cut', z: 'undo' };
|
|
80
|
+
if (map[k.key.toLowerCase()])
|
|
81
|
+
commands.push(map[k.key.toLowerCase()]);
|
|
82
|
+
}
|
|
83
|
+
await session.send('Input.dispatchKeyEvent', { type: k.text ? 'keyDown' : 'rawKeyDown', ...base, text: k.text, unmodifiedText: k.text, commands });
|
|
84
|
+
await session.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
|
|
85
|
+
for (const m of mods.reverse()) {
|
|
86
|
+
active &= ~m;
|
|
87
|
+
const [key, kc] = MOD_KEYS[m];
|
|
88
|
+
await session.send('Input.dispatchKeyEvent', { type: 'keyUp', modifiers: active, key, code: key + 'Left', windowsVirtualKeyCode: kc });
|
|
89
|
+
}
|
|
90
|
+
}
|