relay-companion 0.1.577 → 0.1.579
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.
|
@@ -151,7 +151,11 @@ async function handoffApplication({ activationEnabled = false, envelope, version
|
|
|
151
151
|
const reconciled = await reconcile({ homeDir, acquireLock: sharedLock, health });
|
|
152
152
|
if (!reconciled.ok) throw new Error("Runtime recovery must finish before resuming native handoff");
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
// A background app update never runs the destructive repair: it would end
|
|
155
|
+
// every process in the recovery folder, including this worker, and leave
|
|
156
|
+
// Relay stopped with no recovery task (Shane's laptop, 2026-10-02). An
|
|
157
|
+
// unhealthy Relay makes this step fail, and restorePrevious repairs it.
|
|
158
|
+
const result = await activate({ homeDir, allowRecovery: false, resourcesDir: location.resourcesDir, applicationRoot: location.root,
|
|
155
159
|
executable: location.executable, activationEnabled: true, acquireLock: sharedLock, drain: async () => () => {} });
|
|
156
160
|
return { ok: result.ok === true };
|
|
157
161
|
},
|
|
@@ -33,7 +33,10 @@ async function pauseRecoveryChecks({ homeDir, timeoutMs = 3 * 60_000, pollMs = 2
|
|
|
33
33
|
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
34
34
|
const intent = require("./recovery-intent.cjs");
|
|
35
35
|
const wasStopped = intent.stopped(homeDir);
|
|
36
|
-
|
|
36
|
+
// Recorded as held by this setup, so Relay's own setup and pill launch during
|
|
37
|
+
// activation leave it paused; a dead holder no longer counts.
|
|
38
|
+
const hold = () => intent.setStopped(true, homeDir, { heldBy: "setup", pid: process.pid });
|
|
39
|
+
hold();
|
|
37
40
|
const runLock = path.join(homeDir, ".relay", "recovery", "run.lock");
|
|
38
41
|
for (const deadline = Date.now() + timeoutMs; ;) {
|
|
39
42
|
try { bootstrap.acquireCanonicalLock(runLock).release(); break; }
|
|
@@ -44,7 +47,7 @@ async function pauseRecoveryChecks({ homeDir, timeoutMs = 3 * 60_000, pollMs = 2
|
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
49
|
return {
|
|
47
|
-
repause:
|
|
50
|
+
repause: hold,
|
|
48
51
|
resume: succeeded => intent.setStopped(succeeded ? false : wasStopped, homeDir),
|
|
49
52
|
};
|
|
50
53
|
}
|
|
@@ -107,6 +110,7 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
|
|
|
107
110
|
homeDir = os.homedir(), verify = verifyBundle, extract = extractBundle, activate = bootstrap.activateRuntime,
|
|
108
111
|
acquireLock = bootstrap.acquireCanonicalLock, health = require("./runtime-health.cjs").exactRuntimeHealth,
|
|
109
112
|
recover = recovery.recoverWithCandidate, pauseRecovery = pauseRecoveryChecks,
|
|
113
|
+
hidePill = require("./previous-pill.cjs").hidePreviousPill,
|
|
110
114
|
drain = require("./update-activity.cjs").drainCalls, download = bootstrap.downloadVerifiedArtifact,
|
|
111
115
|
onProgress = () => {}, signal } = {}) {
|
|
112
116
|
onProgress({ phase: "verifying", canCancel: false });
|
|
@@ -128,7 +132,7 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
|
|
|
128
132
|
const journalPath = path.join(homeDir, ".relay", "application-migration.json");
|
|
129
133
|
let releaseDrain;
|
|
130
134
|
let downloadDirectory;
|
|
131
|
-
let pausedRecovery, succeeded = false;
|
|
135
|
+
let pausedRecovery, previousPill, succeeded = false;
|
|
132
136
|
try {
|
|
133
137
|
const recordedCurrent = recovery.marker(path.join(runtimeRoot, "current.json"));
|
|
134
138
|
const removed = recovery.marker(path.join(homeDir, ".relay", "application-uninstall.json"));
|
|
@@ -162,6 +166,10 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
|
|
|
162
166
|
const newerRuntime = Boolean(current && /^\d+\.\d+\.\d+$/.test(current.version || "")
|
|
163
167
|
&& versionCompare(bundle.receipt.version, current.version) < 0);
|
|
164
168
|
if (newerRuntime && recovery.marker(ownerPath)?.appId !== APPLICATION_ID) throw new Error("An older installer cannot downgrade Relay");
|
|
169
|
+
// The same runtime version is the same signed runtime: an installed app whose
|
|
170
|
+
// Relay is already current only changes owner, without touching services.
|
|
171
|
+
const currentRuntime = Boolean(current && /^\d+\.\d+\.\d+$/.test(current.version || "")
|
|
172
|
+
&& versionCompare(bundle.receipt.version, current.version) === 0 && recovery.marker(ownerPath)?.appId === APPLICATION_ID);
|
|
165
173
|
if (current && /^\d+\.\d+\.\d+$/.test(current.version || "") && versionCompare(current.version, "0.1.500") < 0) needsRecovery = true;
|
|
166
174
|
// Never execute an old CLI as a prerequisite for rescuing it.
|
|
167
175
|
if (current && !needsRecovery && !(await health(current, { platform: process.platform })).ok) needsRecovery = true;
|
|
@@ -182,9 +190,10 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
|
|
|
182
190
|
onProgress({ phase: "ready", canCancel: false });
|
|
183
191
|
return { ok: true, alreadyInstalled: true, owner: installedOwner, runtime: current, updateOwner: "canonical-runtime" };
|
|
184
192
|
}
|
|
185
|
-
if (newerRuntime) {
|
|
193
|
+
if (newerRuntime || (currentRuntime && !needsRecovery)) {
|
|
186
194
|
// Repairing would install this older runtime over the newer one; the
|
|
187
|
-
// runtime's own recovery owns that. A healthy newer runtime
|
|
195
|
+
// runtime's own recovery owns that. A healthy current or newer runtime
|
|
196
|
+
// keeps running; an unhealthy same-version one is repaired below.
|
|
188
197
|
if (needsRecovery) throw new Error("An older installer cannot downgrade Relay");
|
|
189
198
|
const owner = { schema: 1, appId: APPLICATION_ID, platform: process.platform,
|
|
190
199
|
root, executable, receipt: receiptPath, version: bundle.receipt.version,
|
|
@@ -199,6 +208,8 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
|
|
|
199
208
|
return { ok: true, applicationOnly: true, owner, runtime: current, updateOwner: "canonical-runtime" };
|
|
200
209
|
}
|
|
201
210
|
pausedRecovery = await pauseRecovery({ homeDir });
|
|
211
|
+
// Only the old pill goes now; its background service runs until the switch.
|
|
212
|
+
if (current?.active === true) previousPill = hidePill({ current });
|
|
202
213
|
if (bundle.receipt.runtimeDelivery === "download") {
|
|
203
214
|
signal?.throwIfAborted();
|
|
204
215
|
// Stage beside releases: tar requires archive and destination on one volume.
|
|
@@ -275,6 +286,7 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
|
|
|
275
286
|
try { releaseDrain?.(); }
|
|
276
287
|
finally {
|
|
277
288
|
try { pausedRecovery?.resume(succeeded); } catch { /* recovery reads a missing intent as running */ }
|
|
289
|
+
if (!succeeded) try { previousPill?.restore(); } catch { /* recovery restores a missing pill */ }
|
|
278
290
|
lock.release();
|
|
279
291
|
if (downloadDirectory) fs.rmSync(downloadDirectory, { recursive: true, force: true });
|
|
280
292
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The installer's setup leaves the existing Relay running while it downloads,
|
|
3
|
+
// so a failed download still leaves a working Relay. Its pill, though, stayed
|
|
4
|
+
// on screen beside the setup window looking active (Shane, 2026-10-02). Hide
|
|
5
|
+
// only the old pill when setup starts; the background service keeps running
|
|
6
|
+
// until activation switches over and starts the new pill. If setup fails, the
|
|
7
|
+
// previous Relay opens its pill again.
|
|
8
|
+
const { spawnSync, spawn } = require("node:child_process");
|
|
9
|
+
|
|
10
|
+
const MAC_PILL_LABEL = "work.relay.companion.pill";
|
|
11
|
+
// Every stock pill runs relay-companion's overlay/main.cjs under Electron.
|
|
12
|
+
const WINDOWS_STOP_PILL_PS = [
|
|
13
|
+
"$p=Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {",
|
|
14
|
+
" $_.CommandLine -and ($_.CommandLine -match '[\\\\/]relay-companion[\\\\/]') -and ($_.CommandLine -match '[\\\\/]overlay[\\\\/]main')",
|
|
15
|
+
"}; foreach($x in $p){ try { Invoke-CimMethod -InputObject $x -MethodName Terminate -ErrorAction Stop | Out-Null } catch {} }",
|
|
16
|
+
].join(" ");
|
|
17
|
+
|
|
18
|
+
function hidePreviousPill({ current, platform = process.platform, uid = process.getuid?.(), run = spawnSync, launch = spawn } = {}) {
|
|
19
|
+
let hidden = false;
|
|
20
|
+
try {
|
|
21
|
+
if (platform === "win32") {
|
|
22
|
+
const result = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_STOP_PILL_PS], { windowsHide: true, timeout: 60_000 });
|
|
23
|
+
hidden = !result?.error && result?.status === 0;
|
|
24
|
+
} else if (platform === "darwin") {
|
|
25
|
+
// launchd keeps the pill alive, so stop its job rather than its process.
|
|
26
|
+
const result = run("/bin/launchctl", ["bootout", `gui/${uid}/${MAC_PILL_LABEL}`], { timeout: 30_000 });
|
|
27
|
+
hidden = !result?.error && result?.status === 0;
|
|
28
|
+
} else {
|
|
29
|
+
const result = run("pkill", ["-u", String(uid), "-f", "relay-companion/overlay/main"], { timeout: 30_000 });
|
|
30
|
+
hidden = !result?.error && result?.status === 0;
|
|
31
|
+
}
|
|
32
|
+
} catch { hidden = false; }
|
|
33
|
+
return {
|
|
34
|
+
hidden,
|
|
35
|
+
// Only an active previous runtime can reopen its own pill.
|
|
36
|
+
restore() {
|
|
37
|
+
if (!hidden || current?.active !== true || typeof current.node !== "string" || typeof current.bin !== "string") return false;
|
|
38
|
+
try {
|
|
39
|
+
const child = launch(current.node, [current.bin, "pill"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
40
|
+
child.unref?.();
|
|
41
|
+
return true;
|
|
42
|
+
} catch { return false; }
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { hidePreviousPill, WINDOWS_STOP_PILL_PS, MAC_PILL_LABEL };
|
|
@@ -271,7 +271,11 @@ function windowsStopRecoveryScript(root) {
|
|
|
271
271
|
`$root=${quoted};`,
|
|
272
272
|
"$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value;",
|
|
273
273
|
"$inside={ param($text) $text -and $text.IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0 };",
|
|
274
|
-
|
|
274
|
+
// Never end the process that asked for this sweep or anything above it: a
|
|
275
|
+
// Relay worker running on the recovery folder's Node would end itself.
|
|
276
|
+
"$all=@(Get-CimInstance Win32_Process -ErrorAction Stop); $up=@{}; $cur=($all | Where-Object ProcessId -eq $PID).ParentProcessId;",
|
|
277
|
+
"for($i=0;$i -lt 32 -and $cur -and -not $up.ContainsKey([int]$cur);$i++){ $up[[int]$cur]=$true; $cur=($all | Where-Object ProcessId -eq $cur).ParentProcessId };",
|
|
278
|
+
"$find={ @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.ProcessId -ne $PID -and -not $up.ContainsKey([int]$_.ProcessId) -and ((& $inside $_.ExecutablePath) -or (& $inside $_.CommandLine)) } | Where-Object { try { (Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid -ErrorAction Stop).Sid -eq $sid } catch { $false } }) };",
|
|
275
279
|
"$p=@(&$find); for($i=0;$i -lt 10 -and $p.Count;$i++){ foreach($x in $p){ try { Invoke-CimMethod -InputObject $x -MethodName Terminate -ErrorAction Stop | Out-Null } catch {} }; Start-Sleep -Milliseconds 200; $p=@(&$find) };",
|
|
276
280
|
"if($p.Count){ Write-Error ('Relay recovery processes did not stop: '+(($p | ForEach-Object ProcessId) -join ', ')); exit 1 }",
|
|
277
281
|
].join(" ");
|
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
|
|
3
3
|
const file = homeDir => path.join(homeDir, ".relay", "recovery", "intent.json");
|
|
4
|
+
function read(homeDir) {
|
|
5
|
+
try { return { value: JSON.parse(fs.readFileSync(file(homeDir), "utf8")) }; }
|
|
6
|
+
catch (error) { return { missing: error.code === "ENOENT" }; }
|
|
7
|
+
}
|
|
8
|
+
function alive(pid) {
|
|
9
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
|
|
10
|
+
}
|
|
11
|
+
// The installer's setup pauses recovery while it replaces Relay and records
|
|
12
|
+
// itself as the holder. A hold whose setup process is gone no longer counts,
|
|
13
|
+
// so a killed setup can never leave recovery paused for good.
|
|
14
|
+
function heldBySetup(value) {
|
|
15
|
+
return value?.stopped === true && value.heldBy === "setup" && Number.isInteger(value.pid) && alive(value.pid);
|
|
16
|
+
}
|
|
4
17
|
function stopped(homeDir = os.homedir()) {
|
|
5
|
-
|
|
6
|
-
|
|
18
|
+
const { value, missing } = read(homeDir);
|
|
19
|
+
if (!value) return !missing;
|
|
20
|
+
if (value.stopped === true && value.heldBy === "setup") return heldBySetup(value);
|
|
21
|
+
return value.stopped === true;
|
|
22
|
+
}
|
|
23
|
+
function setStopped(value, homeDir = os.homedir(), { heldBy, pid } = {}) {
|
|
24
|
+
require("./recovery-launcher.cjs").write(file(homeDir), { schema: 1, stopped: value, at: Date.now(), ...(value && heldBy ? { heldBy, pid } : {}) });
|
|
7
25
|
}
|
|
8
|
-
|
|
9
|
-
|
|
26
|
+
// Starting Relay (setup, the pill) resumes recovery, except while an
|
|
27
|
+
// installer's setup holds the pause; that setup lifts it when it ends.
|
|
28
|
+
function resumeUnlessHeld(homeDir = os.homedir()) {
|
|
29
|
+
if (heldBySetup(read(homeDir).value)) return false;
|
|
30
|
+
setStopped(false, homeDir);
|
|
31
|
+
return true;
|
|
10
32
|
}
|
|
11
|
-
module.exports = { stopped, setStopped };
|
|
33
|
+
module.exports = { stopped, setStopped, resumeUnlessHeld };
|
package/package.json
CHANGED