@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.
- package/LICENSE +22 -0
- package/README.md +445 -0
- package/docs/README.zh-CN.md +421 -0
- package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
- package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
- package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
- package/docs/benchmarks/browser-readiness-cost.json +106 -0
- package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
- package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
- package/docs/benchmarks/browser-runtime-balanced.json +412 -0
- package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
- package/docs/benchmarks/browser-runtime.json +254 -0
- package/docs/benchmarks/browser-use-parity.json +54 -0
- package/docs/benchmarks/codex-extension-audit.json +131 -0
- package/docs/benchmarks/codex-native-protocol.md +69 -0
- package/docs/benchmarks/codex-native-replay.js +116 -0
- package/docs/benchmarks/codex-native-status.json +81 -0
- package/docs/benchmarks/codex-native.json +1003 -0
- package/docs/benchmarks/extension-sessions.png +0 -0
- package/docs/benchmarks/extension-tasks.png +0 -0
- package/docs/benchmarks/iframe-routing-regression.json +37 -0
- package/docs/browser-use-comparison.md +331 -0
- package/docs/browser-use-gap-audit.md +141 -0
- package/docs/browser-use-parity.md +105 -0
- package/docs/demo/intranet.html +103 -0
- package/docs/releases/v1.5.0.md +59 -0
- package/docs/releases/v1.5.1.md +16 -0
- package/docs/releases/v1.5.2.md +42 -0
- package/docs/releases/v1.5.3.md +33 -0
- package/docs/releases/v1.5.4.md +42 -0
- package/docs/releases/v1.6.0.md +16 -0
- package/docs/remote-control-hub.md +523 -0
- package/extension/activity.js +328 -0
- package/extension/automation.js +1839 -0
- package/extension/background.js +1854 -0
- package/extension/i18n.js +149 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon32.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +47 -0
- package/extension/observations.js +109 -0
- package/extension/options.html +289 -0
- package/extension/options.js +269 -0
- package/extension/popup.html +74 -0
- package/extension/popup.js +105 -0
- package/extension/protocol.js +45 -0
- package/extension/remote-auth.js +18 -0
- package/extension/sessions.js +134 -0
- package/extension/snapshot.js +161 -0
- package/extension/task-groups.js +108 -0
- package/extension/tasks.js +186 -0
- package/extension/wait.js +89 -0
- package/hub/README.md +42 -0
- package/hub/package-lock.json +1544 -0
- package/hub/package.json +13 -0
- package/hub/src/rpc.js +41 -0
- package/hub/src/worker.js +322 -0
- package/hub/wrangler.example.toml +19 -0
- package/package.json +83 -0
- package/server/cdp-bridge.js +200 -0
- package/server/cli.js +1798 -0
- package/server/hub-server.js +258 -0
- package/server/install.js +250 -0
- package/server/mcp-server.js +504 -0
- package/server/npx-runner.js +96 -0
- package/server/relay-server.js +1356 -0
- package/server/remote-protocol.js +76 -0
- package/server/runtime-worker.js +166 -0
- package/server/script-runtime.js +189 -0
- package/server/sdk.js +307 -0
- package/server/service-state.js +103 -0
- package/server/snapshot.js +161 -0
- package/server/uninstall.js +61 -0
- package/server/windows-service-entry.js +58 -0
- package/server/windows-service.js +360 -0
- package/skills/browser-relay/SKILL.md +192 -0
- package/skills/browser-relay/references/legacy-api.md +163 -0
- package/skills/browser-relay/references/runtime.md +240 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { webcrypto } from "node:crypto";
|
|
4
|
+
import { sendRpc } from "../hub/src/rpc.js";
|
|
5
|
+
if (!globalThis.crypto) globalThis.crypto = webcrypto;
|
|
6
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
7
|
+
import {
|
|
8
|
+
bearerToken,
|
|
9
|
+
deriveRouteId,
|
|
10
|
+
errorPayload,
|
|
11
|
+
jsonHeaders,
|
|
12
|
+
REMOTE_RPC_TIMEOUT_MS,
|
|
13
|
+
safeEqualString,
|
|
14
|
+
sha256Hex,
|
|
15
|
+
} from "./remote-protocol.js";
|
|
16
|
+
|
|
17
|
+
const HUB_HOST = process.env.BROWSER_RELAY_HUB_HOST || "127.0.0.1";
|
|
18
|
+
const HUB_PORT = Number.parseInt(process.env.BROWSER_RELAY_HUB_PORT || "18796", 10);
|
|
19
|
+
const MAX_BODY_SIZE = 128 * 1024;
|
|
20
|
+
const DEVICE_AUTH_TIMEOUT_MS = Number.parseInt(process.env.BROWSER_RELAY_DEVICE_AUTH_TIMEOUT_MS || "5000", 10);
|
|
21
|
+
|
|
22
|
+
const devices = new Map(); // routeId -> { secretHash, ws, hello, connectedAt, lastSeen, pending }
|
|
23
|
+
|
|
24
|
+
function log(event, data = {}) {
|
|
25
|
+
console.log(JSON.stringify({ timestamp: new Date().toISOString(), event, ...data }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function jsonResponse(res, status, body, extraHeaders = {}) {
|
|
29
|
+
const payload = JSON.stringify(body);
|
|
30
|
+
res.writeHead(status, { ...jsonHeaders(extraHeaders), "Content-Length": Buffer.byteLength(payload) });
|
|
31
|
+
res.end(payload);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readBody(req) {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
for await (const chunk of req) {
|
|
38
|
+
size += chunk.length;
|
|
39
|
+
if (size > MAX_BODY_SIZE) throw Object.assign(new Error("Request body too large"), { status: 413, code: "request_body_too_large" });
|
|
40
|
+
chunks.push(chunk);
|
|
41
|
+
}
|
|
42
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
43
|
+
if (!raw.trim()) return {};
|
|
44
|
+
try { return JSON.parse(raw); }
|
|
45
|
+
catch { throw Object.assign(new Error("Invalid JSON in request body"), { status: 400, code: "invalid_json" }); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getDevice(routeId) {
|
|
49
|
+
let device = devices.get(routeId);
|
|
50
|
+
if (!device) {
|
|
51
|
+
device = { secretHash: null, ws: null, hello: null, connectedAt: null, lastSeen: null, pending: new Map() };
|
|
52
|
+
devices.set(routeId, device);
|
|
53
|
+
}
|
|
54
|
+
return device;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function authorizeDevice(routeId, secret, { claim = false } = {}) {
|
|
58
|
+
if (!routeId || !secret) return { ok: false, status: 401, code: "invalid_remote_device", message: "Invalid or missing remote device credentials" };
|
|
59
|
+
if (!safeEqualString(routeId, deriveRouteId(secret))) {
|
|
60
|
+
return { ok: false, status: 401, code: "invalid_remote_device", message: "Invalid or revoked remote device id" };
|
|
61
|
+
}
|
|
62
|
+
const device = getDevice(routeId);
|
|
63
|
+
const secretHash = sha256Hex(secret);
|
|
64
|
+
if (!device.secretHash) {
|
|
65
|
+
if (claim) {
|
|
66
|
+
device.secretHash = secretHash;
|
|
67
|
+
return { ok: true, device, claimed: true };
|
|
68
|
+
}
|
|
69
|
+
return { ok: false, status: 409, code: "remote_device_offline", message: "Remote Browser Relay device is offline" };
|
|
70
|
+
}
|
|
71
|
+
if (!safeEqualString(device.secretHash, secretHash)) {
|
|
72
|
+
return { ok: false, status: 401, code: "invalid_remote_device", message: "Invalid or revoked remote device id" };
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, device, claimed: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function rejectUpgrade(socket, status, message) {
|
|
78
|
+
const body = JSON.stringify(errorPayload(status === 401 ? "invalid_remote_device" : "upgrade_failed", message, { status }));
|
|
79
|
+
socket.write(`HTTP/1.1 ${status} ${message}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
80
|
+
socket.destroy();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clearPending(device, payload) {
|
|
84
|
+
for (const [id, pending] of device.pending) {
|
|
85
|
+
clearTimeout(pending.timer);
|
|
86
|
+
pending.reject(payload);
|
|
87
|
+
device.pending.delete(id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handleDeviceMessage(routeId, device, ws, raw) {
|
|
92
|
+
let msg;
|
|
93
|
+
try { msg = JSON.parse(String(raw)); }
|
|
94
|
+
catch { return; }
|
|
95
|
+
if (device.ws !== ws) return;
|
|
96
|
+
device.lastSeen = new Date().toISOString();
|
|
97
|
+
|
|
98
|
+
if (msg.type === "device.hello") {
|
|
99
|
+
device.hello = msg;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (msg.type === "rpc.response" && msg.id) {
|
|
104
|
+
const pending = device.pending.get(msg.id);
|
|
105
|
+
if (!pending) return;
|
|
106
|
+
clearTimeout(pending.timer);
|
|
107
|
+
device.pending.delete(msg.id);
|
|
108
|
+
pending.resolve(msg);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sendRpcToDevice(device, request, signal) {
|
|
113
|
+
if (!device.ws || device.ws.readyState !== WebSocket.OPEN) {
|
|
114
|
+
throw errorPayload("remote_device_offline", "Remote Browser Relay device is offline", { status: 409, retryable: true });
|
|
115
|
+
}
|
|
116
|
+
return sendRpc(device.ws,device.pending,request,{signal,timeoutMs:REMOTE_RPC_TIMEOUT_MS});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const server = createServer(async (req, res) => {
|
|
120
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
121
|
+
try {
|
|
122
|
+
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/v1/health")) {
|
|
123
|
+
return jsonResponse(res, 200, { ok: true, service: "browser-relay-hub", devices: devices.size });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (req.method === "GET" && url.pathname.startsWith("/v1/status/")) {
|
|
127
|
+
const routeId = decodeURIComponent(url.pathname.slice("/v1/status/".length));
|
|
128
|
+
const secret = bearerToken(req.headers);
|
|
129
|
+
const auth = authorizeDevice(routeId, secret);
|
|
130
|
+
if (!auth.ok) return jsonResponse(res, auth.status, errorPayload(auth.code, auth.message, { status: auth.status }));
|
|
131
|
+
const device = auth.device;
|
|
132
|
+
return jsonResponse(res, 200, {
|
|
133
|
+
ok: true,
|
|
134
|
+
routeId,
|
|
135
|
+
connected: !!device.ws && device.ws.readyState === WebSocket.OPEN,
|
|
136
|
+
connectedAt: device.connectedAt,
|
|
137
|
+
lastSeen: device.lastSeen,
|
|
138
|
+
hello: device.hello,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (req.method === "POST" && url.pathname === "/v1/rpc") {
|
|
143
|
+
const body = await readBody(req);
|
|
144
|
+
const routeId = String(body.routeId || "");
|
|
145
|
+
const secret = bearerToken(req.headers);
|
|
146
|
+
const auth = authorizeDevice(routeId, secret);
|
|
147
|
+
if (!auth.ok) return jsonResponse(res, auth.status, errorPayload(auth.code, auth.message, { status: auth.status }));
|
|
148
|
+
if (!body.method || !body.path) {
|
|
149
|
+
return jsonResponse(res, 400, errorPayload("invalid_request", "method and path are required", { status: 400 }));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const controller=new AbortController();
|
|
153
|
+
const abort=()=>{if(!res.writableFinished)controller.abort();};
|
|
154
|
+
res.once('close',abort);
|
|
155
|
+
if(req.aborted || res.destroyed)controller.abort();
|
|
156
|
+
try {
|
|
157
|
+
const response = await sendRpcToDevice(auth.device, body,controller.signal);
|
|
158
|
+
const status = Number(response.status) || 200;
|
|
159
|
+
const responseBody = response.body ?? null;
|
|
160
|
+
const headers = response.headers && typeof response.headers === "object" ? response.headers : {};
|
|
161
|
+
if (typeof responseBody === "string") {
|
|
162
|
+
res.writeHead(status, { "Content-Type": headers["content-type"] || headers["Content-Type"] || "text/plain" });
|
|
163
|
+
res.end(responseBody);
|
|
164
|
+
} else {
|
|
165
|
+
jsonResponse(res, status, responseBody, headers);
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const payload = err && typeof err === "object" && "code" in err
|
|
169
|
+
? err
|
|
170
|
+
: errorPayload("remote_rpc_failed", err instanceof Error ? err.message : String(err), { status: 502, retryable: true });
|
|
171
|
+
if(!res.destroyed)jsonResponse(res, payload.status || 502, payload);
|
|
172
|
+
} finally {res.removeListener('close',abort);}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
jsonResponse(res, 404, errorPayload("endpoint_not_found", `Unknown hub endpoint: ${url.pathname}`, { status: 404 }));
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const status = err.status || 500;
|
|
179
|
+
const code = err.code || "internal_error";
|
|
180
|
+
jsonResponse(res, status, errorPayload(code, err instanceof Error ? err.message : String(err), { status }));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
185
|
+
|
|
186
|
+
server.on("upgrade", (req, socket, head) => {
|
|
187
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
188
|
+
if (url.pathname !== "/v1/device/connect") return rejectUpgrade(socket, 404, "Not Found");
|
|
189
|
+
const routeId = url.searchParams.get("routeId") || "";
|
|
190
|
+
if (!routeId) return rejectUpgrade(socket, 401, "Unauthorized");
|
|
191
|
+
const legacySecret = url.searchParams.get("token") || bearerToken(req.headers);
|
|
192
|
+
|
|
193
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
194
|
+
wss.emit("connection", ws, req, routeId, legacySecret);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
wss.on("connection", (ws, req, routeId, legacySecret) => {
|
|
199
|
+
let authenticated = false;
|
|
200
|
+
let device = null;
|
|
201
|
+
const authTimer = setTimeout(() => {
|
|
202
|
+
if (!authenticated) ws.close(4008, "authentication timeout");
|
|
203
|
+
}, DEVICE_AUTH_TIMEOUT_MS);
|
|
204
|
+
|
|
205
|
+
const authenticate = (secret, { legacy = false } = {}) => {
|
|
206
|
+
if (authenticated) return true;
|
|
207
|
+
const auth = authorizeDevice(routeId, secret, { claim: true });
|
|
208
|
+
if (!auth.ok) {
|
|
209
|
+
clearTimeout(authTimer);
|
|
210
|
+
ws.close(4003, "authentication failed");
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
device = auth.device;
|
|
214
|
+
if (device.ws && device.ws.readyState === WebSocket.OPEN && device.ws !== ws) {
|
|
215
|
+
try { device.ws.close(4001, "superseded"); } catch {}
|
|
216
|
+
}
|
|
217
|
+
device.ws = ws;
|
|
218
|
+
device.connectedAt = new Date().toISOString();
|
|
219
|
+
device.lastSeen = device.connectedAt;
|
|
220
|
+
authenticated = true;
|
|
221
|
+
clearTimeout(authTimer);
|
|
222
|
+
ws.send(JSON.stringify({ type: "device.authenticated" }));
|
|
223
|
+
log("device.connect", { routeId, claimed: auth.claimed, legacy, remote: req.socket.remoteAddress || "unknown" });
|
|
224
|
+
return true;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (legacySecret) authenticate(legacySecret, { legacy: true });
|
|
228
|
+
|
|
229
|
+
ws.on("message", (raw) => {
|
|
230
|
+
let msg;
|
|
231
|
+
try { msg = JSON.parse(String(raw)); } catch { return; }
|
|
232
|
+
if (!authenticated) {
|
|
233
|
+
if (msg.type === "device.auth" && typeof msg.secret === "string") authenticate(msg.secret);
|
|
234
|
+
else {
|
|
235
|
+
clearTimeout(authTimer);
|
|
236
|
+
ws.close(4003, "authenticate first");
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (msg.type === "device.auth") return;
|
|
241
|
+
handleDeviceMessage(routeId, device, ws, raw);
|
|
242
|
+
});
|
|
243
|
+
ws.on("close", (code) => {
|
|
244
|
+
clearTimeout(authTimer);
|
|
245
|
+
if (!authenticated || device?.ws !== ws) return;
|
|
246
|
+
device.ws = null;
|
|
247
|
+
device.lastSeen = new Date().toISOString();
|
|
248
|
+
clearPending(device, errorPayload("remote_device_offline", "Remote Browser Relay device disconnected", { status: 409, retryable: true }));
|
|
249
|
+
log("device.disconnect", { routeId, code });
|
|
250
|
+
});
|
|
251
|
+
ws.on("error", () => {});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
server.listen(HUB_PORT, HUB_HOST, () => {
|
|
255
|
+
console.log(`Browser Relay Hub listening on http://${HUB_HOST}:${HUB_PORT}`);
|
|
256
|
+
console.log(`Device WSS: ws://${HUB_HOST}:${HUB_PORT}/v1/device/connect?routeId=...`);
|
|
257
|
+
console.log(`RPC: http://${HUB_HOST}:${HUB_PORT}/v1/rpc`);
|
|
258
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { homedir, platform } from "node:os";
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
import { installWindowsTask } from "./windows-service.js";
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const RELAY_DIR = dirname(__dirname);
|
|
11
|
+
const EXTENSION_DIR = join(RELAY_DIR, "extension");
|
|
12
|
+
const CLI_PATH = join(__dirname, "cli.js");
|
|
13
|
+
const WINDOWS_SERVICE_ENTRY_PATH = join(__dirname, "windows-service-entry.js");
|
|
14
|
+
const NODE_PATH = process.execPath;
|
|
15
|
+
const LAUNCHD_LABEL = "org.browser-relay.service";
|
|
16
|
+
|
|
17
|
+
function log(msg) {
|
|
18
|
+
console.log(`[browser-relay] ${msg}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function xmlEscape(s) {
|
|
22
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function installDarwin() {
|
|
26
|
+
const plistDst = join(homedir(), `Library/LaunchAgents/${LAUNCHD_LABEL}.plist`);
|
|
27
|
+
|
|
28
|
+
// Call node + cli.js directly by absolute path — avoids bin shim path
|
|
29
|
+
// discovery (npm bin -g was removed in npm 9+) and launchd's minimal PATH
|
|
30
|
+
// (shebang #!/usr/bin/env node can't find node outside /usr/bin).
|
|
31
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
32
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
33
|
+
<plist version="1.0">
|
|
34
|
+
<dict>
|
|
35
|
+
<key>Label</key>
|
|
36
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
37
|
+
<key>ProgramArguments</key>
|
|
38
|
+
<array>
|
|
39
|
+
<string>${xmlEscape(NODE_PATH)}</string>
|
|
40
|
+
<string>${xmlEscape(CLI_PATH)}</string>
|
|
41
|
+
</array>
|
|
42
|
+
<key>EnvironmentVariables</key>
|
|
43
|
+
<dict>
|
|
44
|
+
<key>BROWSER_RELAY_HOST</key>
|
|
45
|
+
<string>127.0.0.1</string>
|
|
46
|
+
<key>BROWSER_RELAY_PORT</key>
|
|
47
|
+
<string>18795</string>
|
|
48
|
+
<key>PATH</key>
|
|
49
|
+
<string>${xmlEscape(dirname(NODE_PATH))}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
50
|
+
</dict>
|
|
51
|
+
<key>RunAtLoad</key>
|
|
52
|
+
<true/>
|
|
53
|
+
<key>KeepAlive</key>
|
|
54
|
+
<dict>
|
|
55
|
+
<key>SuccessfulExit</key>
|
|
56
|
+
<false/>
|
|
57
|
+
</dict>
|
|
58
|
+
<key>ThrottleInterval</key>
|
|
59
|
+
<integer>10</integer>
|
|
60
|
+
<key>StandardOutPath</key>
|
|
61
|
+
<string>/tmp/browser-relay.log</string>
|
|
62
|
+
<key>StandardErrorPath</key>
|
|
63
|
+
<string>/tmp/browser-relay.error.log</string>
|
|
64
|
+
</dict>
|
|
65
|
+
</plist>`;
|
|
66
|
+
|
|
67
|
+
mkdirSync(dirname(plistDst), { recursive: true });
|
|
68
|
+
writeFileSync(plistDst, plistContent);
|
|
69
|
+
|
|
70
|
+
// Use bootstrap/bootout — `launchctl load/unload` is the legacy API and
|
|
71
|
+
// often silently fails or returns EBADEXEC on macOS 13+. bootout first for
|
|
72
|
+
// idempotency; ignore "service not found" errors.
|
|
73
|
+
const domain = `gui/${process.getuid()}`;
|
|
74
|
+
try { execSync(`launchctl bootout ${domain} "${plistDst}" 2>/dev/null`); } catch {}
|
|
75
|
+
try {
|
|
76
|
+
execSync(`launchctl bootstrap ${domain} "${plistDst}"`);
|
|
77
|
+
log(`Registered with launchd (node=${NODE_PATH}).`);
|
|
78
|
+
return true;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
log(`launchctl bootstrap failed: ${e.message}. Check /tmp/browser-relay.error.log`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function installLinux() {
|
|
86
|
+
const serviceDst = join(homedir(), ".config/systemd/user/browser-relay.service");
|
|
87
|
+
|
|
88
|
+
// Same approach as macOS: invoke node + cli.js by absolute path so we don't
|
|
89
|
+
// depend on PATH discovery or the npm bin shim.
|
|
90
|
+
const serviceContent = `[Unit]
|
|
91
|
+
Description=Browser Relay Server
|
|
92
|
+
After=network.target
|
|
93
|
+
|
|
94
|
+
[Service]
|
|
95
|
+
ExecStart=${NODE_PATH} ${CLI_PATH}
|
|
96
|
+
Environment=BROWSER_RELAY_HOST=127.0.0.1
|
|
97
|
+
Environment=BROWSER_RELAY_PORT=18795
|
|
98
|
+
Restart=always
|
|
99
|
+
RestartSec=5
|
|
100
|
+
|
|
101
|
+
[Install]
|
|
102
|
+
WantedBy=default.target
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
mkdirSync(dirname(serviceDst), { recursive: true });
|
|
106
|
+
writeFileSync(serviceDst, serviceContent);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
execSync("systemctl --user daemon-reload");
|
|
110
|
+
execSync("systemctl --user enable browser-relay");
|
|
111
|
+
execSync("systemctl --user start browser-relay");
|
|
112
|
+
log(`Registered with systemd (node=${NODE_PATH}).`);
|
|
113
|
+
return true;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
const msg = (e instanceof Error ? e.message : String(e)).split("\n")[0];
|
|
116
|
+
log(`systemd registration skipped (${msg}). Run manually: ${NODE_PATH} ${CLI_PATH}`);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function installWin32() {
|
|
122
|
+
const result = installWindowsTask({
|
|
123
|
+
nodePath: NODE_PATH,
|
|
124
|
+
serviceEntryPath: WINDOWS_SERVICE_ENTRY_PATH,
|
|
125
|
+
cliPath: CLI_PATH,
|
|
126
|
+
});
|
|
127
|
+
log(`Registered with Windows Task Scheduler for the current user (node=${NODE_PATH}).`);
|
|
128
|
+
log(`Logs: ${result.paths.stdoutLog}`);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Agents keep their own copy of the skill (installed via npx skills add).
|
|
133
|
+
// On upgrade, find copies that no longer match the shipped skill and remind
|
|
134
|
+
// the user to re-run the install command — the copies belong to the agent
|
|
135
|
+
// tools, so we never overwrite them ourselves.
|
|
136
|
+
const AGENT_SKILL_ROOTS = [
|
|
137
|
+
".agents/skills",
|
|
138
|
+
".claude/skills",
|
|
139
|
+
".codex/skills",
|
|
140
|
+
".cursor/skills",
|
|
141
|
+
".windsurf/skills",
|
|
142
|
+
".gemini/skills",
|
|
143
|
+
".copilot/skills",
|
|
144
|
+
".config/opencode/skills",
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
function checkInstalledSkills() {
|
|
148
|
+
let shipped;
|
|
149
|
+
try { shipped = readFileSync(join(RELAY_DIR, "skills/browser-relay/SKILL.md"), "utf-8"); } catch { return; }
|
|
150
|
+
const stale = [];
|
|
151
|
+
for (const root of AGENT_SKILL_ROOTS) {
|
|
152
|
+
const dir = join(homedir(), root, "browser-relay");
|
|
153
|
+
try {
|
|
154
|
+
if (readFileSync(join(dir, "SKILL.md"), "utf-8") !== shipped) stale.push(dir);
|
|
155
|
+
} catch { /* skill not installed for this agent */ }
|
|
156
|
+
}
|
|
157
|
+
if (!stale.length) return;
|
|
158
|
+
log("");
|
|
159
|
+
log("⚠ Installed agent skill is out of date:");
|
|
160
|
+
for (const dir of stale) log(` ${dir}`);
|
|
161
|
+
log(" Run 'browser-relay skill help', then reinstall for codex, claude-code, or universal.");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function waitForRelay(expectedVersion) {
|
|
165
|
+
for (let i = 0; i < 20; i++) {
|
|
166
|
+
try {
|
|
167
|
+
const response = await fetch("http://127.0.0.1:18795/api/debug", {
|
|
168
|
+
signal: AbortSignal.timeout(1000),
|
|
169
|
+
});
|
|
170
|
+
if (response.ok) {
|
|
171
|
+
const data = await response.json();
|
|
172
|
+
if (data && data.version === expectedVersion && typeof data.connected === "boolean") return true;
|
|
173
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
if (i < 19) await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function installService(options = {}) {
|
|
181
|
+
const explicit = options.explicit === true;
|
|
182
|
+
const strict = options.strict === true;
|
|
183
|
+
// Only register a background service on global installs. `npm i` (local) is
|
|
184
|
+
// typically a developer adding the package as a dependency — they don't want
|
|
185
|
+
// a daemon hijacking their machine.
|
|
186
|
+
const isGlobal = process.env.npm_config_global === "true";
|
|
187
|
+
if (!explicit && !isGlobal) {
|
|
188
|
+
log("Local install — skipping background service registration.");
|
|
189
|
+
log(`Install globally to enable auto-start: npm install -g @xnng/browser-relay`);
|
|
190
|
+
log(`Or run manually: ${NODE_PATH} ${CLI_PATH}`);
|
|
191
|
+
log("");
|
|
192
|
+
log(`📦 Chrome Extension: ${EXTENSION_DIR}`);
|
|
193
|
+
log(`📖 Agent Skill: ${join(RELAY_DIR, "skills/browser-relay/SKILL.md")}`);
|
|
194
|
+
return { installed: false, skipped: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
log("Setting up Browser Relay...");
|
|
198
|
+
|
|
199
|
+
const sys = platform();
|
|
200
|
+
|
|
201
|
+
let registered = false;
|
|
202
|
+
if (sys === "darwin") {
|
|
203
|
+
registered = installDarwin();
|
|
204
|
+
} else if (sys === "linux") {
|
|
205
|
+
registered = installLinux();
|
|
206
|
+
} else if (sys === "win32") {
|
|
207
|
+
registered = installWin32();
|
|
208
|
+
} else {
|
|
209
|
+
log(`Unsupported platform: ${sys}. Run 'browser-relay' manually.`);
|
|
210
|
+
if (strict) throw new Error(`Background service is not supported on ${sys}`);
|
|
211
|
+
return { installed: false, skipped: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!registered) {
|
|
215
|
+
if (strict) throw new Error("Background service registration failed");
|
|
216
|
+
} else {
|
|
217
|
+
const pkg = JSON.parse(readFileSync(join(RELAY_DIR, "package.json"), "utf-8"));
|
|
218
|
+
const healthy = await waitForRelay(pkg.version);
|
|
219
|
+
if (healthy) {
|
|
220
|
+
log("✅ Relay is running on http://127.0.0.1:18795");
|
|
221
|
+
} else {
|
|
222
|
+
const message = "Relay did not become healthy with the installed version. Run: browser-relay status";
|
|
223
|
+
log(`⚠️ ${message}`);
|
|
224
|
+
if (strict) throw new Error(message);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
checkInstalledSkills();
|
|
229
|
+
|
|
230
|
+
log("");
|
|
231
|
+
log("📦 Chrome Extension:");
|
|
232
|
+
log(` ${EXTENSION_DIR}`);
|
|
233
|
+
log(" Load at: chrome://extensions -> Developer mode -> Load unpacked");
|
|
234
|
+
log(" Already loaded? It reloads itself on its next relay reconnect (~30s).");
|
|
235
|
+
log("");
|
|
236
|
+
log("📖 Agent Skill:");
|
|
237
|
+
log(` ${join(RELAY_DIR, "skills/browser-relay/SKILL.md")}`);
|
|
238
|
+
return { installed: registered, skipped: false };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Postinstall must never fail npm install — wrap everything.
|
|
242
|
+
const isDirect = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
243
|
+
if (isDirect) {
|
|
244
|
+
try {
|
|
245
|
+
await installService();
|
|
246
|
+
} catch (e) {
|
|
247
|
+
log(`postinstall warning: ${e instanceof Error ? e.message : String(e)}`);
|
|
248
|
+
log(`Run manually: ${NODE_PATH} ${CLI_PATH}`);
|
|
249
|
+
}
|
|
250
|
+
}
|