replica-bridge 0.3.6 → 0.3.9
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 +13 -2
- package/src/bridge/service.js +18 -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
|
}
|
|
@@ -108,6 +113,8 @@ async function wantBackground() {
|
|
|
108
113
|
|
|
109
114
|
function install() {
|
|
110
115
|
const plan = installService(startArgs(), console.log);
|
|
116
|
+
// The service writes its pid once it has started; `status` right after the install said "not running" before that.
|
|
117
|
+
for (let waited = 0; !runningPid() && waited < 10_000; waited += 200) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
111
118
|
console.log(`Running in the background: ${plan.where}. It starts at login and comes back if it stops, so this terminal can be closed.`);
|
|
112
119
|
console.log(`Log: ${logPath()}. Remove it with "npx replica-bridge uninstall".`);
|
|
113
120
|
// The install asked for lingering (the service starts at boot, not at login); some systems only let root grant it.
|
|
@@ -210,6 +217,10 @@ async function main() {
|
|
|
210
217
|
case "start": {
|
|
211
218
|
await ensureEnrolled(values.relay, console.log, values.name);
|
|
212
219
|
const asService = process.env.REPLICA_BRIDGE_SERVICE === "1";
|
|
220
|
+
if (asService && servicePaused()) {
|
|
221
|
+
console.log(`another bridge took this computer over; the background service stays stopped until "npx replica-bridge install" or the next restart`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
213
224
|
if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()})${asService ? "" : '; "npx replica-bridge uninstall" stops the background one'}`);
|
|
214
225
|
const bridge = startBridge();
|
|
215
226
|
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();
|
|
@@ -236,6 +250,9 @@ export class BridgeService {
|
|
|
236
250
|
const resumed = !!stream && stream.deviceId === conn.deviceId;
|
|
237
251
|
if (!resumed) {
|
|
238
252
|
stream = await this.openStream(conn.deviceId);
|
|
253
|
+
// The phone may have left while the app-server came up. It never heard this stream's id, so it cannot come back
|
|
254
|
+
// for it, and detach found no stream to start the timer on: left alone, its proxy stays as long as the bridge.
|
|
255
|
+
if (this.stopped || this.conns.get(conn.id) !== conn) return this.endStream(stream, "the phone left before its stream opened");
|
|
239
256
|
}
|
|
240
257
|
const s = stream ;
|
|
241
258
|
if (s.conn && s.conn !== conn) this.closeConn(s.conn, "resumed elsewhere");
|