@ricsam/r5d-browser 0.0.43 → 0.0.45
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 +1 -1
- package/dist/cjs/main.cjs +119 -6
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/playwright-install.cjs +49 -0
- package/dist/mjs/main.mjs +119 -6
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/playwright-install.mjs +15 -0
- package/dist/types/playwright-install.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,4 +7,4 @@ npm install -g @ricsam/r5d-browser
|
|
|
7
7
|
r5d-browser start
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
The profile is stored under `~/.r5d/browser/profile`. Browser screenshots are session artifacts in r5d.dev and are not retained by the browser process.
|
|
10
|
+
The profile is stored under `~/.r5d/browser/profile`. Completed downloads are retained under `~/.r5d/browser/downloads`; agents can list them and copy a selected file into the active session's artifacts. That artifact is then synchronized to every connected worker. Browser screenshots are session artifacts in r5d.dev and are not retained by the browser process.
|
package/dist/cjs/main.cjs
CHANGED
|
@@ -25,14 +25,15 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
25
25
|
var import_node_fs = __toESM(require("node:fs"), 1);
|
|
26
26
|
var import_node_os = __toESM(require("node:os"), 1);
|
|
27
27
|
var import_node_path = __toESM(require("node:path"), 1);
|
|
28
|
-
var import_node_module = require("node:module");
|
|
29
28
|
var import_node_child_process = require("node:child_process");
|
|
30
29
|
var import_promises = require("node:timers/promises");
|
|
31
30
|
var import_ws = __toESM(require("ws"), 1);
|
|
31
|
+
var import_playwright_install = require("./playwright-install.cjs");
|
|
32
32
|
const DEFAULT_BASE_URL = "https://r5d.dev";
|
|
33
33
|
const PACKAGE_NAME = "@ricsam/r5d-browser";
|
|
34
34
|
const RECONNECT_DELAY_MS = 2e3;
|
|
35
35
|
const SERVER_HEARTBEAT_TIMEOUT_MS = 45e3;
|
|
36
|
+
const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
|
|
36
37
|
function browserRoot() {
|
|
37
38
|
return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "browser");
|
|
38
39
|
}
|
|
@@ -146,8 +147,7 @@ function resolveConnection(options) {
|
|
|
146
147
|
function installChromium(browsersPath) {
|
|
147
148
|
import_node_fs.default.mkdirSync(browsersPath, { recursive: true });
|
|
148
149
|
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
149
|
-
const
|
|
150
|
-
const cliPath = require2.resolve("playwright/cli");
|
|
150
|
+
const cliPath = (0, import_playwright_install.resolvePlaywrightCliPath)();
|
|
151
151
|
process.stdout.write(`[r5d-browser] Installing Chromium under ${browsersPath}...
|
|
152
152
|
`);
|
|
153
153
|
const result = (0, import_node_child_process.spawnSync)(process.execPath, [cliPath, "install", "chromium"], {
|
|
@@ -170,27 +170,133 @@ function loadInstanceId() {
|
|
|
170
170
|
return id;
|
|
171
171
|
}
|
|
172
172
|
class BrowserRuntime {
|
|
173
|
-
constructor(context) {
|
|
173
|
+
constructor(context, downloadsPath) {
|
|
174
174
|
this.context = context;
|
|
175
|
+
this.downloadsPath = downloadsPath;
|
|
176
|
+
import_node_fs.default.mkdirSync(downloadsPath, { recursive: true });
|
|
175
177
|
for (const page of context.pages()) this.track(page);
|
|
176
178
|
context.on("page", (page) => this.track(page));
|
|
177
179
|
}
|
|
178
180
|
context;
|
|
181
|
+
downloadsPath;
|
|
179
182
|
ids = /* @__PURE__ */ new WeakMap();
|
|
180
183
|
pages = /* @__PURE__ */ new Map();
|
|
181
184
|
cursor = /* @__PURE__ */ new Map();
|
|
185
|
+
downloadSaveQueue = Promise.resolve();
|
|
182
186
|
track(page) {
|
|
183
187
|
const existing = this.ids.get(page);
|
|
184
188
|
if (existing) return existing;
|
|
185
189
|
const id = crypto.randomUUID();
|
|
186
190
|
this.ids.set(page, id);
|
|
187
191
|
this.pages.set(id, page);
|
|
192
|
+
page.on("download", (download) => {
|
|
193
|
+
this.downloadSaveQueue = this.downloadSaveQueue.then(() => this.persistDownload(download)).catch((error) => {
|
|
194
|
+
process.stderr.write(`[r5d-browser] failed to save download: ${error instanceof Error ? error.message : String(error)}
|
|
195
|
+
`);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
188
198
|
page.once("close", () => {
|
|
189
199
|
this.pages.delete(id);
|
|
190
200
|
this.cursor.delete(id);
|
|
191
201
|
});
|
|
192
202
|
return id;
|
|
193
203
|
}
|
|
204
|
+
safeDownloadFilename(suggestedFilename) {
|
|
205
|
+
const basename = import_node_path.default.basename(suggestedFilename).replace(/[\\/\0]/g, "-").trim();
|
|
206
|
+
if (!basename || basename === "." || basename === "..") return "download";
|
|
207
|
+
const extension = import_node_path.default.extname(basename).slice(0, 30);
|
|
208
|
+
let stem = basename.slice(0, basename.length - import_node_path.default.extname(basename).length) || "download";
|
|
209
|
+
while (Buffer.byteLength(`${stem}${extension}`) > 200) stem = stem.slice(0, -1);
|
|
210
|
+
return `${stem || "download"}${extension}`;
|
|
211
|
+
}
|
|
212
|
+
availableDownloadPath(suggestedFilename) {
|
|
213
|
+
const filename = this.safeDownloadFilename(suggestedFilename);
|
|
214
|
+
const extension = import_node_path.default.extname(filename);
|
|
215
|
+
const stem = filename.slice(0, filename.length - extension.length) || "download";
|
|
216
|
+
for (let suffix = 1; ; suffix += 1) {
|
|
217
|
+
const candidate = suffix === 1 ? filename : `${stem}-${suffix}${extension}`;
|
|
218
|
+
const candidatePath = import_node_path.default.join(this.downloadsPath, candidate);
|
|
219
|
+
if (!import_node_fs.default.existsSync(candidatePath)) return candidatePath;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async persistDownload(download) {
|
|
223
|
+
const failure = await download.failure();
|
|
224
|
+
if (failure) throw new Error(failure);
|
|
225
|
+
const targetPath = this.availableDownloadPath(download.suggestedFilename());
|
|
226
|
+
await download.saveAs(targetPath);
|
|
227
|
+
process.stdout.write(`[r5d-browser] downloaded ${import_node_path.default.basename(targetPath)}
|
|
228
|
+
`);
|
|
229
|
+
}
|
|
230
|
+
downloadId(filename) {
|
|
231
|
+
return Buffer.from(filename, "utf8").toString("base64url");
|
|
232
|
+
}
|
|
233
|
+
resolveDownload(downloadId) {
|
|
234
|
+
if (typeof downloadId !== "string" || !downloadId) throw new Error("downloadId is required.");
|
|
235
|
+
const filename = Buffer.from(downloadId, "base64url").toString("utf8");
|
|
236
|
+
if (this.downloadId(filename) !== downloadId || filename !== import_node_path.default.basename(filename) || filename.includes("\0")) {
|
|
237
|
+
throw new Error("Invalid downloadId.");
|
|
238
|
+
}
|
|
239
|
+
const filePath = import_node_path.default.join(this.downloadsPath, filename);
|
|
240
|
+
let stats;
|
|
241
|
+
try {
|
|
242
|
+
stats = import_node_fs.default.lstatSync(filePath);
|
|
243
|
+
} catch {
|
|
244
|
+
throw new Error(`Browser download ${downloadId} no longer exists.`);
|
|
245
|
+
}
|
|
246
|
+
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Browser download is not a regular file.");
|
|
247
|
+
return { filename, filePath, stats };
|
|
248
|
+
}
|
|
249
|
+
async listDownloads() {
|
|
250
|
+
await this.downloadSaveQueue;
|
|
251
|
+
return import_node_fs.default.readdirSync(this.downloadsPath, { withFileTypes: true }).flatMap((entry) => {
|
|
252
|
+
if (!entry.isFile() || entry.name.startsWith(".")) return [];
|
|
253
|
+
const { filename, stats } = this.resolveDownload(this.downloadId(entry.name));
|
|
254
|
+
return [
|
|
255
|
+
{
|
|
256
|
+
downloadId: this.downloadId(filename),
|
|
257
|
+
filename,
|
|
258
|
+
size: stats.size,
|
|
259
|
+
modifiedAt: stats.mtime.toISOString()
|
|
260
|
+
}
|
|
261
|
+
];
|
|
262
|
+
}).sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt) || left.filename.localeCompare(right.filename));
|
|
263
|
+
}
|
|
264
|
+
async getDownloadChunk(input) {
|
|
265
|
+
await this.downloadSaveQueue;
|
|
266
|
+
const { filename, filePath, stats } = this.resolveDownload(input.downloadId);
|
|
267
|
+
const offset = input.offset === void 0 ? 0 : Number(input.offset);
|
|
268
|
+
const requestedBytes = input.maxBytes === void 0 ? MAX_DOWNLOAD_CHUNK_BYTES : Number(input.maxBytes);
|
|
269
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > stats.size) throw new Error("Invalid download offset.");
|
|
270
|
+
if (!Number.isSafeInteger(requestedBytes) || requestedBytes < 1) throw new Error("Invalid download chunk size.");
|
|
271
|
+
const chunkSize = Math.min(requestedBytes, MAX_DOWNLOAD_CHUNK_BYTES, stats.size - offset);
|
|
272
|
+
const bytes = Buffer.alloc(chunkSize);
|
|
273
|
+
const handle = import_node_fs.default.openSync(filePath, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
|
|
274
|
+
let bytesRead = 0;
|
|
275
|
+
try {
|
|
276
|
+
const openedStats = import_node_fs.default.fstatSync(handle);
|
|
277
|
+
if (!openedStats.isFile() || openedStats.dev !== stats.dev || openedStats.ino !== stats.ino) {
|
|
278
|
+
throw new Error("Browser download changed before it could be read.");
|
|
279
|
+
}
|
|
280
|
+
bytesRead = import_node_fs.default.readSync(handle, bytes, 0, chunkSize, offset);
|
|
281
|
+
const completedStats = import_node_fs.default.fstatSync(handle);
|
|
282
|
+
if (completedStats.size !== stats.size || completedStats.mtimeMs !== stats.mtimeMs) {
|
|
283
|
+
throw new Error("Browser download changed while it was being read.");
|
|
284
|
+
}
|
|
285
|
+
} finally {
|
|
286
|
+
import_node_fs.default.closeSync(handle);
|
|
287
|
+
}
|
|
288
|
+
const nextOffset = offset + bytesRead;
|
|
289
|
+
return {
|
|
290
|
+
downloadId: this.downloadId(filename),
|
|
291
|
+
filename,
|
|
292
|
+
size: stats.size,
|
|
293
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
294
|
+
offset,
|
|
295
|
+
nextOffset,
|
|
296
|
+
eof: nextOffset === stats.size,
|
|
297
|
+
base64: bytes.subarray(0, bytesRead).toString("base64")
|
|
298
|
+
};
|
|
299
|
+
}
|
|
194
300
|
page(tabId) {
|
|
195
301
|
if (typeof tabId !== "string") throw new Error("tabId is required.");
|
|
196
302
|
const page = this.pages.get(tabId);
|
|
@@ -214,6 +320,8 @@ class BrowserRuntime {
|
|
|
214
320
|
}
|
|
215
321
|
async execute(action, input) {
|
|
216
322
|
if (action === "list_tabs") return { tabs: await this.listTabs() };
|
|
323
|
+
if (action === "list_downloads") return { downloads: await this.listDownloads() };
|
|
324
|
+
if (action === "get_download") return await this.getDownloadChunk(input);
|
|
217
325
|
if (action === "open_tab") {
|
|
218
326
|
const previous = this.context.pages().findLast((candidate) => !candidate.isClosed());
|
|
219
327
|
let page2;
|
|
@@ -395,6 +503,8 @@ async function start(options) {
|
|
|
395
503
|
const { baseUrl, token } = resolveConnection(options);
|
|
396
504
|
const browsersPath = import_node_path.default.resolve(options.browsersPath ?? import_node_path.default.join(browserRoot(), "browsers"));
|
|
397
505
|
const profilePath = import_node_path.default.resolve(options.profilePath ?? import_node_path.default.join(browserRoot(), "profile"));
|
|
506
|
+
const downloadsPath = import_node_path.default.join(browserRoot(), "downloads");
|
|
507
|
+
const playwrightDownloadsPath = import_node_path.default.join(downloadsPath, ".playwright");
|
|
398
508
|
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
399
509
|
let playwright = await import("playwright");
|
|
400
510
|
if (!import_node_fs.default.existsSync(playwright.chromium.executablePath())) {
|
|
@@ -402,15 +512,18 @@ async function start(options) {
|
|
|
402
512
|
playwright = await import("playwright");
|
|
403
513
|
}
|
|
404
514
|
import_node_fs.default.mkdirSync(profilePath, { recursive: true });
|
|
515
|
+
import_node_fs.default.mkdirSync(playwrightDownloadsPath, { recursive: true });
|
|
405
516
|
const context = await playwright.chromium.launchPersistentContext(profilePath, {
|
|
406
517
|
headless: false,
|
|
407
518
|
viewport: { width: 1440, height: 900 },
|
|
408
519
|
acceptDownloads: true,
|
|
409
|
-
downloadsPath:
|
|
520
|
+
downloadsPath: playwrightDownloadsPath
|
|
410
521
|
});
|
|
411
522
|
if (context.pages().length === 0) await context.newPage();
|
|
412
|
-
const runtime = new BrowserRuntime(context);
|
|
523
|
+
const runtime = new BrowserRuntime(context, downloadsPath);
|
|
413
524
|
process.stdout.write(`[r5d-browser] profile: ${profilePath}
|
|
525
|
+
`);
|
|
526
|
+
process.stdout.write(`[r5d-browser] downloads: ${downloadsPath}
|
|
414
527
|
`);
|
|
415
528
|
process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
|
|
416
529
|
`);
|
package/dist/cjs/package.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var playwright_install_exports = {};
|
|
30
|
+
__export(playwright_install_exports, {
|
|
31
|
+
resolvePlaywrightCliPath: () => resolvePlaywrightCliPath
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(playwright_install_exports);
|
|
34
|
+
var import_node_fs = __toESM(require("node:fs"), 1);
|
|
35
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
36
|
+
var import_node_module = require("node:module");
|
|
37
|
+
function resolvePlaywrightCliPath(fromPath = process.argv[1] ?? import_node_path.default.join(process.cwd(), "package.json")) {
|
|
38
|
+
const require2 = (0, import_node_module.createRequire)(fromPath);
|
|
39
|
+
const packageJsonPath = require2.resolve("playwright/package.json");
|
|
40
|
+
const cliPath = import_node_path.default.join(import_node_path.default.dirname(packageJsonPath), "cli.js");
|
|
41
|
+
if (!import_node_fs.default.existsSync(cliPath)) {
|
|
42
|
+
throw new Error(`The Playwright CLI was not found next to ${packageJsonPath}. Reinstall @ricsam/r5d-browser.`);
|
|
43
|
+
}
|
|
44
|
+
return cliPath;
|
|
45
|
+
}
|
|
46
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
47
|
+
0 && (module.exports = {
|
|
48
|
+
resolvePlaywrightCliPath
|
|
49
|
+
});
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
5
|
import { spawnSync } from "node:child_process";
|
|
7
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
8
7
|
import WebSocket from "ws";
|
|
8
|
+
import { resolvePlaywrightCliPath } from "./playwright-install.mjs";
|
|
9
9
|
const DEFAULT_BASE_URL = "https://r5d.dev";
|
|
10
10
|
const PACKAGE_NAME = "@ricsam/r5d-browser";
|
|
11
11
|
const RECONNECT_DELAY_MS = 2e3;
|
|
12
12
|
const SERVER_HEARTBEAT_TIMEOUT_MS = 45e3;
|
|
13
|
+
const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
|
|
13
14
|
function browserRoot() {
|
|
14
15
|
return path.join(os.homedir(), ".r5d", "browser");
|
|
15
16
|
}
|
|
@@ -123,8 +124,7 @@ function resolveConnection(options) {
|
|
|
123
124
|
function installChromium(browsersPath) {
|
|
124
125
|
fs.mkdirSync(browsersPath, { recursive: true });
|
|
125
126
|
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
126
|
-
const
|
|
127
|
-
const cliPath = require2.resolve("playwright/cli");
|
|
127
|
+
const cliPath = resolvePlaywrightCliPath();
|
|
128
128
|
process.stdout.write(`[r5d-browser] Installing Chromium under ${browsersPath}...
|
|
129
129
|
`);
|
|
130
130
|
const result = spawnSync(process.execPath, [cliPath, "install", "chromium"], {
|
|
@@ -147,27 +147,133 @@ function loadInstanceId() {
|
|
|
147
147
|
return id;
|
|
148
148
|
}
|
|
149
149
|
class BrowserRuntime {
|
|
150
|
-
constructor(context) {
|
|
150
|
+
constructor(context, downloadsPath) {
|
|
151
151
|
this.context = context;
|
|
152
|
+
this.downloadsPath = downloadsPath;
|
|
153
|
+
fs.mkdirSync(downloadsPath, { recursive: true });
|
|
152
154
|
for (const page of context.pages()) this.track(page);
|
|
153
155
|
context.on("page", (page) => this.track(page));
|
|
154
156
|
}
|
|
155
157
|
context;
|
|
158
|
+
downloadsPath;
|
|
156
159
|
ids = /* @__PURE__ */ new WeakMap();
|
|
157
160
|
pages = /* @__PURE__ */ new Map();
|
|
158
161
|
cursor = /* @__PURE__ */ new Map();
|
|
162
|
+
downloadSaveQueue = Promise.resolve();
|
|
159
163
|
track(page) {
|
|
160
164
|
const existing = this.ids.get(page);
|
|
161
165
|
if (existing) return existing;
|
|
162
166
|
const id = crypto.randomUUID();
|
|
163
167
|
this.ids.set(page, id);
|
|
164
168
|
this.pages.set(id, page);
|
|
169
|
+
page.on("download", (download) => {
|
|
170
|
+
this.downloadSaveQueue = this.downloadSaveQueue.then(() => this.persistDownload(download)).catch((error) => {
|
|
171
|
+
process.stderr.write(`[r5d-browser] failed to save download: ${error instanceof Error ? error.message : String(error)}
|
|
172
|
+
`);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
165
175
|
page.once("close", () => {
|
|
166
176
|
this.pages.delete(id);
|
|
167
177
|
this.cursor.delete(id);
|
|
168
178
|
});
|
|
169
179
|
return id;
|
|
170
180
|
}
|
|
181
|
+
safeDownloadFilename(suggestedFilename) {
|
|
182
|
+
const basename = path.basename(suggestedFilename).replace(/[\\/\0]/g, "-").trim();
|
|
183
|
+
if (!basename || basename === "." || basename === "..") return "download";
|
|
184
|
+
const extension = path.extname(basename).slice(0, 30);
|
|
185
|
+
let stem = basename.slice(0, basename.length - path.extname(basename).length) || "download";
|
|
186
|
+
while (Buffer.byteLength(`${stem}${extension}`) > 200) stem = stem.slice(0, -1);
|
|
187
|
+
return `${stem || "download"}${extension}`;
|
|
188
|
+
}
|
|
189
|
+
availableDownloadPath(suggestedFilename) {
|
|
190
|
+
const filename = this.safeDownloadFilename(suggestedFilename);
|
|
191
|
+
const extension = path.extname(filename);
|
|
192
|
+
const stem = filename.slice(0, filename.length - extension.length) || "download";
|
|
193
|
+
for (let suffix = 1; ; suffix += 1) {
|
|
194
|
+
const candidate = suffix === 1 ? filename : `${stem}-${suffix}${extension}`;
|
|
195
|
+
const candidatePath = path.join(this.downloadsPath, candidate);
|
|
196
|
+
if (!fs.existsSync(candidatePath)) return candidatePath;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async persistDownload(download) {
|
|
200
|
+
const failure = await download.failure();
|
|
201
|
+
if (failure) throw new Error(failure);
|
|
202
|
+
const targetPath = this.availableDownloadPath(download.suggestedFilename());
|
|
203
|
+
await download.saveAs(targetPath);
|
|
204
|
+
process.stdout.write(`[r5d-browser] downloaded ${path.basename(targetPath)}
|
|
205
|
+
`);
|
|
206
|
+
}
|
|
207
|
+
downloadId(filename) {
|
|
208
|
+
return Buffer.from(filename, "utf8").toString("base64url");
|
|
209
|
+
}
|
|
210
|
+
resolveDownload(downloadId) {
|
|
211
|
+
if (typeof downloadId !== "string" || !downloadId) throw new Error("downloadId is required.");
|
|
212
|
+
const filename = Buffer.from(downloadId, "base64url").toString("utf8");
|
|
213
|
+
if (this.downloadId(filename) !== downloadId || filename !== path.basename(filename) || filename.includes("\0")) {
|
|
214
|
+
throw new Error("Invalid downloadId.");
|
|
215
|
+
}
|
|
216
|
+
const filePath = path.join(this.downloadsPath, filename);
|
|
217
|
+
let stats;
|
|
218
|
+
try {
|
|
219
|
+
stats = fs.lstatSync(filePath);
|
|
220
|
+
} catch {
|
|
221
|
+
throw new Error(`Browser download ${downloadId} no longer exists.`);
|
|
222
|
+
}
|
|
223
|
+
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Browser download is not a regular file.");
|
|
224
|
+
return { filename, filePath, stats };
|
|
225
|
+
}
|
|
226
|
+
async listDownloads() {
|
|
227
|
+
await this.downloadSaveQueue;
|
|
228
|
+
return fs.readdirSync(this.downloadsPath, { withFileTypes: true }).flatMap((entry) => {
|
|
229
|
+
if (!entry.isFile() || entry.name.startsWith(".")) return [];
|
|
230
|
+
const { filename, stats } = this.resolveDownload(this.downloadId(entry.name));
|
|
231
|
+
return [
|
|
232
|
+
{
|
|
233
|
+
downloadId: this.downloadId(filename),
|
|
234
|
+
filename,
|
|
235
|
+
size: stats.size,
|
|
236
|
+
modifiedAt: stats.mtime.toISOString()
|
|
237
|
+
}
|
|
238
|
+
];
|
|
239
|
+
}).sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt) || left.filename.localeCompare(right.filename));
|
|
240
|
+
}
|
|
241
|
+
async getDownloadChunk(input) {
|
|
242
|
+
await this.downloadSaveQueue;
|
|
243
|
+
const { filename, filePath, stats } = this.resolveDownload(input.downloadId);
|
|
244
|
+
const offset = input.offset === void 0 ? 0 : Number(input.offset);
|
|
245
|
+
const requestedBytes = input.maxBytes === void 0 ? MAX_DOWNLOAD_CHUNK_BYTES : Number(input.maxBytes);
|
|
246
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > stats.size) throw new Error("Invalid download offset.");
|
|
247
|
+
if (!Number.isSafeInteger(requestedBytes) || requestedBytes < 1) throw new Error("Invalid download chunk size.");
|
|
248
|
+
const chunkSize = Math.min(requestedBytes, MAX_DOWNLOAD_CHUNK_BYTES, stats.size - offset);
|
|
249
|
+
const bytes = Buffer.alloc(chunkSize);
|
|
250
|
+
const handle = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
251
|
+
let bytesRead = 0;
|
|
252
|
+
try {
|
|
253
|
+
const openedStats = fs.fstatSync(handle);
|
|
254
|
+
if (!openedStats.isFile() || openedStats.dev !== stats.dev || openedStats.ino !== stats.ino) {
|
|
255
|
+
throw new Error("Browser download changed before it could be read.");
|
|
256
|
+
}
|
|
257
|
+
bytesRead = fs.readSync(handle, bytes, 0, chunkSize, offset);
|
|
258
|
+
const completedStats = fs.fstatSync(handle);
|
|
259
|
+
if (completedStats.size !== stats.size || completedStats.mtimeMs !== stats.mtimeMs) {
|
|
260
|
+
throw new Error("Browser download changed while it was being read.");
|
|
261
|
+
}
|
|
262
|
+
} finally {
|
|
263
|
+
fs.closeSync(handle);
|
|
264
|
+
}
|
|
265
|
+
const nextOffset = offset + bytesRead;
|
|
266
|
+
return {
|
|
267
|
+
downloadId: this.downloadId(filename),
|
|
268
|
+
filename,
|
|
269
|
+
size: stats.size,
|
|
270
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
271
|
+
offset,
|
|
272
|
+
nextOffset,
|
|
273
|
+
eof: nextOffset === stats.size,
|
|
274
|
+
base64: bytes.subarray(0, bytesRead).toString("base64")
|
|
275
|
+
};
|
|
276
|
+
}
|
|
171
277
|
page(tabId) {
|
|
172
278
|
if (typeof tabId !== "string") throw new Error("tabId is required.");
|
|
173
279
|
const page = this.pages.get(tabId);
|
|
@@ -191,6 +297,8 @@ class BrowserRuntime {
|
|
|
191
297
|
}
|
|
192
298
|
async execute(action, input) {
|
|
193
299
|
if (action === "list_tabs") return { tabs: await this.listTabs() };
|
|
300
|
+
if (action === "list_downloads") return { downloads: await this.listDownloads() };
|
|
301
|
+
if (action === "get_download") return await this.getDownloadChunk(input);
|
|
194
302
|
if (action === "open_tab") {
|
|
195
303
|
const previous = this.context.pages().findLast((candidate) => !candidate.isClosed());
|
|
196
304
|
let page2;
|
|
@@ -372,6 +480,8 @@ async function start(options) {
|
|
|
372
480
|
const { baseUrl, token } = resolveConnection(options);
|
|
373
481
|
const browsersPath = path.resolve(options.browsersPath ?? path.join(browserRoot(), "browsers"));
|
|
374
482
|
const profilePath = path.resolve(options.profilePath ?? path.join(browserRoot(), "profile"));
|
|
483
|
+
const downloadsPath = path.join(browserRoot(), "downloads");
|
|
484
|
+
const playwrightDownloadsPath = path.join(downloadsPath, ".playwright");
|
|
375
485
|
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
376
486
|
let playwright = await import("playwright");
|
|
377
487
|
if (!fs.existsSync(playwright.chromium.executablePath())) {
|
|
@@ -379,15 +489,18 @@ async function start(options) {
|
|
|
379
489
|
playwright = await import("playwright");
|
|
380
490
|
}
|
|
381
491
|
fs.mkdirSync(profilePath, { recursive: true });
|
|
492
|
+
fs.mkdirSync(playwrightDownloadsPath, { recursive: true });
|
|
382
493
|
const context = await playwright.chromium.launchPersistentContext(profilePath, {
|
|
383
494
|
headless: false,
|
|
384
495
|
viewport: { width: 1440, height: 900 },
|
|
385
496
|
acceptDownloads: true,
|
|
386
|
-
downloadsPath:
|
|
497
|
+
downloadsPath: playwrightDownloadsPath
|
|
387
498
|
});
|
|
388
499
|
if (context.pages().length === 0) await context.newPage();
|
|
389
|
-
const runtime = new BrowserRuntime(context);
|
|
500
|
+
const runtime = new BrowserRuntime(context, downloadsPath);
|
|
390
501
|
process.stdout.write(`[r5d-browser] profile: ${profilePath}
|
|
502
|
+
`);
|
|
503
|
+
process.stdout.write(`[r5d-browser] downloads: ${downloadsPath}
|
|
391
504
|
`);
|
|
392
505
|
process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
|
|
393
506
|
`);
|
package/dist/mjs/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
function resolvePlaywrightCliPath(fromPath = process.argv[1] ?? path.join(process.cwd(), "package.json")) {
|
|
5
|
+
const require2 = createRequire(fromPath);
|
|
6
|
+
const packageJsonPath = require2.resolve("playwright/package.json");
|
|
7
|
+
const cliPath = path.join(path.dirname(packageJsonPath), "cli.js");
|
|
8
|
+
if (!fs.existsSync(cliPath)) {
|
|
9
|
+
throw new Error(`The Playwright CLI was not found next to ${packageJsonPath}. Reinstall @ricsam/r5d-browser.`);
|
|
10
|
+
}
|
|
11
|
+
return cliPath;
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
resolvePlaywrightCliPath
|
|
15
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resolvePlaywrightCliPath(fromPath?: string): string;
|