relay-companion 0.1.69 → 0.1.71
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/overlay/inbox.html +19 -0
- package/overlay/main.cjs +170 -46
- package/overlay/preload.cjs +3 -0
- package/overlay/space-presence.cjs +32 -14
- package/package.json +1 -1
- package/src/auto-update.js +391 -41
- package/src/install.js +23 -3
package/overlay/inbox.html
CHANGED
|
@@ -1432,6 +1432,7 @@
|
|
|
1432
1432
|
}
|
|
1433
1433
|
function openRelayFromUI(id, source = "relay", mode = "open") {
|
|
1434
1434
|
if (!id || openingIds.has(id)) return;
|
|
1435
|
+
clearRowNote(id); // a previous open's failure text must not outlive the retry
|
|
1435
1436
|
openingIds.set(id, performance.now());
|
|
1436
1437
|
for (const el of openingTargets(id)) el.classList.add("opening");
|
|
1437
1438
|
setTimeout(() => stopOpening(id), 6000); // safety: never spin forever if openDone is missed
|
|
@@ -1634,6 +1635,14 @@
|
|
|
1634
1635
|
if (el) el.textContent = message || "";
|
|
1635
1636
|
}
|
|
1636
1637
|
function cssId(id) { return String(id).replace(/"/g, '\\"'); }
|
|
1638
|
+
// The .row-err slot carries both inline errors and the green injection note, and one
|
|
1639
|
+
// relay can be rendered into more than one list, so wipe every copy of the row's slot.
|
|
1640
|
+
function clearRowNote(id) {
|
|
1641
|
+
for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
|
|
1642
|
+
el.textContent = "";
|
|
1643
|
+
el.classList.remove("ok");
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1637
1646
|
|
|
1638
1647
|
async function runRowAction(id, btn, fn) {
|
|
1639
1648
|
if (!btn || btn.disabled) return;
|
|
@@ -2711,6 +2720,16 @@
|
|
|
2711
2720
|
}, 12000));
|
|
2712
2721
|
}
|
|
2713
2722
|
});
|
|
2723
|
+
// An open that failed leaves the relay UNREAD on purpose (it never reached the user),
|
|
2724
|
+
// so the row has to say what happened — otherwise the spinner just stops and the click
|
|
2725
|
+
// looks like it worked. Cleared by the next open of the same row.
|
|
2726
|
+
if (window.relay.onOpenError) window.relay.onOpenError((id, message) => {
|
|
2727
|
+
if (!message) return;
|
|
2728
|
+
for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
|
|
2729
|
+
el.classList.remove("ok");
|
|
2730
|
+
el.innerHTML = esc(message);
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2714
2733
|
window.relay.refresh().then((p) => {
|
|
2715
2734
|
onPayload(p);
|
|
2716
2735
|
console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
|
package/overlay/main.cjs
CHANGED
|
@@ -1375,6 +1375,95 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = f
|
|
|
1375
1375
|
}, 1000);
|
|
1376
1376
|
}
|
|
1377
1377
|
|
|
1378
|
+
// The CLI's `open` prints ONE pretty-printed JSON object on stdout and sends every
|
|
1379
|
+
// log line to stderr, so a plain parse should suffice — but a single stray stdout
|
|
1380
|
+
// line (a Node warning, a dep's console.log) used to turn a SUCCESSFUL open into a
|
|
1381
|
+
// browser fallback, so recover by slicing the outermost {...} instead of giving up.
|
|
1382
|
+
function parseOpenResult(out) {
|
|
1383
|
+
const text = String(out || "").trim();
|
|
1384
|
+
const read = (raw) => {
|
|
1385
|
+
const parsed = JSON.parse(raw);
|
|
1386
|
+
return {
|
|
1387
|
+
url: parsed.url || null,
|
|
1388
|
+
skipExternalOpen: Boolean(parsed.skipExternalOpen),
|
|
1389
|
+
freshlyForged: Boolean(parsed.claudeFreshlyForged),
|
|
1390
|
+
};
|
|
1391
|
+
};
|
|
1392
|
+
try {
|
|
1393
|
+
return read(text);
|
|
1394
|
+
} catch {}
|
|
1395
|
+
const start = text.indexOf("{");
|
|
1396
|
+
const end = text.lastIndexOf("}");
|
|
1397
|
+
if (start >= 0 && end > start) {
|
|
1398
|
+
try {
|
|
1399
|
+
return read(text.slice(start, end + 1));
|
|
1400
|
+
} catch {}
|
|
1401
|
+
}
|
|
1402
|
+
return { url: null, skipExternalOpen: false, freshlyForged: false };
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Both stdout and stderr in the failure log: a Windows field report where stdout
|
|
1406
|
+
// was empty is the whole reason the "exit" -> "close" fix below exists.
|
|
1407
|
+
function tailFor(stream) {
|
|
1408
|
+
return String(stream || "").trim().split("\n").slice(-2).join(" | ");
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Whether the `relay claude-hook` runtime is registered in ~/.claude/settings.json.
|
|
1412
|
+
// "Open in current chat" stages a file that ONLY that runtime consumes, so without
|
|
1413
|
+
// it the click is a silent no-op. Loaded lazily like the modules above (install.js
|
|
1414
|
+
// is ESM; this overlay is CommonJS).
|
|
1415
|
+
let claudeHooksModulePromise = null;
|
|
1416
|
+
function loadClaudeHooksModule() {
|
|
1417
|
+
if (!claudeHooksModulePromise) {
|
|
1418
|
+
const installUrl = pathToFileURL(path.join(__dirname, "..", "src", "install.js")).href;
|
|
1419
|
+
claudeHooksModulePromise = import(installUrl).catch((error) => {
|
|
1420
|
+
claudeHooksModulePromise = null;
|
|
1421
|
+
throw error;
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
return claudeHooksModulePromise;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// A missing or unparseable settings file counts as NOT installed: staging into a
|
|
1428
|
+
// machine with no hook runtime is worse than a fresh open, so fail closed.
|
|
1429
|
+
function claudeHooksInstalled(install) {
|
|
1430
|
+
let settings;
|
|
1431
|
+
try {
|
|
1432
|
+
settings = JSON.parse(fs.readFileSync(install.claudeSettingsPath(), "utf8")) || {};
|
|
1433
|
+
} catch {
|
|
1434
|
+
return false;
|
|
1435
|
+
}
|
|
1436
|
+
const hooks = settings.hooks;
|
|
1437
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
|
|
1438
|
+
for (const entries of Object.values(hooks)) {
|
|
1439
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
1440
|
+
for (const hook of entry && Array.isArray(entry.hooks) ? entry.hooks : []) {
|
|
1441
|
+
if (install.isRelayClaudeHookCommand(hook && hook.command)) return true;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return false;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Register the claude-hook runtime for FUTURE Claude sessions (idempotent; it preserves
|
|
1449
|
+
// every user hook). installClaudeHooks defaults its node to process.execPath, which under
|
|
1450
|
+
// Electron is the OVERLAY binary — a hook command Claude could never run, and one that
|
|
1451
|
+
// would make the presence check above pass forever. So resolve a real node first and skip
|
|
1452
|
+
// the repair entirely when the machine has none.
|
|
1453
|
+
function repairClaudeHooks(install) {
|
|
1454
|
+
execFile(process.platform === "win32" ? "where" : "/usr/bin/which", ["node"], (error, out) => {
|
|
1455
|
+
const node = String(out || "").split("\n")[0].trim();
|
|
1456
|
+
if (error || !node) {
|
|
1457
|
+
console.error("[overlay] cannot register the claude-hook runtime: no node found on PATH");
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
const result = install.installClaudeHooks(undefined, install.stableNodePath(node));
|
|
1461
|
+
if (!result || !result.ok) {
|
|
1462
|
+
console.error("[overlay] claude-hook registration failed:", (result && result.reason) || "unknown");
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1378
1467
|
// Click-to-open a Relay row. A relay materializes a REAL native agent session
|
|
1379
1468
|
// inside the already-running foregrounded host (Claude Desktop or Codex) via the
|
|
1380
1469
|
// companion CLI's `open` command, so it appears in that app's recents rail. The
|
|
@@ -1404,20 +1493,29 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
|
|
|
1404
1493
|
}
|
|
1405
1494
|
const row = rowById(materializationId);
|
|
1406
1495
|
if (win && !win.isDestroyed()) win.webContents.send("opening", packetId);
|
|
1407
|
-
|
|
1496
|
+
// Ack marks the row read AND drops it from the attention queue, so a relay that
|
|
1497
|
+
// never reached the user must NOT be acked — otherwise a failed click silently
|
|
1498
|
+
// retires the notification. The spinner stop, in contrast, owes every path.
|
|
1499
|
+
const finishOpened = () => {
|
|
1408
1500
|
if (!sent) ackPacket(packetId);
|
|
1409
1501
|
if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId); // stop the row spinner
|
|
1410
1502
|
};
|
|
1503
|
+
const finishFailed = (message) => {
|
|
1504
|
+
if (win && !win.isDestroyed()) {
|
|
1505
|
+
if (message) win.webContents.send("openError", packetId, message);
|
|
1506
|
+
win.webContents.send("openDone", packetId);
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1411
1509
|
if (!TASK_FEATURES_ALLOWED && (row?.taskId || isRelayTaskWebTarget(row?.actionUrl))) {
|
|
1412
1510
|
console.error("[overlay] refusing to open a Relay Task in messages-only mode:", packetId);
|
|
1413
|
-
return
|
|
1511
|
+
return finishFailed();
|
|
1414
1512
|
}
|
|
1415
|
-
if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") return
|
|
1513
|
+
if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") return finishOpened();
|
|
1416
1514
|
if (row && row.relayNotificationKind === "connector_reauth") {
|
|
1417
1515
|
shell
|
|
1418
1516
|
.openExternal(`${webBase()}/app/connectors`)
|
|
1419
1517
|
.catch((error) => console.error("[overlay] open failed:", error && error.message));
|
|
1420
|
-
return
|
|
1518
|
+
return finishOpened();
|
|
1421
1519
|
}
|
|
1422
1520
|
frontmostBundleId((bundle) => {
|
|
1423
1521
|
const host = resolveClickHost(bundle);
|
|
@@ -1446,16 +1544,11 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
|
|
|
1446
1544
|
let err = "";
|
|
1447
1545
|
child.stdout.on("data", (data) => (out += data));
|
|
1448
1546
|
child.stderr.on("data", (data) => (err += data));
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
const parsed = JSON.parse(out.trim());
|
|
1455
|
-
url = parsed.url || null;
|
|
1456
|
-
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
1457
|
-
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
1458
|
-
} catch {}
|
|
1547
|
+
// "close", never "exit": on Windows "exit" routinely fires BEFORE the stdout pipe
|
|
1548
|
+
// drains, so `out` was empty, the parse failed, and a perfectly successful open was
|
|
1549
|
+
// routed to the web fallback — the user got a browser instead of Claude.
|
|
1550
|
+
child.on("close", (code) => {
|
|
1551
|
+
const { url, skipExternalOpen, freshlyForged } = parseOpenResult(out);
|
|
1459
1552
|
if (url || skipExternalOpen) {
|
|
1460
1553
|
// Raise the host so the relay is visible, whichever open path ran (Claude deep
|
|
1461
1554
|
// link, Codex bridge, or a plain url). This is the fix for "opened in the right
|
|
@@ -1469,6 +1562,7 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
|
|
|
1469
1562
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
1470
1563
|
}
|
|
1471
1564
|
}
|
|
1565
|
+
finishOpened();
|
|
1472
1566
|
} else {
|
|
1473
1567
|
console.error(
|
|
1474
1568
|
"[overlay] open failed for",
|
|
@@ -1477,22 +1571,25 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
|
|
|
1477
1571
|
host,
|
|
1478
1572
|
"code",
|
|
1479
1573
|
code,
|
|
1574
|
+
"stdout:",
|
|
1575
|
+
tailFor(out),
|
|
1480
1576
|
"stderr:",
|
|
1481
|
-
err
|
|
1577
|
+
tailFor(err),
|
|
1482
1578
|
);
|
|
1483
|
-
// Last-resort web fallback so the click is never a dead end
|
|
1579
|
+
// Last-resort web fallback so the click is never a dead end — but the relay
|
|
1580
|
+
// did not open where it was meant to, so it stays unread and in the queue.
|
|
1484
1581
|
const target = row && row.taskId
|
|
1485
1582
|
? taskWebUrl(row.taskId)
|
|
1486
1583
|
: row && row.actionUrl
|
|
1487
1584
|
? absoluteUrl(row.actionUrl)
|
|
1488
1585
|
: webBase();
|
|
1489
1586
|
shell.openExternal(target).catch((e) => console.error("[overlay] web fallback failed:", e && e.message));
|
|
1587
|
+
finishFailed("Couldn't open in Claude — opened on the web instead.");
|
|
1490
1588
|
}
|
|
1491
|
-
finish();
|
|
1492
1589
|
});
|
|
1493
1590
|
child.on("error", (error) => {
|
|
1494
1591
|
console.error("[overlay] open spawn error:", error && error.message);
|
|
1495
|
-
|
|
1592
|
+
finishFailed("Couldn't open — Relay's open helper failed to start.");
|
|
1496
1593
|
});
|
|
1497
1594
|
});
|
|
1498
1595
|
}
|
|
@@ -1594,22 +1691,50 @@ async function openPacketInCurrent(packetId) {
|
|
|
1594
1691
|
}
|
|
1595
1692
|
}
|
|
1596
1693
|
if (!target) return fallbackFresh();
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1694
|
+
const stageNow = () => {
|
|
1695
|
+
try {
|
|
1696
|
+
claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
|
|
1697
|
+
relayId: packetId,
|
|
1698
|
+
senderName: row.senderName || "",
|
|
1699
|
+
title: row.title || row.displayTitle || "",
|
|
1700
|
+
...threadInfoFor(row),
|
|
1701
|
+
});
|
|
1702
|
+
} catch (error) {
|
|
1703
|
+
console.error("[overlay] claude injection staging failed:", error && error.message);
|
|
1704
|
+
return fallbackFresh();
|
|
1705
|
+
}
|
|
1706
|
+
// Raise Claude Desktop so the user watches the instruction land; a
|
|
1707
|
+
// terminal-only session stays where it is (its window is not ours to raise).
|
|
1708
|
+
if (target.source === "desktop") activateHost("claude", bundle);
|
|
1709
|
+
confirmInjected("claude");
|
|
1710
|
+
finish();
|
|
1711
|
+
};
|
|
1712
|
+
// NOTHING but the `relay claude-hook` runtime registered in ~/.claude/settings.json
|
|
1713
|
+
// ever reads the staged file. Where setup never wrote those hooks (Windows installs
|
|
1714
|
+
// where the `claude` CLI isn't on PATH — see src/install.js runSetupInstall) staging
|
|
1715
|
+
// succeeds and the click is a silent, acked no-op. Check before staging. Two-arg
|
|
1716
|
+
// then, not .catch: a throw out of stageNow must not re-enter the failure handler
|
|
1717
|
+
// and finish the click twice.
|
|
1718
|
+
loadClaudeHooksModule().then(
|
|
1719
|
+
(install) => {
|
|
1720
|
+
if (claudeHooksInstalled(install)) return stageNow();
|
|
1721
|
+
// Repair for FUTURE sessions — but a Claude session that is already running
|
|
1722
|
+
// loaded its hooks at startup and will never consume a file staged now, so this
|
|
1723
|
+
// click has to take the fresh-open path, the only one that actually shows the
|
|
1724
|
+
// relay. No ack here: openPacket owns the read state for the fresh open.
|
|
1725
|
+
console.error(
|
|
1726
|
+
"[overlay] claude-hook runtime not registered in",
|
|
1727
|
+
install.claudeSettingsPath(),
|
|
1728
|
+
"— 'Open in current chat' would have been a silent no-op; installing it for future sessions and opening a fresh chat instead (restart Claude to use it)",
|
|
1729
|
+
);
|
|
1730
|
+
repairClaudeHooks(install);
|
|
1731
|
+
fallbackFresh();
|
|
1732
|
+
},
|
|
1733
|
+
(error) => {
|
|
1734
|
+
console.error("[overlay] claude-hook runtime check failed:", error && error.message);
|
|
1735
|
+
fallbackFresh();
|
|
1736
|
+
},
|
|
1737
|
+
);
|
|
1613
1738
|
});
|
|
1614
1739
|
}
|
|
1615
1740
|
|
|
@@ -1645,16 +1770,10 @@ function openTaskDetail(taskId) {
|
|
|
1645
1770
|
let err = "";
|
|
1646
1771
|
child.stdout.on("data", (data) => (out += data));
|
|
1647
1772
|
child.stderr.on("data", (data) => (err += data));
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
try {
|
|
1653
|
-
const parsed = JSON.parse(out.trim());
|
|
1654
|
-
url = parsed.url || null;
|
|
1655
|
-
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
1656
|
-
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
1657
|
-
} catch {}
|
|
1773
|
+
// "close", never "exit" — same Windows stdout-drain race as openPacket: an empty
|
|
1774
|
+
// parse used to send a successful task open to the browser.
|
|
1775
|
+
child.on("close", (code) => {
|
|
1776
|
+
const { url, skipExternalOpen, freshlyForged } = parseOpenResult(out);
|
|
1658
1777
|
if (url || skipExternalOpen) {
|
|
1659
1778
|
activateHost(host, bundle); // raise the host so the opened task is visible
|
|
1660
1779
|
if (url && !skipExternalOpen) {
|
|
@@ -1672,8 +1791,10 @@ function openTaskDetail(taskId) {
|
|
|
1672
1791
|
host,
|
|
1673
1792
|
"code",
|
|
1674
1793
|
code,
|
|
1794
|
+
"stdout:",
|
|
1795
|
+
tailFor(out),
|
|
1675
1796
|
"stderr:",
|
|
1676
|
-
err
|
|
1797
|
+
tailFor(err),
|
|
1677
1798
|
);
|
|
1678
1799
|
// Last-resort web fallback so the click is never a dead end.
|
|
1679
1800
|
shell
|
|
@@ -1949,7 +2070,10 @@ function createWindow() {
|
|
|
1949
2070
|
},
|
|
1950
2071
|
});
|
|
1951
2072
|
|
|
1952
|
-
|
|
2073
|
+
// Windows strips WS_EX_TOPMOST from a scheduled-task-launched overlay that only asks
|
|
2074
|
+
// politely ("floating"); "screen-saver" is the level that survives. The periodic
|
|
2075
|
+
// re-assert that recovers from later demotions lives in space-presence.cjs.
|
|
2076
|
+
win.setAlwaysOnTop(true, process.platform === "win32" ? "screen-saver" : "floating");
|
|
1953
2077
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
1954
2078
|
// Empty canvas is click-through; startHitTest() below owns this from here on.
|
|
1955
2079
|
win.setIgnoreMouseEvents(true, { forward: true });
|
package/overlay/preload.cjs
CHANGED
|
@@ -8,6 +8,9 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
8
8
|
// "Open in current chat" staged an injection: the UI confirms it so the
|
|
9
9
|
// click has visible feedback (delivery is checkpoint-based, not instant).
|
|
10
10
|
onInjected: (cb) => ipcRenderer.on("injected", (_e, id, info) => cb(id, info || {})),
|
|
11
|
+
// The open failed (CLI error / helper wouldn't spawn). The row stays unread, so
|
|
12
|
+
// say why inline instead of letting the spinner just stop.
|
|
13
|
+
onOpenError: (cb) => ipcRenderer.on("openError", (_e, id, message) => cb(id, message || "")),
|
|
11
14
|
onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // status-area icon clicked
|
|
12
15
|
onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
|
|
13
16
|
|
|
@@ -18,22 +18,40 @@ function resetWindowZoom(win) {
|
|
|
18
18
|
return true;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function reinforceSpacePresence(win, { moveTop = false } = {}) {
|
|
21
|
+
function reinforceSpacePresence(win, { moveTop = false, platform = process.platform } = {}) {
|
|
22
22
|
if (!isUsableWindow(win)) return false;
|
|
23
23
|
resetWindowZoom(win);
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
const isWindows = platform === "win32";
|
|
25
|
+
// All-Spaces is a macOS Spaces concept. On Windows isVisibleOnAllWorkspaces()
|
|
26
|
+
// always reports false, so the drift check below re-called the setter on every
|
|
27
|
+
// 1.5s poll for nothing.
|
|
28
|
+
if (!isWindows) {
|
|
29
|
+
// Only touch window-server state that actually drifted. Re-asserting
|
|
30
|
+
// setVisibleOnAllWorkspaces / setAlwaysOnTop unconditionally (every 1.5s poll +
|
|
31
|
+
// every Space change) reorders the window each time and reads as flicker.
|
|
32
|
+
try {
|
|
33
|
+
if (typeof win.isVisibleOnAllWorkspaces !== "function" || !win.isVisibleOnAllWorkspaces()) {
|
|
34
|
+
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
if (isWindows) {
|
|
39
|
+
// Re-assert unconditionally: Electron's isAlwaysOnTop() is a cached flag from
|
|
40
|
+
// its own last setAlwaysOnTop call and cannot observe the OS stripping
|
|
41
|
+
// WS_EX_TOPMOST (scheduled-task-launched windows come up without it and sink
|
|
42
|
+
// behind maximized windows). Trusting the getter made the repair a permanent
|
|
43
|
+
// no-op. SetWindowPos-to-topmost on an already-topmost window is not a visible
|
|
44
|
+
// reorder here, so the macOS flicker rationale above does not apply.
|
|
45
|
+
try {
|
|
46
|
+
win.setAlwaysOnTop(true, "screen-saver");
|
|
47
|
+
} catch {}
|
|
48
|
+
} else {
|
|
49
|
+
try {
|
|
50
|
+
if (typeof win.isAlwaysOnTop !== "function" || !win.isAlwaysOnTop()) {
|
|
51
|
+
win.setAlwaysOnTop(true, "floating");
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
37
55
|
// moveTop is macOS orderWindow:NSWindowAbove — on a HIDDEN window that orders it
|
|
38
56
|
// back on-screen. That made the dismissed pill flash on every Space switch (shown
|
|
39
57
|
// here, then hidden again by the visibility sync). Raising only means anything
|
package/package.json
CHANGED
package/src/auto-update.js
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
// Companion self-update.
|
|
2
2
|
//
|
|
3
|
-
// The companion ships as an npm package (`relay-companion`) run by two
|
|
4
|
-
// agents (work.relay.companion = daemon,
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// The companion ships as an npm package (`relay-companion`) run by two always-on
|
|
4
|
+
// services — on macOS two launchd agents (work.relay.companion = daemon,
|
|
5
|
+
// work.relay.companion.pill = overlay), on Windows two Scheduled Tasks ("Relay
|
|
6
|
+
// Companion Daemon" / "Relay Companion Pill") — all pointing at ABSOLUTE paths
|
|
7
|
+
// inside the npm-managed install. Publishing a new version to npm is the
|
|
8
|
+
// production deploy — but nothing pulled it onto users' machines, so a
|
|
9
|
+
// click-routing fix (or any fix) only reached people who manually re-ran
|
|
10
|
+
// `npm i -g relay-companion@latest`.
|
|
9
11
|
//
|
|
10
12
|
// This closes that loop: the daemon (the single always-on owner, kept alive by
|
|
11
|
-
// launchd KeepAlive) checks npm's latest dist-tag, installs that
|
|
12
|
-
// the prefix which owns the running package, verifies
|
|
13
|
-
// and
|
|
13
|
+
// launchd KeepAlive / Task Scheduler) checks npm's latest dist-tag, installs that
|
|
14
|
+
// exact version into the prefix which owns the running package, verifies the
|
|
15
|
+
// freshly written tree, and restarts both services (pill first, daemon last).
|
|
16
|
+
//
|
|
17
|
+
// macOS restart hand-off: the detached updater shell DESCENDS from the daemon's
|
|
18
|
+
// launchd job. Booting out an ancestor job orphans the shell's Mach bootstrap
|
|
19
|
+
// session, after which every `launchctl bootstrap` fails with EIO ("Bootstrap
|
|
20
|
+
// failed: 5: Input/output error"), stranding BOTH services unloaded (observed in
|
|
21
|
+
// the field on 0.1.68). The restart phase is therefore submitted to launchd as an
|
|
22
|
+
// independent one-shot job (`launchctl submit`) that does not descend from the
|
|
23
|
+
// daemon, verifies both services are listed afterwards, retries once, and rolls
|
|
24
|
+
// back to the backed-up tree when verification fails.
|
|
14
25
|
|
|
15
26
|
import os from "node:os";
|
|
16
27
|
import path from "node:path";
|
|
@@ -26,6 +37,9 @@ import {
|
|
|
26
37
|
export const PACKAGE_NAME = "relay-companion";
|
|
27
38
|
const DAEMON_LABEL = "work.relay.companion";
|
|
28
39
|
const PILL_LABEL = "work.relay.companion.pill";
|
|
40
|
+
// Must match the Scheduled Task names registered by src/install.js.
|
|
41
|
+
const WINDOWS_DAEMON_TASK_NAME = "Relay Companion Daemon";
|
|
42
|
+
const WINDOWS_PILL_TASK_NAME = "Relay Companion Pill";
|
|
29
43
|
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
30
44
|
// A production publish should reach an idle machine promptly. The registry request is
|
|
31
45
|
// a tiny dist-tags document and the daemon is the only checker, so one minute gives us
|
|
@@ -75,10 +89,41 @@ export function isManagedInstall(packageRoot) {
|
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
// Resolve the npm prefix which OWNS this exact package tree. Global npm installs live
|
|
78
|
-
// at <prefix>/lib/node_modules
|
|
79
|
-
// <
|
|
80
|
-
//
|
|
81
|
-
|
|
92
|
+
// at <prefix>/lib/node_modules (macOS/Linux) or <prefix>\node_modules (Windows);
|
|
93
|
+
// Relay's no-sudo fallback lives at <home>/.relay/lib/node_modules. Passing the
|
|
94
|
+
// explicit prefix to npm prevents an nvm/fnm/volta daemon from accidentally updating
|
|
95
|
+
// Homebrew's copy (or the reverse).
|
|
96
|
+
export function managedInstallInfo(
|
|
97
|
+
packageRoot,
|
|
98
|
+
{ home = os.homedir(), platform = process.platform, exists = fs.existsSync } = {},
|
|
99
|
+
) {
|
|
100
|
+
if (platform === "win32") {
|
|
101
|
+
// Decide with path.win32 so the same logic is exact both on a real Windows
|
|
102
|
+
// daemon and when unit tests exercise it from POSIX. Windows paths are
|
|
103
|
+
// case-insensitive; compare accordingly.
|
|
104
|
+
const win = path.win32;
|
|
105
|
+
const root = win.resolve(String(packageRoot || ""));
|
|
106
|
+
const nmSuffix = win.sep + win.join("node_modules", PACKAGE_NAME);
|
|
107
|
+
if (!root.toLowerCase().endsWith(nmSuffix.toLowerCase())) return null;
|
|
108
|
+
const prefix = root.slice(0, root.length - nmSuffix.length) || win.parse(root).root;
|
|
109
|
+
const relayLocalSuffix = win.sep + win.join(".relay", "lib");
|
|
110
|
+
if (prefix.toLowerCase().endsWith(relayLocalSuffix.toLowerCase())) {
|
|
111
|
+
return { packageRoot: root, prefix, global: false };
|
|
112
|
+
}
|
|
113
|
+
// A Windows global prefix has the same <dir>\node_modules\<pkg> shape as an
|
|
114
|
+
// arbitrary project checkout, which is NOT a safe rollback unit (the project
|
|
115
|
+
// hoists Relay's dependencies beside it). Only adopt a prefix that carries
|
|
116
|
+
// npm's global bin shims: `npm i -g relay-companion` always writes
|
|
117
|
+
// <prefix>\relay.cmd, and custom prefixes that own npm itself carry npm.cmd /
|
|
118
|
+
// node_modules\npm. The darwin branch refuses arbitrary node_modules for the
|
|
119
|
+
// same reason.
|
|
120
|
+
for (const marker of ["relay.cmd", "npm.cmd", win.join("node_modules", "npm")]) {
|
|
121
|
+
try {
|
|
122
|
+
if (exists(win.join(prefix, marker))) return { packageRoot: root, prefix, global: true };
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
82
127
|
const root = path.resolve(String(packageRoot || ""));
|
|
83
128
|
const globalSuffix = path.join("lib", "node_modules", PACKAGE_NAME);
|
|
84
129
|
const localSuffix = path.join("node_modules", PACKAGE_NAME);
|
|
@@ -191,6 +236,207 @@ export function runtimeNpmDir(execPath = process.execPath) {
|
|
|
191
236
|
return null;
|
|
192
237
|
}
|
|
193
238
|
|
|
239
|
+
// Build the Windows (win32) update script: a single PowerShell program with the same
|
|
240
|
+
// guarantees as the darwin shell script — single-writer pid-aware lock, local backup
|
|
241
|
+
// of the running tree, exact-version install + verification BEFORE any restart,
|
|
242
|
+
// Scheduled Task restart (pill first, daemon last), and restore-on-any-failure.
|
|
243
|
+
// It is spawned detached via `powershell.exe -EncodedCommand` (base64 UTF-16LE),
|
|
244
|
+
// which sidesteps every powershell/cmd quoting layer for the multi-line script.
|
|
245
|
+
export function buildWindowsUpdateScript({
|
|
246
|
+
home = os.homedir(),
|
|
247
|
+
currentVersion = null,
|
|
248
|
+
targetVersion = null,
|
|
249
|
+
packageRoot = companionPackageRoot(),
|
|
250
|
+
execPath = process.execPath,
|
|
251
|
+
logPath = path.win32.join(home, ".relay", "update.log"),
|
|
252
|
+
lockPath = path.win32.join(home, ".relay", "update.lock"),
|
|
253
|
+
exists = fs.existsSync,
|
|
254
|
+
} = {}) {
|
|
255
|
+
const install = managedInstallInfo(packageRoot, { home, platform: "win32", exists });
|
|
256
|
+
if (!install) throw new Error(`cannot update unmanaged package root: ${packageRoot}`);
|
|
257
|
+
// Safe single-quote for PowerShell literals: inside '...' the only escape is ''.
|
|
258
|
+
const pq = (s) => `'${String(s).replaceAll("'", "''")}'`;
|
|
259
|
+
// Restore target if the new install is broken: reinstall the version we're running
|
|
260
|
+
// now so the on-disk tree stays loadable for the next Scheduled Task launch.
|
|
261
|
+
const restore = parseVersion(currentVersion) ? currentVersion : "latest";
|
|
262
|
+
const target = parseVersion(targetVersion) ? String(targetVersion) : null;
|
|
263
|
+
const installTarget = target || "latest";
|
|
264
|
+
const installFlags = install.global ? "-g " : "";
|
|
265
|
+
const win = path.win32;
|
|
266
|
+
// Prefer the npm.cmd that sits beside the runtime node.exe which owns the running
|
|
267
|
+
// install (nvm-windows/volta), mirroring darwin's runtime-PATH prepend. The install
|
|
268
|
+
// command separately pins the owning prefix, so an unusual npm config still cannot
|
|
269
|
+
// redirect the update to another copy.
|
|
270
|
+
const execDir = execPath ? win.dirname(String(execPath)) : "";
|
|
271
|
+
const execBase = execPath ? win.basename(String(execPath)).toLowerCase() : "";
|
|
272
|
+
let runtimeDir = null;
|
|
273
|
+
if (execDir && execDir !== "." && !execBase.includes("electron")) {
|
|
274
|
+
try {
|
|
275
|
+
if (exists(win.join(execDir, "npm.cmd")) || exists(win.join(execDir, "npm"))) runtimeDir = execDir;
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
const npmInstallLine =
|
|
279
|
+
`npm install ${installFlags}--prefix "${install.prefix}" "${PACKAGE_NAME}@${installTarget}"` +
|
|
280
|
+
" --prefer-online --no-audit --no-fund --no-save --package-lock=false" +
|
|
281
|
+
" --fetch-retries=3 --fetch-retry-mintimeout=1000 --fetch-retry-maxtimeout=10000";
|
|
282
|
+
const lines = [
|
|
283
|
+
`$ErrorActionPreference = 'Continue'`,
|
|
284
|
+
`$Log = ${pq(logPath)}`,
|
|
285
|
+
`$Lock = ${pq(lockPath)}`,
|
|
286
|
+
`$Prefix = ${pq(install.prefix)}`,
|
|
287
|
+
`$Pkg = ${pq(install.packageRoot)}`,
|
|
288
|
+
`$PkgParent = Split-Path -Parent $Pkg`,
|
|
289
|
+
// Global npm on Windows nests Relay's dependencies inside the package directory,
|
|
290
|
+
// making it a complete rollback unit. The no-sudo fallback hoists dependencies
|
|
291
|
+
// into the Relay-owned node_modules, so that whole directory is the unit there.
|
|
292
|
+
install.global ? `$LiveTree = $Pkg` : `$LiveTree = Join-Path $Prefix 'node_modules'`,
|
|
293
|
+
install.global
|
|
294
|
+
? `$Backup = Join-Path $PkgParent '.relay-companion-update-backup'`
|
|
295
|
+
: `$Backup = Join-Path $Prefix '.relay-companion-node_modules-update-backup'`,
|
|
296
|
+
`$Node = ${pq(execPath || "node.exe")}`,
|
|
297
|
+
`$ComSpec = if ($env:ComSpec) { $env:ComSpec } else { Join-Path $env:SystemRoot 'System32\\cmd.exe' }`,
|
|
298
|
+
`$NodeCmd = if (Test-Path -LiteralPath $Node) { '"' + $Node + '"' } else { 'node' }`,
|
|
299
|
+
// Relay's own postinstall would repair/restart surfaces mid-update; this script
|
|
300
|
+
// owns the verify + restart sequencing itself (matches the darwin script).
|
|
301
|
+
`$env:RELAY_SKIP_DESKTOP_POSTINSTALL = '1'`,
|
|
302
|
+
...(runtimeDir ? [`$env:Path = ${pq(`${runtimeDir};`)} + $env:Path`] : []),
|
|
303
|
+
`function Write-UpdateLog([string]$Message) {`,
|
|
304
|
+
` try { Add-Content -LiteralPath $Log -Value ('[relay-update] ' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $Message) } catch {}`,
|
|
305
|
+
`}`,
|
|
306
|
+
// cmd /d /s /c resolves npm.cmd/schtasks through PATH exactly like a console
|
|
307
|
+
// would, and appends all native output to the update log, mirroring the darwin
|
|
308
|
+
// script's ">> LOG 2>&1" on every native step. Windows PowerShell 5.1 passes the
|
|
309
|
+
// argument with inner quotes verbatim, which is precisely the /s /c contract.
|
|
310
|
+
`function Invoke-Native([string]$CommandLine) {`,
|
|
311
|
+
` & $ComSpec /d /s /c ($CommandLine + ' >> "' + $Log + '" 2>&1')`,
|
|
312
|
+
` return $LASTEXITCODE`,
|
|
313
|
+
`}`,
|
|
314
|
+
`function Restore-Tree {`,
|
|
315
|
+
` if (Test-Path -LiteralPath $Backup) {`,
|
|
316
|
+
` Remove-Item -LiteralPath $LiveTree -Recurse -Force -ErrorAction SilentlyContinue`,
|
|
317
|
+
` Move-Item -LiteralPath $Backup -Destination $LiveTree -Force -ErrorAction SilentlyContinue`,
|
|
318
|
+
` Write-UpdateLog 'restored previous package tree'`,
|
|
319
|
+
` }`,
|
|
320
|
+
`}`,
|
|
321
|
+
`$null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Lock) -ErrorAction SilentlyContinue`,
|
|
322
|
+
// Never steal from a live slow install. A recorded dead owner is recoverable
|
|
323
|
+
// immediately; an ownerless lock is stolen only when truly stale (>20 minutes).
|
|
324
|
+
`if (Test-Path -LiteralPath $Lock) {`,
|
|
325
|
+
` $LockPidRaw = $null`,
|
|
326
|
+
` try { $LockPidRaw = Get-Content -LiteralPath (Join-Path $Lock 'pid') -TotalCount 1 -ErrorAction Stop } catch {}`,
|
|
327
|
+
` if ($null -ne $LockPidRaw -and ('' + $LockPidRaw).Trim() -ne '') {`,
|
|
328
|
+
` $OwnerAlive = $false`,
|
|
329
|
+
` try { $OwnerAlive = [bool](Get-Process -Id ([int](('' + $LockPidRaw).Trim())) -ErrorAction Stop) } catch { $OwnerAlive = $false }`,
|
|
330
|
+
` if (-not $OwnerAlive) { Remove-Item -LiteralPath $Lock -Recurse -Force -ErrorAction SilentlyContinue }`,
|
|
331
|
+
` } else {`,
|
|
332
|
+
` $LockItem = $null`,
|
|
333
|
+
` try { $LockItem = Get-Item -LiteralPath $Lock -ErrorAction Stop } catch {}`,
|
|
334
|
+
` if ($LockItem -and ((Get-Date) - $LockItem.LastWriteTime).TotalMinutes -gt 20) { Remove-Item -LiteralPath $Lock -Recurse -Force -ErrorAction SilentlyContinue }`,
|
|
335
|
+
` }`,
|
|
336
|
+
`}`,
|
|
337
|
+
// Single-writer: New-Item -ItemType Directory WITHOUT -Force is CreateDirectory —
|
|
338
|
+
// atomic, and fails when the directory already exists (= mkdir lock semantics).
|
|
339
|
+
`try { $null = New-Item -ItemType Directory -Path $Lock -ErrorAction Stop } catch { Write-UpdateLog 'another update in progress; skipping'; exit 0 }`,
|
|
340
|
+
`$KeepNew = $false`,
|
|
341
|
+
// PowerShell runs `finally` on normal completion, thrown errors, and `exit`,
|
|
342
|
+
// covering the same paths as the darwin EXIT/INT/TERM trap.
|
|
343
|
+
`try {`,
|
|
344
|
+
` try { Set-Content -LiteralPath (Join-Path $Lock 'pid') -Value ([string]$PID) -ErrorAction Stop } catch {}`,
|
|
345
|
+
// Sweep trash a previous commit could not delete (a locked file keeps a renamed
|
|
346
|
+
// backup dir alive; see the commit step below).
|
|
347
|
+
` try { Get-ChildItem -LiteralPath (Split-Path -Parent $Backup) -Force -Filter ((Split-Path -Leaf $Backup) + '.trash-*') -ErrorAction Stop | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue } catch {}`,
|
|
348
|
+
// A hard kill bypasses `finally` and can leave both the known-good backup and a
|
|
349
|
+
// partially written live tree. The backup always wins.
|
|
350
|
+
` if (Test-Path -LiteralPath $Backup) {`,
|
|
351
|
+
` Write-UpdateLog 'recovering interrupted prior update'`,
|
|
352
|
+
` Remove-Item -LiteralPath $LiveTree -Recurse -Force -ErrorAction SilentlyContinue`,
|
|
353
|
+
` Move-Item -LiteralPath $Backup -Destination $LiveTree -Force -ErrorAction SilentlyContinue`,
|
|
354
|
+
` if (Test-Path -LiteralPath $Backup) { Write-UpdateLog 'could not restore interrupted backup; aborting safely'; exit 1 }`,
|
|
355
|
+
` }`,
|
|
356
|
+
// Same-volume directory rename: atomic, and legal on Windows even while files
|
|
357
|
+
// inside are open/executing (delete is what NTFS blocks, not rename).
|
|
358
|
+
` Move-Item -LiteralPath $LiveTree -Destination $Backup -Force -ErrorAction SilentlyContinue`,
|
|
359
|
+
` if ((Test-Path -LiteralPath $LiveTree) -or -not (Test-Path -LiteralPath $Backup)) { Write-UpdateLog ('could not back up ' + $LiveTree + '; aborting safely'); exit 1 }`,
|
|
360
|
+
` Write-UpdateLog ('installing ${PACKAGE_NAME}@${installTarget} into ' + $Pkg + ' (current ${restore})')`,
|
|
361
|
+
` $InstallExit = Invoke-Native ${pq(npmInstallLine)}`,
|
|
362
|
+
` if ($InstallExit -ne 0) { Write-UpdateLog 'install FAILED; restoring ${restore} from local backup'; exit 1 }`,
|
|
363
|
+
// Electron lives nested under the package (global layout) or hoisted beside it
|
|
364
|
+
// (Relay's local-prefix layout). Repair a skipped binary download before verify.
|
|
365
|
+
` $ElectronDir = $null`,
|
|
366
|
+
` foreach ($Candidate in @((Join-Path $Pkg 'node_modules\\electron'), (Join-Path $PkgParent 'electron'))) {`,
|
|
367
|
+
` if (Test-Path -LiteralPath (Join-Path $Candidate 'package.json')) { $ElectronDir = $Candidate; break }`,
|
|
368
|
+
` }`,
|
|
369
|
+
` $Electron = $null`,
|
|
370
|
+
` if ($ElectronDir) { $Electron = Join-Path $ElectronDir 'dist\\electron.exe' }`,
|
|
371
|
+
` if ($ElectronDir -and -not (Test-Path -LiteralPath $Electron) -and (Test-Path -LiteralPath (Join-Path $ElectronDir 'install.js'))) {`,
|
|
372
|
+
` Write-UpdateLog 'electron runtime missing; running electron install.js'`,
|
|
373
|
+
` $null = Invoke-Native ($NodeCmd + ' "' + (Join-Path $ElectronDir 'install.js') + '"')`,
|
|
374
|
+
` }`,
|
|
375
|
+
// Verify the package at the exact Task-Scheduler-owned root reached the exact
|
|
376
|
+
// discovered version. A mismatched prefix or stale npm response must never
|
|
377
|
+
// trigger a restart.
|
|
378
|
+
` $InstalledVersion = $null`,
|
|
379
|
+
` try { $InstalledVersion = (Get-Content -LiteralPath (Join-Path $Pkg 'package.json') -Raw -ErrorAction Stop | ConvertFrom-Json).version } catch {}`,
|
|
380
|
+
` Write-UpdateLog ('installed version=' + $InstalledVersion + ' at ' + $Pkg)`,
|
|
381
|
+
target
|
|
382
|
+
? ` $VersionOk = $false; if ($InstalledVersion -ceq '${target}') { $VersionOk = $true } else { Write-UpdateLog ('verify FAILED: installed ' + $InstalledVersion + ' != exact target ${target}') }`
|
|
383
|
+
: ` $VersionOk = $true`,
|
|
384
|
+
` $VerifyOk = $false`,
|
|
385
|
+
` if ($VersionOk -and $Electron -and (Test-Path -LiteralPath $Electron) -and (Test-Path -LiteralPath (Join-Path $Pkg 'bin\\relay.js'))) { $VerifyOk = $true }`,
|
|
386
|
+
` if (-not $VerifyOk) { Write-UpdateLog 'post-install verify FAILED; restoring ${restore} from local backup'; exit 1 }`,
|
|
387
|
+
// Unlike macOS there is no repair-desktop step: the Scheduled Task actions embed
|
|
388
|
+
// only update-stable absolute paths (node.exe, bin\relay.js, electron.exe live at
|
|
389
|
+
// the same locations inside the refreshed tree), so re-registration is unneeded —
|
|
390
|
+
// and would /End + /Run tasks outside this script's careful sequencing.
|
|
391
|
+
` Write-UpdateLog 'install verified; restarting services (pill first, daemon last)'`,
|
|
392
|
+
` $null = Invoke-Native 'schtasks /End /TN "${WINDOWS_PILL_TASK_NAME}"'`,
|
|
393
|
+
` $PillRun = Invoke-Native 'schtasks /Run /TN "${WINDOWS_PILL_TASK_NAME}"'`,
|
|
394
|
+
` if ($PillRun -ne 0) {`,
|
|
395
|
+
` Write-UpdateLog 'pill restart FAILED; restoring previous tree'`,
|
|
396
|
+
// End the (possibly half-started) new pill BEFORE restoring: Windows cannot
|
|
397
|
+
// delete a tree whose executables are running.
|
|
398
|
+
` $null = Invoke-Native 'schtasks /End /TN "${WINDOWS_PILL_TASK_NAME}"'`,
|
|
399
|
+
` Restore-Tree`,
|
|
400
|
+
` $null = Invoke-Native 'schtasks /Run /TN "${WINDOWS_PILL_TASK_NAME}"'`,
|
|
401
|
+
` exit 1`,
|
|
402
|
+
` }`,
|
|
403
|
+
// /End on the daemon task may terminate THIS script too (Task Scheduler ends the
|
|
404
|
+
// action's process tree; node's detached spawn does not breakaway from it). The
|
|
405
|
+
// watcher below is created via CIM, so it is parented to the WMI provider — OUT of
|
|
406
|
+
// that tree — and re-runs the daemon task even if this script dies between the
|
|
407
|
+
// /End and the /Run. When this script survives, the extra /Run is a harmless
|
|
408
|
+
// "already running" no-op appended to the log.
|
|
409
|
+
` try {`,
|
|
410
|
+
` $WatchCmd = 'ping -n 16 127.0.0.1 >nul & schtasks /Run /TN "${WINDOWS_DAEMON_TASK_NAME}" >> "' + $Log + '" 2>&1'`,
|
|
411
|
+
` $Startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } -ErrorAction Stop`,
|
|
412
|
+
` $null = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('"' + $ComSpec + '" /d /s /c "' + $WatchCmd + '"'); ProcessStartupInformation = $Startup } -ErrorAction Stop`,
|
|
413
|
+
` } catch { Write-UpdateLog 'daemon revival watcher unavailable; relying on direct schtasks restart' }`,
|
|
414
|
+
` $null = Invoke-Native 'schtasks /End /TN "${WINDOWS_DAEMON_TASK_NAME}"'`,
|
|
415
|
+
` $DaemonRun = Invoke-Native 'schtasks /Run /TN "${WINDOWS_DAEMON_TASK_NAME}"'`,
|
|
416
|
+
` if ($DaemonRun -ne 0) {`,
|
|
417
|
+
` Write-UpdateLog 'daemon restart FAILED; restoring previous tree'`,
|
|
418
|
+
` $null = Invoke-Native 'schtasks /End /TN "${WINDOWS_PILL_TASK_NAME}"'`,
|
|
419
|
+
` Restore-Tree`,
|
|
420
|
+
` $null = Invoke-Native 'schtasks /Run /TN "${WINDOWS_PILL_TASK_NAME}"'`,
|
|
421
|
+
` $null = Invoke-Native 'schtasks /Run /TN "${WINDOWS_DAEMON_TASK_NAME}"'`,
|
|
422
|
+
` exit 1`,
|
|
423
|
+
` }`,
|
|
424
|
+
` $KeepNew = $true`,
|
|
425
|
+
// Rename-then-delete: if a leftover locked file blocks deletion, the rename has
|
|
426
|
+
// already moved the dir OUT of the backup name, so a later "recovering
|
|
427
|
+
// interrupted prior update" can never restore a stale tree over a good one.
|
|
428
|
+
` $BackupTrash = $Backup + '.trash-' + $PID`,
|
|
429
|
+
` try { Move-Item -LiteralPath $Backup -Destination $BackupTrash -Force -ErrorAction Stop } catch { $BackupTrash = $Backup }`,
|
|
430
|
+
` Remove-Item -LiteralPath $BackupTrash -Recurse -Force -ErrorAction SilentlyContinue`,
|
|
431
|
+
` Write-UpdateLog 'update committed; pill and daemon restarted from the new tree'`,
|
|
432
|
+
`} finally {`,
|
|
433
|
+
` if (-not $KeepNew) { Restore-Tree }`,
|
|
434
|
+
` Remove-Item -LiteralPath $Lock -Recurse -Force -ErrorAction SilentlyContinue`,
|
|
435
|
+
`}`,
|
|
436
|
+
];
|
|
437
|
+
return lines.join("\n");
|
|
438
|
+
}
|
|
439
|
+
|
|
194
440
|
export function spawnDetachedUpdate({
|
|
195
441
|
home = os.homedir(),
|
|
196
442
|
uid = typeof process.getuid === "function" ? process.getuid() : 0,
|
|
@@ -199,18 +445,52 @@ export function spawnDetachedUpdate({
|
|
|
199
445
|
packageRoot = companionPackageRoot(),
|
|
200
446
|
execPath = process.execPath,
|
|
201
447
|
launchctlPath = "/bin/launchctl",
|
|
448
|
+
platform = process.platform,
|
|
202
449
|
logPath = path.join(home, ".relay", "update.log"),
|
|
203
450
|
lockPath = path.join(home, ".relay", "update.lock"),
|
|
204
451
|
spawnImpl = spawn,
|
|
205
452
|
log = () => {},
|
|
206
453
|
ensureDir = true,
|
|
207
454
|
mode = DEFAULT_COMPANION_MODE,
|
|
455
|
+
exists = fs.existsSync,
|
|
208
456
|
} = {}) {
|
|
209
457
|
if (ensureDir) {
|
|
210
458
|
try {
|
|
211
459
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
212
460
|
} catch {}
|
|
213
461
|
}
|
|
462
|
+
// A detached child with NO 'error' listener turns an async spawn failure (EMFILE,
|
|
463
|
+
// EAGAIN, ENOENT on the shell binary) into an unhandled 'error' that would crash
|
|
464
|
+
// this always-on daemon. Attach the listener BEFORE unref, then release the child
|
|
465
|
+
// so it survives this process being killed by the restart it performs.
|
|
466
|
+
const attachAndRelease = (child) => {
|
|
467
|
+
if (child && typeof child.on === "function") {
|
|
468
|
+
child.on("error", (err) => log(`auto-update spawn error: ${err && err.message ? err.message : String(err)}`));
|
|
469
|
+
}
|
|
470
|
+
if (child && typeof child.unref === "function") child.unref();
|
|
471
|
+
return child;
|
|
472
|
+
};
|
|
473
|
+
if (platform === "win32") {
|
|
474
|
+
const script = buildWindowsUpdateScript({
|
|
475
|
+
home,
|
|
476
|
+
currentVersion,
|
|
477
|
+
targetVersion,
|
|
478
|
+
packageRoot,
|
|
479
|
+
execPath,
|
|
480
|
+
logPath,
|
|
481
|
+
lockPath,
|
|
482
|
+
exists,
|
|
483
|
+
});
|
|
484
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
485
|
+
const systemRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
|
|
486
|
+
const powershellPath = path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
487
|
+
const child = spawnImpl(
|
|
488
|
+
powershellPath,
|
|
489
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded],
|
|
490
|
+
{ detached: true, stdio: "ignore", windowsHide: true },
|
|
491
|
+
);
|
|
492
|
+
return attachAndRelease(child);
|
|
493
|
+
}
|
|
214
494
|
const q = (s) => `'${String(s).replaceAll("'", "'\\''")}'`; // safe single-quote for the shell
|
|
215
495
|
const LOG = q(logPath);
|
|
216
496
|
const LOCK = q(lockPath);
|
|
@@ -230,8 +510,66 @@ export function spawnDetachedUpdate({
|
|
|
230
510
|
const installTarget = target || "latest";
|
|
231
511
|
const installFlags = install.global ? "-g " : "";
|
|
232
512
|
const launchAgentsDir = path.join(home, "Library", "LaunchAgents");
|
|
513
|
+
const pillPlistPath = path.join(launchAgentsDir, `${PILL_LABEL}.plist`);
|
|
514
|
+
const daemonPlistPath = path.join(launchAgentsDir, `${DAEMON_LABEL}.plist`);
|
|
515
|
+
const liveTreePath = install.global ? install.packageRoot : path.join(install.prefix, "node_modules");
|
|
516
|
+
const backupPath = install.global
|
|
517
|
+
? path.join(path.dirname(install.packageRoot), ".relay-companion-update-backup")
|
|
518
|
+
: path.join(install.prefix, ".relay-companion-node_modules-update-backup");
|
|
233
519
|
const npmInstall = (version) =>
|
|
234
520
|
`RELAY_SKIP_DESKTOP_POSTINSTALL=1 npm install ${installFlags}--prefix "$PREFIX" ${q(`${PACKAGE_NAME}@${version}`)} --prefer-online --no-audit --no-fund --no-save --package-lock=false --fetch-retries=3 --fetch-retry-mintimeout=1000 --fetch-retry-maxtimeout=10000`;
|
|
521
|
+
// The restart phase, written to $LOCK/restart.sh and SUBMITTED to launchd as an
|
|
522
|
+
// independent one-shot job. It must not run from this detached shell: the shell
|
|
523
|
+
// descends from the daemon's launchd job, and booting out an ancestor job orphans
|
|
524
|
+
// the shell's Mach bootstrap session — every subsequent `launchctl bootstrap`
|
|
525
|
+
// (and `load`) then fails with EIO, leaving BOTH services unloaded until a human
|
|
526
|
+
// intervenes (observed in the field on the 0.1.68 rollout). A submitted job is
|
|
527
|
+
// parented to the gui domain itself, so it can bootout/bootstrap anything.
|
|
528
|
+
// After a successful submit the main script sets HANDOFF=1: lock, backup, and
|
|
529
|
+
// commit/rollback responsibility all transfer to this job.
|
|
530
|
+
const restartScript = [
|
|
531
|
+
`# Relay update restart phase — runs as its own launchd job (see updater log).`,
|
|
532
|
+
`RESTART_LABEL="$1"`,
|
|
533
|
+
`LOCK=${LOCK}`,
|
|
534
|
+
`printf '%s\n' "$$" > "$LOCK/pid" 2>/dev/null || true`,
|
|
535
|
+
`LIVE_TREE=${q(liveTreePath)}`,
|
|
536
|
+
`BACKUP=${q(backupPath)}`,
|
|
537
|
+
`LAUNCHCTL=${q(launchctlPath)}`,
|
|
538
|
+
`PILL_PLIST=${q(pillPlistPath)}`,
|
|
539
|
+
`DAEMON_PLIST=${q(daemonPlistPath)}`,
|
|
540
|
+
`log_line() { echo "[relay-update] $(date) $1" >> ${LOG} 2>&1; }`,
|
|
541
|
+
`restore_tree() { if [ -d "$BACKUP" ]; then rm -rf "$LIVE_TREE"; mv "$BACKUP" "$LIVE_TREE"; log_line "restored previous package tree"; fi; }`,
|
|
542
|
+
`start_job() { "$LAUNCHCTL" bootstrap gui/${uid} "$1" >> ${LOG} 2>&1 || "$LAUNCHCTL" load "$1" >> ${LOG} 2>&1 || "$LAUNCHCTL" kickstart -k "gui/${uid}/$2" >> ${LOG} 2>&1; }`,
|
|
543
|
+
// Verification with one retry: a service missing from launchd after the restart
|
|
544
|
+
// is bootstrapped again, loudly. Only a job listed by `launchctl print` counts.
|
|
545
|
+
`ensure_job() { if "$LAUNCHCTL" print "gui/${uid}/$2" >/dev/null 2>&1; then return 0; fi; log_line "$2 missing after restart; bootstrapping again"; start_job "$1" "$2"; "$LAUNCHCTL" print "gui/${uid}/$2" >/dev/null 2>&1; }`,
|
|
546
|
+
`RESTART_FAILED=0`,
|
|
547
|
+
// Pill first, daemon last (unchanged ordering contract).
|
|
548
|
+
`"$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true`,
|
|
549
|
+
`start_job "$PILL_PLIST" ${PILL_LABEL} || true`,
|
|
550
|
+
`"$LAUNCHCTL" bootout gui/${uid}/${DAEMON_LABEL} >> ${LOG} 2>&1 || true`,
|
|
551
|
+
`start_job "$DAEMON_PLIST" ${DAEMON_LABEL} || true`,
|
|
552
|
+
`ensure_job "$PILL_PLIST" ${PILL_LABEL} || { log_line "pill restart verification FAILED"; RESTART_FAILED=1; }`,
|
|
553
|
+
`ensure_job "$DAEMON_PLIST" ${DAEMON_LABEL} || { log_line "daemon restart verification FAILED"; RESTART_FAILED=1; }`,
|
|
554
|
+
`if [ "$RESTART_FAILED" = 1 ]; then`,
|
|
555
|
+
` log_line "restart verification FAILED; restoring previous tree"`,
|
|
556
|
+
` restore_tree`,
|
|
557
|
+
` "$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true`,
|
|
558
|
+
` "$LAUNCHCTL" bootout gui/${uid}/${DAEMON_LABEL} >> ${LOG} 2>&1 || true`,
|
|
559
|
+
` start_job "$PILL_PLIST" ${PILL_LABEL} || true`,
|
|
560
|
+
` start_job "$DAEMON_PLIST" ${DAEMON_LABEL} || true`,
|
|
561
|
+
` ensure_job "$PILL_PLIST" ${PILL_LABEL} || log_line "pill STILL missing after rollback; run 'relay setup' to repair"`,
|
|
562
|
+
` ensure_job "$DAEMON_PLIST" ${DAEMON_LABEL} || log_line "daemon STILL missing after rollback; run 'relay setup' to repair"`,
|
|
563
|
+
`else`,
|
|
564
|
+
` rm -rf "$BACKUP" 2>/dev/null`,
|
|
565
|
+
` log_line "update committed; pill and daemon restarted from the new tree"`,
|
|
566
|
+
`fi`,
|
|
567
|
+
`rm -rf "$LOCK" 2>/dev/null`,
|
|
568
|
+
// One-shot label cleanup: the submitted job removes itself from launchd. This is
|
|
569
|
+
// deliberately the last line — remove may terminate the job's own process.
|
|
570
|
+
`"$LAUNCHCTL" remove "$RESTART_LABEL" >/dev/null 2>&1 || true`,
|
|
571
|
+
`exit 0`,
|
|
572
|
+
].join("\n");
|
|
235
573
|
const script = [
|
|
236
574
|
`export PATH=${pathPrefix}"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"`,
|
|
237
575
|
`PREFIX=${q(install.prefix)}`,
|
|
@@ -245,8 +583,8 @@ export function spawnDetachedUpdate({
|
|
|
245
583
|
: `LIVE_TREE="$PREFIX/node_modules"; BACKUP="$PREFIX/.relay-companion-node_modules-update-backup"`,
|
|
246
584
|
`NODE=${q(execPath)}`,
|
|
247
585
|
`LAUNCHCTL=${q(launchctlPath)}`,
|
|
248
|
-
`PILL_PLIST=${q(
|
|
249
|
-
`DAEMON_PLIST=${q(
|
|
586
|
+
`PILL_PLIST=${q(pillPlistPath)}`,
|
|
587
|
+
`DAEMON_PLIST=${q(daemonPlistPath)}`,
|
|
250
588
|
`if [ ! -x "$NODE" ]; then NODE="$(command -v node 2>/dev/null)"; fi`,
|
|
251
589
|
// Hold the lock path in a shell var and reference "$LOCK" everywhere so every use
|
|
252
590
|
// (including the single-quoted trap action) stays correct even if the home path
|
|
@@ -264,8 +602,11 @@ export function spawnDetachedUpdate({
|
|
|
264
602
|
// restore the known-running tree without relying on npm or the network. The EXIT
|
|
265
603
|
// trap also covers shell termination between moving the old tree and verification.
|
|
266
604
|
`KEEP_NEW=0`,
|
|
605
|
+
// After a successful hand-off to the independent restart job, the lock (and the
|
|
606
|
+
// restart.sh inside it) belong to THAT job — the exit trap must not remove them.
|
|
607
|
+
`HANDOFF=0`,
|
|
267
608
|
`restore_tree() { if [ -d "$BACKUP" ]; then rm -rf "$LIVE_TREE"; mv "$BACKUP" "$LIVE_TREE"; echo "[relay-update] $(date) restored previous package tree" >> ${LOG} 2>&1; fi; KEEP_NEW=1; }`,
|
|
268
|
-
`cleanup_update() { CODE=$?; trap - EXIT INT TERM; if [ "$KEEP_NEW" != 1 ]; then restore_tree; fi; rm -rf "$LOCK" 2>/dev/null; exit "$CODE"; }`,
|
|
609
|
+
`cleanup_update() { CODE=$?; trap - EXIT INT TERM; if [ "$KEEP_NEW" != 1 ]; then restore_tree; fi; if [ "$HANDOFF" != 1 ]; then rm -rf "$LOCK" 2>/dev/null; fi; exit "$CODE"; }`,
|
|
269
610
|
`trap cleanup_update EXIT INT TERM`,
|
|
270
611
|
// A SIGKILL bypasses EXIT cleanup and can leave both the known-good backup and a
|
|
271
612
|
// partially written live tree. The backup always wins; never delete our only
|
|
@@ -296,16 +637,31 @@ export function spawnDetachedUpdate({
|
|
|
296
637
|
` if "$NODE" "$PKG/bin/relay.js" repair-desktop --no-restart${repairModeArgs ? ` ${repairModeArgs}` : ""} >> ${LOG} 2>&1; then DESKTOP_OK=1; else echo "[relay-update] $(date) desktop repair FAILED" >> ${LOG} 2>&1; fi`,
|
|
297
638
|
` fi`,
|
|
298
639
|
` if [ "$DESKTOP_OK" = 1 ]; then`,
|
|
299
|
-
` echo "[relay-update] $(date) install and desktop repair verified;
|
|
300
|
-
// A kickstart does NOT reload changed ProgramArguments from disk
|
|
301
|
-
// bootstrap
|
|
302
|
-
// job
|
|
303
|
-
//
|
|
640
|
+
` echo "[relay-update] $(date) install and desktop repair verified; handing restart to an independent launchd job" >> ${LOG} 2>&1`,
|
|
641
|
+
// A kickstart does NOT reload changed ProgramArguments from disk, so the restart
|
|
642
|
+
// must bootout + bootstrap. But this shell DESCENDS from the daemon's launchd
|
|
643
|
+
// job: booting out that ancestor orphans this shell's Mach bootstrap session,
|
|
644
|
+
// after which bootstrap/load fail with EIO and both services are stranded
|
|
645
|
+
// unloaded (field-observed on 0.1.68). Hand the whole restart phase to a
|
|
646
|
+
// launchd-submitted one-shot job instead; start_job stays defined here only for
|
|
647
|
+
// the degraded inline fallback when submit itself is unavailable.
|
|
304
648
|
` start_job() { "$LAUNCHCTL" bootstrap gui/${uid} "$1" >> ${LOG} 2>&1 || "$LAUNCHCTL" load "$1" >> ${LOG} 2>&1 || "$LAUNCHCTL" kickstart -k "gui/${uid}/$2" >> ${LOG} 2>&1; }`,
|
|
305
|
-
` "$
|
|
306
|
-
|
|
307
|
-
`
|
|
308
|
-
`
|
|
649
|
+
` cat > "$LOCK/restart.sh" <<'RELAY_RESTART_EOF'`,
|
|
650
|
+
restartScript,
|
|
651
|
+
`RELAY_RESTART_EOF`,
|
|
652
|
+
` RESTART_LABEL="work.relay.update.restart.$(date +%s).$$"`,
|
|
653
|
+
` if "$LAUNCHCTL" submit -l "$RESTART_LABEL" -- /bin/sh "$LOCK/restart.sh" "$RESTART_LABEL" >> ${LOG} 2>&1; then`,
|
|
654
|
+
` KEEP_NEW=1; HANDOFF=1`,
|
|
655
|
+
` echo "[relay-update] $(date) restart phase submitted as $RESTART_LABEL (lock, backup, and rollback now owned by that job)" >> ${LOG} 2>&1`,
|
|
656
|
+
` else`,
|
|
657
|
+
` echo "[relay-update] $(date) launchctl submit FAILED; degraded inline restart (pill reload; daemon kickstart without plist reload)" >> ${LOG} 2>&1`,
|
|
658
|
+
` "$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true`,
|
|
659
|
+
` if ! start_job "$PILL_PLIST" ${PILL_LABEL}; then echo "[relay-update] $(date) pill restart FAILED; restoring previous tree" >> ${LOG} 2>&1; restore_tree; start_job "$PILL_PLIST" ${PILL_LABEL} || true; exit 1; fi`,
|
|
660
|
+
// Never bootout our own ancestor inline (the EIO trap above). kickstart -k
|
|
661
|
+
// restarts the daemon without removing its job; stale ProgramArguments, if any,
|
|
662
|
+
// are corrected by the next successful submit-based update.
|
|
663
|
+
` if "$LAUNCHCTL" kickstart -k "gui/${uid}/${DAEMON_LABEL}" >> ${LOG} 2>&1; then KEEP_NEW=1; rm -rf "$BACKUP" 2>/dev/null; else echo "[relay-update] $(date) daemon kickstart FAILED; restoring previous tree" >> ${LOG} 2>&1; restore_tree; "$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true; start_job "$PILL_PLIST" ${PILL_LABEL} || true; "$LAUNCHCTL" kickstart -k "gui/${uid}/${DAEMON_LABEL}" >> ${LOG} 2>&1 || true; exit 1; fi`,
|
|
664
|
+
` fi`,
|
|
309
665
|
` else`,
|
|
310
666
|
` echo "[relay-update] $(date) post-install verify FAILED; restoring ${restore} from local backup" >> ${LOG} 2>&1`,
|
|
311
667
|
` exit 1`,
|
|
@@ -316,14 +672,7 @@ export function spawnDetachedUpdate({
|
|
|
316
672
|
`fi`,
|
|
317
673
|
].join("\n");
|
|
318
674
|
const child = spawnImpl("/bin/sh", ["-c", script], { detached: true, stdio: "ignore" });
|
|
319
|
-
|
|
320
|
-
// EAGAIN, ENOENT on /bin/sh) into an unhandled 'error' that would crash this
|
|
321
|
-
// always-on daemon. Swallow it — the update simply doesn't happen this cycle.
|
|
322
|
-
if (child && typeof child.on === "function") {
|
|
323
|
-
child.on("error", (err) => log(`auto-update spawn error: ${err && err.message ? err.message : String(err)}`));
|
|
324
|
-
}
|
|
325
|
-
if (child && typeof child.unref === "function") child.unref();
|
|
326
|
-
return child;
|
|
675
|
+
return attachAndRelease(child);
|
|
327
676
|
}
|
|
328
677
|
|
|
329
678
|
// ---- orchestrator -------------------------------------------------------
|
|
@@ -390,7 +739,7 @@ export function createAutoUpdater({
|
|
|
390
739
|
state.updating = true;
|
|
391
740
|
state.updateStartedAt = t;
|
|
392
741
|
try {
|
|
393
|
-
spawnUpdate({ log, currentVersion: runningVersion, targetVersion: latest, packageRoot, mode: normalizedMode });
|
|
742
|
+
spawnUpdate({ log, currentVersion: runningVersion, targetVersion: latest, packageRoot, mode: normalizedMode, platform });
|
|
394
743
|
} catch (err) {
|
|
395
744
|
state.updating = false;
|
|
396
745
|
log(`auto-update launch failed: ${err && err.message ? err.message : String(err)}`);
|
|
@@ -401,15 +750,16 @@ export function createAutoUpdater({
|
|
|
401
750
|
|
|
402
751
|
async function tick() {
|
|
403
752
|
if (!autoUpdateEnabled(env)) return { status: "disabled" };
|
|
404
|
-
// Only
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
753
|
+
// Only platforms with a real restart path may update: darwin (launchd bootout +
|
|
754
|
+
// bootstrap, handed to an independent submitted job) and win32 (Scheduled Tasks
|
|
755
|
+
// via schtasks /End + /Run, pill first, daemon last). Elsewhere a detached
|
|
756
|
+
// script would falsely report "updating" and never restart — the daemon would
|
|
757
|
+
// run stale code forever while thinking it self-updates. Gate explicitly.
|
|
758
|
+
if (platform !== "darwin" && platform !== "win32") return { status: "unsupported-platform" };
|
|
409
759
|
// The broad layout check is useful for diagnostics, but only update trees we can
|
|
410
760
|
// safely roll back as one unit: a true global install or Relay's dedicated local
|
|
411
761
|
// fallback prefix. Never adopt an arbitrary project's hoisted node_modules.
|
|
412
|
-
if (!managedInstallInfo(packageRoot)) return { status: "unmanaged" };
|
|
762
|
+
if (!managedInstallInfo(packageRoot, { platform })) return { status: "unmanaged" };
|
|
413
763
|
const t = now();
|
|
414
764
|
// An update was launched but we're still alive (install failed, or restart is
|
|
415
765
|
// pending) — hold off briefly, then retry the exact pending version. A successful
|
|
@@ -489,7 +839,7 @@ export async function runUpdateOnce({
|
|
|
489
839
|
log("could not reach the npm registry to check for updates.");
|
|
490
840
|
break;
|
|
491
841
|
case "unsupported-platform":
|
|
492
|
-
log("self-update is only supported on macOS right now — update with `npm i -g relay-companion@latest`.");
|
|
842
|
+
log("self-update is only supported on macOS and Windows right now — update with `npm i -g relay-companion@latest`.");
|
|
493
843
|
break;
|
|
494
844
|
case "deferred-busy":
|
|
495
845
|
log(`an update to ${result.latest} is ready but deferred while an agent turn is active; it will install once idle.`);
|
package/src/install.js
CHANGED
|
@@ -501,6 +501,21 @@ export function claudeSettingsPath() {
|
|
|
501
501
|
);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
/**
|
|
505
|
+
* True when Claude looks present on this machine, judged ONLY by the directory
|
|
506
|
+
* that holds claudeSettingsPath() (so CLAUDE_SETTINGS / CLAUDE_HOME overrides are
|
|
507
|
+
* respected for tests). Deliberately not a `claude` CLI probe: Windows installs
|
|
508
|
+
* routinely have Claude Code/Desktop with ~/.claude configured by hand and no
|
|
509
|
+
* `claude` on PATH, and those installs still need the hook runtime.
|
|
510
|
+
*/
|
|
511
|
+
export function claudeAppearsPresent({ settingsPath = claudeSettingsPath() } = {}) {
|
|
512
|
+
try {
|
|
513
|
+
return fs.existsSync(path.dirname(settingsPath));
|
|
514
|
+
} catch {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
504
519
|
function shellArg(value) {
|
|
505
520
|
const clean = String(value);
|
|
506
521
|
return /\s/.test(clean) ? `"${clean.replaceAll('"', '\\"')}"` : clean;
|
|
@@ -1009,14 +1024,19 @@ export async function runSetupInstall({ mode = DEFAULT_COMPANION_MODE } = {}) {
|
|
|
1009
1024
|
if (claude.ok) {
|
|
1010
1025
|
installed.push("Claude Code");
|
|
1011
1026
|
activations.push(verifyClaudeMcpRegistration({ configPath: claude.configPath }));
|
|
1012
|
-
// The Open-in-current-chat hook runtime rides along with the MCP
|
|
1013
|
-
// registration (both are what "Relay is installed into Claude" means).
|
|
1014
|
-
claudeHooks = installClaudeHooks(bin, node);
|
|
1015
1027
|
} else if (claude.reason === "claude_code_not_found") {
|
|
1016
1028
|
missing.push("Claude Code");
|
|
1017
1029
|
} else {
|
|
1018
1030
|
missing.push("Claude Code (registration failed)");
|
|
1019
1031
|
}
|
|
1032
|
+
// The Open-in-current-chat hook runtime rides along with the MCP registration
|
|
1033
|
+
// (both are what "Relay is installed into Claude" means) — but it must NOT
|
|
1034
|
+
// depend on the `claude` CLI being on PATH. On Windows installClaudeCode fails
|
|
1035
|
+
// with claude_code_not_found whenever the CLI is absent even though Claude
|
|
1036
|
+
// Code/Desktop is installed and the MCP was registered by hand; without this
|
|
1037
|
+
// fallback the hook is never written and the pill's "Open in current chat" is a
|
|
1038
|
+
// silent no-op on exactly those machines.
|
|
1039
|
+
if (claude.ok || claudeAppearsPresent()) claudeHooks = installClaudeHooks(bin, node);
|
|
1020
1040
|
const codex = installCodex(bin, node, { mode: normalizedMode });
|
|
1021
1041
|
if (codex.ok) {
|
|
1022
1042
|
installed.push("Codex");
|