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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 bridge when it exits.
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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, "/SC", "ONLOGON", "/RL", "LIMITED", "/TR", `wscript.exe "${script}"`] },
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
- return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid, user: username };
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();
@@ -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
- const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced: () => process.exit(0) });
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); }
@@ -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
- ws.on("open", () => { this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); });
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();