@omercnet/paseo-shared-browser 0.3.1-next.72.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/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/client/browser.tsx +2098 -0
- package/docs/images/shared-browser-compact.png +0 -0
- package/docs/images/shared-browser-wide.png +0 -0
- package/index.client.tsx +35 -0
- package/index.server.ts +118 -0
- package/package.json +78 -0
- package/paseo-plugin.json +10 -0
- package/scripts/prepare-dependencies.mjs +25 -0
- package/scripts/prepare-runtime.mjs +168 -0
- package/server/agent-browser-runtime.ts +970 -0
- package/server/browser-policy.ts +836 -0
- package/server/browser.ts +306 -0
- package/server/cdp.ts +265 -0
- package/server/electron.d.ts +1 -0
- package/server/mcp-entry.ts +194 -0
- package/server/runtime-owner.ts +122 -0
- package/server/runtime-protocol.ts +260 -0
- package/server/supervisor-client.ts +402 -0
- package/server/supervisor-entry.ts +9 -0
- package/server/supervisor.ts +1081 -0
- package/shared/browser.ts +364 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import {
|
|
6
|
+
CdpConnection,
|
|
7
|
+
CdpSession,
|
|
8
|
+
CdpUnavailableError,
|
|
9
|
+
CdpUnknownOutcomeError,
|
|
10
|
+
attachToTarget,
|
|
11
|
+
listPageTargets,
|
|
12
|
+
type CdpEvent,
|
|
13
|
+
type CdpTarget,
|
|
14
|
+
} from "./cdp";
|
|
15
|
+
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
export const AGENT_BROWSER_VERSION = "0.37.1";
|
|
18
|
+
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
19
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
21
|
+
const SCREENCAST_SETUP_RETRY_MS = 25;
|
|
22
|
+
|
|
23
|
+
export class AgentBrowserUnavailableError extends Error {
|
|
24
|
+
override readonly name = "AgentBrowserUnavailableError";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class AgentBrowserIncompatibleError extends Error {
|
|
28
|
+
override readonly name = "AgentBrowserIncompatibleError";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { CdpUnknownOutcomeError as UnknownMutationOutcomeError };
|
|
32
|
+
|
|
33
|
+
export interface BrowserViewport {
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
deviceScaleFactor: number;
|
|
37
|
+
mobile: boolean;
|
|
38
|
+
touch: boolean;
|
|
39
|
+
userAgent?: string;
|
|
40
|
+
platform?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RuntimeIdentity {
|
|
44
|
+
agentBrowserVersion: string;
|
|
45
|
+
browserProduct: string;
|
|
46
|
+
browserRevision: string;
|
|
47
|
+
userAgent: string;
|
|
48
|
+
protocolVersion: string;
|
|
49
|
+
processId: number | null;
|
|
50
|
+
targetId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RuntimeHealth {
|
|
54
|
+
ready: boolean;
|
|
55
|
+
session: string;
|
|
56
|
+
profilePath: string;
|
|
57
|
+
targetCount: number;
|
|
58
|
+
identity: RuntimeIdentity | null;
|
|
59
|
+
error: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RuntimeTarget extends CdpTarget {
|
|
63
|
+
openerId?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RuntimePageState {
|
|
67
|
+
url: string;
|
|
68
|
+
title: string;
|
|
69
|
+
canGoBack: boolean;
|
|
70
|
+
canGoForward: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RuntimeFrame {
|
|
74
|
+
dataBase64: string;
|
|
75
|
+
byteLength: number;
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
transport: "cdp-screencast" | "screenshot";
|
|
79
|
+
capturedAt: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DeviceEmulation extends BrowserViewport {
|
|
83
|
+
screenWidth?: number;
|
|
84
|
+
screenHeight?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type MouseButton = "left" | "middle" | "right";
|
|
88
|
+
|
|
89
|
+
export interface AgentBrowserRuntimeOptions {
|
|
90
|
+
binaryPath: string;
|
|
91
|
+
executablePath: string;
|
|
92
|
+
profilePath: string;
|
|
93
|
+
ipcDirectory: string;
|
|
94
|
+
session: string;
|
|
95
|
+
initialUrl?: string;
|
|
96
|
+
timeoutMs?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface VersionResult {
|
|
100
|
+
protocolVersion: string;
|
|
101
|
+
product: string;
|
|
102
|
+
revision: string;
|
|
103
|
+
userAgent: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface NavigationHistory {
|
|
107
|
+
currentIndex: number;
|
|
108
|
+
entries: Array<{ id: number; url: string; title: string }>;
|
|
109
|
+
}
|
|
110
|
+
interface PageLifecycleEvent {
|
|
111
|
+
name: string;
|
|
112
|
+
loaderId: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface ScreencastFrame {
|
|
116
|
+
data: string;
|
|
117
|
+
metadata: { deviceWidth: number; deviceHeight: number };
|
|
118
|
+
sessionId: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function requireAbsolute(path: string, label: string): string {
|
|
122
|
+
if (!isAbsolute(path)) throw new AgentBrowserIncompatibleError(`${label} must be absolute`);
|
|
123
|
+
return resolve(path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function runtimeEnvironment(ipcDirectory: string): NodeJS.ProcessEnv {
|
|
127
|
+
const environment: NodeJS.ProcessEnv = {};
|
|
128
|
+
for (const key of [
|
|
129
|
+
"PATH",
|
|
130
|
+
"HOME",
|
|
131
|
+
"LANG",
|
|
132
|
+
"LANGUAGE",
|
|
133
|
+
"LC_ALL",
|
|
134
|
+
"LC_CTYPE",
|
|
135
|
+
"TMPDIR",
|
|
136
|
+
"TMP",
|
|
137
|
+
"TEMP",
|
|
138
|
+
"XDG_RUNTIME_DIR",
|
|
139
|
+
"DISPLAY",
|
|
140
|
+
"WAYLAND_DISPLAY",
|
|
141
|
+
"XAUTHORITY",
|
|
142
|
+
]) {
|
|
143
|
+
const value = process.env[key];
|
|
144
|
+
if (value !== undefined) environment[key] = value;
|
|
145
|
+
}
|
|
146
|
+
environment.AGENT_BROWSER_SOCKET_DIR = ipcDirectory;
|
|
147
|
+
environment.AGENT_BROWSER_IDLE_TIMEOUT_MS = "0";
|
|
148
|
+
environment.AGENT_BROWSER_STREAM_PORT = "0";
|
|
149
|
+
environment.AGENT_BROWSER_NO_AUTO_DIALOG = "1";
|
|
150
|
+
return environment;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseJsonOutput(stdout: string): unknown {
|
|
154
|
+
const lines = stdout.trim().split("\n").reverse();
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(line);
|
|
158
|
+
} catch {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
throw new AgentBrowserIncompatibleError("agent-browser returned invalid JSON");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function findString(value: unknown, keys: readonly string[]): string | null {
|
|
166
|
+
if (!value || typeof value !== "object") return null;
|
|
167
|
+
const record = value as Record<string, unknown>;
|
|
168
|
+
for (const key of keys) {
|
|
169
|
+
if (typeof record[key] === "string") return record[key];
|
|
170
|
+
}
|
|
171
|
+
for (const child of Object.values(record)) {
|
|
172
|
+
const found = findString(child, keys);
|
|
173
|
+
if (found) return found;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class AgentBrowserRuntime {
|
|
179
|
+
readonly binaryPath: string;
|
|
180
|
+
readonly executablePath: string;
|
|
181
|
+
readonly profilePath: string;
|
|
182
|
+
readonly ipcDirectory: string;
|
|
183
|
+
readonly session: string;
|
|
184
|
+
private readonly initialUrl: string;
|
|
185
|
+
private readonly timeoutMs: number;
|
|
186
|
+
private readonly environment: NodeJS.ProcessEnv;
|
|
187
|
+
private connection: CdpConnection | null = null;
|
|
188
|
+
private page: CdpSession | null = null;
|
|
189
|
+
private targetId: string | null = null;
|
|
190
|
+
private viewport: BrowserViewport | null = null;
|
|
191
|
+
private screencastFrame: RuntimeFrame | null = null;
|
|
192
|
+
private screencastWaiters = new Set<(frame: RuntimeFrame | null) => void>();
|
|
193
|
+
private screencastActive = false;
|
|
194
|
+
private screencastQuality = 65;
|
|
195
|
+
private heldButtons = new Set<MouseButton>();
|
|
196
|
+
private heldKeys = new Set<string>();
|
|
197
|
+
private stopping = false;
|
|
198
|
+
|
|
199
|
+
constructor(options: AgentBrowserRuntimeOptions) {
|
|
200
|
+
this.binaryPath = requireAbsolute(options.binaryPath, "agent-browser binary path");
|
|
201
|
+
this.executablePath = requireAbsolute(options.executablePath, "Chromium executable path");
|
|
202
|
+
this.profilePath = requireAbsolute(options.profilePath, "profile path");
|
|
203
|
+
this.ipcDirectory = requireAbsolute(options.ipcDirectory, "IPC directory");
|
|
204
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(options.session)) {
|
|
205
|
+
throw new AgentBrowserIncompatibleError("agent-browser session name is invalid");
|
|
206
|
+
}
|
|
207
|
+
this.session = options.session;
|
|
208
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
209
|
+
this.environment = runtimeEnvironment(this.ipcDirectory);
|
|
210
|
+
this.initialUrl = options.initialUrl ?? "about:blank";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async launch(): Promise<void> {
|
|
214
|
+
if (this.connection?.isOpen) return;
|
|
215
|
+
this.invalidateScreencastFrame();
|
|
216
|
+
this.stopping = false;
|
|
217
|
+
await mkdir(this.profilePath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
|
218
|
+
await mkdir(this.ipcDirectory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
|
219
|
+
await Promise.all([
|
|
220
|
+
chmod(this.profilePath, PRIVATE_DIRECTORY_MODE),
|
|
221
|
+
chmod(this.ipcDirectory, PRIVATE_DIRECTORY_MODE),
|
|
222
|
+
]);
|
|
223
|
+
await this.assertVersion();
|
|
224
|
+
const opened = await this.invoke([
|
|
225
|
+
"--session",
|
|
226
|
+
this.session,
|
|
227
|
+
"--profile",
|
|
228
|
+
this.profilePath,
|
|
229
|
+
"--executable-path",
|
|
230
|
+
this.executablePath,
|
|
231
|
+
"--json",
|
|
232
|
+
"open",
|
|
233
|
+
this.initialUrl,
|
|
234
|
+
]);
|
|
235
|
+
const targetId = findString(opened, ["targetId", "target_id"]);
|
|
236
|
+
await this.protectIpcMetadata();
|
|
237
|
+
await this.connectCdp(targetId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async reconnect(): Promise<void> {
|
|
241
|
+
const targetId = this.targetId;
|
|
242
|
+
this.invalidateScreencastFrame();
|
|
243
|
+
this.connection?.close();
|
|
244
|
+
this.connection = null;
|
|
245
|
+
this.page = null;
|
|
246
|
+
await this.assertVersion();
|
|
247
|
+
await this.connectCdp(targetId);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async health(): Promise<RuntimeHealth> {
|
|
251
|
+
try {
|
|
252
|
+
if (!this.connection?.isOpen) await this.reconnect();
|
|
253
|
+
const targets = await this.targets();
|
|
254
|
+
return {
|
|
255
|
+
ready: true,
|
|
256
|
+
session: this.session,
|
|
257
|
+
profilePath: this.profilePath,
|
|
258
|
+
targetCount: targets.length,
|
|
259
|
+
identity: await this.identity(),
|
|
260
|
+
error: null,
|
|
261
|
+
};
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return {
|
|
264
|
+
ready: false,
|
|
265
|
+
session: this.session,
|
|
266
|
+
profilePath: this.profilePath,
|
|
267
|
+
targetCount: 0,
|
|
268
|
+
identity: null,
|
|
269
|
+
error: error instanceof Error ? error.message : String(error),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async identity(): Promise<RuntimeIdentity> {
|
|
275
|
+
const connection = this.requireConnection();
|
|
276
|
+
const version = await connection.send<VersionResult>("Browser.getVersion");
|
|
277
|
+
const processId = await this.daemonPid();
|
|
278
|
+
const page = await this.requirePage();
|
|
279
|
+
return {
|
|
280
|
+
agentBrowserVersion: AGENT_BROWSER_VERSION,
|
|
281
|
+
browserProduct: version.product,
|
|
282
|
+
browserRevision: version.revision,
|
|
283
|
+
userAgent: version.userAgent,
|
|
284
|
+
protocolVersion: version.protocolVersion,
|
|
285
|
+
processId,
|
|
286
|
+
targetId: page.targetId,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async targets(): Promise<RuntimeTarget[]> {
|
|
291
|
+
return listPageTargets(this.requireConnection());
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async selectTarget(targetId: string): Promise<void> {
|
|
295
|
+
const target = (await this.targets()).find((candidate) => candidate.targetId === targetId);
|
|
296
|
+
if (!target) throw new CdpUnavailableError(`Unknown page target: ${targetId}`);
|
|
297
|
+
this.invalidateScreencastFrame();
|
|
298
|
+
if (this.page) {
|
|
299
|
+
if (this.screencastActive) {
|
|
300
|
+
await this.page.send("Page.stopScreencast", {}, { mutation: true });
|
|
301
|
+
}
|
|
302
|
+
await this.page.detach();
|
|
303
|
+
}
|
|
304
|
+
this.invalidateScreencastFrame();
|
|
305
|
+
await this.requireConnection().send("Target.activateTarget", { targetId }, { mutation: true });
|
|
306
|
+
this.page = await attachToTarget(this.requireConnection(), targetId);
|
|
307
|
+
this.targetId = targetId;
|
|
308
|
+
await this.bindPageEvents(this.page);
|
|
309
|
+
if (this.screencastActive) await this.startScreencastSession(this.page);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async navigationHistory(page: CdpSession): Promise<NavigationHistory> {
|
|
313
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
314
|
+
for (;;) {
|
|
315
|
+
try {
|
|
316
|
+
return await page.send<NavigationHistory>("Page.getNavigationHistory");
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (!String(error).includes("Not attached to an active page") || Date.now() >= deadline) {
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
322
|
+
setTimeout(resolve, 25);
|
|
323
|
+
await promise;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async state(): Promise<RuntimePageState> {
|
|
328
|
+
const page = await this.requirePage();
|
|
329
|
+
const history = await this.navigationHistory(page);
|
|
330
|
+
const evaluated = await page.send<{
|
|
331
|
+
result: { value?: { url?: string; title?: string } };
|
|
332
|
+
}>("Runtime.evaluate", {
|
|
333
|
+
expression: "({url: location.href, title: document.title})",
|
|
334
|
+
returnByValue: true,
|
|
335
|
+
});
|
|
336
|
+
return {
|
|
337
|
+
url: evaluated.result.value?.url ?? history.entries[history.currentIndex]?.url ?? "",
|
|
338
|
+
title: evaluated.result.value?.title ?? history.entries[history.currentIndex]?.title ?? "",
|
|
339
|
+
canGoBack: history.currentIndex > 0,
|
|
340
|
+
canGoForward: history.currentIndex < history.entries.length - 1,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async navigate(url: string): Promise<void> {
|
|
345
|
+
await this.withInvalidatedScreencast(async (page) => {
|
|
346
|
+
await page.send("Page.setLifecycleEventsEnabled", { enabled: true });
|
|
347
|
+
const observedLoaders = new Set<string>();
|
|
348
|
+
const loaded = Promise.withResolvers<void>();
|
|
349
|
+
let expectedLoaderId: string | undefined;
|
|
350
|
+
const onLifecycle = (event: PageLifecycleEvent) => {
|
|
351
|
+
if (event.name !== "DOMContentLoaded") return;
|
|
352
|
+
if (expectedLoaderId === undefined) {
|
|
353
|
+
observedLoaders.add(event.loaderId);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (event.loaderId === expectedLoaderId) loaded.resolve();
|
|
357
|
+
};
|
|
358
|
+
page.on("Page.lifecycleEvent", onLifecycle);
|
|
359
|
+
let timer: NodeJS.Timeout | undefined;
|
|
360
|
+
try {
|
|
361
|
+
const result = await page.send<{ errorText?: string; loaderId?: string }>(
|
|
362
|
+
"Page.navigate",
|
|
363
|
+
{ url },
|
|
364
|
+
{ mutation: true, timeoutMs: 30_000 },
|
|
365
|
+
);
|
|
366
|
+
if (result.errorText) throw new Error(`Navigation failed: ${result.errorText}`);
|
|
367
|
+
if (!result.loaderId) return;
|
|
368
|
+
expectedLoaderId = result.loaderId;
|
|
369
|
+
if (observedLoaders.has(expectedLoaderId)) return;
|
|
370
|
+
timer = setTimeout(
|
|
371
|
+
() =>
|
|
372
|
+
loaded.reject(
|
|
373
|
+
new CdpUnknownOutcomeError(
|
|
374
|
+
"Page.navigate did not reach DOMContentLoaded; mutation outcome is unknown",
|
|
375
|
+
),
|
|
376
|
+
),
|
|
377
|
+
30_000,
|
|
378
|
+
);
|
|
379
|
+
timer.unref();
|
|
380
|
+
await loaded.promise;
|
|
381
|
+
} finally {
|
|
382
|
+
clearTimeout(timer);
|
|
383
|
+
page.off("Page.lifecycleEvent", onLifecycle);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async back(): Promise<void> {
|
|
389
|
+
await this.withInvalidatedScreencast(async (page) => {
|
|
390
|
+
const history = await this.navigationHistory(page);
|
|
391
|
+
const entry = history.entries[history.currentIndex - 1];
|
|
392
|
+
if (!entry) throw new Error("Browser cannot go back");
|
|
393
|
+
await page.send("Page.navigateToHistoryEntry", { entryId: entry.id }, { mutation: true });
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async forward(): Promise<void> {
|
|
398
|
+
await this.withInvalidatedScreencast(async (page) => {
|
|
399
|
+
const history = await this.navigationHistory(page);
|
|
400
|
+
const entry = history.entries[history.currentIndex + 1];
|
|
401
|
+
if (!entry) throw new Error("Browser cannot go forward");
|
|
402
|
+
await page.send("Page.navigateToHistoryEntry", { entryId: entry.id }, { mutation: true });
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async reload(ignoreCache = false): Promise<void> {
|
|
407
|
+
await this.withInvalidatedScreencast((page) =>
|
|
408
|
+
page.send("Page.reload", { ignoreCache }, { mutation: true }),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async emulate(device: DeviceEmulation): Promise<void> {
|
|
413
|
+
this.assertViewport(device);
|
|
414
|
+
await this.withInvalidatedScreencast(async (page) => {
|
|
415
|
+
await page.send(
|
|
416
|
+
"Emulation.setDeviceMetricsOverride",
|
|
417
|
+
{
|
|
418
|
+
width: device.width,
|
|
419
|
+
height: device.height,
|
|
420
|
+
deviceScaleFactor: device.deviceScaleFactor,
|
|
421
|
+
mobile: device.mobile,
|
|
422
|
+
screenWidth: device.screenWidth ?? device.width,
|
|
423
|
+
screenHeight: device.screenHeight ?? device.height,
|
|
424
|
+
screenOrientation: {
|
|
425
|
+
type: device.width > device.height ? "landscapePrimary" : "portraitPrimary",
|
|
426
|
+
angle: device.width > device.height ? 90 : 0,
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
{ mutation: true },
|
|
430
|
+
);
|
|
431
|
+
await page.send(
|
|
432
|
+
"Emulation.setTouchEmulationEnabled",
|
|
433
|
+
{ enabled: device.touch, maxTouchPoints: device.touch ? 5 : 1 },
|
|
434
|
+
{ mutation: true },
|
|
435
|
+
);
|
|
436
|
+
if (device.userAgent) {
|
|
437
|
+
await page.send(
|
|
438
|
+
"Emulation.setUserAgentOverride",
|
|
439
|
+
{ userAgent: device.userAgent, platform: device.platform ?? "" },
|
|
440
|
+
{ mutation: true },
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
this.viewport = { ...device };
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async startScreencast(quality = 65): Promise<void> {
|
|
448
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
|
|
449
|
+
throw new RangeError("Screencast quality must be an integer from 1 to 100");
|
|
450
|
+
}
|
|
451
|
+
this.screencastActive = true;
|
|
452
|
+
this.screencastQuality = quality;
|
|
453
|
+
const page = await this.requirePage();
|
|
454
|
+
await this.startScreencastSession(page);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async stopScreencast(): Promise<void> {
|
|
458
|
+
this.screencastActive = false;
|
|
459
|
+
this.invalidateScreencastFrame();
|
|
460
|
+
if (!this.page) return;
|
|
461
|
+
try {
|
|
462
|
+
await this.page.send("Page.stopScreencast", {}, { mutation: true });
|
|
463
|
+
} finally {
|
|
464
|
+
this.invalidateScreencastFrame();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async frame(maxBytes: number, quality = 65, waitMs = 500): Promise<RuntimeFrame> {
|
|
469
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1)
|
|
470
|
+
throw new RangeError("maxBytes must be positive");
|
|
471
|
+
const streamed = this.screencastFrame ?? (await this.waitForFrame(waitMs));
|
|
472
|
+
if (streamed && streamed.byteLength <= maxBytes) return streamed;
|
|
473
|
+
const page = await this.requirePage();
|
|
474
|
+
for (const candidate of [quality, 50, 35, 20, 10, 1]) {
|
|
475
|
+
const boundedQuality = Math.max(1, Math.min(100, Math.round(candidate)));
|
|
476
|
+
const result = await page.send<{ data: string }>("Page.captureScreenshot", {
|
|
477
|
+
format: "jpeg",
|
|
478
|
+
quality: boundedQuality,
|
|
479
|
+
fromSurface: true,
|
|
480
|
+
captureBeyondViewport: false,
|
|
481
|
+
});
|
|
482
|
+
const byteLength = Buffer.byteLength(result.data, "base64");
|
|
483
|
+
if (byteLength <= maxBytes) {
|
|
484
|
+
const viewport = this.requireViewport();
|
|
485
|
+
return {
|
|
486
|
+
dataBase64: result.data,
|
|
487
|
+
byteLength,
|
|
488
|
+
width: viewport.width,
|
|
489
|
+
height: viewport.height,
|
|
490
|
+
transport: "screenshot",
|
|
491
|
+
capturedAt: new Date().toISOString(),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
throw new Error(`JPEG screenshot exceeds ${maxBytes} bytes at minimum quality`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async mouseMove(x: number, y: number): Promise<void> {
|
|
499
|
+
this.assertPoint(x, y);
|
|
500
|
+
await (
|
|
501
|
+
await this.requirePage()
|
|
502
|
+
).send(
|
|
503
|
+
"Input.dispatchMouseEvent",
|
|
504
|
+
{
|
|
505
|
+
type: "mouseMoved",
|
|
506
|
+
x,
|
|
507
|
+
y,
|
|
508
|
+
button: "none",
|
|
509
|
+
buttons: this.buttonMask(),
|
|
510
|
+
},
|
|
511
|
+
{ mutation: true },
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async mouseDown(
|
|
516
|
+
x: number,
|
|
517
|
+
y: number,
|
|
518
|
+
button: MouseButton = "left",
|
|
519
|
+
clickCount = 1,
|
|
520
|
+
): Promise<void> {
|
|
521
|
+
this.assertPoint(x, y);
|
|
522
|
+
await (
|
|
523
|
+
await this.requirePage()
|
|
524
|
+
).send(
|
|
525
|
+
"Input.dispatchMouseEvent",
|
|
526
|
+
{
|
|
527
|
+
type: "mousePressed",
|
|
528
|
+
x,
|
|
529
|
+
y,
|
|
530
|
+
button,
|
|
531
|
+
buttons: this.buttonMask(button),
|
|
532
|
+
clickCount,
|
|
533
|
+
},
|
|
534
|
+
{ mutation: true },
|
|
535
|
+
);
|
|
536
|
+
this.heldButtons.add(button);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async mouseUp(x: number, y: number, button: MouseButton = "left", clickCount = 1): Promise<void> {
|
|
540
|
+
this.assertPoint(x, y);
|
|
541
|
+
await (
|
|
542
|
+
await this.requirePage()
|
|
543
|
+
).send(
|
|
544
|
+
"Input.dispatchMouseEvent",
|
|
545
|
+
{
|
|
546
|
+
type: "mouseReleased",
|
|
547
|
+
x,
|
|
548
|
+
y,
|
|
549
|
+
button,
|
|
550
|
+
buttons: this.buttonMask(undefined, button),
|
|
551
|
+
clickCount,
|
|
552
|
+
},
|
|
553
|
+
{ mutation: true },
|
|
554
|
+
);
|
|
555
|
+
this.heldButtons.delete(button);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async wheel(x: number, y: number, deltaX: number, deltaY: number): Promise<void> {
|
|
559
|
+
this.assertPoint(x, y);
|
|
560
|
+
await (
|
|
561
|
+
await this.requirePage()
|
|
562
|
+
).send(
|
|
563
|
+
"Input.dispatchMouseEvent",
|
|
564
|
+
{
|
|
565
|
+
type: "mouseWheel",
|
|
566
|
+
x,
|
|
567
|
+
y,
|
|
568
|
+
deltaX,
|
|
569
|
+
deltaY,
|
|
570
|
+
button: "none",
|
|
571
|
+
buttons: this.buttonMask(),
|
|
572
|
+
},
|
|
573
|
+
{ mutation: true },
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async insertText(text: string): Promise<void> {
|
|
578
|
+
await (await this.requirePage()).send("Input.insertText", { text }, { mutation: true });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async keyDown(key: string, code = key): Promise<void> {
|
|
582
|
+
await (
|
|
583
|
+
await this.requirePage()
|
|
584
|
+
).send(
|
|
585
|
+
"Input.dispatchKeyEvent",
|
|
586
|
+
{
|
|
587
|
+
type: "keyDown",
|
|
588
|
+
key,
|
|
589
|
+
code,
|
|
590
|
+
text: key.length === 1 ? key : undefined,
|
|
591
|
+
},
|
|
592
|
+
{ mutation: true },
|
|
593
|
+
);
|
|
594
|
+
this.heldKeys.add(key);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async keyUp(key: string, code = key): Promise<void> {
|
|
598
|
+
await (
|
|
599
|
+
await this.requirePage()
|
|
600
|
+
).send("Input.dispatchKeyEvent", { type: "keyUp", key, code }, { mutation: true });
|
|
601
|
+
this.heldKeys.delete(key);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async touch(
|
|
605
|
+
type: "touchStart" | "touchMove" | "touchEnd" | "touchCancel",
|
|
606
|
+
points: Array<{ x: number; y: number; id?: number }>,
|
|
607
|
+
): Promise<void> {
|
|
608
|
+
for (const point of points) this.assertPoint(point.x, point.y);
|
|
609
|
+
await (
|
|
610
|
+
await this.requirePage()
|
|
611
|
+
).send(
|
|
612
|
+
"Input.dispatchTouchEvent",
|
|
613
|
+
{
|
|
614
|
+
type,
|
|
615
|
+
touchPoints: points.map((point, index) => ({
|
|
616
|
+
x: point.x,
|
|
617
|
+
y: point.y,
|
|
618
|
+
id: point.id ?? index,
|
|
619
|
+
})),
|
|
620
|
+
},
|
|
621
|
+
{ mutation: true },
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async releaseHeldInput(): Promise<void> {
|
|
626
|
+
const page = this.page;
|
|
627
|
+
if (!page) {
|
|
628
|
+
this.heldButtons.clear();
|
|
629
|
+
this.heldKeys.clear();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const buttons = [...this.heldButtons];
|
|
633
|
+
const keys = [...this.heldKeys];
|
|
634
|
+
this.heldButtons.clear();
|
|
635
|
+
this.heldKeys.clear();
|
|
636
|
+
await Promise.allSettled([
|
|
637
|
+
...buttons.map((button) =>
|
|
638
|
+
page.send(
|
|
639
|
+
"Input.dispatchMouseEvent",
|
|
640
|
+
{
|
|
641
|
+
type: "mouseReleased",
|
|
642
|
+
x: 0,
|
|
643
|
+
y: 0,
|
|
644
|
+
button,
|
|
645
|
+
buttons: 0,
|
|
646
|
+
clickCount: 1,
|
|
647
|
+
},
|
|
648
|
+
{ mutation: true },
|
|
649
|
+
),
|
|
650
|
+
),
|
|
651
|
+
...keys.map((key) =>
|
|
652
|
+
page.send(
|
|
653
|
+
"Input.dispatchKeyEvent",
|
|
654
|
+
{
|
|
655
|
+
type: "keyUp",
|
|
656
|
+
key,
|
|
657
|
+
code: key,
|
|
658
|
+
},
|
|
659
|
+
{ mutation: true },
|
|
660
|
+
),
|
|
661
|
+
),
|
|
662
|
+
]);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async shutdown(force = false): Promise<void> {
|
|
666
|
+
if (this.stopping) return;
|
|
667
|
+
this.stopping = true;
|
|
668
|
+
await this.releaseHeldInput();
|
|
669
|
+
this.invalidateScreencastFrame();
|
|
670
|
+
if (!force) {
|
|
671
|
+
try {
|
|
672
|
+
await this.invoke(["--session", this.session, "--json", "close"]);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
this.stopping = false;
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
const pid = await this.daemonPid();
|
|
679
|
+
if (pid !== null) {
|
|
680
|
+
try {
|
|
681
|
+
process.kill(pid, "SIGKILL");
|
|
682
|
+
} catch (error) {
|
|
683
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
await this.removeIpcMetadata();
|
|
687
|
+
}
|
|
688
|
+
this.connection?.close();
|
|
689
|
+
this.connection = null;
|
|
690
|
+
this.page = null;
|
|
691
|
+
this.targetId = null;
|
|
692
|
+
this.invalidateScreencastFrame();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private async assertVersion(): Promise<void> {
|
|
696
|
+
try {
|
|
697
|
+
const { stdout } = await execFileAsync(this.binaryPath, ["--version"], {
|
|
698
|
+
env: this.environment,
|
|
699
|
+
timeout: this.timeoutMs,
|
|
700
|
+
encoding: "utf8",
|
|
701
|
+
});
|
|
702
|
+
const version = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/)?.[0];
|
|
703
|
+
if (version !== AGENT_BROWSER_VERSION) {
|
|
704
|
+
throw new AgentBrowserIncompatibleError(
|
|
705
|
+
`Expected agent-browser ${AGENT_BROWSER_VERSION}, received ${version ?? "unknown"}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
} catch (error) {
|
|
709
|
+
if (error instanceof AgentBrowserIncompatibleError) throw error;
|
|
710
|
+
throw new AgentBrowserUnavailableError(
|
|
711
|
+
`agent-browser is unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private async invoke(arguments_: string[]): Promise<unknown> {
|
|
717
|
+
try {
|
|
718
|
+
const { stdout } = await execFileAsync(this.binaryPath, arguments_, {
|
|
719
|
+
env: this.environment,
|
|
720
|
+
timeout: this.timeoutMs,
|
|
721
|
+
encoding: "utf8",
|
|
722
|
+
maxBuffer: 1024 * 1024,
|
|
723
|
+
});
|
|
724
|
+
return parseJsonOutput(stdout);
|
|
725
|
+
} catch (error) {
|
|
726
|
+
const typed = error as NodeJS.ErrnoException & {
|
|
727
|
+
killed?: boolean;
|
|
728
|
+
stderr?: string;
|
|
729
|
+
stdout?: string;
|
|
730
|
+
};
|
|
731
|
+
if (typed.killed) {
|
|
732
|
+
throw new CdpUnknownOutcomeError("agent-browser command timed out; outcome is unknown");
|
|
733
|
+
}
|
|
734
|
+
console.error(
|
|
735
|
+
"Shared Browser agent-browser command failed:",
|
|
736
|
+
typed.stderr?.trim() || typed.stdout?.trim() || typed.message || "unknown error",
|
|
737
|
+
);
|
|
738
|
+
throw new AgentBrowserUnavailableError("agent-browser command failed");
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private async connectCdp(preferredTargetId: string | null = null): Promise<void> {
|
|
743
|
+
const response = await this.invoke(["--session", this.session, "--json", "get", "cdp-url"]);
|
|
744
|
+
const url = findString(response, ["cdpUrl", "cdp_url", "url"]);
|
|
745
|
+
if (!url) throw new AgentBrowserIncompatibleError("agent-browser did not return a CDP URL");
|
|
746
|
+
const endpoint = new URL(url);
|
|
747
|
+
if (!["127.0.0.1", "localhost", "::1", "[::1]"].includes(endpoint.hostname)) {
|
|
748
|
+
throw new AgentBrowserIncompatibleError("agent-browser exposed a non-loopback CDP endpoint");
|
|
749
|
+
}
|
|
750
|
+
const connection = await CdpConnection.connect(url, {
|
|
751
|
+
commandTimeoutMs: this.timeoutMs,
|
|
752
|
+
connectTimeoutMs: this.timeoutMs,
|
|
753
|
+
});
|
|
754
|
+
this.connection = connection;
|
|
755
|
+
connection.once("disconnect", () => {
|
|
756
|
+
if (this.connection !== connection) return;
|
|
757
|
+
this.page = null;
|
|
758
|
+
this.targetId = null;
|
|
759
|
+
this.invalidateScreencastFrame();
|
|
760
|
+
});
|
|
761
|
+
const targets = await listPageTargets(this.connection);
|
|
762
|
+
const target =
|
|
763
|
+
targets.find((candidate) => candidate.targetId === preferredTargetId) ?? targets[0];
|
|
764
|
+
if (!target) throw new CdpUnavailableError("Chromium has no page target");
|
|
765
|
+
await this.selectTarget(target.targetId);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private async requirePage(): Promise<CdpSession> {
|
|
769
|
+
if (this.page && this.connection?.isOpen) return this.page;
|
|
770
|
+
await this.reconnect();
|
|
771
|
+
if (!this.page) throw new CdpUnavailableError("No page target is attached");
|
|
772
|
+
return this.page;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private requireConnection(): CdpConnection {
|
|
776
|
+
if (!this.connection?.isOpen) throw new CdpUnavailableError("Browser runtime is disconnected");
|
|
777
|
+
return this.connection;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
private requireViewport(): BrowserViewport {
|
|
781
|
+
if (!this.viewport) throw new AgentBrowserIncompatibleError("Viewport has not been configured");
|
|
782
|
+
return this.viewport;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private async bindPageEvents(page: CdpSession): Promise<void> {
|
|
786
|
+
page.on("Page.screencastFrame", this.onScreencastFrame);
|
|
787
|
+
page.on("event", (event: CdpEvent) => {
|
|
788
|
+
if (event.method === "Inspector.targetCrashed") this.invalidateScreencastFrame();
|
|
789
|
+
});
|
|
790
|
+
await Promise.all([page.send("Page.enable"), page.send("Runtime.enable")]);
|
|
791
|
+
await this.navigationHistory(page);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private async startScreencastSession(page: CdpSession): Promise<void> {
|
|
795
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
796
|
+
let candidate = page;
|
|
797
|
+
for (;;) {
|
|
798
|
+
try {
|
|
799
|
+
await Promise.all([candidate.send("Page.enable"), candidate.send("Runtime.enable")]);
|
|
800
|
+
await this.navigationHistory(candidate);
|
|
801
|
+
if (this.page !== candidate) {
|
|
802
|
+
candidate = await this.requirePage();
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
await candidate.send(
|
|
806
|
+
"Page.startScreencast",
|
|
807
|
+
{ format: "jpeg", quality: this.screencastQuality, everyNthFrame: 1 },
|
|
808
|
+
{ mutation: true },
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
811
|
+
} catch (error) {
|
|
812
|
+
if (Date.now() >= deadline) throw error;
|
|
813
|
+
try {
|
|
814
|
+
candidate = await this.reattachPageForScreencast(candidate);
|
|
815
|
+
} catch (reattachError) {
|
|
816
|
+
if (Date.now() >= deadline) throw reattachError;
|
|
817
|
+
}
|
|
818
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
819
|
+
setTimeout(resolve, SCREENCAST_SETUP_RETRY_MS);
|
|
820
|
+
await promise;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private async reattachPageForScreencast(previous: CdpSession): Promise<CdpSession> {
|
|
826
|
+
const connection = this.requireConnection();
|
|
827
|
+
const targets = await listPageTargets(connection);
|
|
828
|
+
const target = targets.find((candidate) => candidate.targetId === this.targetId) ?? targets[0];
|
|
829
|
+
if (!target) throw new CdpUnavailableError("Chromium has no page target");
|
|
830
|
+
await connection.send(
|
|
831
|
+
"Target.activateTarget",
|
|
832
|
+
{ targetId: target.targetId },
|
|
833
|
+
{ mutation: true },
|
|
834
|
+
);
|
|
835
|
+
const replacement = await attachToTarget(connection, target.targetId);
|
|
836
|
+
await this.bindPageEvents(replacement);
|
|
837
|
+
if (this.page === previous) {
|
|
838
|
+
this.page = replacement;
|
|
839
|
+
this.targetId = target.targetId;
|
|
840
|
+
} else {
|
|
841
|
+
await replacement.detach().catch(() => undefined);
|
|
842
|
+
}
|
|
843
|
+
await previous.detach().catch(() => undefined);
|
|
844
|
+
return this.page ?? replacement;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private async withInvalidatedScreencast(
|
|
848
|
+
mutation: (page: CdpSession) => Promise<unknown>,
|
|
849
|
+
): Promise<void> {
|
|
850
|
+
const page = await this.requirePage();
|
|
851
|
+
const restart = this.screencastActive;
|
|
852
|
+
this.invalidateScreencastFrame();
|
|
853
|
+
if (restart) await page.send("Page.stopScreencast", {}, { mutation: true });
|
|
854
|
+
try {
|
|
855
|
+
await mutation(page);
|
|
856
|
+
} finally {
|
|
857
|
+
this.invalidateScreencastFrame();
|
|
858
|
+
if (restart && this.page === page) await this.startScreencastSession(page);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private invalidateScreencastFrame(): void {
|
|
863
|
+
this.screencastFrame = null;
|
|
864
|
+
this.resolveFrameWaiters(null);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private readonly onScreencastFrame = (event: ScreencastFrame): void => {
|
|
868
|
+
void this.page
|
|
869
|
+
?.send("Page.screencastFrameAck", { sessionId: event.sessionId })
|
|
870
|
+
.catch(() => undefined);
|
|
871
|
+
const width = Math.round(event.metadata.deviceWidth);
|
|
872
|
+
const height = Math.round(event.metadata.deviceHeight);
|
|
873
|
+
const viewport = this.viewport;
|
|
874
|
+
if (!viewport || width !== viewport.width || height !== viewport.height) return;
|
|
875
|
+
const frame: RuntimeFrame = {
|
|
876
|
+
dataBase64: event.data,
|
|
877
|
+
byteLength: Buffer.byteLength(event.data, "base64"),
|
|
878
|
+
width,
|
|
879
|
+
height,
|
|
880
|
+
transport: "cdp-screencast",
|
|
881
|
+
capturedAt: new Date().toISOString(),
|
|
882
|
+
};
|
|
883
|
+
this.screencastFrame = frame;
|
|
884
|
+
this.resolveFrameWaiters(frame);
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
private waitForFrame(waitMs: number): Promise<RuntimeFrame | null> {
|
|
888
|
+
const { promise, resolve } = Promise.withResolvers<RuntimeFrame | null>();
|
|
889
|
+
const timer = setTimeout(() => {
|
|
890
|
+
this.screencastWaiters.delete(done);
|
|
891
|
+
resolve(null);
|
|
892
|
+
}, waitMs);
|
|
893
|
+
const done = (frame: RuntimeFrame | null): void => {
|
|
894
|
+
clearTimeout(timer);
|
|
895
|
+
resolve(frame);
|
|
896
|
+
};
|
|
897
|
+
this.screencastWaiters.add(done);
|
|
898
|
+
return promise;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private resolveFrameWaiters(frame: RuntimeFrame | null): void {
|
|
902
|
+
const waiters = [...this.screencastWaiters];
|
|
903
|
+
this.screencastWaiters.clear();
|
|
904
|
+
for (const resolveWaiter of waiters) resolveWaiter(frame);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private assertViewport(viewport: BrowserViewport): void {
|
|
908
|
+
if (
|
|
909
|
+
!Number.isInteger(viewport.width) ||
|
|
910
|
+
viewport.width < 1 ||
|
|
911
|
+
viewport.width > 16_384 ||
|
|
912
|
+
!Number.isInteger(viewport.height) ||
|
|
913
|
+
viewport.height < 1 ||
|
|
914
|
+
viewport.height > 16_384 ||
|
|
915
|
+
!Number.isFinite(viewport.deviceScaleFactor) ||
|
|
916
|
+
viewport.deviceScaleFactor <= 0 ||
|
|
917
|
+
viewport.deviceScaleFactor > 10
|
|
918
|
+
)
|
|
919
|
+
throw new RangeError("Invalid viewport emulation values");
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private assertPoint(x: number, y: number): void {
|
|
923
|
+
const viewport = this.requireViewport();
|
|
924
|
+
if (
|
|
925
|
+
!Number.isFinite(x) ||
|
|
926
|
+
!Number.isFinite(y) ||
|
|
927
|
+
x < 0 ||
|
|
928
|
+
y < 0 ||
|
|
929
|
+
x > viewport.width ||
|
|
930
|
+
y > viewport.height
|
|
931
|
+
) {
|
|
932
|
+
throw new RangeError("Input coordinates are outside the CSS viewport");
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private buttonMask(add?: MouseButton, remove?: MouseButton): number {
|
|
937
|
+
const active = new Set(this.heldButtons);
|
|
938
|
+
if (add) active.add(add);
|
|
939
|
+
if (remove) active.delete(remove);
|
|
940
|
+
return (
|
|
941
|
+
(active.has("left") ? 1 : 0) | (active.has("right") ? 2 : 0) | (active.has("middle") ? 4 : 0)
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private async daemonPid(): Promise<number | null> {
|
|
946
|
+
try {
|
|
947
|
+
const value = await readFile(resolve(this.ipcDirectory, `${this.session}.pid`), "utf8");
|
|
948
|
+
const pid = Number.parseInt(value.trim(), 10);
|
|
949
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
950
|
+
} catch {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private async protectIpcMetadata(): Promise<void> {
|
|
956
|
+
await Promise.all(
|
|
957
|
+
["sock", "pid", "version", "config", "stream"]
|
|
958
|
+
.map((extension) => resolve(this.ipcDirectory, `${this.session}.${extension}`))
|
|
959
|
+
.map((path) => chmod(path, PRIVATE_FILE_MODE).catch(() => undefined)),
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private async removeIpcMetadata(): Promise<void> {
|
|
964
|
+
await Promise.all(
|
|
965
|
+
["sock", "pid", "version", "config", "stream", "engine", "provider", "extensions"]
|
|
966
|
+
.map((extension) => resolve(this.ipcDirectory, `${this.session}.${extension}`))
|
|
967
|
+
.map((path) => rm(path, { force: true }).catch(() => undefined)),
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|