@ricsam/r5d-browser 0.0.0 → 0.0.44
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 +7 -42
- package/dist/cjs/main.cjs +440 -0
- package/dist/cjs/package.json +5 -0
- package/dist/cjs/playwright-install.cjs +49 -0
- package/dist/mjs/main.mjs +417 -0
- package/dist/mjs/package.json +5 -0
- package/dist/mjs/playwright-install.mjs +15 -0
- package/dist/types/main.d.ts +2 -0
- package/dist/types/playwright-install.d.ts +1 -0
- package/package.json +36 -7
package/README.md
CHANGED
|
@@ -1,45 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# r5d-browser
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`r5d-browser` starts a dedicated, persistent Chromium profile on a Mac and connects it to r5d.dev. Sign in to sites in the opened browser normally; agents use Chromium's automation protocol without moving the real system pointer or taking keyboard focus.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g @ricsam/r5d-browser
|
|
7
|
+
r5d-browser start
|
|
8
|
+
```
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
## Purpose
|
|
10
|
-
|
|
11
|
-
This package exists to:
|
|
12
|
-
1. Configure OIDC trusted publishing for the package name `@ricsam/r5d-browser`
|
|
13
|
-
2. Enable secure, token-less publishing from CI/CD workflows
|
|
14
|
-
3. Establish provenance for packages published under this name
|
|
15
|
-
|
|
16
|
-
## What is OIDC Trusted Publishing?
|
|
17
|
-
|
|
18
|
-
OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
|
|
19
|
-
|
|
20
|
-
## Setup Instructions
|
|
21
|
-
|
|
22
|
-
To properly configure OIDC trusted publishing for this package:
|
|
23
|
-
|
|
24
|
-
1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
|
|
25
|
-
2. Configure the trusted publisher (e.g., GitHub Actions)
|
|
26
|
-
3. Specify the repository and workflow that should be allowed to publish
|
|
27
|
-
4. Use the configured workflow to publish your actual package
|
|
28
|
-
|
|
29
|
-
## DO NOT USE THIS PACKAGE
|
|
30
|
-
|
|
31
|
-
This package is a placeholder for OIDC configuration only. It:
|
|
32
|
-
- Contains no executable code
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
36
|
-
|
|
37
|
-
## More Information
|
|
38
|
-
|
|
39
|
-
For more details about npm's trusted publishing feature, see:
|
|
40
|
-
- [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
|
|
41
|
-
- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
**Maintained for OIDC setup purposes only**
|
|
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.
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
var import_node_fs = __toESM(require("node:fs"), 1);
|
|
26
|
+
var import_node_os = __toESM(require("node:os"), 1);
|
|
27
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
28
|
+
var import_node_child_process = require("node:child_process");
|
|
29
|
+
var import_promises = require("node:timers/promises");
|
|
30
|
+
var import_ws = __toESM(require("ws"), 1);
|
|
31
|
+
var import_playwright_install = require("./playwright-install.cjs");
|
|
32
|
+
const DEFAULT_BASE_URL = "https://r5d.dev";
|
|
33
|
+
const PACKAGE_NAME = "@ricsam/r5d-browser";
|
|
34
|
+
const RECONNECT_DELAY_MS = 2e3;
|
|
35
|
+
const SERVER_HEARTBEAT_TIMEOUT_MS = 45e3;
|
|
36
|
+
function browserRoot() {
|
|
37
|
+
return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "browser");
|
|
38
|
+
}
|
|
39
|
+
function defaultConfigPath() {
|
|
40
|
+
return import_node_path.default.join(import_node_os.default.homedir(), ".config", "r5d", "r5dctl", "config.json");
|
|
41
|
+
}
|
|
42
|
+
function printHelp() {
|
|
43
|
+
process.stdout.write(`Usage:
|
|
44
|
+
r5d-browser start [--base-url <url>] [--token <token>] [--api-key <key>]
|
|
45
|
+
r5d-browser install
|
|
46
|
+
r5d-browser --version
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
--base-url <url> r5d.dev base URL (default from r5dctl config or ${DEFAULT_BASE_URL})
|
|
50
|
+
--token <token> Interactive r5d token
|
|
51
|
+
--api-key <key> r5d API key
|
|
52
|
+
--config <path> Shared r5dctl config path
|
|
53
|
+
--profile <path> Chromium profile path (default ~/.r5d/browser/profile)
|
|
54
|
+
--browsers-path <dir> Playwright browser binaries (default ~/.r5d/browser/browsers)
|
|
55
|
+
-v, --version Show r5d-browser version
|
|
56
|
+
-h, --help Show this help
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
function findVersion() {
|
|
60
|
+
let current = process.argv[1] ? import_node_path.default.dirname(import_node_path.default.resolve(process.argv[1])) : process.cwd();
|
|
61
|
+
for (let index = 0; index < 12; index += 1) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(import_node_fs.default.readFileSync(import_node_path.default.join(current, "package.json"), "utf8"));
|
|
64
|
+
if (parsed.name === PACKAGE_NAME && parsed.version) return parsed.version;
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const sourceVersion = import_node_fs.default.readFileSync(import_node_path.default.join(current, "VERSION.txt"), "utf8").trim();
|
|
69
|
+
if (sourceVersion && import_node_path.default.basename(current) === "packages") return sourceVersion;
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
const parent = import_node_path.default.dirname(current);
|
|
73
|
+
if (parent === current) break;
|
|
74
|
+
current = parent;
|
|
75
|
+
}
|
|
76
|
+
return "unknown";
|
|
77
|
+
}
|
|
78
|
+
function parseArgs(argv) {
|
|
79
|
+
const options = { configPath: defaultConfigPath(), help: false, version: false };
|
|
80
|
+
const rest = [];
|
|
81
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
82
|
+
const arg = argv[index];
|
|
83
|
+
if (!arg) continue;
|
|
84
|
+
if (arg === "-h" || arg === "--help") {
|
|
85
|
+
options.help = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (arg === "-v" || arg === "--version") {
|
|
89
|
+
options.version = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const readOption = (name) => {
|
|
93
|
+
if (arg === name) {
|
|
94
|
+
index += 1;
|
|
95
|
+
const value = argv[index];
|
|
96
|
+
if (!value) throw new Error(`Missing value for ${name}`);
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
return arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : void 0;
|
|
100
|
+
};
|
|
101
|
+
const entries = [
|
|
102
|
+
["--base-url", "baseUrl"],
|
|
103
|
+
["--token", "token"],
|
|
104
|
+
["--api-key", "apiKey"],
|
|
105
|
+
["--config", "configPath"],
|
|
106
|
+
["--profile", "profilePath"],
|
|
107
|
+
["--browsers-path", "browsersPath"]
|
|
108
|
+
];
|
|
109
|
+
let matched = false;
|
|
110
|
+
for (const [name, key] of entries) {
|
|
111
|
+
const value = readOption(name);
|
|
112
|
+
if (value !== void 0) {
|
|
113
|
+
options[key] = value;
|
|
114
|
+
matched = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!matched) rest.push(arg);
|
|
119
|
+
}
|
|
120
|
+
if (options.version) return { command: "version", options };
|
|
121
|
+
if (options.help || rest.length === 0) return { command: "help", options };
|
|
122
|
+
if (rest[0] !== "start" && rest[0] !== "install") throw new Error(`Unknown command: ${rest[0]}`);
|
|
123
|
+
return { command: rest[0], options };
|
|
124
|
+
}
|
|
125
|
+
function readConfig(configPath) {
|
|
126
|
+
if (!import_node_fs.default.existsSync(configPath)) return {};
|
|
127
|
+
const parsed = JSON.parse(import_node_fs.default.readFileSync(configPath, "utf8"));
|
|
128
|
+
return {
|
|
129
|
+
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : void 0,
|
|
130
|
+
token: typeof parsed.token === "string" ? parsed.token : void 0,
|
|
131
|
+
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : void 0
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function resolveConnection(options) {
|
|
135
|
+
const config = readConfig(options.configPath);
|
|
136
|
+
const token = options.token ?? process.env.R5D_BROWSER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
|
|
137
|
+
if (!token) throw new Error("Authentication required. Run `r5dctl auth login` or pass --token/--api-key.");
|
|
138
|
+
return {
|
|
139
|
+
baseUrl: (options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.R5DCTL_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL).replace(
|
|
140
|
+
/\/+$/,
|
|
141
|
+
""
|
|
142
|
+
),
|
|
143
|
+
token
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function installChromium(browsersPath) {
|
|
147
|
+
import_node_fs.default.mkdirSync(browsersPath, { recursive: true });
|
|
148
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
149
|
+
const cliPath = (0, import_playwright_install.resolvePlaywrightCliPath)();
|
|
150
|
+
process.stdout.write(`[r5d-browser] Installing Chromium under ${browsersPath}...
|
|
151
|
+
`);
|
|
152
|
+
const result = (0, import_node_child_process.spawnSync)(process.execPath, [cliPath, "install", "chromium"], {
|
|
153
|
+
stdio: "inherit",
|
|
154
|
+
env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browsersPath }
|
|
155
|
+
});
|
|
156
|
+
if (result.status !== 0) throw new Error(`Chromium installation failed with exit code ${result.status ?? "unknown"}.`);
|
|
157
|
+
}
|
|
158
|
+
function loadInstanceId() {
|
|
159
|
+
const instancePath = import_node_path.default.join(browserRoot(), "instance-id");
|
|
160
|
+
import_node_fs.default.mkdirSync(import_node_path.default.dirname(instancePath), { recursive: true });
|
|
161
|
+
try {
|
|
162
|
+
const id2 = import_node_fs.default.readFileSync(instancePath, "utf8").trim();
|
|
163
|
+
if (id2) return id2;
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
166
|
+
const id = crypto.randomUUID();
|
|
167
|
+
import_node_fs.default.writeFileSync(instancePath, `${id}
|
|
168
|
+
`, { mode: 384 });
|
|
169
|
+
return id;
|
|
170
|
+
}
|
|
171
|
+
class BrowserRuntime {
|
|
172
|
+
constructor(context) {
|
|
173
|
+
this.context = context;
|
|
174
|
+
for (const page of context.pages()) this.track(page);
|
|
175
|
+
context.on("page", (page) => this.track(page));
|
|
176
|
+
}
|
|
177
|
+
context;
|
|
178
|
+
ids = /* @__PURE__ */ new WeakMap();
|
|
179
|
+
pages = /* @__PURE__ */ new Map();
|
|
180
|
+
cursor = /* @__PURE__ */ new Map();
|
|
181
|
+
track(page) {
|
|
182
|
+
const existing = this.ids.get(page);
|
|
183
|
+
if (existing) return existing;
|
|
184
|
+
const id = crypto.randomUUID();
|
|
185
|
+
this.ids.set(page, id);
|
|
186
|
+
this.pages.set(id, page);
|
|
187
|
+
page.once("close", () => {
|
|
188
|
+
this.pages.delete(id);
|
|
189
|
+
this.cursor.delete(id);
|
|
190
|
+
});
|
|
191
|
+
return id;
|
|
192
|
+
}
|
|
193
|
+
page(tabId) {
|
|
194
|
+
if (typeof tabId !== "string") throw new Error("tabId is required.");
|
|
195
|
+
const page = this.pages.get(tabId);
|
|
196
|
+
if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
|
|
197
|
+
return page;
|
|
198
|
+
}
|
|
199
|
+
async tabInfo(page) {
|
|
200
|
+
const tabId = this.track(page);
|
|
201
|
+
let windowId = "unknown";
|
|
202
|
+
try {
|
|
203
|
+
const cdp = await this.context.newCDPSession(page);
|
|
204
|
+
const result = await cdp.send("Browser.getWindowForTarget");
|
|
205
|
+
windowId = String(result.windowId);
|
|
206
|
+
await cdp.detach();
|
|
207
|
+
} catch {
|
|
208
|
+
}
|
|
209
|
+
return { tabId, windowId, title: await page.title().catch(() => ""), url: page.url() };
|
|
210
|
+
}
|
|
211
|
+
async listTabs() {
|
|
212
|
+
return await Promise.all([...this.pages.values()].filter((page) => !page.isClosed()).map((page) => this.tabInfo(page)));
|
|
213
|
+
}
|
|
214
|
+
async execute(action, input) {
|
|
215
|
+
if (action === "list_tabs") return { tabs: await this.listTabs() };
|
|
216
|
+
if (action === "open_tab") {
|
|
217
|
+
const previous = this.context.pages().findLast((candidate) => !candidate.isClosed());
|
|
218
|
+
let page2;
|
|
219
|
+
if (previous) {
|
|
220
|
+
const cdp = await this.context.newCDPSession(previous);
|
|
221
|
+
const pagePromise = this.context.waitForEvent("page");
|
|
222
|
+
await cdp.send("Target.createTarget", {
|
|
223
|
+
url: "about:blank",
|
|
224
|
+
newWindow: input.disposition === "window",
|
|
225
|
+
background: true
|
|
226
|
+
});
|
|
227
|
+
page2 = await pagePromise;
|
|
228
|
+
await cdp.detach();
|
|
229
|
+
if (input.disposition === "window") {
|
|
230
|
+
const pageCdp = await this.context.newCDPSession(page2);
|
|
231
|
+
const { windowId } = await pageCdp.send("Browser.getWindowForTarget");
|
|
232
|
+
await pageCdp.send("Browser.setWindowBounds", { windowId, bounds: { windowState: "minimized" } });
|
|
233
|
+
await pageCdp.detach();
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
page2 = await this.context.newPage();
|
|
237
|
+
}
|
|
238
|
+
const url = typeof input.url === "string" ? input.url : "about:blank";
|
|
239
|
+
if (url !== "about:blank") await page2.goto(url, { waitUntil: "domcontentloaded" });
|
|
240
|
+
return { tab: await this.tabInfo(page2) };
|
|
241
|
+
}
|
|
242
|
+
const page = this.page(input.tabId);
|
|
243
|
+
const tabId = input.tabId;
|
|
244
|
+
switch (action) {
|
|
245
|
+
case "close_tab":
|
|
246
|
+
await page.close();
|
|
247
|
+
return { closed: true, tabId };
|
|
248
|
+
case "navigate":
|
|
249
|
+
await page.goto(String(input.url), { waitUntil: "domcontentloaded" });
|
|
250
|
+
return { tab: await this.tabInfo(page) };
|
|
251
|
+
case "screenshot": {
|
|
252
|
+
const bytes = await page.screenshot({ type: "png" });
|
|
253
|
+
const viewport = page.viewportSize();
|
|
254
|
+
const cursor = this.cursor.get(tabId);
|
|
255
|
+
return {
|
|
256
|
+
base64: bytes.toString("base64"),
|
|
257
|
+
width: viewport?.width ?? Number(await page.evaluate(() => window.innerWidth)),
|
|
258
|
+
height: viewport?.height ?? Number(await page.evaluate(() => window.innerHeight)),
|
|
259
|
+
cursorX: cursor?.x,
|
|
260
|
+
cursorY: cursor?.y
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
case "move_mouse": {
|
|
264
|
+
const x = Number(input.x);
|
|
265
|
+
const y = Number(input.y);
|
|
266
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("move_mouse requires numeric x and y coordinates.");
|
|
267
|
+
await page.mouse.move(x, y);
|
|
268
|
+
this.cursor.set(tabId, { x, y });
|
|
269
|
+
return { tabId, x, y };
|
|
270
|
+
}
|
|
271
|
+
case "mouse_click": {
|
|
272
|
+
const cursor = this.cursor.get(tabId);
|
|
273
|
+
if (!cursor) throw new Error("Move the mouse in this tab before clicking.");
|
|
274
|
+
const button = input.button === "middle" || input.button === "right" ? input.button : "left";
|
|
275
|
+
await page.mouse.click(cursor.x, cursor.y, { button, clickCount: input.clickCount === 2 ? 2 : 1 });
|
|
276
|
+
return { tabId, ...cursor, button };
|
|
277
|
+
}
|
|
278
|
+
case "keyboard": {
|
|
279
|
+
const keys = Array.isArray(input.keys) ? input.keys : [];
|
|
280
|
+
const modifiers = Array.isArray(input.modifiers) ? input.modifiers.filter((value) => typeof value === "string") : [];
|
|
281
|
+
for (const modifier of modifiers) await page.keyboard.down(modifier);
|
|
282
|
+
try {
|
|
283
|
+
for (const key of keys) {
|
|
284
|
+
if (typeof key !== "string") throw new Error("keyboard keys must all be strings.");
|
|
285
|
+
await page.keyboard.press(key);
|
|
286
|
+
}
|
|
287
|
+
} finally {
|
|
288
|
+
for (const modifier of modifiers.toReversed()) await page.keyboard.up(modifier);
|
|
289
|
+
}
|
|
290
|
+
return { tabId, keys, modifiers };
|
|
291
|
+
}
|
|
292
|
+
case "keyboard_type":
|
|
293
|
+
if (typeof input.text !== "string") throw new Error("keyboard_type requires text.");
|
|
294
|
+
await page.keyboard.insertText(input.text);
|
|
295
|
+
return { tabId, characters: input.text.length };
|
|
296
|
+
case "run_js": {
|
|
297
|
+
if (typeof input.code !== "string") throw new Error("run_js requires code.");
|
|
298
|
+
const result = await page.evaluate(async (code) => {
|
|
299
|
+
const invoke = new Function(`"use strict"; return (async () => {
|
|
300
|
+
${code}
|
|
301
|
+
})()`);
|
|
302
|
+
return await invoke();
|
|
303
|
+
}, input.code);
|
|
304
|
+
const serialized = JSON.stringify(result);
|
|
305
|
+
if (serialized && Buffer.byteLength(serialized) > 1024 * 1024) {
|
|
306
|
+
throw new Error("Browser JavaScript result exceeded the 1 MiB limit.");
|
|
307
|
+
}
|
|
308
|
+
return { tabId, result };
|
|
309
|
+
}
|
|
310
|
+
default:
|
|
311
|
+
throw new Error(`Unsupported browser operation: ${action}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async function connectLoop(params) {
|
|
316
|
+
for (; ; ) {
|
|
317
|
+
const url = new URL("/browser/ws", params.baseUrl);
|
|
318
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
319
|
+
url.searchParams.set("instance", params.instanceId);
|
|
320
|
+
url.searchParams.set("version", findVersion());
|
|
321
|
+
let socket = null;
|
|
322
|
+
try {
|
|
323
|
+
socket = new import_ws.default(url, { headers: { Authorization: `Bearer ${params.token}` } });
|
|
324
|
+
await new Promise((resolve, reject) => {
|
|
325
|
+
socket.addEventListener("open", () => resolve(), { once: true });
|
|
326
|
+
socket.addEventListener("error", () => reject(new Error("WebSocket connection failed.")), { once: true });
|
|
327
|
+
});
|
|
328
|
+
process.stdout.write(`[r5d-browser] connected to ${params.baseUrl}
|
|
329
|
+
`);
|
|
330
|
+
socket.send(
|
|
331
|
+
JSON.stringify({
|
|
332
|
+
type: "hello",
|
|
333
|
+
hostInfo: {
|
|
334
|
+
hostname: import_node_os.default.hostname(),
|
|
335
|
+
platform: process.platform,
|
|
336
|
+
arch: process.arch,
|
|
337
|
+
version: findVersion(),
|
|
338
|
+
chromiumVersion: params.chromiumVersion,
|
|
339
|
+
profilePath: params.profilePath
|
|
340
|
+
},
|
|
341
|
+
tabs: await params.runtime.listTabs()
|
|
342
|
+
})
|
|
343
|
+
);
|
|
344
|
+
let lastServerMessageAt = Date.now();
|
|
345
|
+
const watchdog = setInterval(() => {
|
|
346
|
+
if (Date.now() - lastServerMessageAt >= SERVER_HEARTBEAT_TIMEOUT_MS) socket?.close();
|
|
347
|
+
}, 5e3);
|
|
348
|
+
await new Promise((resolve) => {
|
|
349
|
+
socket.addEventListener("message", (event) => {
|
|
350
|
+
lastServerMessageAt = Date.now();
|
|
351
|
+
void (async () => {
|
|
352
|
+
const message = JSON.parse(String(event.data));
|
|
353
|
+
if (message.type === "ping") {
|
|
354
|
+
socket?.send(JSON.stringify({ type: "pong" }));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (message.type !== "operation") return;
|
|
358
|
+
try {
|
|
359
|
+
const beforeTabs = await params.runtime.listTabs();
|
|
360
|
+
const operationResult = await params.runtime.execute(message.action, message.input);
|
|
361
|
+
const tabs = await params.runtime.listTabs();
|
|
362
|
+
const beforeIds = new Set(beforeTabs.map((tab) => tab.tabId));
|
|
363
|
+
const openedTabs = tabs.filter((tab) => !beforeIds.has(tab.tabId));
|
|
364
|
+
const result = operationResult && typeof operationResult === "object" ? { ...operationResult, openedTabs } : { result: operationResult, openedTabs };
|
|
365
|
+
socket?.send(JSON.stringify({ type: "operation_result", requestId: message.requestId, result }));
|
|
366
|
+
socket?.send(JSON.stringify({ type: "tabs_snapshot", tabs }));
|
|
367
|
+
} catch (error) {
|
|
368
|
+
socket?.send(
|
|
369
|
+
JSON.stringify({
|
|
370
|
+
type: "operation_result",
|
|
371
|
+
requestId: message.requestId,
|
|
372
|
+
error: error instanceof Error ? error.message : String(error)
|
|
373
|
+
})
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
})();
|
|
377
|
+
});
|
|
378
|
+
socket.addEventListener("close", () => resolve(), { once: true });
|
|
379
|
+
});
|
|
380
|
+
clearInterval(watchdog);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
process.stderr.write(`[r5d-browser] ${error instanceof Error ? error.message : String(error)}
|
|
383
|
+
`);
|
|
384
|
+
} finally {
|
|
385
|
+
socket?.close();
|
|
386
|
+
}
|
|
387
|
+
process.stdout.write(`[r5d-browser] disconnected; reconnecting...
|
|
388
|
+
`);
|
|
389
|
+
await (0, import_promises.setTimeout)(RECONNECT_DELAY_MS);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function start(options) {
|
|
393
|
+
if (process.platform !== "darwin") throw new Error("r5d-browser currently supports macOS only.");
|
|
394
|
+
const { baseUrl, token } = resolveConnection(options);
|
|
395
|
+
const browsersPath = import_node_path.default.resolve(options.browsersPath ?? import_node_path.default.join(browserRoot(), "browsers"));
|
|
396
|
+
const profilePath = import_node_path.default.resolve(options.profilePath ?? import_node_path.default.join(browserRoot(), "profile"));
|
|
397
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
398
|
+
let playwright = await import("playwright");
|
|
399
|
+
if (!import_node_fs.default.existsSync(playwright.chromium.executablePath())) {
|
|
400
|
+
installChromium(browsersPath);
|
|
401
|
+
playwright = await import("playwright");
|
|
402
|
+
}
|
|
403
|
+
import_node_fs.default.mkdirSync(profilePath, { recursive: true });
|
|
404
|
+
const context = await playwright.chromium.launchPersistentContext(profilePath, {
|
|
405
|
+
headless: false,
|
|
406
|
+
viewport: { width: 1440, height: 900 },
|
|
407
|
+
acceptDownloads: true,
|
|
408
|
+
downloadsPath: import_node_path.default.join(browserRoot(), "downloads")
|
|
409
|
+
});
|
|
410
|
+
if (context.pages().length === 0) await context.newPage();
|
|
411
|
+
const runtime = new BrowserRuntime(context);
|
|
412
|
+
process.stdout.write(`[r5d-browser] profile: ${profilePath}
|
|
413
|
+
`);
|
|
414
|
+
process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
|
|
415
|
+
`);
|
|
416
|
+
process.on("SIGINT", () => void context.close().finally(() => process.exit(0)));
|
|
417
|
+
process.on("SIGTERM", () => void context.close().finally(() => process.exit(0)));
|
|
418
|
+
return connectLoop({
|
|
419
|
+
runtime,
|
|
420
|
+
baseUrl,
|
|
421
|
+
token,
|
|
422
|
+
instanceId: loadInstanceId(),
|
|
423
|
+
profilePath,
|
|
424
|
+
chromiumVersion: context.browser()?.version() ?? "unknown"
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
async function main() {
|
|
428
|
+
const { command, options } = parseArgs(process.argv.slice(2));
|
|
429
|
+
if (command === "help") return printHelp();
|
|
430
|
+
if (command === "version") return void process.stdout.write(`r5d-browser ${findVersion()}
|
|
431
|
+
`);
|
|
432
|
+
const browsersPath = import_node_path.default.resolve(options.browsersPath ?? import_node_path.default.join(browserRoot(), "browsers"));
|
|
433
|
+
if (command === "install") return installChromium(browsersPath);
|
|
434
|
+
await start(options);
|
|
435
|
+
}
|
|
436
|
+
main().catch((error) => {
|
|
437
|
+
process.stderr.write(`r5d-browser: ${error instanceof Error ? error.message : String(error)}
|
|
438
|
+
`);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
|
+
import WebSocket from "ws";
|
|
8
|
+
import { resolvePlaywrightCliPath } from "./playwright-install.mjs";
|
|
9
|
+
const DEFAULT_BASE_URL = "https://r5d.dev";
|
|
10
|
+
const PACKAGE_NAME = "@ricsam/r5d-browser";
|
|
11
|
+
const RECONNECT_DELAY_MS = 2e3;
|
|
12
|
+
const SERVER_HEARTBEAT_TIMEOUT_MS = 45e3;
|
|
13
|
+
function browserRoot() {
|
|
14
|
+
return path.join(os.homedir(), ".r5d", "browser");
|
|
15
|
+
}
|
|
16
|
+
function defaultConfigPath() {
|
|
17
|
+
return path.join(os.homedir(), ".config", "r5d", "r5dctl", "config.json");
|
|
18
|
+
}
|
|
19
|
+
function printHelp() {
|
|
20
|
+
process.stdout.write(`Usage:
|
|
21
|
+
r5d-browser start [--base-url <url>] [--token <token>] [--api-key <key>]
|
|
22
|
+
r5d-browser install
|
|
23
|
+
r5d-browser --version
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--base-url <url> r5d.dev base URL (default from r5dctl config or ${DEFAULT_BASE_URL})
|
|
27
|
+
--token <token> Interactive r5d token
|
|
28
|
+
--api-key <key> r5d API key
|
|
29
|
+
--config <path> Shared r5dctl config path
|
|
30
|
+
--profile <path> Chromium profile path (default ~/.r5d/browser/profile)
|
|
31
|
+
--browsers-path <dir> Playwright browser binaries (default ~/.r5d/browser/browsers)
|
|
32
|
+
-v, --version Show r5d-browser version
|
|
33
|
+
-h, --help Show this help
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
function findVersion() {
|
|
37
|
+
let current = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : process.cwd();
|
|
38
|
+
for (let index = 0; index < 12; index += 1) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(current, "package.json"), "utf8"));
|
|
41
|
+
if (parsed.name === PACKAGE_NAME && parsed.version) return parsed.version;
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const sourceVersion = fs.readFileSync(path.join(current, "VERSION.txt"), "utf8").trim();
|
|
46
|
+
if (sourceVersion && path.basename(current) === "packages") return sourceVersion;
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
const parent = path.dirname(current);
|
|
50
|
+
if (parent === current) break;
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
return "unknown";
|
|
54
|
+
}
|
|
55
|
+
function parseArgs(argv) {
|
|
56
|
+
const options = { configPath: defaultConfigPath(), help: false, version: false };
|
|
57
|
+
const rest = [];
|
|
58
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
59
|
+
const arg = argv[index];
|
|
60
|
+
if (!arg) continue;
|
|
61
|
+
if (arg === "-h" || arg === "--help") {
|
|
62
|
+
options.help = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (arg === "-v" || arg === "--version") {
|
|
66
|
+
options.version = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const readOption = (name) => {
|
|
70
|
+
if (arg === name) {
|
|
71
|
+
index += 1;
|
|
72
|
+
const value = argv[index];
|
|
73
|
+
if (!value) throw new Error(`Missing value for ${name}`);
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
return arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : void 0;
|
|
77
|
+
};
|
|
78
|
+
const entries = [
|
|
79
|
+
["--base-url", "baseUrl"],
|
|
80
|
+
["--token", "token"],
|
|
81
|
+
["--api-key", "apiKey"],
|
|
82
|
+
["--config", "configPath"],
|
|
83
|
+
["--profile", "profilePath"],
|
|
84
|
+
["--browsers-path", "browsersPath"]
|
|
85
|
+
];
|
|
86
|
+
let matched = false;
|
|
87
|
+
for (const [name, key] of entries) {
|
|
88
|
+
const value = readOption(name);
|
|
89
|
+
if (value !== void 0) {
|
|
90
|
+
options[key] = value;
|
|
91
|
+
matched = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!matched) rest.push(arg);
|
|
96
|
+
}
|
|
97
|
+
if (options.version) return { command: "version", options };
|
|
98
|
+
if (options.help || rest.length === 0) return { command: "help", options };
|
|
99
|
+
if (rest[0] !== "start" && rest[0] !== "install") throw new Error(`Unknown command: ${rest[0]}`);
|
|
100
|
+
return { command: rest[0], options };
|
|
101
|
+
}
|
|
102
|
+
function readConfig(configPath) {
|
|
103
|
+
if (!fs.existsSync(configPath)) return {};
|
|
104
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
105
|
+
return {
|
|
106
|
+
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : void 0,
|
|
107
|
+
token: typeof parsed.token === "string" ? parsed.token : void 0,
|
|
108
|
+
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : void 0
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function resolveConnection(options) {
|
|
112
|
+
const config = readConfig(options.configPath);
|
|
113
|
+
const token = options.token ?? process.env.R5D_BROWSER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
|
|
114
|
+
if (!token) throw new Error("Authentication required. Run `r5dctl auth login` or pass --token/--api-key.");
|
|
115
|
+
return {
|
|
116
|
+
baseUrl: (options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.R5DCTL_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL).replace(
|
|
117
|
+
/\/+$/,
|
|
118
|
+
""
|
|
119
|
+
),
|
|
120
|
+
token
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function installChromium(browsersPath) {
|
|
124
|
+
fs.mkdirSync(browsersPath, { recursive: true });
|
|
125
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
126
|
+
const cliPath = resolvePlaywrightCliPath();
|
|
127
|
+
process.stdout.write(`[r5d-browser] Installing Chromium under ${browsersPath}...
|
|
128
|
+
`);
|
|
129
|
+
const result = spawnSync(process.execPath, [cliPath, "install", "chromium"], {
|
|
130
|
+
stdio: "inherit",
|
|
131
|
+
env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browsersPath }
|
|
132
|
+
});
|
|
133
|
+
if (result.status !== 0) throw new Error(`Chromium installation failed with exit code ${result.status ?? "unknown"}.`);
|
|
134
|
+
}
|
|
135
|
+
function loadInstanceId() {
|
|
136
|
+
const instancePath = path.join(browserRoot(), "instance-id");
|
|
137
|
+
fs.mkdirSync(path.dirname(instancePath), { recursive: true });
|
|
138
|
+
try {
|
|
139
|
+
const id2 = fs.readFileSync(instancePath, "utf8").trim();
|
|
140
|
+
if (id2) return id2;
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
const id = crypto.randomUUID();
|
|
144
|
+
fs.writeFileSync(instancePath, `${id}
|
|
145
|
+
`, { mode: 384 });
|
|
146
|
+
return id;
|
|
147
|
+
}
|
|
148
|
+
class BrowserRuntime {
|
|
149
|
+
constructor(context) {
|
|
150
|
+
this.context = context;
|
|
151
|
+
for (const page of context.pages()) this.track(page);
|
|
152
|
+
context.on("page", (page) => this.track(page));
|
|
153
|
+
}
|
|
154
|
+
context;
|
|
155
|
+
ids = /* @__PURE__ */ new WeakMap();
|
|
156
|
+
pages = /* @__PURE__ */ new Map();
|
|
157
|
+
cursor = /* @__PURE__ */ new Map();
|
|
158
|
+
track(page) {
|
|
159
|
+
const existing = this.ids.get(page);
|
|
160
|
+
if (existing) return existing;
|
|
161
|
+
const id = crypto.randomUUID();
|
|
162
|
+
this.ids.set(page, id);
|
|
163
|
+
this.pages.set(id, page);
|
|
164
|
+
page.once("close", () => {
|
|
165
|
+
this.pages.delete(id);
|
|
166
|
+
this.cursor.delete(id);
|
|
167
|
+
});
|
|
168
|
+
return id;
|
|
169
|
+
}
|
|
170
|
+
page(tabId) {
|
|
171
|
+
if (typeof tabId !== "string") throw new Error("tabId is required.");
|
|
172
|
+
const page = this.pages.get(tabId);
|
|
173
|
+
if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
|
|
174
|
+
return page;
|
|
175
|
+
}
|
|
176
|
+
async tabInfo(page) {
|
|
177
|
+
const tabId = this.track(page);
|
|
178
|
+
let windowId = "unknown";
|
|
179
|
+
try {
|
|
180
|
+
const cdp = await this.context.newCDPSession(page);
|
|
181
|
+
const result = await cdp.send("Browser.getWindowForTarget");
|
|
182
|
+
windowId = String(result.windowId);
|
|
183
|
+
await cdp.detach();
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
186
|
+
return { tabId, windowId, title: await page.title().catch(() => ""), url: page.url() };
|
|
187
|
+
}
|
|
188
|
+
async listTabs() {
|
|
189
|
+
return await Promise.all([...this.pages.values()].filter((page) => !page.isClosed()).map((page) => this.tabInfo(page)));
|
|
190
|
+
}
|
|
191
|
+
async execute(action, input) {
|
|
192
|
+
if (action === "list_tabs") return { tabs: await this.listTabs() };
|
|
193
|
+
if (action === "open_tab") {
|
|
194
|
+
const previous = this.context.pages().findLast((candidate) => !candidate.isClosed());
|
|
195
|
+
let page2;
|
|
196
|
+
if (previous) {
|
|
197
|
+
const cdp = await this.context.newCDPSession(previous);
|
|
198
|
+
const pagePromise = this.context.waitForEvent("page");
|
|
199
|
+
await cdp.send("Target.createTarget", {
|
|
200
|
+
url: "about:blank",
|
|
201
|
+
newWindow: input.disposition === "window",
|
|
202
|
+
background: true
|
|
203
|
+
});
|
|
204
|
+
page2 = await pagePromise;
|
|
205
|
+
await cdp.detach();
|
|
206
|
+
if (input.disposition === "window") {
|
|
207
|
+
const pageCdp = await this.context.newCDPSession(page2);
|
|
208
|
+
const { windowId } = await pageCdp.send("Browser.getWindowForTarget");
|
|
209
|
+
await pageCdp.send("Browser.setWindowBounds", { windowId, bounds: { windowState: "minimized" } });
|
|
210
|
+
await pageCdp.detach();
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
page2 = await this.context.newPage();
|
|
214
|
+
}
|
|
215
|
+
const url = typeof input.url === "string" ? input.url : "about:blank";
|
|
216
|
+
if (url !== "about:blank") await page2.goto(url, { waitUntil: "domcontentloaded" });
|
|
217
|
+
return { tab: await this.tabInfo(page2) };
|
|
218
|
+
}
|
|
219
|
+
const page = this.page(input.tabId);
|
|
220
|
+
const tabId = input.tabId;
|
|
221
|
+
switch (action) {
|
|
222
|
+
case "close_tab":
|
|
223
|
+
await page.close();
|
|
224
|
+
return { closed: true, tabId };
|
|
225
|
+
case "navigate":
|
|
226
|
+
await page.goto(String(input.url), { waitUntil: "domcontentloaded" });
|
|
227
|
+
return { tab: await this.tabInfo(page) };
|
|
228
|
+
case "screenshot": {
|
|
229
|
+
const bytes = await page.screenshot({ type: "png" });
|
|
230
|
+
const viewport = page.viewportSize();
|
|
231
|
+
const cursor = this.cursor.get(tabId);
|
|
232
|
+
return {
|
|
233
|
+
base64: bytes.toString("base64"),
|
|
234
|
+
width: viewport?.width ?? Number(await page.evaluate(() => window.innerWidth)),
|
|
235
|
+
height: viewport?.height ?? Number(await page.evaluate(() => window.innerHeight)),
|
|
236
|
+
cursorX: cursor?.x,
|
|
237
|
+
cursorY: cursor?.y
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
case "move_mouse": {
|
|
241
|
+
const x = Number(input.x);
|
|
242
|
+
const y = Number(input.y);
|
|
243
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("move_mouse requires numeric x and y coordinates.");
|
|
244
|
+
await page.mouse.move(x, y);
|
|
245
|
+
this.cursor.set(tabId, { x, y });
|
|
246
|
+
return { tabId, x, y };
|
|
247
|
+
}
|
|
248
|
+
case "mouse_click": {
|
|
249
|
+
const cursor = this.cursor.get(tabId);
|
|
250
|
+
if (!cursor) throw new Error("Move the mouse in this tab before clicking.");
|
|
251
|
+
const button = input.button === "middle" || input.button === "right" ? input.button : "left";
|
|
252
|
+
await page.mouse.click(cursor.x, cursor.y, { button, clickCount: input.clickCount === 2 ? 2 : 1 });
|
|
253
|
+
return { tabId, ...cursor, button };
|
|
254
|
+
}
|
|
255
|
+
case "keyboard": {
|
|
256
|
+
const keys = Array.isArray(input.keys) ? input.keys : [];
|
|
257
|
+
const modifiers = Array.isArray(input.modifiers) ? input.modifiers.filter((value) => typeof value === "string") : [];
|
|
258
|
+
for (const modifier of modifiers) await page.keyboard.down(modifier);
|
|
259
|
+
try {
|
|
260
|
+
for (const key of keys) {
|
|
261
|
+
if (typeof key !== "string") throw new Error("keyboard keys must all be strings.");
|
|
262
|
+
await page.keyboard.press(key);
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
for (const modifier of modifiers.toReversed()) await page.keyboard.up(modifier);
|
|
266
|
+
}
|
|
267
|
+
return { tabId, keys, modifiers };
|
|
268
|
+
}
|
|
269
|
+
case "keyboard_type":
|
|
270
|
+
if (typeof input.text !== "string") throw new Error("keyboard_type requires text.");
|
|
271
|
+
await page.keyboard.insertText(input.text);
|
|
272
|
+
return { tabId, characters: input.text.length };
|
|
273
|
+
case "run_js": {
|
|
274
|
+
if (typeof input.code !== "string") throw new Error("run_js requires code.");
|
|
275
|
+
const result = await page.evaluate(async (code) => {
|
|
276
|
+
const invoke = new Function(`"use strict"; return (async () => {
|
|
277
|
+
${code}
|
|
278
|
+
})()`);
|
|
279
|
+
return await invoke();
|
|
280
|
+
}, input.code);
|
|
281
|
+
const serialized = JSON.stringify(result);
|
|
282
|
+
if (serialized && Buffer.byteLength(serialized) > 1024 * 1024) {
|
|
283
|
+
throw new Error("Browser JavaScript result exceeded the 1 MiB limit.");
|
|
284
|
+
}
|
|
285
|
+
return { tabId, result };
|
|
286
|
+
}
|
|
287
|
+
default:
|
|
288
|
+
throw new Error(`Unsupported browser operation: ${action}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function connectLoop(params) {
|
|
293
|
+
for (; ; ) {
|
|
294
|
+
const url = new URL("/browser/ws", params.baseUrl);
|
|
295
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
296
|
+
url.searchParams.set("instance", params.instanceId);
|
|
297
|
+
url.searchParams.set("version", findVersion());
|
|
298
|
+
let socket = null;
|
|
299
|
+
try {
|
|
300
|
+
socket = new WebSocket(url, { headers: { Authorization: `Bearer ${params.token}` } });
|
|
301
|
+
await new Promise((resolve, reject) => {
|
|
302
|
+
socket.addEventListener("open", () => resolve(), { once: true });
|
|
303
|
+
socket.addEventListener("error", () => reject(new Error("WebSocket connection failed.")), { once: true });
|
|
304
|
+
});
|
|
305
|
+
process.stdout.write(`[r5d-browser] connected to ${params.baseUrl}
|
|
306
|
+
`);
|
|
307
|
+
socket.send(
|
|
308
|
+
JSON.stringify({
|
|
309
|
+
type: "hello",
|
|
310
|
+
hostInfo: {
|
|
311
|
+
hostname: os.hostname(),
|
|
312
|
+
platform: process.platform,
|
|
313
|
+
arch: process.arch,
|
|
314
|
+
version: findVersion(),
|
|
315
|
+
chromiumVersion: params.chromiumVersion,
|
|
316
|
+
profilePath: params.profilePath
|
|
317
|
+
},
|
|
318
|
+
tabs: await params.runtime.listTabs()
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
let lastServerMessageAt = Date.now();
|
|
322
|
+
const watchdog = setInterval(() => {
|
|
323
|
+
if (Date.now() - lastServerMessageAt >= SERVER_HEARTBEAT_TIMEOUT_MS) socket?.close();
|
|
324
|
+
}, 5e3);
|
|
325
|
+
await new Promise((resolve) => {
|
|
326
|
+
socket.addEventListener("message", (event) => {
|
|
327
|
+
lastServerMessageAt = Date.now();
|
|
328
|
+
void (async () => {
|
|
329
|
+
const message = JSON.parse(String(event.data));
|
|
330
|
+
if (message.type === "ping") {
|
|
331
|
+
socket?.send(JSON.stringify({ type: "pong" }));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (message.type !== "operation") return;
|
|
335
|
+
try {
|
|
336
|
+
const beforeTabs = await params.runtime.listTabs();
|
|
337
|
+
const operationResult = await params.runtime.execute(message.action, message.input);
|
|
338
|
+
const tabs = await params.runtime.listTabs();
|
|
339
|
+
const beforeIds = new Set(beforeTabs.map((tab) => tab.tabId));
|
|
340
|
+
const openedTabs = tabs.filter((tab) => !beforeIds.has(tab.tabId));
|
|
341
|
+
const result = operationResult && typeof operationResult === "object" ? { ...operationResult, openedTabs } : { result: operationResult, openedTabs };
|
|
342
|
+
socket?.send(JSON.stringify({ type: "operation_result", requestId: message.requestId, result }));
|
|
343
|
+
socket?.send(JSON.stringify({ type: "tabs_snapshot", tabs }));
|
|
344
|
+
} catch (error) {
|
|
345
|
+
socket?.send(
|
|
346
|
+
JSON.stringify({
|
|
347
|
+
type: "operation_result",
|
|
348
|
+
requestId: message.requestId,
|
|
349
|
+
error: error instanceof Error ? error.message : String(error)
|
|
350
|
+
})
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
})();
|
|
354
|
+
});
|
|
355
|
+
socket.addEventListener("close", () => resolve(), { once: true });
|
|
356
|
+
});
|
|
357
|
+
clearInterval(watchdog);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
process.stderr.write(`[r5d-browser] ${error instanceof Error ? error.message : String(error)}
|
|
360
|
+
`);
|
|
361
|
+
} finally {
|
|
362
|
+
socket?.close();
|
|
363
|
+
}
|
|
364
|
+
process.stdout.write(`[r5d-browser] disconnected; reconnecting...
|
|
365
|
+
`);
|
|
366
|
+
await sleep(RECONNECT_DELAY_MS);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function start(options) {
|
|
370
|
+
if (process.platform !== "darwin") throw new Error("r5d-browser currently supports macOS only.");
|
|
371
|
+
const { baseUrl, token } = resolveConnection(options);
|
|
372
|
+
const browsersPath = path.resolve(options.browsersPath ?? path.join(browserRoot(), "browsers"));
|
|
373
|
+
const profilePath = path.resolve(options.profilePath ?? path.join(browserRoot(), "profile"));
|
|
374
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
375
|
+
let playwright = await import("playwright");
|
|
376
|
+
if (!fs.existsSync(playwright.chromium.executablePath())) {
|
|
377
|
+
installChromium(browsersPath);
|
|
378
|
+
playwright = await import("playwright");
|
|
379
|
+
}
|
|
380
|
+
fs.mkdirSync(profilePath, { recursive: true });
|
|
381
|
+
const context = await playwright.chromium.launchPersistentContext(profilePath, {
|
|
382
|
+
headless: false,
|
|
383
|
+
viewport: { width: 1440, height: 900 },
|
|
384
|
+
acceptDownloads: true,
|
|
385
|
+
downloadsPath: path.join(browserRoot(), "downloads")
|
|
386
|
+
});
|
|
387
|
+
if (context.pages().length === 0) await context.newPage();
|
|
388
|
+
const runtime = new BrowserRuntime(context);
|
|
389
|
+
process.stdout.write(`[r5d-browser] profile: ${profilePath}
|
|
390
|
+
`);
|
|
391
|
+
process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
|
|
392
|
+
`);
|
|
393
|
+
process.on("SIGINT", () => void context.close().finally(() => process.exit(0)));
|
|
394
|
+
process.on("SIGTERM", () => void context.close().finally(() => process.exit(0)));
|
|
395
|
+
return connectLoop({
|
|
396
|
+
runtime,
|
|
397
|
+
baseUrl,
|
|
398
|
+
token,
|
|
399
|
+
instanceId: loadInstanceId(),
|
|
400
|
+
profilePath,
|
|
401
|
+
chromiumVersion: context.browser()?.version() ?? "unknown"
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
async function main() {
|
|
405
|
+
const { command, options } = parseArgs(process.argv.slice(2));
|
|
406
|
+
if (command === "help") return printHelp();
|
|
407
|
+
if (command === "version") return void process.stdout.write(`r5d-browser ${findVersion()}
|
|
408
|
+
`);
|
|
409
|
+
const browsersPath = path.resolve(options.browsersPath ?? path.join(browserRoot(), "browsers"));
|
|
410
|
+
if (command === "install") return installChromium(browsersPath);
|
|
411
|
+
await start(options);
|
|
412
|
+
}
|
|
413
|
+
main().catch((error) => {
|
|
414
|
+
process.stderr.write(`r5d-browser: ${error instanceof Error ? error.message : String(error)}
|
|
415
|
+
`);
|
|
416
|
+
process.exit(1);
|
|
417
|
+
});
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ricsam/r5d-browser",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
"version": "0.0.44",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/cjs/main.cjs",
|
|
6
|
+
"module": "./dist/mjs/main.mjs",
|
|
7
|
+
"types": "./dist/types/main.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/types/main.d.ts",
|
|
11
|
+
"import": "./dist/mjs/main.mjs",
|
|
12
|
+
"require": "./dist/cjs/main.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/ricsam/r5d-dev.git",
|
|
18
|
+
"directory": "packages/r5d-browser"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"r5d-browser": "dist/cjs/main.cjs"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"playwright": "1.62.1",
|
|
25
|
+
"ws": "^8.18.3"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"bun": ">=1.3.0",
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"sideEffects": false
|
|
10
39
|
}
|