replica-bridge 0.3.6 → 0.3.8
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/package.json +1 -1
- package/src/bridge/install.js +61 -6
- package/src/bridge/main.js +11 -2
- package/src/bridge/service.js +15 -1
package/package.json
CHANGED
package/src/bridge/install.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Keeping the bridge running after its terminal closes, the way Codex keeps its app-server: a per-user background
|
|
2
2
|
// service that starts at login and restarts when it fails. macOS: a launchd agent. Linux: a systemd user service.
|
|
3
|
-
// Windows: a Task Scheduler task at logon that runs a hidden script, which restarts the
|
|
3
|
+
// Windows: a Task Scheduler task at logon (and checked every minute) that runs a hidden script, which restarts the
|
|
4
|
+
// bridge when it exits.
|
|
4
5
|
// The service runs a copy installed under ~/.config/replica-bridge/app, not the npx cache, which npm may clear.
|
|
5
6
|
import fs from "node:fs";
|
|
6
7
|
import os from "node:os";
|
|
@@ -24,10 +25,15 @@ export const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.resolve(i
|
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
27
32
|
|
|
28
33
|
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
|
|
36
|
+
|
|
31
37
|
|
|
32
38
|
|
|
33
39
|
|
|
@@ -39,6 +45,7 @@ export const serviceLabel = () => process.env.REPLICA_BRIDGE_LABEL ?? "replica-b
|
|
|
39
45
|
export const logPath = (home = bridgeHome()) => path.join(home, "bridge.log");
|
|
40
46
|
const infoPath = () => path.join(bridgeHome(), "service.json");
|
|
41
47
|
const pidPath = () => path.join(bridgeHome(), "bridge.pid");
|
|
48
|
+
const pausedPath = () => path.join(bridgeHome(), "service-paused");
|
|
42
49
|
|
|
43
50
|
const xml = (text ) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
44
51
|
/** systemd unit-file quoting: specifiers (%) doubled, backslashes and quotes escaped. */
|
|
@@ -126,6 +133,7 @@ WantedBy=default.target
|
|
|
126
133
|
}
|
|
127
134
|
case "win32": {
|
|
128
135
|
const script = path.join(spec.bridgeHome, "run-bridge.vbs");
|
|
136
|
+
const task = path.join(spec.bridgeHome, "replica-bridge-task.xml");
|
|
129
137
|
// wscript shows no window; the loop brings the bridge back ten seconds after it fails. Exit code 0 means another
|
|
130
138
|
// bridge took this computer over (or it was told to stop), so the loop ends.
|
|
131
139
|
const content = [
|
|
@@ -139,11 +147,42 @@ WantedBy=default.target
|
|
|
139
147
|
`Loop`,
|
|
140
148
|
"",
|
|
141
149
|
].join("\r\n");
|
|
150
|
+
// `schtasks /SC ONLOGON` left Task Scheduler's defaults, which stopped the bridge on a laptop that went on battery
|
|
151
|
+
// (and never started it on one), stopped it after three days, and started it again only at the next login: the
|
|
152
|
+
// computer went offline for the phones until someone logged in. The task XML turns those off, retries a failed
|
|
153
|
+
// start, and checks every minute that the task runs (IgnoreNew: never a second copy while one runs). The logon
|
|
154
|
+
// trigger is this account's alone; one for every user needs an elevated terminal.
|
|
155
|
+
const user = spec.account ? `<UserId>${xml(spec.account)}</UserId>` : "";
|
|
156
|
+
// Without a zone Task Scheduler reads it as local time; a UTC one would delay the first check by the offset.
|
|
157
|
+
const now = spec.now ?? new Date();
|
|
158
|
+
const start = new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 19);
|
|
159
|
+
const taskXML = `<?xml version="1.0" encoding="UTF-16"?>
|
|
160
|
+
<!-- Elements in the order Task Scheduler exports them. -->
|
|
161
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
162
|
+
<RegistrationInfo><Description>replica-bridge: ChatReplica phones use Codex on this computer. Made by "replica-bridge install".</Description></RegistrationInfo>
|
|
163
|
+
<Triggers>
|
|
164
|
+
<LogonTrigger><Enabled>true</Enabled>${user}</LogonTrigger>
|
|
165
|
+
<TimeTrigger><Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>${start}</StartBoundary><Enabled>true</Enabled></TimeTrigger>
|
|
166
|
+
</Triggers>
|
|
167
|
+
<Principals><Principal id="Author">${user}<LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
168
|
+
<Settings>
|
|
169
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
170
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
171
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
172
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
173
|
+
<IdleSettings><StopOnIdleEnd>false</StopOnIdleEnd><RestartOnIdle>false</RestartOnIdle></IdleSettings>
|
|
174
|
+
<Enabled>true</Enabled>
|
|
175
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
176
|
+
<RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>
|
|
177
|
+
</Settings>
|
|
178
|
+
<Actions Context="Author"><Exec><Command>wscript.exe</Command><Arguments>${xml(`"${script}"`)}</Arguments></Exec></Actions>
|
|
179
|
+
</Task>
|
|
180
|
+
`;
|
|
142
181
|
return {
|
|
143
|
-
files: [{ path: script, content }],
|
|
182
|
+
files: [{ path: script, content }, { path: task, content: taskXML, encoding: "utf16le" }],
|
|
144
183
|
install: [
|
|
145
184
|
{ command: "schtasks", args: ["/End", "/TN", spec.label], optional: true },
|
|
146
|
-
{ command: "schtasks", args: ["/Create", "/F", "/TN", spec.label, "/
|
|
185
|
+
{ command: "schtasks", args: ["/Create", "/F", "/TN", spec.label, "/XML", task] },
|
|
147
186
|
{ command: "schtasks", args: ["/Run", "/TN", spec.label] },
|
|
148
187
|
],
|
|
149
188
|
uninstall: [
|
|
@@ -242,7 +281,8 @@ export function readServiceInfo() {
|
|
|
242
281
|
|
|
243
282
|
function spec(program ) {
|
|
244
283
|
const { uid, username } = os.userInfo();
|
|
245
|
-
|
|
284
|
+
const account = process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${username}` : username;
|
|
285
|
+
return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid, user: username, account };
|
|
246
286
|
}
|
|
247
287
|
|
|
248
288
|
/** Installs (or replaces) the background service and starts it. `startArgs` are the options `start` keeps. */
|
|
@@ -254,8 +294,9 @@ export function installService(startArgs , say )
|
|
|
254
294
|
fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
|
|
255
295
|
for (const file of plan.files) {
|
|
256
296
|
fs.mkdirSync(path.dirname(file.path), { recursive: true });
|
|
257
|
-
fs.writeFileSync(file.path, file.content);
|
|
297
|
+
fs.writeFileSync(file.path, file.encoding === "utf16le" ? Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(file.content, "utf16le")]) : file.content);
|
|
258
298
|
}
|
|
299
|
+
fs.rmSync(pausedPath(), { force: true });
|
|
259
300
|
run(plan.install);
|
|
260
301
|
const info = { platform: process.platform, label: serviceLabel(), version: PACKAGE_VERSION, program, installedAt: new Date().toISOString() };
|
|
261
302
|
fs.writeFileSync(infoPath(), JSON.stringify(info, null, 2) + "\n");
|
|
@@ -306,6 +347,20 @@ export function runningPid() {
|
|
|
306
347
|
try { process.kill(pid, 0); return pid; } catch (error) { return (error ).code === "EPERM" ? pid : null; }
|
|
307
348
|
}
|
|
308
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Another bridge took this computer over at the relay. The Windows task's every-minute check would start this one
|
|
352
|
+
* again, and the two would take turns kicking each other off; this marks the service stopped until the next install
|
|
353
|
+
* or the next boot, the way launchd and systemd leave a service that exited 0.
|
|
354
|
+
*/
|
|
355
|
+
export function pauseService() {
|
|
356
|
+
fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
|
|
357
|
+
fs.writeFileSync(pausedPath(), new Date().toISOString() + "\n");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function servicePaused() {
|
|
361
|
+
try { return !writtenBeforeBoot(fs.statSync(pausedPath()).mtimeMs); } catch { return false; }
|
|
362
|
+
}
|
|
363
|
+
|
|
309
364
|
/** The service manager stops its own process; on Windows the loop's bridge is left, so it goes by pid. */
|
|
310
365
|
function stopRunning() {
|
|
311
366
|
const pid = runningPid();
|
package/src/bridge/main.js
CHANGED
|
@@ -18,7 +18,7 @@ import { parseArgs, format } from "node:util";
|
|
|
18
18
|
import { relayRequest } from "../http.js";
|
|
19
19
|
import { spawnCommandSync } from "./command.js";
|
|
20
20
|
import { defaultRelayURL, enroll, ensureEnrolled, ensureRelay } from "./enroll.js";
|
|
21
|
-
import { PACKAGE_VERSION, clearPid, installService, logPath, readServiceInfo, runningPid, servicePlan, uninstallService, writePid } from "./install.js";
|
|
21
|
+
import { PACKAGE_VERSION, clearPid, installService, logPath, pauseService, readServiceInfo, runningPid, servicePaused, servicePlan, uninstallService, writePid } from "./install.js";
|
|
22
22
|
import { pair, publicRelayURL } from "./pair.js";
|
|
23
23
|
import { BridgeService } from "./service.js";
|
|
24
24
|
import { loadState, saveState } from "./state.js";
|
|
@@ -73,7 +73,12 @@ function startArgs() {
|
|
|
73
73
|
|
|
74
74
|
function startBridge() {
|
|
75
75
|
// Exit 0: the background services restart the bridge only when it fails, so the newer bridge keeps the computer.
|
|
76
|
-
|
|
76
|
+
// The Windows task also checks every minute, so there the service is marked paused first.
|
|
77
|
+
const onReplaced = () => {
|
|
78
|
+
if (process.env.REPLICA_BRIDGE_SERVICE === "1" && process.platform === "win32") pauseService();
|
|
79
|
+
process.exit(0);
|
|
80
|
+
};
|
|
81
|
+
const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced });
|
|
77
82
|
service.start();
|
|
78
83
|
return service;
|
|
79
84
|
}
|
|
@@ -210,6 +215,10 @@ async function main() {
|
|
|
210
215
|
case "start": {
|
|
211
216
|
await ensureEnrolled(values.relay, console.log, values.name);
|
|
212
217
|
const asService = process.env.REPLICA_BRIDGE_SERVICE === "1";
|
|
218
|
+
if (asService && servicePaused()) {
|
|
219
|
+
console.log(`another bridge took this computer over; the background service stays stopped until "npx replica-bridge install" or the next restart`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
213
222
|
if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()})${asService ? "" : '; "npx replica-bridge uninstall" stops the background one'}`);
|
|
214
223
|
const bridge = startBridge();
|
|
215
224
|
if (asService) { writePid(); process.on("exit", clearPid); }
|
package/src/bridge/service.js
CHANGED
|
@@ -37,6 +37,8 @@ import { annotateWriterError } from "./writer.js";
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
|
|
41
|
+
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
|
|
@@ -100,7 +102,18 @@ export class BridgeService {
|
|
|
100
102
|
const state = loadState();
|
|
101
103
|
const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
|
|
102
104
|
this.relay = ws;
|
|
103
|
-
|
|
105
|
+
// A laptop that sleeps or changes networks keeps a socket the relay dropped long ago, and nothing here would
|
|
106
|
+
// notice until the bridge sent something: the computer stayed offline for the phones. A missed pong ends it.
|
|
107
|
+
let answered = true;
|
|
108
|
+
const ping = setInterval(() => {
|
|
109
|
+
if (!answered) { this.log("relay did not answer a ping; reconnecting"); return ws.terminate(); }
|
|
110
|
+
answered = false;
|
|
111
|
+
// Still connecting counts as a missed pong too, so a handshake that never finishes ends at the next tick.
|
|
112
|
+
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
|
113
|
+
}, (this.options.relayPingSeconds ?? 30) * 1000);
|
|
114
|
+
ws.on("pong", () => { answered = true; });
|
|
115
|
+
ws.on("ping", () => { answered = true; });
|
|
116
|
+
ws.on("open", () => { answered = true; this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); });
|
|
104
117
|
ws.on("message", (data ) => {
|
|
105
118
|
let envelope ;
|
|
106
119
|
try { envelope = JSON.parse(data.toString()); } catch { return; }
|
|
@@ -116,6 +129,7 @@ export class BridgeService {
|
|
|
116
129
|
});
|
|
117
130
|
ws.on("error", (error) => this.log(`relay connection error: ${error.message}`));
|
|
118
131
|
ws.on("close", (code) => {
|
|
132
|
+
clearInterval(ping);
|
|
119
133
|
if (ws !== this.relay) return; // a socket this bridge already replaced itself
|
|
120
134
|
for (const conn of this.conns.values()) this.detach(conn);
|
|
121
135
|
this.conns.clear();
|