@reclaimprotocol/agent 0.1.24 → 0.1.26
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 +5 -2
- package/lib/cdp/claims.d.ts +21 -0
- package/lib/cdp/claims.js +129 -0
- package/lib/cdp/client.d.ts +0 -10
- package/lib/cdp/container.d.ts +85 -0
- package/lib/cdp/container.js +481 -0
- package/lib/live/tunnel.d.ts +6 -3
- package/lib/live/tunnel.js +57 -17
- package/lib/mcp/instructions.js +2 -1
- package/lib/mcp/start.js +58 -3
- package/lib/mcp/tools/agent/attach.d.ts +22 -2
- package/lib/mcp/tools/agent/attach.js +197 -55
- package/lib/mcp/tools/agent/build.d.ts +5 -0
- package/lib/mcp/tools/agent/build.js +9 -2
- package/lib/mcp/tools/agent/index.d.ts +1 -2
- package/lib/mcp/tools/agent/index.js +1 -1
- package/lib/mcp/tools/agent/live-view.d.ts +3 -7
- package/lib/mcp/tools/agent/live-view.js +74 -81
- package/lib/mcp/tools/agent/session.d.ts +14 -0
- package/lib/mcp/tools/agent/session.js +14 -8
- package/lib/old/client.d.ts +83 -0
- package/lib/old/client.js +46 -0
- package/lib/old/tools/build.js +2 -0
- package/lib/old/tools/session-logs.d.ts +3 -0
- package/lib/old/tools/session-logs.js +310 -0
- package/package.json +2 -2
- package/skill/SKILL.common.md +115 -38
- package/skill/SKILL.old.md +28 -2
- package/skill/builder-mode.md +9 -7
- package/skill/interception.md +3 -2
- package/skill/old-mode.md +14 -10
- package/src/cdp/claims.ts +153 -0
- package/src/cdp/client.ts +0 -8
- package/src/cdp/container.ts +593 -0
- package/src/live/tunnel.ts +71 -20
- package/src/mcp/instructions.ts +2 -1
- package/src/mcp/start.ts +65 -2
- package/src/mcp/tools/agent/attach.ts +229 -60
- package/src/mcp/tools/agent/build.ts +10 -2
- package/src/mcp/tools/agent/index.ts +6 -2
- package/src/mcp/tools/agent/live-view.ts +77 -93
- package/src/mcp/tools/agent/session.ts +14 -8
- package/src/old/client.ts +128 -0
- package/src/old/tools/build.ts +2 -0
- package/src/old/tools/session-logs.ts +395 -0
- package/lib/cdp/browser-proxy.d.ts +0 -78
- package/lib/cdp/browser-proxy.js +0 -186
- package/lib/live/page.d.ts +0 -20
- package/lib/live/page.js +0 -134
- package/lib/live/viewer.d.ts +0 -28
- package/lib/live/viewer.js +0 -232
- package/lib/mcp/tools/agent/proxy.d.ts +0 -3
- package/lib/mcp/tools/agent/proxy.js +0 -199
- package/src/live/page.ts +0 -145
- package/src/live/viewer.ts +0 -293
package/src/live/page.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The live-view page served to whoever opens the tunnel URL.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately dependency-free and inline: it is delivered through a throwaway
|
|
5
|
-
* tunnel, so it cannot rely on a CDN, a bundler, or anything cached.
|
|
6
|
-
*
|
|
7
|
-
* Frames arrive as base64 JPEG over Server-Sent Events rather than a
|
|
8
|
-
* WebSocket. SSE is one plain GET, so it survives tunnels and proxies that
|
|
9
|
-
* mishandle upgrades, and it reconnects on its own. Input goes the other way
|
|
10
|
-
* as small POSTs.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Escape a value for embedding in a single-quoted JS string literal. */
|
|
14
|
-
function js(value: string): string {
|
|
15
|
-
return value
|
|
16
|
-
.replace(/\\/g, '\\\\')
|
|
17
|
-
.replace(/'/g, "\\'")
|
|
18
|
-
.replace(/</g, '\\x3c')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface PageOptions {
|
|
22
|
-
/** Path prefix that carries the access token, for example `/v/<token>`. */
|
|
23
|
-
base: string
|
|
24
|
-
/** Whether to wire up mouse and keyboard forwarding. */
|
|
25
|
-
interactive: boolean
|
|
26
|
-
/** Shown in the header so a viewer knows which tab they are watching. */
|
|
27
|
-
title: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function renderViewerPage(opts: PageOptions): string {
|
|
31
|
-
return `<!doctype html>
|
|
32
|
-
<html lang="en">
|
|
33
|
-
<head>
|
|
34
|
-
<meta charset="utf-8">
|
|
35
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
36
|
-
<meta name="robots" content="noindex, nofollow">
|
|
37
|
-
<title>Reclaim live view</title>
|
|
38
|
-
<style>
|
|
39
|
-
:root { color-scheme: dark; }
|
|
40
|
-
* { box-sizing: border-box; }
|
|
41
|
-
body { margin: 0; background: #0b0b0c; color: #e7e7ea; display: flex;
|
|
42
|
-
flex-direction: column; height: 100vh;
|
|
43
|
-
font: 14px/1.4 ui-sans-serif, system-ui, sans-serif; }
|
|
44
|
-
header { display: flex; align-items: center; gap: .75rem;
|
|
45
|
-
padding: .5rem .75rem; border-bottom: 1px solid #26262b; }
|
|
46
|
-
.dot { width: .5rem; height: .5rem; border-radius: 50%; background: #f5a524; }
|
|
47
|
-
.dot.live { background: #17c964; }
|
|
48
|
-
.title { color: #9a9aa4; overflow: hidden; text-overflow: ellipsis;
|
|
49
|
-
white-space: nowrap; }
|
|
50
|
-
.badge { margin-left: auto; font-size: 12px; padding: .15rem .4rem;
|
|
51
|
-
border: 1px solid #26262b; border-radius: .25rem; color: #9a9aa4; }
|
|
52
|
-
main { flex: 1; min-height: 0; display: grid; place-items: center;
|
|
53
|
-
padding: .75rem; }
|
|
54
|
-
img { max-width: 100%; max-height: 100%; display: block;
|
|
55
|
-
background: #fff; border-radius: .25rem; }
|
|
56
|
-
img.interactive { cursor: crosshair; }
|
|
57
|
-
.msg { color: #9a9aa4; }
|
|
58
|
-
</style>
|
|
59
|
-
</head>
|
|
60
|
-
<body>
|
|
61
|
-
<header>
|
|
62
|
-
<span class="dot" id="dot"></span>
|
|
63
|
-
<span class="title" id="title">${js(opts.title)}</span>
|
|
64
|
-
<span class="badge">${opts.interactive ? 'interactive' : 'view only'}</span>
|
|
65
|
-
</header>
|
|
66
|
-
<main><img id="screen" alt="Live view of the browser tab"><p class="msg"
|
|
67
|
-
id="msg">Connecting…</p></main>
|
|
68
|
-
<script>
|
|
69
|
-
(function () {
|
|
70
|
-
var BASE = '${js(opts.base)}';
|
|
71
|
-
var INTERACTIVE = ${opts.interactive ? 'true' : 'false'};
|
|
72
|
-
var screen = document.getElementById('screen');
|
|
73
|
-
var msg = document.getElementById('msg');
|
|
74
|
-
var dot = document.getElementById('dot');
|
|
75
|
-
var title = document.getElementById('title');
|
|
76
|
-
|
|
77
|
-
var es = new EventSource(BASE + '/stream');
|
|
78
|
-
es.addEventListener('frame', function (e) {
|
|
79
|
-
var payload = JSON.parse(e.data);
|
|
80
|
-
screen.src = 'data:image/jpeg;base64,' + payload.data;
|
|
81
|
-
if (payload.title) { title.textContent = payload.title; }
|
|
82
|
-
msg.style.display = 'none';
|
|
83
|
-
dot.className = 'dot live';
|
|
84
|
-
});
|
|
85
|
-
es.onerror = function () {
|
|
86
|
-
dot.className = 'dot';
|
|
87
|
-
msg.style.display = '';
|
|
88
|
-
msg.textContent = 'Reconnecting…';
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
if (!INTERACTIVE) { return; }
|
|
92
|
-
screen.className = 'interactive';
|
|
93
|
-
|
|
94
|
-
// The <img> is letterboxed by object-fit, so a click at some point in the
|
|
95
|
-
// element maps to page coordinates by the ratio of natural to rendered size.
|
|
96
|
-
function toPage(ev) {
|
|
97
|
-
var r = screen.getBoundingClientRect();
|
|
98
|
-
if (!screen.naturalWidth || !r.width) { return null; }
|
|
99
|
-
return {
|
|
100
|
-
x: Math.round((ev.clientX - r.left) * (screen.naturalWidth / r.width)),
|
|
101
|
-
y: Math.round((ev.clientY - r.top) * (screen.naturalHeight / r.height))
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function post(body) {
|
|
106
|
-
return fetch(BASE + '/input', {
|
|
107
|
-
method: 'POST',
|
|
108
|
-
headers: { 'content-type': 'application/json' },
|
|
109
|
-
body: JSON.stringify(body)
|
|
110
|
-
}).catch(function () {});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
screen.addEventListener('mousedown', function (ev) {
|
|
114
|
-
var p = toPage(ev); if (!p) { return; }
|
|
115
|
-
ev.preventDefault();
|
|
116
|
-
post({ kind: 'mouse', type: 'mousePressed', x: p.x, y: p.y,
|
|
117
|
-
button: 'left', clickCount: 1 });
|
|
118
|
-
});
|
|
119
|
-
screen.addEventListener('mouseup', function (ev) {
|
|
120
|
-
var p = toPage(ev); if (!p) { return; }
|
|
121
|
-
post({ kind: 'mouse', type: 'mouseReleased', x: p.x, y: p.y,
|
|
122
|
-
button: 'left', clickCount: 1 });
|
|
123
|
-
});
|
|
124
|
-
screen.addEventListener('wheel', function (ev) {
|
|
125
|
-
var p = toPage(ev); if (!p) { return; }
|
|
126
|
-
ev.preventDefault();
|
|
127
|
-
post({ kind: 'mouse', type: 'mouseWheel', x: p.x, y: p.y,
|
|
128
|
-
deltaX: ev.deltaX, deltaY: ev.deltaY });
|
|
129
|
-
}, { passive: false });
|
|
130
|
-
|
|
131
|
-
// Typing goes to the page, so the viewer never needs to focus anything.
|
|
132
|
-
window.addEventListener('keydown', function (ev) {
|
|
133
|
-
if (ev.metaKey || ev.ctrlKey) { return; }
|
|
134
|
-
ev.preventDefault();
|
|
135
|
-
post({ kind: 'key', type: 'keyDown', key: ev.key, code: ev.code });
|
|
136
|
-
});
|
|
137
|
-
window.addEventListener('keyup', function (ev) {
|
|
138
|
-
if (ev.metaKey || ev.ctrlKey) { return; }
|
|
139
|
-
post({ kind: 'key', type: 'keyUp', key: ev.key, code: ev.code });
|
|
140
|
-
});
|
|
141
|
-
})();
|
|
142
|
-
</script>
|
|
143
|
-
</body>
|
|
144
|
-
</html>`
|
|
145
|
-
}
|
package/src/live/viewer.ts
DELETED
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Interactive live view of one attached tab, over a public URL.
|
|
3
|
-
*
|
|
4
|
-
* Two halves:
|
|
5
|
-
* - OUT: CDP `Page.startScreencast` pushes JPEG frames; each is forwarded to
|
|
6
|
-
* every connected browser as a Server-Sent Event and acked back to CDP.
|
|
7
|
-
* - IN: the viewer POSTs pointer and key events, which are replayed into the
|
|
8
|
-
* page with `Input.dispatch*`.
|
|
9
|
-
*
|
|
10
|
-
* Only ever bound to 127.0.0.1. Reaching it from outside is the tunnel's job
|
|
11
|
-
* (see `tunnel.ts`), and because a quick tunnel is unauthenticated, EVERY
|
|
12
|
-
* route sits behind an unguessable token in the path. A request without it
|
|
13
|
-
* gets a flat 404 — not a 401, which would confirm the server exists.
|
|
14
|
-
*
|
|
15
|
-
* Interactivity is the caller's choice. `interactive: false` still streams,
|
|
16
|
-
* but the input route refuses, which is the right setting for showing someone
|
|
17
|
-
* a run rather than handing them the wheel.
|
|
18
|
-
*/
|
|
19
|
-
import { randomBytes } from 'node:crypto'
|
|
20
|
-
import {
|
|
21
|
-
createServer,
|
|
22
|
-
type IncomingMessage,
|
|
23
|
-
type Server,
|
|
24
|
-
type ServerResponse,
|
|
25
|
-
} from 'node:http'
|
|
26
|
-
import type { AddressInfo } from 'node:net'
|
|
27
|
-
import type { CdpTabClient } from '../cdp/client.ts'
|
|
28
|
-
import { LOGGER } from '../logger.ts'
|
|
29
|
-
import { renderViewerPage } from './page.ts'
|
|
30
|
-
|
|
31
|
-
/** JPEG quality for streamed frames. 60 keeps a text-heavy page readable
|
|
32
|
-
* while staying small enough for a tunnel's uplink. */
|
|
33
|
-
const FRAME_QUALITY = 60
|
|
34
|
-
/** Cap the streamed frame size. Screencast scales to fit, so this bounds
|
|
35
|
-
* bandwidth without changing the page's own layout viewport. */
|
|
36
|
-
const MAX_FRAME_WIDTH = 1280
|
|
37
|
-
const MAX_FRAME_HEIGHT = 800
|
|
38
|
-
/** Bytes accepted on an input POST. Events are tiny; anything larger is
|
|
39
|
-
* either a bug or someone probing. */
|
|
40
|
-
const MAX_INPUT_BYTES = 4096
|
|
41
|
-
|
|
42
|
-
export interface LiveViewOptions {
|
|
43
|
-
/** The tab to stream. */
|
|
44
|
-
tab: CdpTabClient
|
|
45
|
-
/** Forward viewer input into the page. */
|
|
46
|
-
interactive: boolean
|
|
47
|
-
/** Shown in the viewer's header. */
|
|
48
|
-
title?: string
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface LiveView {
|
|
52
|
-
/** Loopback origin the tunnel should point at. */
|
|
53
|
-
localUrl: string
|
|
54
|
-
/** Port the server bound to. */
|
|
55
|
-
port: number
|
|
56
|
-
/** Unguessable path segment guarding every route. */
|
|
57
|
-
token: string
|
|
58
|
-
/** Path prefix, `/v/<token>`. Append this to the public origin. */
|
|
59
|
-
path: string
|
|
60
|
-
/** Number of viewers currently streaming. */
|
|
61
|
-
viewers: () => number
|
|
62
|
-
stop: () => Promise<void>
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Start the loopback live-view server for `tab`. Resolves once it is
|
|
67
|
-
* listening; the screencast starts with the first viewer and stops with the
|
|
68
|
-
* last, so an idle share costs the page nothing.
|
|
69
|
-
*/
|
|
70
|
-
export async function startLiveView(
|
|
71
|
-
opts: LiveViewOptions,
|
|
72
|
-
): Promise<LiveView> {
|
|
73
|
-
const token = randomBytes(24).toString('base64url')
|
|
74
|
-
const path = `/v/${token}`
|
|
75
|
-
const clients = new Set<ServerResponse>()
|
|
76
|
-
let streaming = false
|
|
77
|
-
let unsubscribe: (() => void) | undefined
|
|
78
|
-
|
|
79
|
-
const startScreencast = async() => {
|
|
80
|
-
if(streaming) {
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
streaming = true
|
|
85
|
-
unsubscribe = opts.tab.subscribe('Page.screencastFrame', (frame) => {
|
|
86
|
-
// Ack first: CDP sends at most one un-acked frame, so a slow or failed
|
|
87
|
-
// write must not stall the stream.
|
|
88
|
-
void opts.tab
|
|
89
|
-
.send('Page.screencastFrameAck', { sessionId: frame.sessionId })
|
|
90
|
-
.catch(() => {})
|
|
91
|
-
const payload = JSON.stringify({ data: frame.data })
|
|
92
|
-
for(const res of clients) {
|
|
93
|
-
res.write(`event: frame\ndata: ${payload}\n\n`)
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
await opts.tab.send('Page.enable')
|
|
97
|
-
await opts.tab.send('Page.startScreencast', {
|
|
98
|
-
format: 'jpeg',
|
|
99
|
-
quality: FRAME_QUALITY,
|
|
100
|
-
maxWidth: MAX_FRAME_WIDTH,
|
|
101
|
-
maxHeight: MAX_FRAME_HEIGHT,
|
|
102
|
-
everyNthFrame: 1,
|
|
103
|
-
})
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const stopScreencast = async() => {
|
|
107
|
-
if(!streaming) {
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
streaming = false
|
|
112
|
-
unsubscribe?.()
|
|
113
|
-
unsubscribe = undefined
|
|
114
|
-
await opts.tab.send('Page.stopScreencast').catch(() => {})
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const server = createServer((req, res) => {
|
|
118
|
-
const url = req.url ?? '/'
|
|
119
|
-
// Constant prefix check: everything is scoped under the token, so an
|
|
120
|
-
// unmatched path is indistinguishable from a server that isn't there.
|
|
121
|
-
if(!url.startsWith(path)) {
|
|
122
|
-
res.writeHead(404).end('not found')
|
|
123
|
-
return
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const route = url.slice(path.length) || '/'
|
|
127
|
-
if(req.method === 'GET' && (route === '/' || route.startsWith('/?'))) {
|
|
128
|
-
const body = renderViewerPage({
|
|
129
|
-
base: path,
|
|
130
|
-
interactive: opts.interactive,
|
|
131
|
-
title: opts.title ?? 'Attached tab',
|
|
132
|
-
})
|
|
133
|
-
res.writeHead(200, {
|
|
134
|
-
'content-type': 'text/html; charset=utf-8',
|
|
135
|
-
// A share URL must never be cached or indexed by anything between
|
|
136
|
-
// the viewer and here.
|
|
137
|
-
'cache-control': 'no-store',
|
|
138
|
-
'referrer-policy': 'no-referrer',
|
|
139
|
-
}).end(body)
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if(req.method === 'GET' && route === '/stream') {
|
|
144
|
-
res.writeHead(200, {
|
|
145
|
-
'content-type': 'text/event-stream',
|
|
146
|
-
'cache-control': 'no-store',
|
|
147
|
-
connection: 'keep-alive',
|
|
148
|
-
// Defeat proxy buffering, which would otherwise hold frames back.
|
|
149
|
-
'x-accel-buffering': 'no',
|
|
150
|
-
})
|
|
151
|
-
res.write(': connected\n\n')
|
|
152
|
-
clients.add(res)
|
|
153
|
-
void startScreencast().catch((err) => {
|
|
154
|
-
LOGGER.warn({ err }, 'live view: screencast failed to start')
|
|
155
|
-
})
|
|
156
|
-
req.on('close', () => {
|
|
157
|
-
clients.delete(res)
|
|
158
|
-
if(!clients.size) {
|
|
159
|
-
void stopScreencast()
|
|
160
|
-
}
|
|
161
|
-
})
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if(req.method === 'POST' && route === '/input') {
|
|
166
|
-
if(!opts.interactive) {
|
|
167
|
-
res.writeHead(403).end('view only')
|
|
168
|
-
return
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
readJson(req, MAX_INPUT_BYTES)
|
|
172
|
-
.then((body) => dispatchInput(opts.tab, body))
|
|
173
|
-
.then(() => res.writeHead(204).end())
|
|
174
|
-
.catch((err) => {
|
|
175
|
-
LOGGER.debug({ err }, 'live view: input rejected')
|
|
176
|
-
res.writeHead(400).end('bad input')
|
|
177
|
-
})
|
|
178
|
-
return
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
res.writeHead(404).end('not found')
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
await new Promise<void>((resolve) => {
|
|
185
|
-
server.listen(0, '127.0.0.1', resolve)
|
|
186
|
-
})
|
|
187
|
-
const port = (server.address() as AddressInfo).port
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
localUrl: `http://127.0.0.1:${port}`,
|
|
191
|
-
port,
|
|
192
|
-
token,
|
|
193
|
-
path,
|
|
194
|
-
viewers: () => clients.size,
|
|
195
|
-
stop: async() => {
|
|
196
|
-
await stopScreencast()
|
|
197
|
-
for(const res of clients) {
|
|
198
|
-
res.end()
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
clients.clear()
|
|
202
|
-
await closeServer(server)
|
|
203
|
-
},
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function closeServer(server: Server): Promise<void> {
|
|
208
|
-
return new Promise((resolve) => {
|
|
209
|
-
server.close(() => resolve())
|
|
210
|
-
// A held-open SSE response would otherwise keep the server alive.
|
|
211
|
-
server.closeAllConnections?.()
|
|
212
|
-
})
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function readJson(
|
|
216
|
-
req: IncomingMessage,
|
|
217
|
-
limit: number,
|
|
218
|
-
): Promise<Record<string, unknown>> {
|
|
219
|
-
return new Promise((resolve, reject) => {
|
|
220
|
-
let size = 0
|
|
221
|
-
const chunks: Buffer[] = []
|
|
222
|
-
req.on('data', (chunk: Buffer) => {
|
|
223
|
-
size += chunk.length
|
|
224
|
-
if(size > limit) {
|
|
225
|
-
reject(new Error('input too large'))
|
|
226
|
-
req.destroy()
|
|
227
|
-
return
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
chunks.push(chunk)
|
|
231
|
-
})
|
|
232
|
-
req.on('end', () => {
|
|
233
|
-
try {
|
|
234
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
235
|
-
} catch(err) {
|
|
236
|
-
reject(err instanceof Error ? err : new Error('bad json'))
|
|
237
|
-
}
|
|
238
|
-
})
|
|
239
|
-
req.on('error', reject)
|
|
240
|
-
})
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Replay one viewer event into the page. Unknown shapes throw, so the route
|
|
244
|
-
* answers 400 rather than forwarding something arbitrary to CDP. */
|
|
245
|
-
async function dispatchInput(
|
|
246
|
-
tab: CdpTabClient,
|
|
247
|
-
body: Record<string, unknown>,
|
|
248
|
-
): Promise<void> {
|
|
249
|
-
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v)
|
|
250
|
-
? v
|
|
251
|
-
: 0)
|
|
252
|
-
|
|
253
|
-
if(body.kind === 'mouse') {
|
|
254
|
-
const type = String(body.type)
|
|
255
|
-
if(!['mousePressed', 'mouseReleased', 'mouseMoved', 'mouseWheel']
|
|
256
|
-
.includes(type)) {
|
|
257
|
-
throw new Error(`unsupported mouse type ${type}`)
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
await tab.send('Input.dispatchMouseEvent', {
|
|
261
|
-
type,
|
|
262
|
-
x: num(body.x),
|
|
263
|
-
y: num(body.y),
|
|
264
|
-
button: type === 'mouseWheel' ? 'none' : 'left',
|
|
265
|
-
clickCount: type === 'mouseWheel' ? 0 : 1,
|
|
266
|
-
...(type === 'mouseWheel'
|
|
267
|
-
? { deltaX: num(body.deltaX), deltaY: num(body.deltaY) }
|
|
268
|
-
: {}),
|
|
269
|
-
})
|
|
270
|
-
return
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if(body.kind === 'key') {
|
|
274
|
-
const type = String(body.type)
|
|
275
|
-
if(type !== 'keyDown' && type !== 'keyUp') {
|
|
276
|
-
throw new Error(`unsupported key type ${type}`)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const key = String(body.key ?? '')
|
|
280
|
-
// A single printable character needs `text` as well, or the page sees the
|
|
281
|
-
// keypress but no character is inserted.
|
|
282
|
-
const text = key.length === 1 ? key : undefined
|
|
283
|
-
await tab.send('Input.dispatchKeyEvent', {
|
|
284
|
-
type,
|
|
285
|
-
key,
|
|
286
|
-
code: String(body.code ?? ''),
|
|
287
|
-
...(text && type === 'keyDown' ? { text } : {}),
|
|
288
|
-
})
|
|
289
|
-
return
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
throw new Error('unknown event kind')
|
|
293
|
-
}
|