@xnng/browser-relay 1.6.0

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.
Files changed (79) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +445 -0
  3. package/docs/README.zh-CN.md +421 -0
  4. package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
  5. package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
  6. package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
  7. package/docs/benchmarks/browser-readiness-cost.json +106 -0
  8. package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
  9. package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
  10. package/docs/benchmarks/browser-runtime-balanced.json +412 -0
  11. package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
  12. package/docs/benchmarks/browser-runtime.json +254 -0
  13. package/docs/benchmarks/browser-use-parity.json +54 -0
  14. package/docs/benchmarks/codex-extension-audit.json +131 -0
  15. package/docs/benchmarks/codex-native-protocol.md +69 -0
  16. package/docs/benchmarks/codex-native-replay.js +116 -0
  17. package/docs/benchmarks/codex-native-status.json +81 -0
  18. package/docs/benchmarks/codex-native.json +1003 -0
  19. package/docs/benchmarks/extension-sessions.png +0 -0
  20. package/docs/benchmarks/extension-tasks.png +0 -0
  21. package/docs/benchmarks/iframe-routing-regression.json +37 -0
  22. package/docs/browser-use-comparison.md +331 -0
  23. package/docs/browser-use-gap-audit.md +141 -0
  24. package/docs/browser-use-parity.md +105 -0
  25. package/docs/demo/intranet.html +103 -0
  26. package/docs/releases/v1.5.0.md +59 -0
  27. package/docs/releases/v1.5.1.md +16 -0
  28. package/docs/releases/v1.5.2.md +42 -0
  29. package/docs/releases/v1.5.3.md +33 -0
  30. package/docs/releases/v1.5.4.md +42 -0
  31. package/docs/releases/v1.6.0.md +16 -0
  32. package/docs/remote-control-hub.md +523 -0
  33. package/extension/activity.js +328 -0
  34. package/extension/automation.js +1839 -0
  35. package/extension/background.js +1854 -0
  36. package/extension/i18n.js +149 -0
  37. package/extension/icons/icon128.png +0 -0
  38. package/extension/icons/icon16.png +0 -0
  39. package/extension/icons/icon32.png +0 -0
  40. package/extension/icons/icon48.png +0 -0
  41. package/extension/manifest.json +47 -0
  42. package/extension/observations.js +109 -0
  43. package/extension/options.html +289 -0
  44. package/extension/options.js +269 -0
  45. package/extension/popup.html +74 -0
  46. package/extension/popup.js +105 -0
  47. package/extension/protocol.js +45 -0
  48. package/extension/remote-auth.js +18 -0
  49. package/extension/sessions.js +134 -0
  50. package/extension/snapshot.js +161 -0
  51. package/extension/task-groups.js +108 -0
  52. package/extension/tasks.js +186 -0
  53. package/extension/wait.js +89 -0
  54. package/hub/README.md +42 -0
  55. package/hub/package-lock.json +1544 -0
  56. package/hub/package.json +13 -0
  57. package/hub/src/rpc.js +41 -0
  58. package/hub/src/worker.js +322 -0
  59. package/hub/wrangler.example.toml +19 -0
  60. package/package.json +83 -0
  61. package/server/cdp-bridge.js +200 -0
  62. package/server/cli.js +1798 -0
  63. package/server/hub-server.js +258 -0
  64. package/server/install.js +250 -0
  65. package/server/mcp-server.js +504 -0
  66. package/server/npx-runner.js +96 -0
  67. package/server/relay-server.js +1356 -0
  68. package/server/remote-protocol.js +76 -0
  69. package/server/runtime-worker.js +166 -0
  70. package/server/script-runtime.js +189 -0
  71. package/server/sdk.js +307 -0
  72. package/server/service-state.js +103 -0
  73. package/server/snapshot.js +161 -0
  74. package/server/uninstall.js +61 -0
  75. package/server/windows-service-entry.js +58 -0
  76. package/server/windows-service.js +360 -0
  77. package/skills/browser-relay/SKILL.md +192 -0
  78. package/skills/browser-relay/references/legacy-api.md +163 -0
  79. package/skills/browser-relay/references/runtime.md +240 -0
package/server/sdk.js ADDED
@@ -0,0 +1,307 @@
1
+ import {
2
+ DEFAULT_REMOTE_HOST,
3
+ parseRemoteDeviceId,
4
+ remoteHttpBase,
5
+ } from "./remote-protocol.js";
6
+ import { randomUUID } from "node:crypto";
7
+ import { isAutomationPath, isTaskRequest } from "../extension/protocol.js";
8
+
9
+ export class BrowserRelayError extends Error {
10
+ constructor(payload) {
11
+ super(payload.message || payload.error || "Browser action failed");
12
+ this.code = payload.code;
13
+ this.payload = payload;
14
+ }
15
+ }
16
+ export function createTransport({
17
+ url = process.env.BROWSER_RELAY_URL || "http://127.0.0.1:18795",
18
+ remoteDeviceId = process.env.BROWSER_RELAY_REMOTE_DEVICE_ID,
19
+ remoteHost = process.env.BROWSER_RELAY_REMOTE_HOST || DEFAULT_REMOTE_HOST,
20
+ } = {}) {
21
+ const remote = remoteDeviceId ? parseRemoteDeviceId(remoteDeviceId) : null;
22
+ const request = async (method, path, body, { signal, timeoutMs = 35000 } = {}) => {
23
+ const query = new URL(path, "http://relay.local");
24
+ const taskRequest = isTaskRequest(method, query.pathname);
25
+ let taskId, sessionId;
26
+ if (taskRequest) {
27
+ taskId = (method === "GET" ? query.searchParams.get("taskId") : body?.taskId) || `job_${randomUUID()}`;
28
+ sessionId = (method === "GET" ? query.searchParams.get("sessionId") : body?.sessionId) || undefined;
29
+ if (method === "GET") {
30
+ query.searchParams.set("taskId", taskId);
31
+ path = query.pathname + query.search;
32
+ } else body = { ...body, taskId };
33
+ }
34
+ const controller = new AbortController();
35
+ const abort = () => controller.abort(signal.reason);
36
+ if (signal?.aborted) abort();
37
+ else signal?.addEventListener("abort", abort, { once: true });
38
+ const timer = setTimeout(
39
+ () => controller.abort(new Error("Browser Relay request timed out")),
40
+ timeoutMs,
41
+ );
42
+ try {
43
+ const response = await fetch(
44
+ remote
45
+ ? `${remoteHttpBase(remoteHost)}/v1/rpc`
46
+ : `${url.replace(/\/$/, "")}${path}`,
47
+ {
48
+ method: remote ? "POST" : method,
49
+ headers: {
50
+ "Content-Type": "application/json",
51
+ ...(remote ? { Authorization: `Bearer ${remote.secret}` } : {}),
52
+ },
53
+ ...(remote
54
+ ? {
55
+ body: JSON.stringify({
56
+ routeId: remote.routeId,
57
+ id: randomUUID(),
58
+ method,
59
+ path,
60
+ body: body ?? null,
61
+ headers: {},
62
+ }),
63
+ }
64
+ : body === undefined
65
+ ? {}
66
+ : { body: JSON.stringify(body) }),
67
+ signal: controller.signal,
68
+ },
69
+ );
70
+ const result = await response.json();
71
+ if (!response.ok || result.ok === false)
72
+ throw new BrowserRelayError(result);
73
+ return result;
74
+ } catch (error) {
75
+ if (error instanceof BrowserRelayError &&
76
+ !["cdp_timeout", "rpc_timeout", "request_timeout", "remote_request_timeout", "extension_not_connected"].includes(error.code)) throw error;
77
+ const code = signal?.aborted ? "request_cancelled" : controller.signal.aborted ? "request_timeout" : error.code || "transport_error";
78
+ clearTimeout(timer);
79
+ // An aborted fetch does not stop Chrome (especially behind a remote hub).
80
+ // Cancel through a fresh request and retain the task identity/progress.
81
+ let task, cancellationError;
82
+ if (taskId) {
83
+ try {
84
+ ({ task } = await request("POST", `/api/tasks/${encodeURIComponent(taskId)}/cancel`,
85
+ { sessionId }, { timeoutMs: 2500 }));
86
+ } catch (cancelError) {
87
+ cancellationError = cancelError.code || "transport_error";
88
+ }
89
+ }
90
+ throw new BrowserRelayError({
91
+ ok: false,
92
+ code,
93
+ message: error.message,
94
+ retryable: !taskId && !signal?.aborted && method === "GET",
95
+ ...(taskId ? { taskId, sessionId, task, cancellationRequested: !!task, cancellationError } : {}),
96
+ });
97
+ } finally {
98
+ clearTimeout(timer);
99
+ signal?.removeEventListener("abort", abort);
100
+ }
101
+ };
102
+ return request;
103
+ }
104
+ export function createBrowser({
105
+ request: transportRequest = createTransport(),
106
+ sessionId = randomUUID(),
107
+ } = {}) {
108
+ let timer,
109
+ disposed = false,
110
+ sessionError;
111
+ const request = async (method, path, body, options) => {
112
+ const history = method === "GET" && path.startsWith("/api/tasks/");
113
+ if (disposed && !history)
114
+ throw new BrowserRelayError({
115
+ code: "session_stopped",
116
+ message: "Browser session disposed; create a new browser session",
117
+ });
118
+ if (sessionError && !history) throw sessionError;
119
+ const modern = isAutomationPath(path.split("?")[0]);
120
+ if (modern && !path.startsWith("/api/capabilities")) {
121
+ if (method === "GET")
122
+ path += `${path.includes("?") ? "&" : "?"}sessionId=${encodeURIComponent(sessionId)}`;
123
+ else body = { ...body, sessionId };
124
+ if (!timer && !history) {
125
+ timer = setInterval(
126
+ () =>
127
+ transportRequest(
128
+ "POST",
129
+ "/api/sessions",
130
+ { sessionId, action: "heartbeat" },
131
+ { timeoutMs: 5000 },
132
+ ).catch((error) => {
133
+ sessionError = error;
134
+ clearInterval(timer);
135
+ }),
136
+ 40000,
137
+ );
138
+ timer.unref?.();
139
+ }
140
+ }
141
+ return transportRequest(method, path, body, options);
142
+ };
143
+ async function waitTask(id, { timeoutMs = 125000 } = {}) {
144
+ const deadline = Date.now() + timeoutMs;
145
+ while (true) {
146
+ const { task } = await request(
147
+ "GET",
148
+ `/api/tasks/${encodeURIComponent(id)}`,
149
+ );
150
+ if (task.status === "completed") return task;
151
+ if (task.status === "failed" || task.status === "cancelled")
152
+ throw new BrowserRelayError({ ...task.error, task });
153
+ if (Date.now() >= deadline) {
154
+ await request(
155
+ "POST",
156
+ `/api/tasks/${encodeURIComponent(id)}/cancel`,
157
+ {},
158
+ );
159
+ throw new BrowserRelayError({
160
+ code: "task_timeout",
161
+ message: "Task wait timed out",
162
+ });
163
+ }
164
+ await new Promise((r) => setTimeout(r, 40));
165
+ }
166
+ }
167
+ function tab(tabId) {
168
+ if (typeof tabId !== "string" || !/^t_[\w-]{10}$/.test(tabId))
169
+ throw new Error("Use a tab id from browser.tabs() or browser.open()");
170
+ const run = async (actions, options = {}) => {
171
+ const { signal, requestTimeoutMs, ...actionOptions } = options;
172
+ const result = await request("POST", "/api/actions", {
173
+ tabId,
174
+ sessionId,
175
+ taskId: `job_${randomUUID()}`,
176
+ actions,
177
+ observe: "snapshot",
178
+ ...actionOptions,
179
+ }, { signal, timeoutMs: requestTimeoutMs });
180
+ return result.task;
181
+ };
182
+ const locator = (target) => ({
183
+ click: (options = {}) => run([{ type: "click", target, ...options }]),
184
+ doubleClick: (options = {}) =>
185
+ run([{ type: "double_click", target, ...options }]),
186
+ fill: (text, options = {}) =>
187
+ run([{ type: "fill", target, text, ...options }]),
188
+ type: (text, options = {}) =>
189
+ run([{ type: "type", target, text, ...options }]),
190
+ hover: (options = {}) => run([{ type: "hover", target, ...options }]),
191
+ select: (value, options = {}) =>
192
+ run([{ type: "select", target, value, ...options }]),
193
+ check: (checked = true, options = {}) =>
194
+ run([{ type: "check", target, checked, ...options }]),
195
+ waitFor: (options = {}) => run([{ type: "wait", target, ...options }]),
196
+ getByRole: (role, options = {}) =>
197
+ locator({ role, ...options, scope: target }),
198
+ read: (options = {}) =>
199
+ request("POST", "/api/read", { tabId, target, ...options }),
200
+ target,
201
+ });
202
+ return {
203
+ id: tabId,
204
+ act: run,
205
+ ref: (ref) => locator({ ref }),
206
+ locator: (selector, options = {}) => locator({ selector, ...options }),
207
+ getByRole: (role, options = {}) => locator({ role, ...options }),
208
+ read: (options = {}) =>
209
+ request("POST", "/api/read", { tabId, ...options }),
210
+ observe: (options = {}) =>
211
+ request("POST", "/api/observe", { tabId, ...options }),
212
+ focus: () => run([{ type: "focus" }]),
213
+ claim: (options = {}) =>
214
+ request("POST", "/api/tabs/claim", { tabId, ...options }),
215
+ release: () => request("POST", "/api/tabs/release", { tabId }),
216
+ handoff: (toSessionId) =>
217
+ request("POST", "/api/tabs/handoff", { tabId, toSessionId }),
218
+ snapshot: (options = {}) =>
219
+ request("POST", "/api/observe", {
220
+ tabId,
221
+ sessionId,
222
+ diff: true,
223
+ ...options,
224
+ }),
225
+ screenshot: (options = {}) =>
226
+ request("POST", "/api/observe", {
227
+ tabId,
228
+ mode: "screenshot",
229
+ ...options,
230
+ }),
231
+ goto: (url, options = {}) =>
232
+ run([
233
+ { type: "navigate", url, ...options },
234
+ ...(options.waitFor
235
+ ? [
236
+ {
237
+ type: "wait",
238
+ target: options.waitFor,
239
+ timeoutMs: options.timeoutMs || 10000,
240
+ },
241
+ ]
242
+ : []),
243
+ ]),
244
+ close: () => request("POST", "/api/tabs/close", { tabId }),
245
+ click: (target, options = {}) =>
246
+ run([{ type: "click", target, ...options }]),
247
+ clickAt: (x, y, options = {}) =>
248
+ run([{ type: "click", x, y, ...options }]),
249
+ key: (key) => run([{ type: "key", key }]),
250
+ scroll: (deltaY, options = {}) =>
251
+ run([{ type: "scroll", x: 400, y: 300, deltaY, ...options }]),
252
+ hover: (target) => run([{ type: "hover", target }]),
253
+ drag: (from, to, options = {}) =>
254
+ run([{ type: "drag", ...from, to, ...options }]),
255
+ eval: async (expression) => {
256
+ const result = await request("POST", "/api/evaluate", {
257
+ tabId,
258
+ expression,
259
+ });
260
+ if (result.exceptionDetails)
261
+ throw new BrowserRelayError({
262
+ code: "evaluation_failed",
263
+ message:
264
+ result.exceptionDetails.exception?.description ||
265
+ result.exceptionDetails.text,
266
+ });
267
+ return result.value;
268
+ },
269
+ };
270
+ }
271
+ return {
272
+ sessionId,
273
+ start: (label) => request('POST', '/api/sessions', { action: 'start', label }),
274
+ complete: async () => {
275
+ // complete 是任务结束,dispose 只是控制器退出;后者继续保留页面。
276
+ const result = await transportRequest('POST', '/api/sessions', { sessionId, action: 'complete' });
277
+ disposed = true;
278
+ clearInterval(timer);
279
+ return result;
280
+ },
281
+ heartbeat: () => request("POST", "/api/sessions", { action: "heartbeat" }),
282
+ claims: () => request("GET", "/api/sessions"),
283
+ dispose: async () => {
284
+ if (disposed) return;
285
+ disposed = true;
286
+ clearInterval(timer);
287
+ await transportRequest(
288
+ "POST",
289
+ "/api/sessions",
290
+ { sessionId, action: "stop" },
291
+ { timeoutMs: 5000 },
292
+ );
293
+ },
294
+ tabs: async () => (await request("GET", "/api/tabs")).tabs,
295
+ tab,
296
+ open: async (url) =>
297
+ tab((await request("POST", "/api/tabs/create", { url })).tabId),
298
+ capabilities: () => request("GET", "/api/capabilities"),
299
+ tasks: {
300
+ get: async (id) =>
301
+ (await request("GET", `/api/tasks/${encodeURIComponent(id)}`)).task,
302
+ wait: waitTask,
303
+ cancel: (id) =>
304
+ request("POST", `/api/tasks/${encodeURIComponent(id)}/cancel`, {}),
305
+ },
306
+ };
307
+ }
@@ -0,0 +1,103 @@
1
+ import { spawnSync as defaultSpawnSync } from "node:child_process";
2
+ import { existsSync as defaultExistsSync } from "node:fs";
3
+
4
+ function outputText(value) {
5
+ if (typeof value === "string") return value;
6
+ if (Buffer.isBuffer(value)) return value.toString("utf-8");
7
+ return "";
8
+ }
9
+
10
+ function firstLine(value) {
11
+ return outputText(value).split(/\r?\n/, 1)[0].trim().slice(0, 240);
12
+ }
13
+
14
+ function probeError(command, label, result) {
15
+ if (result?.error?.code === "ENOENT") return `${command} is unavailable (command not found)`;
16
+ if (result?.error?.code === "ETIMEDOUT") return `${label} timed out`;
17
+ const detail = firstLine(result?.stderr) || firstLine(result?.error?.message);
18
+ return detail ? `${label} is unavailable: ${detail}` : `${label} is unavailable`;
19
+ }
20
+
21
+ function runProbe(spawnSyncFn, command, args) {
22
+ try {
23
+ return spawnSyncFn(command, args, { encoding: "utf-8", timeout: 1500 });
24
+ } catch (error) {
25
+ return { error, status: null, stdout: "", stderr: "" };
26
+ }
27
+ }
28
+
29
+ export function inspectPosixServiceState({
30
+ sys,
31
+ plistPath,
32
+ systemdPath,
33
+ launchdLabel,
34
+ systemdUnit,
35
+ spawnSyncFn = defaultSpawnSync,
36
+ existsSyncFn = defaultExistsSync,
37
+ }) {
38
+ const supported = sys === "darwin" || sys === "linux";
39
+ const registered = sys === "darwin"
40
+ ? existsSyncFn(plistPath)
41
+ : sys === "linux"
42
+ ? existsSyncFn(systemdPath)
43
+ : false;
44
+ const state = {
45
+ loaded: false,
46
+ pid: null,
47
+ supported,
48
+ registered,
49
+ checked: false,
50
+ error: null,
51
+ conflict: false,
52
+ };
53
+
54
+ if (sys === "darwin") {
55
+ const result = runProbe(spawnSyncFn, "launchctl", ["list"]);
56
+ const hasOutput = typeof result?.stdout === "string" || Buffer.isBuffer(result?.stdout);
57
+ if (!result?.error && result?.status === 0 && hasOutput) {
58
+ state.checked = true;
59
+ const line = outputText(result.stdout)
60
+ .split("\n")
61
+ .find((item) => item.trim().split(/\s+/).at(-1) === launchdLabel);
62
+ if (line) {
63
+ state.loaded = true;
64
+ const [pid] = line.trim().split(/\s+/);
65
+ if (pid && pid !== "-") state.pid = pid;
66
+ }
67
+ } else if (!result?.error && result?.status === 0) {
68
+ state.error = "launchctl status is unavailable: command returned no output";
69
+ } else {
70
+ state.error = probeError("launchctl", "launchctl status", result);
71
+ }
72
+ return state;
73
+ }
74
+
75
+ if (sys === "linux") {
76
+ const result = runProbe(spawnSyncFn, "systemctl", ["--user", "is-active", systemdUnit]);
77
+ const serviceStatus = outputText(result?.stdout).trim();
78
+ const knownStates = new Set([
79
+ "active",
80
+ "activating",
81
+ "deactivating",
82
+ "failed",
83
+ "inactive",
84
+ "maintenance",
85
+ "reloading",
86
+ "unknown",
87
+ ]);
88
+ if (!result?.error && knownStates.has(serviceStatus)) {
89
+ state.checked = true;
90
+ state.loaded = serviceStatus === "active";
91
+ } else {
92
+ state.error = probeError("systemctl", "systemd user status", result);
93
+ }
94
+ }
95
+
96
+ return state;
97
+ }
98
+
99
+ export function relayStartRemediation(service) {
100
+ return service?.checked && service.registered
101
+ ? "Run: browser-relay status, then browser-relay logs"
102
+ : "Start the relay in a terminal with: browser-relay";
103
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * DOM-to-text extraction function.
3
+ * This JS string is sent to the browser via Runtime.evaluate and returns
4
+ * a text representation of the visible page suitable for an LLM.
5
+ */
6
+ export const SNAPSHOT_JS = `
7
+ (function() {
8
+ const INTERACTIVE_TAGS = new Set([
9
+ 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'DETAILS', 'SUMMARY',
10
+ ]);
11
+ const SKIP_TAGS = new Set([
12
+ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG', 'PATH', 'TEMPLATE',
13
+ 'IFRAME', 'OBJECT', 'EMBED',
14
+ ]);
15
+ const BLOCK_TAGS = new Set([
16
+ 'DIV', 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
17
+ 'UL', 'OL', 'LI', 'TABLE', 'TR', 'SECTION', 'ARTICLE',
18
+ 'NAV', 'HEADER', 'FOOTER', 'MAIN', 'ASIDE', 'FORM',
19
+ 'BLOCKQUOTE', 'PRE', 'HR', 'BR', 'DL', 'DT', 'DD',
20
+ 'FIGURE', 'FIGCAPTION', 'ADDRESS',
21
+ ]);
22
+
23
+ function isVisible(el) {
24
+ if (!el.offsetParent && el.tagName !== 'BODY' && el.tagName !== 'HTML') {
25
+ var style = getComputedStyle(el);
26
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
27
+ if (style.display !== 'contents' && style.position !== 'fixed' && style.position !== 'sticky') return false;
28
+ }
29
+ if (el.getAttribute('aria-hidden') === 'true') return false;
30
+ if (el.hasAttribute('hidden')) return false;
31
+ return true;
32
+ }
33
+
34
+ function describeElement(el) {
35
+ var tag = el.tagName;
36
+ if (tag === 'A') {
37
+ var href = el.getAttribute('href') || '';
38
+ var text = (el.innerText || el.getAttribute('aria-label') || '').trim();
39
+ return '[link' + (text ? ' "' + text + '"' : '') + (href ? ' href="' + href + '"' : '') + ']';
40
+ }
41
+ if (tag === 'BUTTON' || (el.getAttribute('role') === 'button')) {
42
+ var text = (el.getAttribute('aria-label') || el.innerText || '').trim();
43
+ return '[button' + (text ? ' "' + text + '"' : '') + ']';
44
+ }
45
+ if (tag === 'INPUT') {
46
+ var type = el.getAttribute('type') || 'text';
47
+ var name = el.getAttribute('name') || '';
48
+ var placeholder = el.getAttribute('placeholder') || '';
49
+ var label = el.getAttribute('aria-label') || '';
50
+ var val = type === 'password' ? '[redacted]' : (el.value || '').slice(0, 80);
51
+ var desc = '[input type=' + type;
52
+ if (name) desc += ' name="' + name + '"';
53
+ if (placeholder) desc += ' placeholder="' + placeholder + '"';
54
+ if (label) desc += ' label="' + label + '"';
55
+ if (val) desc += ' value="' + val + '"';
56
+ desc += ']';
57
+ return desc;
58
+ }
59
+ if (tag === 'TEXTAREA') {
60
+ var name = el.getAttribute('name') || '';
61
+ var placeholder = el.getAttribute('placeholder') || '';
62
+ var val = (el.value || '').slice(0, 200);
63
+ var desc = '[textarea';
64
+ if (name) desc += ' name="' + name + '"';
65
+ if (placeholder) desc += ' placeholder="' + placeholder + '"';
66
+ if (val) desc += ' value="' + val + '"';
67
+ desc += ']';
68
+ return desc;
69
+ }
70
+ if (tag === 'SELECT') {
71
+ var name = el.getAttribute('name') || '';
72
+ var selected = el.options && el.selectedIndex >= 0 ? (el.options[el.selectedIndex].text || '').slice(0, 60) : '';
73
+ var desc = '[select';
74
+ if (name) desc += ' name="' + name + '"';
75
+ if (selected) desc += ' selected="' + selected + '"';
76
+ desc += ']';
77
+ return desc;
78
+ }
79
+ if (tag === 'IMG') {
80
+ var alt = el.getAttribute('alt') || '';
81
+ return alt ? '[image "' + alt.slice(0, 100) + '"]' : '[image]';
82
+ }
83
+ return null;
84
+ }
85
+
86
+ var lines = [];
87
+ var maxLen = (typeof __maxLength !== 'undefined') ? __maxLength : 100000;
88
+ var totalLen = 0;
89
+ var truncated = false;
90
+
91
+ function addLine(text, indent) {
92
+ if (truncated) return;
93
+ var line = ' '.repeat(indent) + text;
94
+ totalLen += line.length + 1;
95
+ if (totalLen > maxLen) { truncated = true; return; }
96
+ lines.push(line);
97
+ }
98
+
99
+ function walk(node, depth) {
100
+ if (truncated) return;
101
+ if (node.nodeType === 3) {
102
+ var text = node.textContent.trim();
103
+ if (text) addLine(text, depth);
104
+ return;
105
+ }
106
+ if (node.nodeType !== 1) return;
107
+ var el = node;
108
+ var tag = el.tagName;
109
+ if (SKIP_TAGS.has(tag)) return;
110
+ if (!isVisible(el)) return;
111
+
112
+ var headingMatch = tag.match(/^H([1-6])$/);
113
+ if (headingMatch) {
114
+ var text = (el.innerText || '').trim();
115
+ if (text) addLine('#'.repeat(parseInt(headingMatch[1])) + ' ' + text, depth);
116
+ return;
117
+ }
118
+
119
+ var desc = describeElement(el);
120
+ if (desc) {
121
+ addLine(desc, depth);
122
+ if (tag === 'A' || tag === 'BUTTON') return;
123
+ }
124
+
125
+ var isBlock = BLOCK_TAGS.has(tag);
126
+ var childDepth = desc ? depth + 1 : (isBlock ? depth : depth);
127
+
128
+ if (tag === 'LI') {
129
+ var text = '';
130
+ for (var i = 0; i < el.childNodes.length; i++) {
131
+ var child = el.childNodes[i];
132
+ if (child.nodeType === 3) text += child.textContent;
133
+ }
134
+ text = text.trim();
135
+ if (text) addLine('- ' + text, depth);
136
+ for (var i = 0; i < el.children.length; i++) {
137
+ walk(el.children[i], depth + 1);
138
+ }
139
+ return;
140
+ }
141
+
142
+ if (tag === 'TR') {
143
+ var cells = [];
144
+ for (var i = 0; i < el.children.length; i++) {
145
+ cells.push((el.children[i].innerText || '').trim());
146
+ }
147
+ if (cells.some(function(c) { return c; })) {
148
+ addLine('| ' + cells.join(' | ') + ' |', depth);
149
+ }
150
+ return;
151
+ }
152
+
153
+ for (var i = 0; i < el.childNodes.length; i++) {
154
+ walk(el.childNodes[i], childDepth);
155
+ }
156
+ }
157
+
158
+ walk(document.body, 0);
159
+ return JSON.stringify({ snapshot: lines.join('\\n'), truncated: truncated });
160
+ })()
161
+ `;
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, unlinkSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { homedir, platform } from "node:os";
5
+ import { execSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { uninstallWindowsTask } from "./windows-service.js";
8
+
9
+ function log(msg) {
10
+ console.log(`[browser-relay] ${msg}`);
11
+ }
12
+
13
+ const LAUNCHD_LABEL = "org.browser-relay.service";
14
+
15
+ export function uninstallService(options = {}) {
16
+ const explicit = options.explicit === true;
17
+ const strict = options.strict === true;
18
+ const isGlobal = process.env.npm_config_global === "true";
19
+ if (!explicit && !isGlobal) {
20
+ log("Local uninstall — leaving any global background service unchanged.");
21
+ return { removed: false, skipped: true };
22
+ }
23
+
24
+ const sys = platform();
25
+ try {
26
+ if (sys === "darwin") {
27
+ const plistDst = join(homedir(), `Library/LaunchAgents/${LAUNCHD_LABEL}.plist`);
28
+ if (existsSync(plistDst)) {
29
+ try { execSync(`launchctl bootout gui/${process.getuid()} "${plistDst}" 2>/dev/null`); } catch {}
30
+ unlinkSync(plistDst);
31
+ log("Removed launchd service.");
32
+ }
33
+ } else if (sys === "linux") {
34
+ try {
35
+ execSync("systemctl --user stop browser-relay 2>/dev/null");
36
+ execSync("systemctl --user disable browser-relay 2>/dev/null");
37
+ } catch {}
38
+ const serviceDst = join(homedir(), ".config/systemd/user/browser-relay.service");
39
+ if (existsSync(serviceDst)) {
40
+ unlinkSync(serviceDst);
41
+ execSync("systemctl --user daemon-reload 2>/dev/null");
42
+ log("Removed systemd service.");
43
+ }
44
+ } else if (sys === "win32") {
45
+ const result = uninstallWindowsTask();
46
+ log(result.removedTask ? "Removed Windows Task Scheduler service." : "Windows service was not registered.");
47
+ log(`Logs were preserved at ${result.paths.logs}`);
48
+ } else if (strict) {
49
+ throw new Error(`Background service is not supported on ${sys}`);
50
+ }
51
+ } catch (error) {
52
+ if (strict) throw error;
53
+ log(`postuninstall warning: ${error instanceof Error ? error.message : String(error)}`);
54
+ }
55
+ return { removed: true, skipped: false };
56
+ }
57
+
58
+ const isDirect = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
59
+ if (isDirect) {
60
+ uninstallService();
61
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import { closeSync, mkdirSync, openSync, writeSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ function option(name) {
7
+ const index = process.argv.indexOf(name);
8
+ return index === -1 ? null : process.argv[index + 1] || null;
9
+ }
10
+
11
+ function redirect(stream, file) {
12
+ mkdirSync(dirname(file), { recursive: true });
13
+ const fd = openSync(file, "a");
14
+ const original = stream.write.bind(stream);
15
+ stream.write = (chunk, encoding, callback) => {
16
+ let done = callback;
17
+ let charset = encoding;
18
+ if (typeof encoding === "function") {
19
+ done = encoding;
20
+ charset = undefined;
21
+ }
22
+ try {
23
+ if (typeof chunk === "string") writeSync(fd, chunk, null, typeof charset === "string" ? charset : "utf8");
24
+ else writeSync(fd, chunk);
25
+ if (typeof done === "function") queueMicrotask(done);
26
+ return true;
27
+ } catch (error) {
28
+ return original(chunk, encoding, callback) && !error;
29
+ }
30
+ };
31
+ process.on("exit", () => {
32
+ try { closeSync(fd); } catch {}
33
+ });
34
+ }
35
+
36
+ const entry = option("--entry");
37
+ const stdoutLog = option("--stdout-log");
38
+ const stderrLog = option("--stderr-log");
39
+
40
+ if (!entry || !stdoutLog || !stderrLog) {
41
+ console.error("Usage: windows-service-entry --entry <file> --stdout-log <file> --stderr-log <file>");
42
+ process.exit(2);
43
+ }
44
+
45
+ redirect(process.stdout, stdoutLog);
46
+ redirect(process.stderr, stderrLog);
47
+ // Match the launchd/systemd service definitions: the managed local service is
48
+ // always loopback-only on the standard port. Remote control remains outbound.
49
+ process.env.BROWSER_RELAY_HOST = "127.0.0.1";
50
+ process.env.BROWSER_RELAY_PORT = "18795";
51
+ process.argv = [process.execPath, entry];
52
+
53
+ try {
54
+ await import(pathToFileURL(entry).href);
55
+ } catch (error) {
56
+ console.error(error?.stack || error?.message || String(error));
57
+ process.exitCode = 1;
58
+ }