local-operator-ui 0.17.1 → 0.17.2

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.
Files changed (58) hide show
  1. package/out/main/index.js +1933 -183
  2. package/out/preload/index.js +51 -3
  3. package/out/renderer/assets/{_basePickBy-C3hLSrXE.js → _basePickBy-BvMhn6vs.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-DvVdHboz.js → _baseUniq-BK8pe4uQ.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-6irHwYok.js → agent-details-page-CBSLlAOk.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-t3NZyvhe.js → agent-hub-page-CrP4o6ZW.js} +1 -1
  7. package/out/renderer/assets/{agents-page-Cu3tV_JM.js → agents-page-C6OY5Mpg.js} +2 -2
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-Dy32lq21.js → architectureDiagram-IEHRJDOE-D8MpYATY.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-D9CCHreU.js → blockDiagram-JOT3LUYC-B_I3ZONm.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-D6JYul4W.js → c4Diagram-VJAJSXHY-DfXBiy8U.js} +1 -1
  11. package/out/renderer/assets/channel-QCRg_PDl.js +1 -0
  12. package/out/renderer/assets/{chunk-4BMEZGHF-CWZ3r21W.js → chunk-4BMEZGHF-BJ8Xa2j3.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-_jdJLEL4.js → chunk-A2AXSNBT-C0NnqsE4.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-BRqxxp9X.js → chunk-AEK57VVT-BtRuzQyQ.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-mvuW_LPO.js → chunk-D6G4REZN-BauYOOAw.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-B2kzBW8d.js → chunk-RZ5BOZE2-BzhuQ5O_.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-CqZmhFu_.js → chunk-XZIHB7SX-Ct2eCcqa.js} +1 -1
  18. package/out/renderer/assets/{classDiagram-GIVACNV2-Bu04i3t4.js → classDiagram-GIVACNV2-Bi3kPe4h.js} +1 -1
  19. package/out/renderer/assets/{classDiagram-v2-COTLJTTW-Bu04i3t4.js → classDiagram-v2-COTLJTTW-Bi3kPe4h.js} +1 -1
  20. package/out/renderer/assets/clone-A5TnVgxX.js +1 -0
  21. package/out/renderer/assets/{compact-pagination-BJa5bxJe.js → compact-pagination-Bou_Wr8n.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-BLi9lW3T.js → dagre-OKDRZEBW-CTPq15Dg.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-DQa3WYjU.js → diagram-SSKATNLV-C3Osiilj.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-C99A8WuH.js → diagram-VNBRO52H-Dz1J2itu.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-DLWwdshb.js → erDiagram-Q7BY3M3F-hZV4173D.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-CHDQ3e9O.js → flowDiagram-4HSFHLVR-DJ26Mgv9.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-G4Rf9Yhs.js → ganttDiagram-APWFNJXF-CetzfqU-.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-2JN_jG8T.js → gitGraphDiagram-7IBYFJ6S-p6uBXV2t.js} +1 -1
  29. package/out/renderer/assets/{graph-BR85xHbv.js → graph-DkNd5Vg_.js} +1 -1
  30. package/out/renderer/assets/{index-BIyDfbOQ.js → index-CH2sMRee.js} +1 -1
  31. package/out/renderer/assets/{index-_UzVA1oU.js → index-CsQcGUS4.js} +299 -299
  32. package/out/renderer/assets/{index-BuhuPWhs.js → index-DA5ogML6.js} +1 -1
  33. package/out/renderer/assets/{infoDiagram-PH2N3AL5-xVzynFeN.js → infoDiagram-PH2N3AL5-JCL1S2jO.js} +1 -1
  34. package/out/renderer/assets/{journeyDiagram-U35MCT3I-C8-eHlWx.js → journeyDiagram-U35MCT3I-bsiOJp5O.js} +1 -1
  35. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-C2Oyzo6d.js → kanban-definition-NDS4AKOZ-DqSB69IY.js} +1 -1
  36. package/out/renderer/assets/{layout-mlSyc_4q.js → layout-DOCr1qf_.js} +1 -1
  37. package/out/renderer/assets/{legacy-agents-page-CTGA2lul.js → legacy-agents-page-BjfTT7Fq.js} +1 -1
  38. package/out/renderer/assets/{mermaid.core-BvFuUEpc.js → mermaid.core-B5-LsuoJ.js} +5 -5
  39. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-DWnloutf.js → mindmap-definition-ALO5MXBD-6EZdR0xM.js} +1 -1
  40. package/out/renderer/assets/{parseISO-Bp3fMoKU.js → parseISO-Dwm8HpJw.js} +1 -1
  41. package/out/renderer/assets/{pieDiagram-IB7DONF6-B990Tpnk.js → pieDiagram-IB7DONF6-DJpMDsKb.js} +1 -1
  42. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BYeOekBR.js → quadrantDiagram-7GDLP6J5-DijmJtPz.js} +1 -1
  43. package/out/renderer/assets/{radar-MK3ICKWK-DcxkUK5Z.js → radar-MK3ICKWK-BTOUZlXe.js} +1 -1
  44. package/out/renderer/assets/{radient-auth-buttons-CK9Icvbq.js → radient-auth-buttons-Dwc9qPW2.js} +1 -1
  45. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D_tf_1lk.js → requirementDiagram-KVF5MWMF-D3lSHecQ.js} +1 -1
  46. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-DlDfWN8k.js → sankeyDiagram-QLVOVGJD-0iNyBkZV.js} +1 -1
  47. package/out/renderer/assets/{schedules-page-AQ9gMlkR.js → schedules-page-DardPLDn.js} +1 -1
  48. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-wr3-RcoT.js → sequenceDiagram-X6HHIX6F-CY9L6HEa.js} +1 -1
  49. package/out/renderer/assets/{settings-page-BSjdxTFW.js → settings-page-CjgBGEXW.js} +29 -29
  50. package/out/renderer/assets/{stateDiagram-DGXRK772-BLD6Vmgd.js → stateDiagram-DGXRK772-DJ_q4gER.js} +1 -1
  51. package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-CsGkljza.js → stateDiagram-v2-YXO3MK2T-CM2f7MUh.js} +1 -1
  52. package/out/renderer/assets/{timeline-definition-BDJGKUSR-B-JeF04j.js → timeline-definition-BDJGKUSR-D0Y8Jbrj.js} +1 -1
  53. package/out/renderer/assets/{use-agent-like-mutation-tstZNIUn.js → use-agent-like-mutation-BXxHLNnI.js} +1 -1
  54. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-DuFpzjW6.js → xychartDiagram-VJFVF3MP-Bj6ktAuR.js} +1 -1
  55. package/out/renderer/index.html +1 -1
  56. package/package.json +5 -3
  57. package/out/renderer/assets/channel-CLzqtgQj.js +0 -1
  58. package/out/renderer/assets/clone-DHy7FuoP.js +0 -1
package/out/main/index.js CHANGED
@@ -4422,9 +4422,910 @@ class DesktopNotifier {
4422
4422
  notification.show();
4423
4423
  }
4424
4424
  }
4425
+ const PENDING_INSTALL_MARKER_FILE = "pending-update-install.json";
4426
+ const INSTALL_DISK_SLACK_BYTES = 256 * 1024 * 1024;
4427
+ const DOWNLOAD_PAGE_URL = "https://local-operator.com/download";
4428
+ const WATCHDOG_TIMEOUT_SECONDS = 600;
4429
+ const PLIST_READ_TIMEOUT_SECONDS = 5;
4430
+ const WATCHDOG_TOKEN = "local-operator-update-watchdog";
4431
+ const BAD_BUNDLE_MARKERS = [
4432
+ "errSecCSBadBundleFormat",
4433
+ "-67028",
4434
+ "not a valid code object",
4435
+ "code object is not signed at all",
4436
+ "a sealed resource is missing or invalid",
4437
+ "errSecCSUnsigned",
4438
+ "-67062"
4439
+ ];
4440
+ function appBundleFromExecutable(execPath) {
4441
+ const marker = `${path.join("Contents", "MacOS")}/`;
4442
+ const index = execPath.lastIndexOf(marker);
4443
+ if (index <= 0) return null;
4444
+ return execPath.slice(0, index - 1);
4445
+ }
4446
+ function evaluateBundleSeal(probe) {
4447
+ const output = `${probe.stdout}
4448
+ ${probe.stderr}`.trim();
4449
+ if (probe.ran === false) {
4450
+ return {
4451
+ kind: "unavailable",
4452
+ detail: `codesign --verify did not complete: ${output || "no output"}`
4453
+ };
4454
+ }
4455
+ if (probe.exitCode === 0) return { kind: "sealed" };
4456
+ const marker = BAD_BUNDLE_MARKERS.find(
4457
+ (candidate) => output.includes(candidate)
4458
+ );
4459
+ return {
4460
+ kind: "unsealed",
4461
+ detail: marker ? `${marker}: ${output || "no output"}` : `codesign --verify exited ${probe.exitCode}: ${output || "no output"}`
4462
+ };
4463
+ }
4464
+ function installedBundleSealBlock(appBundlePath, detail, version2) {
4465
+ return {
4466
+ code: "installed-bundle-not-sealed",
4467
+ message: version2 ? `This install of Local Operator can't be updated in place, so the update to version ${version2} was stopped before the app quit.` : "This install of Local Operator can't be updated in place, so the update was stopped before the app quit.",
4468
+ remedy: {
4469
+ text: "Quit Local Operator, then download a fresh copy and replace the app in Applications.",
4470
+ url: DOWNLOAD_PAGE_URL
4471
+ },
4472
+ detail: `${appBundlePath}: ${detail}`
4473
+ };
4474
+ }
4475
+ function metadataBasenames(filePath, updatePath) {
4476
+ const names = [path.basename(filePath)];
4477
+ if (updatePath) names.push(path.basename(updatePath));
4478
+ return names;
4479
+ }
4480
+ function matchArtifactMetadata(filePath, files, updatePath) {
4481
+ const names = metadataBasenames(filePath, updatePath);
4482
+ for (const name of names) {
4483
+ for (const file of files) {
4484
+ let url = file.url;
4485
+ try {
4486
+ url = decodeURIComponent(url);
4487
+ } catch {
4488
+ }
4489
+ if (path.basename(url) === name) return file;
4490
+ }
4491
+ }
4492
+ return null;
4493
+ }
4494
+ function requiredDiskBytes(input) {
4495
+ return input.artifactSize + input.installedBundleSize * 2 + INSTALL_DISK_SLACK_BYTES;
4496
+ }
4497
+ function formatGiB(bytes) {
4498
+ return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
4499
+ }
4500
+ function measureDirectoryBytes(root) {
4501
+ try {
4502
+ const stats = fs.lstatSync(root);
4503
+ if (!stats.isDirectory()) return stats.size;
4504
+ let total = 0;
4505
+ for (const entry of fs.readdirSync(root)) {
4506
+ const child = path.join(root, entry);
4507
+ let childStats;
4508
+ try {
4509
+ childStats = fs.lstatSync(child);
4510
+ } catch {
4511
+ continue;
4512
+ }
4513
+ if (childStats.isDirectory()) {
4514
+ const nested = measureDirectoryBytes(child);
4515
+ if (nested != null) total += nested;
4516
+ } else {
4517
+ total += childStats.size;
4518
+ }
4519
+ }
4520
+ return total;
4521
+ } catch {
4522
+ return null;
4523
+ }
4524
+ }
4525
+ function versionClause(version2) {
4526
+ return version2 ? ` to version ${version2}` : "";
4527
+ }
4528
+ function verifyStagedArtifact(input) {
4529
+ const name = path.basename(input.filePath);
4530
+ const version2 = input.version ?? null;
4531
+ if (input.metadata == null) {
4532
+ return {
4533
+ ok: false,
4534
+ block: {
4535
+ code: "artifact-metadata-missing",
4536
+ message: `The downloaded update${versionClause(version2)} isn't listed in the release metadata, so it wasn't installed.`,
4537
+ remedy: {
4538
+ text: "Check for updates again to re-download the release."
4539
+ },
4540
+ detail: `${name} has no matching entry in the update metadata.`
4541
+ }
4542
+ };
4543
+ }
4544
+ const expectedSize = input.metadata.size ?? null;
4545
+ if (expectedSize != null && input.actualSize !== expectedSize) {
4546
+ return {
4547
+ ok: false,
4548
+ block: {
4549
+ code: "download-verification-failed",
4550
+ message: `The downloaded update${versionClause(version2)} doesn't match the published release, so it wasn't installed.`,
4551
+ remedy: {
4552
+ text: "Check for updates again to re-download the release."
4553
+ },
4554
+ detail: `${name} is ${input.actualSize} bytes; the release lists ${expectedSize}.`
4555
+ }
4556
+ };
4557
+ }
4558
+ const expectedSha = input.metadata.sha512 ?? null;
4559
+ if (expectedSha != null && input.actualSha512 !== expectedSha) {
4560
+ return {
4561
+ ok: false,
4562
+ block: {
4563
+ code: "download-verification-failed",
4564
+ message: `The downloaded update${versionClause(version2)} doesn't match the published release, so it wasn't installed.`,
4565
+ remedy: {
4566
+ text: "Check for updates again to re-download the release."
4567
+ },
4568
+ detail: `${name} sha512 ${input.actualSha512} does not match the release's ${expectedSha}.`
4569
+ }
4570
+ };
4571
+ }
4572
+ const artifactSize = expectedSize ?? input.actualSize;
4573
+ const installedBundleSize = input.installedBundleSize ?? 0;
4574
+ const needed = requiredDiskBytes({ artifactSize, installedBundleSize });
4575
+ if (input.freeBytes < needed) {
4576
+ const footprint = installedBundleSize ? `${formatGiB(artifactSize)} artifact + two ${formatGiB(
4577
+ installedBundleSize
4578
+ )} app copies + ${formatGiB(INSTALL_DISK_SLACK_BYTES)} slack` : `${formatGiB(artifactSize)} artifact + ${formatGiB(INSTALL_DISK_SLACK_BYTES)} slack (the installed app could not be measured)`;
4579
+ return {
4580
+ ok: false,
4581
+ block: {
4582
+ code: "insufficient-disk-space",
4583
+ message: `There isn't enough free disk space to install the update${versionClause(version2)}, so it wasn't installed.`,
4584
+ remedy: {
4585
+ text: `Free up about ${formatGiB(needed - input.freeBytes)}, then check for updates again.`
4586
+ },
4587
+ detail: `Install needs ${formatGiB(needed)} (${footprint}); ${formatGiB(input.freeBytes)} free.`
4588
+ }
4589
+ };
4590
+ }
4591
+ return { ok: true, sha512: input.actualSha512, size: input.actualSize };
4592
+ }
4593
+ function resolveStagedArtifactPath(input) {
4594
+ if (input.downloadHelperFile && fs.existsSync(input.downloadHelperFile)) {
4595
+ return input.downloadHelperFile;
4596
+ }
4597
+ if (input.candidateNames.length === 0 || !fs.existsSync(input.pendingDir)) {
4598
+ return null;
4599
+ }
4600
+ const entries = input.listDir(input.pendingDir);
4601
+ for (const name of input.candidateNames) {
4602
+ const match = entries.find((entry) => entry === name);
4603
+ if (match) return path.join(input.pendingDir, match);
4604
+ }
4605
+ return null;
4606
+ }
4607
+ function pendingInstallMarkerPath(dir) {
4608
+ return path.join(dir, PENDING_INSTALL_MARKER_FILE);
4609
+ }
4610
+ function writePendingInstallMarker(dir, marker) {
4611
+ fs.mkdirSync(dir, { recursive: true });
4612
+ const target2 = pendingInstallMarkerPath(dir);
4613
+ const temp = `${target2}.tmp`;
4614
+ fs.writeFileSync(temp, `${JSON.stringify(marker, null, 2)}
4615
+ `, "utf8");
4616
+ fs.renameSync(temp, target2);
4617
+ return marker;
4618
+ }
4619
+ function parsePendingInstallMarker(raw) {
4620
+ try {
4621
+ const parsed = JSON.parse(raw);
4622
+ if (typeof parsed?.targetVersion !== "string" || parsed.targetVersion.length === 0) {
4623
+ return null;
4624
+ }
4625
+ return {
4626
+ targetVersion: parsed.targetVersion,
4627
+ artifactPath: typeof parsed.artifactPath === "string" ? parsed.artifactPath : "",
4628
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : "",
4629
+ watchdogPid: typeof parsed.watchdogPid === "number" ? parsed.watchdogPid : null
4630
+ };
4631
+ } catch {
4632
+ return null;
4633
+ }
4634
+ }
4635
+ function readPendingInstallMarker(dir) {
4636
+ const path2 = pendingInstallMarkerPath(dir);
4637
+ if (!fs.existsSync(path2)) return null;
4638
+ try {
4639
+ return parsePendingInstallMarker(fs.readFileSync(path2, "utf8"));
4640
+ } catch {
4641
+ return null;
4642
+ }
4643
+ }
4644
+ function clearPendingInstallMarker(dir) {
4645
+ const path2 = pendingInstallMarkerPath(dir);
4646
+ if (!fs.existsSync(path2)) return false;
4647
+ fs.rmSync(path2, { force: true });
4648
+ return true;
4649
+ }
4650
+ const LAST_INSTALL_ATTEMPT_FILE = "last-update-install.json";
4651
+ function lastInstallAttemptPath(dir) {
4652
+ return path.join(dir, LAST_INSTALL_ATTEMPT_FILE);
4653
+ }
4654
+ function readLastInstallAttempt(dir) {
4655
+ const path2 = lastInstallAttemptPath(dir);
4656
+ if (!fs.existsSync(path2)) return null;
4657
+ try {
4658
+ const parsed = JSON.parse(
4659
+ fs.readFileSync(path2, "utf8")
4660
+ );
4661
+ if (typeof parsed?.targetVersion !== "string" || parsed.targetVersion.length === 0) {
4662
+ return null;
4663
+ }
4664
+ return {
4665
+ targetVersion: parsed.targetVersion,
4666
+ runningVersion: typeof parsed.runningVersion === "string" ? parsed.runningVersion : "unknown",
4667
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null,
4668
+ detectedAt: typeof parsed.detectedAt === "string" ? parsed.detectedAt : "",
4669
+ detail: typeof parsed.detail === "string" ? parsed.detail : "",
4670
+ attempts: typeof parsed.attempts === "number" && parsed.attempts > 0 ? parsed.attempts : 1
4671
+ };
4672
+ } catch {
4673
+ return null;
4674
+ }
4675
+ }
4676
+ function recordInstallFailure(dir, input) {
4677
+ const previous = readLastInstallAttempt(dir);
4678
+ const record = {
4679
+ targetVersion: input.payload.targetVersion,
4680
+ runningVersion: previous?.runningVersion ?? "unknown",
4681
+ startedAt: input.startedAt,
4682
+ detectedAt: input.detectedAt,
4683
+ detail: input.payload.detail,
4684
+ attempts: previous && previous.targetVersion === input.payload.targetVersion ? previous.attempts + 1 : 1
4685
+ };
4686
+ fs.mkdirSync(dir, { recursive: true });
4687
+ const target2 = lastInstallAttemptPath(dir);
4688
+ const temp = `${target2}.tmp`;
4689
+ fs.writeFileSync(temp, `${JSON.stringify(record, null, 2)}
4690
+ `, "utf8");
4691
+ fs.renameSync(temp, target2);
4692
+ return record;
4693
+ }
4694
+ const VERSION_TRIPLE_REGEX = /^v?(\d+)\.(\d+)\.(\d+)/;
4695
+ function compareVersions(a, b) {
4696
+ const parse = (value) => {
4697
+ const match = VERSION_TRIPLE_REGEX.exec(value.trim());
4698
+ if (!match) return null;
4699
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
4700
+ };
4701
+ const left = parse(a);
4702
+ const right = parse(b);
4703
+ if (!left || !right) return null;
4704
+ for (let index = 0; index < 3; index++) {
4705
+ if (left[index] !== right[index])
4706
+ return left[index] < right[index] ? -1 : 1;
4707
+ }
4708
+ return 0;
4709
+ }
4710
+ function evaluatePendingInstall(input) {
4711
+ const { marker } = input;
4712
+ if (marker == null) return { kind: "none" };
4713
+ if (marker.targetVersion === input.runningVersion) {
4714
+ return { kind: "succeeded", marker };
4715
+ }
4716
+ const order = compareVersions(marker.targetVersion, input.runningVersion);
4717
+ if (order !== null && order < 0) return { kind: "stale", marker };
4718
+ return { kind: "failed", marker };
4719
+ }
4720
+ function installFailurePayload(marker, runningVersion, options = {}) {
4721
+ const started = marker.startedAt ? new Date(marker.startedAt) : null;
4722
+ const startedText = started && !Number.isNaN(started.getTime()) ? started.toLocaleString() : "at an unknown time";
4723
+ const logText = options.shipItLogPath ? ` Squirrel's own log is at ${options.shipItLogPath}.` : "";
4724
+ return {
4725
+ message: `The update to version ${marker.targetVersion} didn't finish, so version ${runningVersion} is still running.`,
4726
+ remedy: {
4727
+ text: "Quit Local Operator and replace it in Applications with a fresh copy, or update again from the app.",
4728
+ url: DOWNLOAD_PAGE_URL
4729
+ },
4730
+ detail: `Install started ${startedText} from ${marker.artifactPath || "an unknown artifact"}.${logText}`,
4731
+ targetVersion: marker.targetVersion,
4732
+ attempts: options.attempts ?? 1
4733
+ };
4734
+ }
4735
+ function shipItJobLabel(bundleId) {
4736
+ return `${bundleId}.ShipIt`;
4737
+ }
4738
+ function shipItCacheDir(cacheRoot, bundleId) {
4739
+ return path.join(cacheRoot, shipItJobLabel(bundleId));
4740
+ }
4741
+ function watchdogSwapTarget(input) {
4742
+ if (!input.target) return null;
4743
+ if (!input.running) return input.target;
4744
+ const order = compareVersions(input.target, input.running);
4745
+ if (order !== null && order <= 0) return null;
4746
+ if (input.target === input.running) return null;
4747
+ return input.target;
4748
+ }
4749
+ function buildWatchdogPlan(input) {
4750
+ const timeoutSeconds = input.timeoutSeconds ?? WATCHDOG_TIMEOUT_SECONDS;
4751
+ const intervalSeconds = input.intervalSeconds ?? 3;
4752
+ const settleSeconds = input.settleSeconds ?? 5;
4753
+ const appearSeconds = input.appearSeconds ?? 30;
4754
+ const plistTimeoutSeconds = input.plistReadTimeoutSeconds ?? PLIST_READ_TIMEOUT_SECONDS;
4755
+ const script = `#!/bin/sh
4756
+ # ${WATCHDOG_TOKEN}
4757
+ #
4758
+ # Why this exists: a failed Squirrel.Mac install quits the app and never brings
4759
+ # it back - ShipIt logs an installation error, leaves no exit status and does not
4760
+ # run the relaunch - so the user is left with no app and no message (operator
4761
+ # report, 2026-09-11, errSecCSBadBundleFormat -67028).
4762
+ #
4763
+ # How it decides, and why each signal is the one it is:
4764
+ # - "the app has exited" is asked of the app's own pid (kill -0), captured by
4765
+ # the app before it quit. A name probe cannot answer it on macOS: pgrep -f
4766
+ # does not report its own ancestors, and this script is the app's child.
4767
+ # - "the install is over" is asked of BOTH the ShipIt launchd job, by label,
4768
+ # AND the state of the swap on disk. The job is submitted as part of the quit
4769
+ # and unloaded when an install settles, so it is the signal that separates a
4770
+ # hung install from an unrelated process that merely has the word in its
4771
+ # command line - but it is not sufficient on its own: the 0.17.0 failure
4772
+ # left its job loaded and respawning for hours (runs=3114), so waiting only
4773
+ # on the job meant waiting for the bound with the user staring at nothing
4774
+ # (review R11). The version in the target bundle's own Info.plist is what
4775
+ # says the swap landed, whether or not the job ever goes away.
4776
+ #
4777
+ # It starts the app in exactly one situation - the app is not running - and it
4778
+ # reaches that point on three paths:
4779
+ # (a) ShipIt's job is gone: the install is decided. The conservative case.
4780
+ # (b) the bundle at the target path reports the version this update was for, or
4781
+ # one beyond it: the new app is in place and ShipIt has nothing left to do.
4782
+ # This is what makes an early exit safe rather than a guess, and it is the
4783
+ # path a failed-but-swapped install takes.
4784
+ # (c) the bound arrived with no decision. Trying is still better than leaving
4785
+ # the user with nothing, and the bound is what makes the previous
4786
+ # version's silent exit 0 impossible. It is ~2.4x the slowest ShipIt
4787
+ # attempt measured on this machine (255s from request to verdict for a
4788
+ # 1 GiB app), and it is the ONE path that can start the app while a swap
4789
+ # is still in flight - an accepted risk, not an oversight.
4790
+ #
4791
+ # With no job label to ask about, the job cannot be consulted at all, so the swap
4792
+ # state is the whole signal: the script waits for (b) rather than spending an
4793
+ # appear window on a job it cannot see and then relaunching on no evidence.
4794
+ set -u
4795
+ APP_PID="\${LO_UPDATE_WATCHDOG_APP_PID:-}"
4796
+ BUNDLE="\${LO_UPDATE_WATCHDOG_APP_BUNDLE:-}"
4797
+ NAME="\${LO_UPDATE_WATCHDOG_APP_NAME:-}"
4798
+ SHIPIT_JOB="\${LO_UPDATE_WATCHDOG_SHIPIT_JOB:-}"
4799
+ TARGET_VERSION="\${LO_UPDATE_WATCHDOG_TARGET_VERSION:-}"
4800
+ if [ -z "$APP_PID" ] || [ -z "$BUNDLE" ]; then
4801
+ exit 1
4802
+ fi
4803
+ now() { date +%s; }
4804
+ app_running() { kill -0 "$APP_PID" 2>/dev/null; }
4805
+ # launchctl list <label> exits 113 when the job is not loaded, 0 when it is.
4806
+ shipit_loaded() { [ -n "$SHIPIT_JOB" ] && launchctl list "$SHIPIT_JOB" >/dev/null 2>&1; }
4807
+ # (b), the swap's own state: is the app at the target path AT OR BEYOND the
4808
+ # version this update was for? plutil rather than \`defaults read\`, which reads
4809
+ # through a preference domain and can answer from a stale cache; a half-written
4810
+ # plist and a missing one both read as "not landed yet", which is the safe
4811
+ # direction.
4812
+ #
4813
+ # At or beyond rather than an exact match, because this is the same question the
4814
+ # renderer's clear rule asks ("is the server no longer behind the version the
4815
+ # panel named?"): a release that moves on between the offer and the check must
4816
+ # not leave the app waiting out the whole bound for a bundle already past the
4817
+ # target (review Q6).
4818
+ version_at_least() {
4819
+ # An absent version is not a version. This is the ONE input the two halves
4820
+ # answer differently on, deliberately: the renderer pads a missing side with
4821
+ # zero (\`left[index] ?? 0\`), so it reads an empty target as "at least 0",
4822
+ # while a rule that can start a relaunch must not call a version nobody
4823
+ # reported "landed". Both callers prove the operand non-empty first - the
4824
+ # script's \`swap_landed\` declines an empty target outright and the read
4825
+ # answers nothing rather than a version - so no path reaches this with one.
4826
+ [ -n "$1" ] && [ -n "$2" ] || return 1
4827
+ # Exact match first, as the renderer's rule does: a pre-release pair that
4828
+ # matches exactly IS the version that was waited for, and the digit checks
4829
+ # below cannot order a suffix at all. The caller strips a leading \`v\` before
4830
+ # calling, which is the other half of what the renderer's rule does itself.
4831
+ [ "$1" = "$2" ] && return 0
4832
+ # Anything else carrying a suffix is not orderable here, and the renderer's
4833
+ # rule refuses the same inputs for the same reason ("keeps the instruction on
4834
+ # screen rather than clearing over a real gap"). Answered before the digit
4835
+ # walk, because a suffix the unrelated leading numeric components never reach
4836
+ # would otherwise be ignored - \`1.0.0\` against \`0.18.0-rc1\` compared as
4837
+ # landed here and as not-beyond there. Not-landed is the safe direction in
4838
+ # this script: the job and the bound still decide, and the app still comes
4839
+ # back.
4840
+ case "$1$2" in *-*) return 1 ;; esac
4841
+ _va="$1"
4842
+ _vb="$2"
4843
+ while :; do
4844
+ case "$_va" in
4845
+ *.*) _ha="\${_va%%.*}"; _va="\${_va#*.}" ;;
4846
+ *) _ha="$_va"; _va="" ;;
4847
+ esac
4848
+ case "$_vb" in
4849
+ *.*) _hb="\${_vb%%.*}"; _vb="\${_vb#*.}" ;;
4850
+ *) _hb="$_vb"; _vb="" ;;
4851
+ esac
4852
+ # A component with no digits in it is a pre-release suffix, which this
4853
+ # cannot order: it reads as "not landed", so the job and the bound stay
4854
+ # the deciders. Same shape as the renderer's rule.
4855
+ case "$_ha" in *[!0-9]*) return 1 ;; esac
4856
+ case "$_hb" in *[!0-9]*) return 1 ;; esac
4857
+ # A side that has run out contributes zero, which is what the renderer's
4858
+ # \`left[index] ?? 0\` does, so unequally wide dotted versions order the
4859
+ # same way in both halves of "at or beyond". Without this, \`0.18.0.1\`
4860
+ # against \`0.18.0\` read as not-landed here and as landed there (review
4861
+ # round 4, M3): the safe direction is not a reason to leave two
4862
+ # implementations of one rule disagreeing.
4863
+ [ -n "$_ha" ] || _ha=0
4864
+ [ -n "$_hb" ] || _hb=0
4865
+ if [ "$_ha" -gt "$_hb" ]; then return 0; fi
4866
+ if [ "$_ha" -lt "$_hb" ]; then return 1; fi
4867
+ if [ -z "$_va" ] && [ -z "$_vb" ]; then return 0; fi
4868
+ done
4869
+ }
4870
+ # Read the bundle's version under a hard time bound. This sits inside the poll
4871
+ # loop, and an unbounded read of a path on a mount that has stopped answering
4872
+ # would outlive the script's own deadline and leave the user with no app - the
4873
+ # very outcome this script exists to undo. macOS ships no \`timeout(1)\`, so the
4874
+ # bound is a background read the script can stop waiting on (review R17).
4875
+ bundle_version() {
4876
+ _plist="$BUNDLE/Contents/Info.plist"
4877
+ # A plist that is not readable yet - the ordinary state while a swap is in
4878
+ # flight - answers without starting a read at all, so the bound below is only
4879
+ # ever spent on a read that started and did not answer.
4880
+ [ -r "$_plist" ] || return 1
4881
+ _stamp="$$-$(now)"
4882
+ _out="\${TMPDIR:-/tmp}/lo-update-watchdog-version-$_stamp.out"
4883
+ # The pid the bound kills IS the reader: \`exec\` replaces the subshell rather
4884
+ # than wrapping plutil, so nothing is reparented when the kill lands. The
4885
+ # previous shape killed a subshell whose whole job was to drop a completion
4886
+ # marker, and the read that subshell had forked was left blocked under pid 1 -
4887
+ # one more per poll, for as long as the mount stayed dead (review round 4,
4888
+ # Q7). Completion is read off the answer itself instead: plutil writes the
4889
+ # version into this file and nothing else does, so bytes in it mean the read
4890
+ # answered, and a read that fails writes none and is stopped by the same
4891
+ # deadline.
4892
+ ( exec /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$_plist" ) >"$_out" 2>/dev/null &
4893
+ _read_pid=$!
4894
+ _waited=0
4895
+ while [ ! -s "$_out" ]; do
4896
+ if [ "$_waited" -ge ${plistTimeoutSeconds} ]; then
4897
+ kill -9 "$_read_pid" 2>/dev/null
4898
+ rm -f "$_out"
4899
+ return 1
4900
+ fi
4901
+ sleep 1
4902
+ _waited=$((_waited + 1))
4903
+ done
4904
+ _version="$(cat "$_out" 2>/dev/null)"
4905
+ rm -f "$_out"
4906
+ [ -n "$_version" ] || return 1
4907
+ printf '%s
4908
+ ' "$_version"
4909
+ }
4910
+ swap_landed() {
4911
+ [ -n "$TARGET_VERSION" ] || return 1
4912
+ installed=$(bundle_version) || return 1
4913
+ installed=\${installed#v}
4914
+ [ -n "$installed" ] || return 1
4915
+ target=\${TARGET_VERSION#v}
4916
+ [ "$installed" = "$target" ] && return 0
4917
+ version_at_least "$installed" "$target"
4918
+ }
4919
+ job_known=0
4920
+ [ -n "$SHIPIT_JOB" ] && job_known=1
4921
+ decided() {
4922
+ if [ "$job_known" -eq 1 ] && ! shipit_loaded; then return 0; fi
4923
+ swap_landed && return 0
4924
+ return 1
4925
+ }
4926
+ deadline=$(( $(now) + ${timeoutSeconds} ))
4927
+ # 1. The install only starts once the old app has exited.
4928
+ while app_running; do
4929
+ if [ "$(now)" -ge "$deadline" ]; then break; fi
4930
+ sleep ${intervalSeconds}
4931
+ done
4932
+ # 2. ShipIt's job is submitted as part of the quit, so it may not be loaded the
4933
+ # instant the app is gone: give it a bounded window to appear before treating
4934
+ # "no job" as "the install is over". Skipped when there is no label to ask
4935
+ # about: an appear window for an unaskable job is 30s of pretending, and it
4936
+ # used to end by relaunching with no evidence about the install at all.
4937
+ if [ "$job_known" -eq 1 ]; then
4938
+ appear_deadline=$(( $(now) + ${appearSeconds} ))
4939
+ while ! shipit_loaded; do
4940
+ if [ "$(now)" -ge "$appear_deadline" ] || [ "$(now)" -ge "$deadline" ]; then break; fi
4941
+ sleep ${intervalSeconds}
4942
+ done
4943
+ fi
4944
+ # 3. Wait for the install to be decided - by the job going, or by the swap
4945
+ # landing - and never past the bound.
4946
+ while :; do
4947
+ if decided; then break; fi
4948
+ if [ "$(now)" -ge "$deadline" ]; then break; fi
4949
+ sleep ${intervalSeconds}
4950
+ done
4951
+ # Let Squirrel's own relaunch (which happens as the job finishes) land first.
4952
+ sleep ${settleSeconds}
4953
+ # 4. Nothing to do if Squirrel relaunched the app or the user started it. This is
4954
+ # what makes the script a no-op when the install succeeded, by construction
4955
+ # rather than by claim.
4956
+ if app_running; then exit 0; fi
4957
+ if [ -n "$NAME" ] && [ -x "$BUNDLE/Contents/MacOS/$NAME" ]; then
4958
+ open -a "$BUNDLE" >/dev/null 2>&1 || "$BUNDLE/Contents/MacOS/$NAME" >/dev/null 2>&1 &
4959
+ fi
4960
+ exit 0
4961
+ `;
4962
+ return {
4963
+ script,
4964
+ env: {
4965
+ LO_UPDATE_WATCHDOG_APP_PID: String(input.appPid),
4966
+ LO_UPDATE_WATCHDOG_APP_BUNDLE: input.appBundlePath,
4967
+ LO_UPDATE_WATCHDOG_APP_NAME: input.executableName,
4968
+ LO_UPDATE_WATCHDOG_SHIPIT_JOB: input.shipItJob ?? "",
4969
+ LO_UPDATE_WATCHDOG_TARGET_VERSION: input.targetVersion ?? ""
4970
+ },
4971
+ timeoutSeconds
4972
+ };
4973
+ }
4974
+ function reapFailedInstall(input) {
4975
+ const result = {
4976
+ jobLabel: null,
4977
+ jobRemoved: false,
4978
+ removedStaging: [],
4979
+ errors: []
4980
+ };
4981
+ if (!input.bundleId) {
4982
+ input.log(
4983
+ "Skipped cleaning up the failed install: the app's bundle id could not be read."
4984
+ );
4985
+ return result;
4986
+ }
4987
+ const label = shipItJobLabel(input.bundleId);
4988
+ result.jobLabel = label;
4989
+ try {
4990
+ const removal = input.removeJob(label);
4991
+ result.jobRemoved = !removal.notFound;
4992
+ input.log(
4993
+ removal.notFound ? `Leftover install job ${label} was not loaded.` : `Removed the leftover install job ${label}.`
4994
+ );
4995
+ } catch (error) {
4996
+ result.errors.push(`launchctl remove ${label}: ${String(error)}`);
4997
+ input.log(
4998
+ `Could not remove the leftover install job ${label}: ${String(error)}`
4999
+ );
5000
+ }
5001
+ const cacheDir = input.cacheRoot ? shipItCacheDir(input.cacheRoot, input.bundleId) : null;
5002
+ if (!cacheDir) return result;
5003
+ try {
5004
+ if (!input.exists(cacheDir)) return result;
5005
+ for (const entry of input.listDir(cacheDir)) {
5006
+ if (!entry.startsWith("update.")) continue;
5007
+ const staging = path.join(cacheDir, entry);
5008
+ try {
5009
+ input.removeDir(staging);
5010
+ result.removedStaging.push(staging);
5011
+ input.log(`Removed the staged update left behind at ${staging}.`);
5012
+ } catch (error) {
5013
+ result.errors.push(`${staging}: ${String(error)}`);
5014
+ input.log(`Could not remove ${staging}: ${String(error)}`);
5015
+ }
5016
+ }
5017
+ } catch (error) {
5018
+ result.errors.push(`${cacheDir}: ${String(error)}`);
5019
+ input.log(`Could not inspect ${cacheDir}: ${String(error)}`);
5020
+ }
5021
+ return result;
5022
+ }
5023
+ function watchdogIsOurs(input) {
5024
+ if (!input.alive || !input.installSucceeded) return false;
5025
+ return (input.commandLine ?? "").includes(WATCHDOG_TOKEN);
5026
+ }
5027
+ const UV_TOOL_LIST_ENTRY_REGEX = /(^|\n)local-operator v\d/i;
5028
+ const PIPX_LIST_ENTRY_REGEX = /(^|\n)\s*package local-operator\b/i;
5029
+ function uvToolListNamesLocalOperator(output) {
5030
+ return UV_TOOL_LIST_ENTRY_REGEX.test(output ?? "");
5031
+ }
5032
+ function pipxListNamesLocalOperator(output) {
5033
+ return PIPX_LIST_ENTRY_REGEX.test(output ?? "");
5034
+ }
5035
+ const PYTHON_LIB_DIR = /^python\d/;
5036
+ const LOCAL_OPERATOR_DIST_INFO = /^local[-_]operator[-_]/;
5037
+ function resolveDistributionMarkers(prefix) {
5038
+ const sitePackages = [];
5039
+ try {
5040
+ for (const entry of fs.readdirSync(path.join(prefix, "lib"))) {
5041
+ if (!PYTHON_LIB_DIR.test(entry)) continue;
5042
+ const candidate = path.join(prefix, "lib", entry, "site-packages");
5043
+ if (fs.existsSync(candidate)) sitePackages.push(candidate);
5044
+ }
5045
+ } catch {
5046
+ }
5047
+ const windowsSitePackages = path.join(prefix, "Lib", "site-packages");
5048
+ if (fs.existsSync(windowsSitePackages)) sitePackages.push(windowsSitePackages);
5049
+ let installer = null;
5050
+ let editable = false;
5051
+ for (const dir of sitePackages) {
5052
+ let entries;
5053
+ try {
5054
+ entries = fs.readdirSync(dir);
5055
+ } catch {
5056
+ continue;
5057
+ }
5058
+ for (const entry of entries) {
5059
+ if (!LOCAL_OPERATOR_DIST_INFO.test(entry)) continue;
5060
+ if (!entry.endsWith(".dist-info")) continue;
5061
+ const distInfo = path.join(dir, entry);
5062
+ try {
5063
+ const installerText = fs.readFileSync(path.join(distInfo, "INSTALLER"), "utf8");
5064
+ installer = installerText.trim().toLowerCase() || installer;
5065
+ } catch {
5066
+ }
5067
+ try {
5068
+ const payload = JSON.parse(
5069
+ fs.readFileSync(path.join(distInfo, "direct_url.json"), "utf8")
5070
+ );
5071
+ if (payload.editable === true || payload.dir_info?.editable === true) {
5072
+ editable = true;
5073
+ }
5074
+ } catch {
5075
+ }
5076
+ }
5077
+ }
5078
+ return { installer, editable };
5079
+ }
5080
+ const TRAILING_SLASHES = /\/+$/;
5081
+ function commandSearchDirs(env, home) {
5082
+ const dirs = [];
5083
+ const add = (dir) => {
5084
+ if (!dir) return;
5085
+ const trimmed = dir.replace(TRAILING_SLASHES, "");
5086
+ if (trimmed.length === 0 || dirs.includes(trimmed)) return;
5087
+ dirs.push(trimmed);
5088
+ };
5089
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) add(dir);
5090
+ add(env.UV_TOOL_BIN_DIR);
5091
+ add(env.XDG_BIN_HOME);
5092
+ if (env.XDG_DATA_HOME) add(path.join(env.XDG_DATA_HOME, "..", "bin"));
5093
+ add(path.join(home, ".local", "bin"));
5094
+ add("/opt/homebrew/bin");
5095
+ add("/usr/local/bin");
5096
+ add("/usr/bin");
5097
+ add("/bin");
5098
+ return dirs;
5099
+ }
5100
+ function uvToolBinDirs(env, home, listDir, platform) {
5101
+ const root = env.UV_TOOL_DIR ?? (env.XDG_DATA_HOME ? path.join(env.XDG_DATA_HOME, "uv", "tools") : path.join(home, ".local", "share", "uv", "tools"));
5102
+ const scriptDir = platform === "win32" ? "Scripts" : "bin";
5103
+ const dirs = [];
5104
+ try {
5105
+ for (const entry of listDir(root)) {
5106
+ dirs.push(path.join(root, entry, scriptDir));
5107
+ }
5108
+ } catch {
5109
+ }
5110
+ return dirs;
5111
+ }
5112
+ const WINDOWS_DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
5113
+ function windowsCommandExtensions(env) {
5114
+ const raw = (env.PATHEXT ?? "").trim();
5115
+ const parts = (raw.length > 0 ? raw : WINDOWS_DEFAULT_PATHEXT).split(";");
5116
+ const extensions = [];
5117
+ for (const part of parts) {
5118
+ const extension = part.trim();
5119
+ if (extension.length === 0 || extensions.includes(extension)) continue;
5120
+ extensions.push(extension);
5121
+ }
5122
+ return extensions;
5123
+ }
5124
+ function commandCandidates(dir, name, env, platform) {
5125
+ const candidates = [path.join(dir, name)];
5126
+ if (platform !== "win32") return candidates;
5127
+ for (const extension of windowsCommandExtensions(env)) {
5128
+ candidates.push(path.join(dir, name + extension));
5129
+ }
5130
+ return candidates;
5131
+ }
5132
+ function resolveCommandPath(name, options = {}) {
5133
+ const env = options.env ?? process.env;
5134
+ const home = options.home ?? os.homedir();
5135
+ const platform = options.platform ?? process.platform;
5136
+ const exists = options.exists ?? fs.existsSync;
5137
+ const listDir = options.listDir ?? ((dir) => {
5138
+ try {
5139
+ return fs.readdirSync(dir);
5140
+ } catch {
5141
+ return [];
5142
+ }
5143
+ });
5144
+ const dirs = [
5145
+ ...commandSearchDirs(env, home),
5146
+ ...uvToolBinDirs(env, home, listDir, platform)
5147
+ ];
5148
+ for (const dir of dirs) {
5149
+ for (const candidate of commandCandidates(dir, name, env, platform)) {
5150
+ try {
5151
+ if (exists(candidate)) return candidate;
5152
+ } catch {
5153
+ }
5154
+ }
5155
+ }
5156
+ return null;
5157
+ }
5158
+ function readInstallIdentity(shimPath) {
5159
+ if (!shimPath) return { path: null };
5160
+ let realPath = null;
5161
+ try {
5162
+ realPath = fs.realpathSync(shimPath);
5163
+ } catch {
5164
+ realPath = null;
5165
+ }
5166
+ let shebang = null;
5167
+ try {
5168
+ const firstLine = fs.readFileSync(shimPath, "utf8").split("\n", 1)[0] ?? "";
5169
+ shebang = firstLine.startsWith("#!") ? firstLine : null;
5170
+ } catch {
5171
+ shebang = null;
5172
+ }
5173
+ const executable = realPath ?? shimPath;
5174
+ const prefix = path.dirname(path.dirname(executable));
5175
+ const existing = (candidate) => fs.existsSync(candidate) ? candidate : null;
5176
+ const markers = resolveDistributionMarkers(prefix);
5177
+ return {
5178
+ path: shimPath,
5179
+ realPath,
5180
+ shebang,
5181
+ uvReceipt: existing(path.join(prefix, "uv-receipt.toml")),
5182
+ venvPrefix: fs.existsSync(path.join(prefix, "pyvenv.cfg")) ? prefix : null,
5183
+ installer: markers.installer,
5184
+ editable: markers.editable
5185
+ };
5186
+ }
5187
+ function classifyGlobalInstall(identity) {
5188
+ if (!identity.path) return "global-unknown";
5189
+ if (identity.editable) return "editable";
5190
+ const haystack = [
5191
+ identity.path,
5192
+ identity.realPath ?? "",
5193
+ identity.shebang ?? ""
5194
+ ].join("\n").toLowerCase();
5195
+ if (haystack.includes("/uv/tools/")) return "uv-tool";
5196
+ if (identity.uvReceipt) return "uv-tool";
5197
+ if (uvToolListNamesLocalOperator(identity.uvToolList)) return "uv-tool";
5198
+ if (haystack.includes("/pipx/venvs/") || haystack.includes("/pipx/") && haystack.includes("venvs"))
5199
+ return "pipx";
5200
+ if (pipxListNamesLocalOperator(identity.pipxList)) return "pipx";
5201
+ if (identity.venvPrefix) return "pip";
5202
+ if (identity.installer === "pip") return "pip";
5203
+ return "global-unknown";
5204
+ }
5205
+ function resolveGlobalInstallPlan(input) {
5206
+ const kind = classifyGlobalInstall(input.identity);
5207
+ const sourceBuild = kind === "editable" || kind === "uv-tool" && Boolean(input.lopUpdatePath);
5208
+ const detail = input.identity.path ? `local-operator resolves to ${input.identity.path}${input.identity.realPath && input.identity.realPath !== input.identity.path ? ` (${input.identity.realPath})` : ""}, classified as ${kind}${sourceBuild ? ", built from source on this machine, so it follows the checkout rather than the published release" : ""}` : "local-operator was not found on PATH";
5209
+ if (kind === "editable") {
5210
+ return {
5211
+ canManageUpdate: false,
5212
+ updateCommand: "",
5213
+ remedy: "The server is running from a source checkout on this machine rather than an installed copy, so update it with lop-update after your change is merged.",
5214
+ detail,
5215
+ sourceBuild: true
5216
+ };
5217
+ }
5218
+ if (kind === "uv-tool") {
5219
+ const useLopUpdate = Boolean(input.lopUpdatePath);
5220
+ return {
5221
+ canManageUpdate: false,
5222
+ updateCommand: useLopUpdate ? "lop-update" : "uv tool upgrade local-operator",
5223
+ remedy: useLopUpdate ? "The server is a uv tool install built from source on this machine, so update it from your terminal:" : "The server is a uv tool install, so update it from your terminal:",
5224
+ detail,
5225
+ sourceBuild
5226
+ };
5227
+ }
5228
+ if (kind === "pipx") {
5229
+ return {
5230
+ canManageUpdate: false,
5231
+ updateCommand: "pipx upgrade local-operator",
5232
+ remedy: "The server is a pipx install, so update it from your terminal:",
5233
+ detail,
5234
+ sourceBuild: false
5235
+ };
5236
+ }
5237
+ if (kind === "pip") {
5238
+ return {
5239
+ canManageUpdate: false,
5240
+ updateCommand: "pip install --upgrade local-operator",
5241
+ remedy: "The server is a pip install, so update it from your terminal:",
5242
+ detail,
5243
+ sourceBuild: false
5244
+ };
5245
+ }
5246
+ return {
5247
+ canManageUpdate: false,
5248
+ updateCommand: "",
5249
+ remedy: "The app could not tell how this server was installed, so update it with the tool you installed it with - uv, pipx or pip:",
5250
+ detail,
5251
+ sourceBuild: false
5252
+ };
5253
+ }
5254
+ function buildPipUpgradeCommand(pythonPath) {
5255
+ const args = [
5256
+ "-m",
5257
+ "pip",
5258
+ "install",
5259
+ "--upgrade",
5260
+ "--no-input",
5261
+ "--disable-pip-version-check",
5262
+ "local-operator"
5263
+ ];
5264
+ return {
5265
+ command: pythonPath,
5266
+ args,
5267
+ display: `"${pythonPath}" ${args.join(" ")}`
5268
+ };
5269
+ }
5270
+ const PIP_SHOW_VERSION_REGEX = /^Version:\s*(.+)$/m;
5271
+ function parsePipShowVersion(stdout) {
5272
+ const match = stdout.match(PIP_SHOW_VERSION_REGEX);
5273
+ if (!match) return null;
5274
+ const version2 = match[1].trim();
5275
+ return version2.length > 0 ? version2 : null;
5276
+ }
5277
+ function didUpgradeLand(input) {
5278
+ const { before, after, target: target2 } = input;
5279
+ if (after == null) return false;
5280
+ if (before == null) {
5281
+ return target2 != null && after.trim() === target2.trim();
5282
+ }
5283
+ return before.trim() !== after.trim();
5284
+ }
4425
5285
  const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
4426
5286
  const VERSION_CLEAN_REGEX = /^v/i;
4427
5287
  const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
5288
+ const BUNDLE_ID_REGEX = /^[\w.-]+$/;
5289
+ const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
5290
+ const SEAL_PROBE_TIMEOUT_MS = 45e3;
5291
+ function versionSuffix(version2) {
5292
+ return version2 ? ` to version ${version2}` : "";
5293
+ }
5294
+ function runCommand(command, args, options = {}) {
5295
+ return new Promise((resolve) => {
5296
+ node_child_process.execFile(
5297
+ command,
5298
+ args,
5299
+ { timeout: options.timeoutMs ?? 2e4, maxBuffer: 4 * 1024 * 1024 },
5300
+ (error, stdout, stderr) => {
5301
+ const code = error?.code;
5302
+ const killed = error?.killed === true;
5303
+ const out = stdout?.toString() ?? "";
5304
+ const err = stderr?.toString() ?? "";
5305
+ resolve({
5306
+ exitCode: error ? typeof code === "number" ? code : 1 : 0,
5307
+ stdout: out,
5308
+ stderr: err,
5309
+ // A timeout (killed) or a process that failed to start (a string
5310
+ // code such as ENOENT) produced no verdict about the bundle. A
5311
+ // numeric exit with output did.
5312
+ ran: !error || !killed && typeof code === "number" && `${out}${err}`.trim().length > 0
5313
+ });
5314
+ }
5315
+ );
5316
+ });
5317
+ }
5318
+ function readCommandOutput(command, args) {
5319
+ try {
5320
+ const output = node_child_process.execFileSync(command, args, {
5321
+ encoding: "utf8",
5322
+ timeout: 5e3
5323
+ });
5324
+ return output.trim();
5325
+ } catch {
5326
+ return null;
5327
+ }
5328
+ }
4428
5329
  class UpdateService {
4429
5330
  mainWindow = null;
4430
5331
  isDevMode;
@@ -4432,6 +5333,47 @@ class UpdateService {
4432
5333
  backendUrl;
4433
5334
  updateCheckInterval = null;
4434
5335
  backendService = null;
5336
+ /**
5337
+ * What the updater is doing right now.
5338
+ *
5339
+ * Only used to decide whether an `error` event is a failure the user has to
5340
+ * hear about: a download or an install that dies is not "no update
5341
+ * available", which is how `shouldFilterUpdateError` would otherwise read it.
5342
+ */
5343
+ updateStage = "idle";
5344
+ /** The last update the updater told us about, for its file metadata. */
5345
+ lastUpdateInfo = null;
5346
+ /**
5347
+ * The version the published release last answered with, or null.
5348
+ *
5349
+ * The by-hand prompt is produced from a click, not from a check, so it has no
5350
+ * version of its own to name - and the panel that tells the user to go and do
5351
+ * work is the one that most needs to say what they are working towards. The
5352
+ * periodic and start-up checks read this from the published release already,
5353
+ * so it is kept rather than fetched again on the click (review U17).
5354
+ */
5355
+ lastPublishedBackendVersion = null;
5356
+ /** Path of the artifact this run downloaded, when the updater reported one. */
5357
+ downloadedArtifactPath = null;
5358
+ /**
5359
+ * Artifacts that failed their size/sha512/disk check, by version.
5360
+ *
5361
+ * The 5-minute periodic check runs regardless of what the user dismissed, so
5362
+ * without this an update that cannot be verified is offered again every five
5363
+ * minutes for as long as the app is open.
5364
+ */
5365
+ failedVerificationVersions = /* @__PURE__ */ new Set();
5366
+ /** A failed install detected from the marker on this start, if any. */
5367
+ pendingInstallFailure = null;
5368
+ installFailureDelivered = false;
5369
+ /**
5370
+ * True while `quit-and-install` is running its pre-flight.
5371
+ *
5372
+ * The pre-flight can take seconds over a large bundle and artifact, and the
5373
+ * whole point of it is that a second request must not run beside the first (see
5374
+ * the handler).
5375
+ */
5376
+ installPreflightInFlight = false;
4435
5377
  /**
4436
5378
  * Initialize the update service
4437
5379
  * @param mainWindow - The main application window
@@ -4455,8 +5397,9 @@ class UpdateService {
4455
5397
  debug: (message) => logger.debug(message, LogFileType.UPDATE_SERVICE)
4456
5398
  };
4457
5399
  electronUpdater.autoUpdater.autoDownload = false;
4458
- electronUpdater.autoUpdater.autoInstallOnAppQuit = true;
5400
+ electronUpdater.autoUpdater.autoInstallOnAppQuit = false;
4459
5401
  this.setupUpdateEvents();
5402
+ this.recoverPendingInstall();
4460
5403
  this.startPeriodicUpdateChecks();
4461
5404
  }
4462
5405
  /**
@@ -4479,6 +5422,517 @@ class UpdateService {
4479
5422
  LogFileType.UPDATE_SERVICE
4480
5423
  );
4481
5424
  }
5425
+ // -----------------------------------------------------------------------
5426
+ // Install robustness
5427
+ // -----------------------------------------------------------------------
5428
+ /**
5429
+ * Where the pending-update marker lives.
5430
+ *
5431
+ * userData, not the app bundle: the marker has to survive the bundle being
5432
+ * replaced (that is exactly the case it describes) and it must not be inside
5433
+ * the thing ShipIt is swapping.
5434
+ */
5435
+ markerDir() {
5436
+ return electron.app.getPath("userData");
5437
+ }
5438
+ sendToRenderer(channel, payload) {
5439
+ const webContents = this.mainWindow?.webContents;
5440
+ if (!webContents || webContents.isDestroyed()) return false;
5441
+ webContents.send(channel, payload);
5442
+ return true;
5443
+ }
5444
+ /**
5445
+ * Report a refusal to start an install, with the reason and the remedy.
5446
+ *
5447
+ * Sent rather than thrown: the caller is an IPC handler the renderer fires and
5448
+ * forgets, and the whole point of these checks is that the user gets an
5449
+ * explanation instead of an app that quits and never returns.
5450
+ */
5451
+ sendInstallBlock(block, version2) {
5452
+ logger.error(
5453
+ `Update refused (${block.code}): ${block.detail}`,
5454
+ LogFileType.UPDATE_SERVICE
5455
+ );
5456
+ this.sendToRenderer("update-install-blocked", {
5457
+ code: block.code,
5458
+ version: version2 ?? null,
5459
+ message: block.message,
5460
+ remedy: block.remedy,
5461
+ detail: block.detail
5462
+ });
5463
+ }
5464
+ /**
5465
+ * Read the marker a previous run may have left, and act on it.
5466
+ *
5467
+ * A failed Squirrel install leaves no exit status and never relaunches the
5468
+ * app, so the marker plus the running version is the only record that the
5469
+ * update did not happen. Everything the user learns afterwards starts here:
5470
+ * the notice, the durable record it can be found in later, and the cleanup of
5471
+ * what Squirrel left running.
5472
+ */
5473
+ recoverPendingInstall() {
5474
+ const marker = readPendingInstallMarker(this.markerDir());
5475
+ const outcome = evaluatePendingInstall({
5476
+ marker,
5477
+ runningVersion: electron.app.getVersion()
5478
+ });
5479
+ switch (outcome.kind) {
5480
+ case "none":
5481
+ return;
5482
+ case "succeeded":
5483
+ logger.info(
5484
+ `Update marker: install of version ${outcome.marker.targetVersion} succeeded (running ${electron.app.getVersion()}).`,
5485
+ LogFileType.UPDATE_SERVICE
5486
+ );
5487
+ this.reapWatchdog(outcome.marker, true);
5488
+ clearPendingInstallMarker(this.markerDir());
5489
+ return;
5490
+ case "stale":
5491
+ logger.info(
5492
+ `Update marker: ignored a marker for version ${outcome.marker.targetVersion}; version ${electron.app.getVersion()} is running, so it was superseded.`,
5493
+ LogFileType.UPDATE_SERVICE
5494
+ );
5495
+ this.reapWatchdog(outcome.marker, true);
5496
+ clearPendingInstallMarker(this.markerDir());
5497
+ return;
5498
+ case "failed": {
5499
+ const shipItLogPath = this.shipItLog();
5500
+ const record = recordInstallFailure(this.markerDir(), {
5501
+ payload: installFailurePayload(outcome.marker, electron.app.getVersion(), {
5502
+ shipItLogPath
5503
+ }),
5504
+ startedAt: outcome.marker.startedAt || null,
5505
+ detectedAt: (/* @__PURE__ */ new Date()).toISOString()
5506
+ });
5507
+ const payload = installFailurePayload(
5508
+ outcome.marker,
5509
+ electron.app.getVersion(),
5510
+ { attempts: record.attempts, shipItLogPath }
5511
+ );
5512
+ logger.error(
5513
+ `Update marker: install of version ${outcome.marker.targetVersion} did not complete (attempt ${record.attempts}). ${payload.detail}`,
5514
+ LogFileType.UPDATE_SERVICE
5515
+ );
5516
+ this.pendingInstallFailure = payload;
5517
+ this.reapWatchdog(outcome.marker, false);
5518
+ clearPendingInstallMarker(this.markerDir());
5519
+ this.reapFailedInstallLeftovers();
5520
+ this.schedulePendingInstallFailureDelivery();
5521
+ }
5522
+ }
5523
+ }
5524
+ /**
5525
+ * This app's bundle identifier, as macOS records it.
5526
+ *
5527
+ * Read from the running bundle rather than from `package.json`: the running
5528
+ * app is the only thing whose ShipIt job and cache directory this process may
5529
+ * touch, and a build whose `appId` drifted would otherwise have us cleaning up
5530
+ * some other app's leftovers.
5531
+ */
5532
+ bundleIdentifier() {
5533
+ const bundlePath = appBundleFromExecutable(process.execPath);
5534
+ if (!bundlePath) return null;
5535
+ const value = readCommandOutput("/usr/bin/defaults", [
5536
+ "read",
5537
+ path.join(bundlePath, "Contents", "Info.plist"),
5538
+ "CFBundleIdentifier"
5539
+ ]);
5540
+ return value && BUNDLE_ID_REGEX.test(value) ? value : null;
5541
+ }
5542
+ /** The launchd job Squirrel's install runs under, by label. */
5543
+ shipItJob() {
5544
+ const bundleId = this.bundleIdentifier();
5545
+ return bundleId ? shipItJobLabel(bundleId) : null;
5546
+ }
5547
+ /**
5548
+ * Squirrel's own stderr log for this app, when it exists.
5549
+ *
5550
+ * It is the only record of why an install failed: the app is not running
5551
+ * while ShipIt works, so the failure notice cannot quote a reason the way the
5552
+ * pre-flight refusal can. The log is preserved by `reapFailedInstallLeftovers`
5553
+ * for exactly this reason, so the details line points at the thing that is
5554
+ * still there afterwards (review U14).
5555
+ */
5556
+ shipItLog() {
5557
+ const bundleId = this.bundleIdentifier();
5558
+ if (!bundleId) return null;
5559
+ const candidate = path.join(
5560
+ shipItCacheDir(path.join(os.homedir(), "Library", "Caches"), bundleId),
5561
+ "ShipIt_stderr.log"
5562
+ );
5563
+ return fs.existsSync(candidate) ? candidate : null;
5564
+ }
5565
+ /**
5566
+ * Remove the launchd job and the staged update a failed install left behind.
5567
+ *
5568
+ * Why: the 0.17.0 failure left `com.local-operator.ShipIt` loaded in the
5569
+ * user's launchd domain, respawning every ~2.5 s (runs=3114,
5570
+ * LastExitStatus=256) and writing 3.4 MB of "Could not read update request" to
5571
+ * its stderr log, with the staged tree beside it. Squirrel only retires that
5572
+ * job when an install finishes, so a failed one leaves it running forever - it
5573
+ * was removed by hand during round 1 of the review, and the product must not
5574
+ * need that.
5575
+ */
5576
+ reapFailedInstallLeftovers() {
5577
+ if (process.platform !== "darwin") return;
5578
+ const log = (message) => logger.info(message, LogFileType.UPDATE_SERVICE);
5579
+ try {
5580
+ const result = reapFailedInstall({
5581
+ bundleId: this.bundleIdentifier(),
5582
+ cacheRoot: path.join(os.homedir(), "Library", "Caches"),
5583
+ removeJob: (label) => {
5584
+ const result2 = node_child_process.spawnSync("/bin/launchctl", ["remove", label], {
5585
+ encoding: "utf8",
5586
+ timeout: 5e3
5587
+ });
5588
+ const output = `${result2.stdout ?? ""}${result2.stderr ?? ""}`.trim();
5589
+ const notFound = LAUNCHCTL_NOT_LOADED_REGEX.test(output) || output === "" && result2.status !== 0;
5590
+ if (result2.status !== 0 && !notFound) {
5591
+ throw new Error(
5592
+ output || `launchctl remove exited ${result2.status}`
5593
+ );
5594
+ }
5595
+ return { notFound, output };
5596
+ },
5597
+ exists: (path2) => fs.existsSync(path2),
5598
+ listDir: (dir) => fs.readdirSync(dir),
5599
+ removeDir: (dir) => fs.rmSync(dir, { recursive: true, force: true }),
5600
+ log
5601
+ });
5602
+ if (result.errors.length > 0) {
5603
+ logger.warn(
5604
+ `Failed to clean up the leftover install: ${result.errors.join("; ")}`,
5605
+ LogFileType.UPDATE_SERVICE
5606
+ );
5607
+ }
5608
+ } catch (error) {
5609
+ logger.warn(
5610
+ `Could not clean up the leftover install: ${String(error)}`,
5611
+ LogFileType.UPDATE_SERVICE
5612
+ );
5613
+ }
5614
+ }
5615
+ /**
5616
+ * Deliver the failed-install notice once the renderer can hear it.
5617
+ *
5618
+ * The marker is read while the window is still loading, so the push happens
5619
+ * on `did-finish-load` with a delayed fallback: the component subscribes from
5620
+ * a React effect, which may run after the load event.
5621
+ */
5622
+ schedulePendingInstallFailureDelivery() {
5623
+ if (!this.pendingInstallFailure) return;
5624
+ const deliver = () => this.deliverPendingInstallFailure();
5625
+ const webContents = this.mainWindow?.webContents;
5626
+ if (webContents && !webContents.isDestroyed()) {
5627
+ webContents.once("did-finish-load", deliver);
5628
+ }
5629
+ setTimeout(deliver, 5e3);
5630
+ }
5631
+ deliverPendingInstallFailure() {
5632
+ if (!this.pendingInstallFailure || this.installFailureDelivered) return;
5633
+ if (!this.sendToRenderer("update-install-failed", this.pendingInstallFailure)) {
5634
+ return;
5635
+ }
5636
+ this.installFailureDelivered = true;
5637
+ logger.info(
5638
+ "Reported a failed update install to the renderer",
5639
+ LogFileType.UPDATE_SERVICE
5640
+ );
5641
+ }
5642
+ /**
5643
+ * Kill a watchdog still running from the recorded install attempt.
5644
+ *
5645
+ * Killing by pid alone would be unsafe after a pid reuse, so the process's
5646
+ * command line has to still carry the script's sentinel.
5647
+ */
5648
+ reapWatchdog(marker, installSucceeded) {
5649
+ const pid = marker.watchdogPid;
5650
+ if (pid == null) return;
5651
+ let commandLine = null;
5652
+ let alive = false;
5653
+ commandLine = readCommandOutput("/bin/ps", [
5654
+ "-o",
5655
+ "command=",
5656
+ "-p",
5657
+ String(pid)
5658
+ ]);
5659
+ alive = commandLine != null;
5660
+ if (!watchdogIsOurs({ alive, commandLine, installSucceeded })) {
5661
+ if (alive && !installSucceeded) {
5662
+ logger.info(
5663
+ `Relaunch watchdog ${pid} is still running; leaving it to finish or hit its deadline.`,
5664
+ LogFileType.UPDATE_SERVICE
5665
+ );
5666
+ }
5667
+ return;
5668
+ }
5669
+ try {
5670
+ process.kill(pid, "SIGTERM");
5671
+ logger.info(
5672
+ `Reaped relaunch watchdog ${pid} left over from the previous install.`,
5673
+ LogFileType.UPDATE_SERVICE
5674
+ );
5675
+ } catch (error) {
5676
+ logger.warn(
5677
+ `Could not reap relaunch watchdog ${pid}: ${String(error)}`,
5678
+ LogFileType.UPDATE_SERVICE
5679
+ );
5680
+ }
5681
+ }
5682
+ /**
5683
+ * Ask `codesign` whether the installed bundle is a sealed code object.
5684
+ *
5685
+ * This is the exact question ShipIt asks of the same bundle before it
5686
+ * installs (`SecStaticCodeCreateWithPath` in SQRLInstaller): on 2026-09-11 it
5687
+ * answered -67028 errSecCSBadBundleFormat because a Finder copy was replacing
5688
+ * the app at that moment, ShipIt quit, and nothing relaunched the app.
5689
+ * Refusing the install here keeps the user with a working app instead.
5690
+ *
5691
+ * Three deliberate properties, all of them from round 1's findings:
5692
+ *
5693
+ * - `--strict` is gone. It does not test the seal - `--verify` does that, and
5694
+ * it is what catches the mid-replacement -67028 - it additionally rejects
5695
+ * bundles carrying FinderInfo/detritus xattrs that ShipIt itself tolerates,
5696
+ * so it can only refuse installs Squirrel would have performed (R5).
5697
+ * - The timeout is generous because the bundle is ~1 GiB; measured warm at
5698
+ * 6.1 s, and a cold or slow volume is not a reason to refuse.
5699
+ * - A probe that could not run is retried once and then *allowed to proceed*.
5700
+ * "We could not ask" is not "the bundle is bad", and treating it as one is
5701
+ * how a transient hiccup became a permanent reinstall message (R5).
5702
+ */
5703
+ async probeInstalledBundleSeal(version2) {
5704
+ if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
5705
+ const bundlePath = appBundleFromExecutable(process.execPath);
5706
+ if (!bundlePath) {
5707
+ logger.warn(
5708
+ `Could not derive an app bundle from ${process.execPath}; skipping the seal pre-flight.`,
5709
+ LogFileType.UPDATE_SERVICE
5710
+ );
5711
+ return null;
5712
+ }
5713
+ const probeBundle = () => runCommand(
5714
+ "/usr/bin/codesign",
5715
+ ["--verify", "--deep", "--verbose=2", bundlePath],
5716
+ { timeoutMs: SEAL_PROBE_TIMEOUT_MS }
5717
+ );
5718
+ let seal = evaluateBundleSeal(await probeBundle());
5719
+ if (seal.kind === "unavailable") {
5720
+ logger.warn(
5721
+ `Seal check did not complete, retrying once: ${seal.detail}`,
5722
+ LogFileType.UPDATE_SERVICE
5723
+ );
5724
+ seal = evaluateBundleSeal(await probeBundle());
5725
+ }
5726
+ if (seal.kind === "unavailable") {
5727
+ logger.warn(
5728
+ `Seal check could not run twice, continuing with the install: ${seal.detail}`,
5729
+ LogFileType.UPDATE_SERVICE
5730
+ );
5731
+ return null;
5732
+ }
5733
+ if (seal.kind === "sealed") {
5734
+ logger.info(
5735
+ `Installed bundle passed its seal check: ${bundlePath}`,
5736
+ LogFileType.UPDATE_SERVICE
5737
+ );
5738
+ return null;
5739
+ }
5740
+ logger.error(
5741
+ `Installed bundle failed its seal check: ${seal.detail}`,
5742
+ LogFileType.UPDATE_SERVICE
5743
+ );
5744
+ return installedBundleSealBlock(bundlePath, seal.detail, version2);
5745
+ }
5746
+ /** File names the updater's metadata knows this update by. */
5747
+ stagedArtifactCandidates(info) {
5748
+ const names = /* @__PURE__ */ new Set();
5749
+ if (info?.path) names.add(path.basename(info.path));
5750
+ for (const file of info?.files ?? []) {
5751
+ if (file.url) names.add(path.basename(decodeURIComponent(file.url)));
5752
+ }
5753
+ if (this.downloadedArtifactPath) {
5754
+ names.add(path.basename(this.downloadedArtifactPath));
5755
+ }
5756
+ return [...names];
5757
+ }
5758
+ /**
5759
+ * electron-updater exposes no public path for the staged file; the private
5760
+ * download helper is authoritative for a download this run performed, and a
5761
+ * null simply falls back to the updater's pending cache directory.
5762
+ */
5763
+ downloadHelper() {
5764
+ const updater = electronUpdater.autoUpdater;
5765
+ return updater.downloadedUpdateHelper ?? null;
5766
+ }
5767
+ /** Free bytes on the volume holding `dir`, or null when it cannot be read. */
5768
+ freeBytesAt(dir) {
5769
+ try {
5770
+ const stats = fs.statfsSync(dir);
5771
+ return stats.bavail * stats.bsize;
5772
+ } catch (error) {
5773
+ logger.warn(
5774
+ `Could not read free space for ${dir}: ${String(error)}`,
5775
+ LogFileType.UPDATE_SERVICE
5776
+ );
5777
+ return null;
5778
+ }
5779
+ }
5780
+ static async sha512Base64(filePath) {
5781
+ const hash = node_crypto.createHash("sha512");
5782
+ const stream = fs.createReadStream(filePath);
5783
+ for await (const chunk of stream) {
5784
+ hash.update(chunk);
5785
+ }
5786
+ return hash.digest("base64");
5787
+ }
5788
+ /**
5789
+ * Verify the staged artifact against the metadata the updater parsed.
5790
+ *
5791
+ * electron-updater checks a file's sha512 while downloading it, but a staged
5792
+ * file reused on a later start is only checked for existence (`"check here
5793
+ * only existence, not checksum"` in its own DownloadedUpdateHelper), and the
5794
+ * install's real footprint - a full copy of the app - is never checked
5795
+ * against the free space available.
5796
+ */
5797
+ async ensureStagedArtifactVerified(info) {
5798
+ const helper = this.downloadHelper();
5799
+ const pendingDir = helper?.cacheDirForPendingUpdate ?? null;
5800
+ const candidates = this.stagedArtifactCandidates(info);
5801
+ const artifactPath = resolveStagedArtifactPath({
5802
+ downloadHelperFile: this.downloadedArtifactPath ?? helper?.file ?? null,
5803
+ pendingDir: pendingDir ?? "",
5804
+ candidateNames: candidates,
5805
+ listDir: (dir) => {
5806
+ try {
5807
+ return fs.readdirSync(dir);
5808
+ } catch {
5809
+ return [];
5810
+ }
5811
+ }
5812
+ });
5813
+ if (!artifactPath) {
5814
+ return {
5815
+ code: "download-verification-failed",
5816
+ message: `The downloaded update${versionSuffix(info?.version)} could not be found on disk, so it wasn't installed.`,
5817
+ remedy: {
5818
+ text: "Check for updates again to re-download the release."
5819
+ },
5820
+ detail: `No staged artifact matching ${candidates.join(", ") || "the update metadata"}.`
5821
+ };
5822
+ }
5823
+ const files = info?.files ?? [];
5824
+ const metadata = matchArtifactMetadata(
5825
+ artifactPath,
5826
+ files,
5827
+ info?.path ?? null
5828
+ );
5829
+ let actualSize;
5830
+ let actualSha512;
5831
+ try {
5832
+ actualSize = fs.statSync(artifactPath).size;
5833
+ actualSha512 = await UpdateService.sha512Base64(artifactPath);
5834
+ } catch (error) {
5835
+ return {
5836
+ code: "download-verification-failed",
5837
+ message: `The downloaded update${versionSuffix(info?.version)} could not be read, so it wasn't installed.`,
5838
+ remedy: {
5839
+ text: "Check for updates again to re-download the release."
5840
+ },
5841
+ detail: `${artifactPath}: ${String(error)}`
5842
+ };
5843
+ }
5844
+ const bundlePath = appBundleFromExecutable(process.execPath);
5845
+ const installedBundleSize = bundlePath ? measureDirectoryBytes(bundlePath) : null;
5846
+ if (installedBundleSize == null) {
5847
+ logger.warn(
5848
+ `Could not measure the installed app at ${bundlePath ?? "(no bundle path)"}; the free-space guard falls back to the artifact's own size.`,
5849
+ LogFileType.UPDATE_SERVICE
5850
+ );
5851
+ }
5852
+ const freeCandidates = [
5853
+ bundlePath ? this.freeBytesAt(path__namespace.dirname(bundlePath)) : null,
5854
+ this.freeBytesAt(path__namespace.dirname(artifactPath))
5855
+ ].filter((value) => value != null);
5856
+ const freeBytes = freeCandidates.length > 0 ? Math.min(...freeCandidates) : Number.MAX_SAFE_INTEGER;
5857
+ const verdict = verifyStagedArtifact({
5858
+ filePath: artifactPath,
5859
+ actualSize,
5860
+ actualSha512,
5861
+ metadata,
5862
+ freeBytes,
5863
+ installedBundleSize,
5864
+ version: info?.version ?? null
5865
+ });
5866
+ if (verdict.ok) {
5867
+ const required = requiredDiskBytes({
5868
+ artifactSize: verdict.size,
5869
+ installedBundleSize: installedBundleSize ?? 0
5870
+ });
5871
+ logger.info(
5872
+ `Staged artifact verified: ${artifactPath} (${verdict.size} bytes, sha512 matches, ${required} bytes required free, ${installedBundleSize ?? "unknown"} byte app).`,
5873
+ LogFileType.UPDATE_SERVICE
5874
+ );
5875
+ return null;
5876
+ }
5877
+ return verdict.block;
5878
+ }
5879
+ /**
5880
+ * Everything that has to hold before the app is allowed to quit.
5881
+ *
5882
+ * Returns the reason to refuse, or null to go ahead.
5883
+ */
5884
+ async runInstallPreflight(info) {
5885
+ const sealBlock = await this.probeInstalledBundleSeal(
5886
+ info?.version ?? null
5887
+ );
5888
+ if (sealBlock) return sealBlock;
5889
+ return this.ensureStagedArtifactVerified(info);
5890
+ }
5891
+ /**
5892
+ * Start the detached relaunch watchdog.
5893
+ *
5894
+ * Spawned detached so it survives this process exiting, with stdio ignored so
5895
+ * it holds no pipe open and cannot keep the app alive.
5896
+ */
5897
+ launchWatchdog(targetVersion) {
5898
+ if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
5899
+ const bundlePath = appBundleFromExecutable(process.execPath);
5900
+ if (!bundlePath) return null;
5901
+ const plan = buildWatchdogPlan({
5902
+ appBundlePath: bundlePath,
5903
+ executableName: path.basename(process.execPath),
5904
+ // The app's own pid, captured here - before the quit - because the
5905
+ // watchdog cannot ask a name about its own ancestor (review R1).
5906
+ appPid: process.pid,
5907
+ shipItJob: this.shipItJob(),
5908
+ // What "the install is over" is measured against on disk (review R11):
5909
+ // the updater's advertised version, and nothing that is already in place
5910
+ // (review R15).
5911
+ targetVersion: watchdogSwapTarget({
5912
+ target: targetVersion,
5913
+ running: electron.app.getVersion()
5914
+ })
5915
+ });
5916
+ try {
5917
+ const child = node_child_process.spawn("sh", ["-c", plan.script], {
5918
+ detached: true,
5919
+ stdio: "ignore",
5920
+ env: { ...process.env, ...plan.env }
5921
+ });
5922
+ child.unref();
5923
+ logger.info(
5924
+ `Started the update relaunch watchdog (pid ${child.pid ?? "unknown"}): it starts the app again as soon as the install's job goes or version ${plan.env.LO_UPDATE_WATCHDOG_TARGET_VERSION || "unknown"} is in place, and at the ${plan.timeoutSeconds}s bound if neither happens.`,
5925
+ LogFileType.UPDATE_SERVICE
5926
+ );
5927
+ return child.pid ?? null;
5928
+ } catch (error) {
5929
+ logger.error(
5930
+ `Could not start the update relaunch watchdog: ${String(error)}`,
5931
+ LogFileType.UPDATE_SERVICE
5932
+ );
5933
+ return null;
5934
+ }
5935
+ }
4482
5936
  /**
4483
5937
  * Clean up resources when the service is no longer needed
4484
5938
  */
@@ -4494,6 +5948,7 @@ class UpdateService {
4494
5948
  electron.ipcMain.removeHandler("check-for-updates");
4495
5949
  electron.ipcMain.removeHandler("check-for-backend-updates");
4496
5950
  electron.ipcMain.removeHandler("check-for-all-updates");
5951
+ electron.ipcMain.removeHandler("get-last-install-attempt");
4497
5952
  electron.ipcMain.removeHandler("update-backend");
4498
5953
  electron.ipcMain.removeHandler("download-update");
4499
5954
  electron.ipcMain.removeHandler("quit-and-install");
@@ -4585,38 +6040,44 @@ class UpdateService {
4585
6040
  setupUpdateEvents() {
4586
6041
  electronUpdater.autoUpdater.on("update-available", (info) => {
4587
6042
  logger.info("Update available:", LogFileType.UPDATE_SERVICE, info);
4588
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4589
- this.mainWindow.webContents.send("update-available", info);
6043
+ this.lastUpdateInfo = info;
6044
+ if (this.failedVerificationVersions.has(info.version)) {
6045
+ logger.warn(
6046
+ `Not offering version ${info.version} again: its artifact already failed verification in this session.`,
6047
+ LogFileType.UPDATE_SERVICE
6048
+ );
6049
+ return;
4590
6050
  }
6051
+ this.sendToRenderer("update-available", info);
4591
6052
  });
4592
6053
  electronUpdater.autoUpdater.on("update-not-available", (info) => {
4593
6054
  logger.info("No update available:", LogFileType.UPDATE_SERVICE, info);
4594
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4595
- this.mainWindow.webContents.send("update-not-available", info);
4596
- }
6055
+ this.sendToRenderer("update-not-available", info);
4597
6056
  });
4598
- electronUpdater.autoUpdater.on("update-downloaded", (info) => {
6057
+ electronUpdater.autoUpdater.on("update-downloaded", async (info) => {
4599
6058
  logger.info("Update downloaded:", LogFileType.UPDATE_SERVICE, info);
4600
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4601
- this.mainWindow.webContents.send("update-downloaded", info);
6059
+ this.lastUpdateInfo = info;
6060
+ const block = await this.ensureStagedArtifactVerified(info);
6061
+ if (block) {
6062
+ this.failedVerificationVersions.add(info.version);
6063
+ this.sendInstallBlock(block, info.version);
6064
+ return;
4602
6065
  }
6066
+ this.sendToRenderer("update-downloaded", info);
4603
6067
  });
4604
6068
  electronUpdater.autoUpdater.on("error", (err) => {
4605
6069
  logger.error("Update error:", LogFileType.UPDATE_SERVICE, err);
4606
- const shouldFilter = this.shouldFilterUpdateError(err);
6070
+ const actionable = this.updateStage !== "idle";
6071
+ const shouldFilter = !actionable && this.shouldFilterUpdateError(err);
4607
6072
  if (shouldFilter) {
4608
6073
  logger.info(
4609
6074
  "Error filtering result: Reporting as no updates available"
4610
6075
  );
4611
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4612
- this.mainWindow.webContents.send("update-not-available", {
4613
- version: electron.app.getVersion()
4614
- });
4615
- }
6076
+ this.sendToRenderer("update-not-available", {
6077
+ version: electron.app.getVersion()
6078
+ });
4616
6079
  } else {
4617
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4618
- this.mainWindow.webContents.send("update-error", err.message);
4619
- }
6080
+ this.sendToRenderer("update-error", err.message);
4620
6081
  }
4621
6082
  });
4622
6083
  electronUpdater.autoUpdater.on("download-progress", (progressObj) => {
@@ -4640,38 +6101,74 @@ class UpdateService {
4640
6101
  });
4641
6102
  }
4642
6103
  /**
4643
- * Set up IPC handlers for update-related actions
6104
+ * Set up IPC handlers for update-related actions
6105
+ */
6106
+ /**
6107
+ * The last install that did not complete, if any.
6108
+ *
6109
+ * Read from disk rather than from `this.pendingInstallFailure` so it survives
6110
+ * a dismiss: the panel that reports a failure is not the only place the user
6111
+ * can find out what happened, which is what made Dismiss an information loss
6112
+ * (reviews U1, D3).
6113
+ */
6114
+ lastInstallAttempt() {
6115
+ return readLastInstallAttempt(this.markerDir());
6116
+ }
6117
+ /**
6118
+ * Note that a check was asked for, and let an explicit one re-offer a release
6119
+ * whose artifact failed verification.
6120
+ *
6121
+ * Why the distinction exists: every refusal records its version in
6122
+ * `failedVerificationVersions` so the 5-minute timer cannot re-offer an
6123
+ * artifact that will fail again. That record used to apply to user-initiated
6124
+ * checks too, which made two of the panels' own labelled remedies inert - the
6125
+ * user freed disk space or cleared the bad download, pressed "Check for
6126
+ * updates" as instructed, and got silence until the app restarted (reviews
6127
+ * R3, U3). A user who asks again gets a fresh answer; the timer does not.
4644
6128
  */
6129
+ onCheckRequested(options) {
6130
+ if (!options?.manual) return;
6131
+ if (this.failedVerificationVersions.size === 0) return;
6132
+ logger.info(
6133
+ `Explicit check requested; re-offering ${[...this.failedVerificationVersions].join(", ")} after a failed verification.`,
6134
+ LogFileType.UPDATE_SERVICE
6135
+ );
6136
+ this.failedVerificationVersions.clear();
6137
+ }
4645
6138
  setupIpcHandlers() {
4646
- electron.ipcMain.handle("check-for-updates", async () => {
4647
- logger.info("Checking for UI updates...", LogFileType.UPDATE_SERVICE);
4648
- try {
4649
- return await electronUpdater.autoUpdater.checkForUpdates();
4650
- } catch (error) {
4651
- logger.error(
4652
- "Error checking for UI updates:",
4653
- LogFileType.UPDATE_SERVICE,
4654
- error
4655
- );
4656
- const shouldFilter = this.shouldFilterUpdateError(error);
4657
- if (shouldFilter) {
4658
- logger.info(
4659
- "Error filtering result in IPC handler: Reporting as no updates available",
4660
- LogFileType.UPDATE_SERVICE
6139
+ electron.ipcMain.handle(
6140
+ "check-for-updates",
6141
+ async (_event, options) => {
6142
+ logger.info("Checking for UI updates...", LogFileType.UPDATE_SERVICE);
6143
+ this.onCheckRequested(options);
6144
+ try {
6145
+ return await electronUpdater.autoUpdater.checkForUpdates();
6146
+ } catch (error) {
6147
+ logger.error(
6148
+ "Error checking for UI updates:",
6149
+ LogFileType.UPDATE_SERVICE,
6150
+ error
4661
6151
  );
4662
- return {
4663
- updateInfo: {
4664
- version: electron.app.getVersion()
4665
- },
4666
- versionInfo: {
4667
- version: electron.app.getVersion()
4668
- },
4669
- cancellationToken: null
4670
- };
6152
+ const shouldFilter = this.shouldFilterUpdateError(error);
6153
+ if (shouldFilter) {
6154
+ logger.info(
6155
+ "Error filtering result in IPC handler: Reporting as no updates available",
6156
+ LogFileType.UPDATE_SERVICE
6157
+ );
6158
+ return {
6159
+ updateInfo: {
6160
+ version: electron.app.getVersion()
6161
+ },
6162
+ versionInfo: {
6163
+ version: electron.app.getVersion()
6164
+ },
6165
+ cancellationToken: null
6166
+ };
6167
+ }
6168
+ throw error;
4671
6169
  }
4672
- throw error;
4673
6170
  }
4674
- });
6171
+ );
4675
6172
  electron.ipcMain.handle("check-for-backend-updates", async () => {
4676
6173
  logger.info(
4677
6174
  "Checking for backend updates...",
@@ -4688,55 +6185,70 @@ class UpdateService {
4688
6185
  throw error;
4689
6186
  }
4690
6187
  });
4691
- electron.ipcMain.handle("check-for-all-updates", async () => {
4692
- logger.info(
4693
- "Checking for all updates (UI and backend)...",
4694
- LogFileType.UPDATE_SERVICE
4695
- );
4696
- try {
4697
- return await this.checkForAllUpdates();
4698
- } catch (error) {
4699
- logger.error(
4700
- "Error checking for all updates:",
4701
- LogFileType.UPDATE_SERVICE,
4702
- error
6188
+ electron.ipcMain.handle(
6189
+ "check-for-all-updates",
6190
+ async (_event, options) => {
6191
+ logger.info(
6192
+ "Checking for all updates (UI and backend)...",
6193
+ LogFileType.UPDATE_SERVICE
4703
6194
  );
4704
- const shouldFilter = this.shouldFilterUpdateError(error);
4705
- if (shouldFilter) {
4706
- logger.info(
4707
- "Error filtering result in check-for-all-updates: Reporting as no updates available",
4708
- LogFileType.UPDATE_SERVICE
6195
+ this.onCheckRequested(options);
6196
+ try {
6197
+ return await this.checkForAllUpdates();
6198
+ } catch (error) {
6199
+ logger.error(
6200
+ "Error checking for all updates:",
6201
+ LogFileType.UPDATE_SERVICE,
6202
+ error
4709
6203
  );
4710
- return {
4711
- updateInfo: {
4712
- version: electron.app.getVersion()
4713
- },
4714
- versionInfo: {
4715
- version: electron.app.getVersion()
4716
- },
4717
- cancellationToken: null
4718
- };
6204
+ const shouldFilter = this.shouldFilterUpdateError(error);
6205
+ if (shouldFilter) {
6206
+ logger.info(
6207
+ "Error filtering result in check-for-all-updates: Reporting as no updates available",
6208
+ LogFileType.UPDATE_SERVICE
6209
+ );
6210
+ return {
6211
+ updateInfo: {
6212
+ version: electron.app.getVersion()
6213
+ },
6214
+ versionInfo: {
6215
+ version: electron.app.getVersion()
6216
+ },
6217
+ cancellationToken: null
6218
+ };
6219
+ }
6220
+ throw error;
4719
6221
  }
4720
- throw error;
4721
6222
  }
4722
- });
4723
- electron.ipcMain.handle("update-backend", async () => {
4724
- logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
4725
- try {
4726
- return await this.updateBackend();
4727
- } catch (error) {
4728
- logger.error(
4729
- "Error updating backend:",
4730
- LogFileType.UPDATE_SERVICE,
4731
- error
4732
- );
4733
- throw error;
6223
+ );
6224
+ electron.ipcMain.handle("get-last-install-attempt", () => this.lastInstallAttempt());
6225
+ electron.ipcMain.handle(
6226
+ "update-backend",
6227
+ async (_event, targetVersion) => {
6228
+ logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
6229
+ try {
6230
+ return await this.updateBackend(
6231
+ typeof targetVersion === "string" ? targetVersion : void 0
6232
+ );
6233
+ } catch (error) {
6234
+ logger.error(
6235
+ "Error updating backend:",
6236
+ LogFileType.UPDATE_SERVICE,
6237
+ error
6238
+ );
6239
+ throw error;
6240
+ }
4734
6241
  }
4735
- });
6242
+ );
4736
6243
  electron.ipcMain.handle("download-update", async () => {
4737
6244
  logger.info("Downloading update...", LogFileType.UPDATE_SERVICE);
6245
+ this.updateStage = "downloading";
4738
6246
  try {
4739
- return await electronUpdater.autoUpdater.downloadUpdate();
6247
+ const downloadedPaths = await electronUpdater.autoUpdater.downloadUpdate();
6248
+ if (Array.isArray(downloadedPaths) && downloadedPaths.length > 0) {
6249
+ this.downloadedArtifactPath = downloadedPaths[0];
6250
+ }
6251
+ return downloadedPaths;
4740
6252
  } catch (error) {
4741
6253
  logger.error(
4742
6254
  "Error downloading update:",
@@ -4759,15 +6271,50 @@ class UpdateService {
4759
6271
  };
4760
6272
  }
4761
6273
  throw error;
6274
+ } finally {
6275
+ this.updateStage = "idle";
4762
6276
  }
4763
6277
  });
4764
- electron.ipcMain.handle("quit-and-install", () => {
6278
+ electron.ipcMain.handle("quit-and-install", async () => {
6279
+ if (this.installPreflightInFlight) {
6280
+ logger.info(
6281
+ "Ignoring a second install request while the pre-flight is still running.",
6282
+ LogFileType.UPDATE_SERVICE
6283
+ );
6284
+ return false;
6285
+ }
6286
+ logger.info(
6287
+ "Preparing to quit and install the update...",
6288
+ LogFileType.UPDATE_SERVICE
6289
+ );
6290
+ this.installPreflightInFlight = true;
6291
+ this.updateStage = "installing";
6292
+ let block = null;
6293
+ try {
6294
+ block = await this.runInstallPreflight(this.lastUpdateInfo);
6295
+ } finally {
6296
+ this.installPreflightInFlight = false;
6297
+ }
6298
+ if (block) {
6299
+ this.updateStage = "idle";
6300
+ this.sendInstallBlock(block, this.lastUpdateInfo?.version ?? void 0);
6301
+ return false;
6302
+ }
6303
+ const targetVersion = this.lastUpdateInfo?.version ?? electron.app.getVersion();
6304
+ const watchdogPid = this.launchWatchdog(targetVersion);
6305
+ const marker = writePendingInstallMarker(this.markerDir(), {
6306
+ targetVersion,
6307
+ artifactPath: this.downloadedArtifactPath ?? this.downloadHelper()?.file ?? "unknown",
6308
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
6309
+ watchdogPid
6310
+ });
4765
6311
  logger.info(
4766
- "Quitting and installing update...",
6312
+ `Pending install marker written for version ${marker.targetVersion} (watchdog pid ${watchdogPid ?? "none"}).`,
4767
6313
  LogFileType.UPDATE_SERVICE
4768
6314
  );
4769
6315
  this.backendService?.setAutoUpdating(true);
4770
6316
  electronUpdater.autoUpdater.quitAndInstall(false, true);
6317
+ return true;
4771
6318
  });
4772
6319
  }
4773
6320
  /**
@@ -5075,6 +6622,103 @@ class UpdateService {
5075
6622
  * @param silent - Whether to suppress notifications on no update
5076
6623
  * @returns Promise resolving to update info or null if no update is available
5077
6624
  */
6625
+ /**
6626
+ * Decide what the backend update prompt may offer for a startup mode.
6627
+ *
6628
+ * GLOBAL_INSTALL is the mode that produced the operator's report: the app
6629
+ * offered `pip install --upgrade local-operator` for a server that was a uv
6630
+ * tool install (0.54.17, read from /health) with `canManageUpdate: true`. pip
6631
+ * is the wrong installer for that environment - uv owns it, and the
6632
+ * environment lives outside anything the app may write to.
6633
+ *
6634
+ * EXISTING_SERVER takes the same classification as GLOBAL_INSTALL, because it
6635
+ * is the same question: a server the app attaches to but does not own was
6636
+ * still being told to run pip, three lines below the code that had already
6637
+ * learned better (reviews U4, D4).
6638
+ */
6639
+ async resolveBackendUpdatePlan(startupMode) {
6640
+ if (startupMode === LocalOperatorStartupMode.EXISTING_SERVER || startupMode === LocalOperatorStartupMode.GLOBAL_INSTALL) {
6641
+ const plan = resolveGlobalInstallPlan({
6642
+ identity: this.resolveInstallIdentity(),
6643
+ lopUpdatePath: this.resolveLopUpdatePath()
6644
+ });
6645
+ logger.info(
6646
+ `External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\``,
6647
+ LogFileType.UPDATE_SERVICE
6648
+ );
6649
+ return {
6650
+ canManageUpdate: plan.canManageUpdate,
6651
+ updateCommand: plan.updateCommand,
6652
+ remedy: plan.remedy,
6653
+ detail: plan.detail,
6654
+ sourceBuild: plan.sourceBuild
6655
+ };
6656
+ }
6657
+ return {
6658
+ canManageUpdate: true,
6659
+ updateCommand: "pip install --upgrade local-operator",
6660
+ remedy: "Updating the server will improve AI functionality.",
6661
+ detail: `The app started this server itself (${startupMode}).`,
6662
+ sourceBuild: false
6663
+ };
6664
+ }
6665
+ /**
6666
+ * Path the `local-operator` shim resolves to, or null when it is not installed.
6667
+ *
6668
+ * Sync on purpose: it runs once per backend check, and the answer decides
6669
+ * which installer the prompt names, so the caller wants it before it builds
6670
+ * the message rather than a promise to settle afterwards.
6671
+ *
6672
+ * It does NOT ask the shell's `which` on macOS: the app is normally started
6673
+ * by Finder or `open`, whose PATH is the launchd default and does not contain
6674
+ * `~/.local/bin` - so the uv tool install this whole classification exists to
6675
+ * describe was invisible, and its remedy came out with no command in it.
6676
+ * `resolveCommandPath` searches the standard install locations for the same
6677
+ * markers (see `commandSearchDirs`). Windows keeps `where`, which already
6678
+ * searches the machine's and the user's own install locations.
6679
+ */
6680
+ resolveLocalOperatorPath() {
6681
+ if (process.platform === "win32") {
6682
+ const first = readCommandOutput("where", ["local-operator"])?.split("\n")[0]?.trim();
6683
+ return first && first.length > 0 ? first : null;
6684
+ }
6685
+ return resolveCommandPath("local-operator");
6686
+ }
6687
+ /**
6688
+ * Path the `lop-update` shim resolves to, or null when this machine has none.
6689
+ *
6690
+ * The same problem as the shim above, for the remedy rather than the
6691
+ * classification: `lop-update` is what the source-build wording tells the user
6692
+ * to run, and it lives in `~/.local/bin`, which a Finder launch cannot see
6693
+ * through PATH alone - so the plan fell back to the pip/pipx wording for an
6694
+ * install that is neither. It is a shell script, so there is no Windows
6695
+ * counterpart to look for.
6696
+ */
6697
+ resolveLopUpdatePath() {
6698
+ if (process.platform === "win32") return null;
6699
+ return resolveCommandPath("lop-update");
6700
+ }
6701
+ /**
6702
+ * Everything `classifyGlobalInstall` needs to tell a uv tool install from a
6703
+ * pipx one from an ordinary pip venv from the checkout a developer runs.
6704
+ *
6705
+ * The cheap signals are gathered first - `readInstallIdentity` reads the
6706
+ * shim's path, its resolved target, its shebang and the dist-info's own
6707
+ * markers - and the two CLI probes are skipped when a string test already
6708
+ * answered: spawning `uv` and `pipx` on every backend check for a question
6709
+ * three string tests settled is work nobody asked for.
6710
+ */
6711
+ resolveInstallIdentity() {
6712
+ const identity = readInstallIdentity(this.resolveLocalOperatorPath());
6713
+ if (identity.path && classifyGlobalInstall(identity) !== "global-unknown") {
6714
+ return identity;
6715
+ }
6716
+ const uv = resolveCommandPath("uv");
6717
+ const pipx = resolveCommandPath("pipx");
6718
+ identity.uvToolList = uv ? readCommandOutput(uv, ["tool", "list"]) : null;
6719
+ identity.pipxList = pipx ? readCommandOutput(pipx, ["list"]) : null;
6720
+ return identity;
6721
+ }
5078
6722
  async checkForBackendUpdates(silent = false) {
5079
6723
  logger.info(
5080
6724
  `Checking for backend updates... (silent mode: ${silent})`,
@@ -5104,20 +6748,34 @@ class UpdateService {
5104
6748
  }
5105
6749
  const installedVersion = await this.getInstalledBackendVersion();
5106
6750
  const latestVersion = await this.getLatestPypiVersion();
6751
+ if (latestVersion) this.lastPublishedBackendVersion = latestVersion;
5107
6752
  if (!installedVersion || !latestVersion) {
5108
6753
  logger.error(
5109
6754
  "Unable to determine backend versions.",
5110
6755
  LogFileType.UPDATE_SERVICE
5111
6756
  );
5112
- if (!silent && this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
5113
- this.mainWindow.webContents.send(
6757
+ if (!silent) {
6758
+ this.sendToRenderer(
5114
6759
  "backend-update-error",
5115
6760
  "Unable to determine backend version."
5116
6761
  );
5117
6762
  }
5118
6763
  return null;
5119
6764
  }
5120
- const shouldUpdate = installedVersion === "Unknown" || this.isNewerVersion(latestVersion, installedVersion);
6765
+ if (installedVersion === "Unknown") {
6766
+ logger.error(
6767
+ "The installed backend version could not be determined from the health endpoint.",
6768
+ LogFileType.UPDATE_SERVICE
6769
+ );
6770
+ if (!silent) {
6771
+ this.sendToRenderer(
6772
+ "backend-update-error",
6773
+ "The installed server version could not be determined, so no update was offered. Restart the app to try again."
6774
+ );
6775
+ }
6776
+ return null;
6777
+ }
6778
+ const shouldUpdate = this.isNewerVersion(latestVersion, installedVersion);
5121
6779
  logger.info(
5122
6780
  `Installed backend version: ${installedVersion}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Startup mode: ${startupMode}`,
5123
6781
  LogFileType.UPDATE_SERVICE
@@ -5127,33 +6785,29 @@ class UpdateService {
5127
6785
  `New backend version available: ${latestVersion} (installed: ${installedVersion})`,
5128
6786
  LogFileType.UPDATE_SERVICE
5129
6787
  );
5130
- const canManageUpdate = startupMode !== LocalOperatorStartupMode.EXISTING_SERVER;
5131
- let updateCommand = "pip install --upgrade local-operator";
5132
- if (startupMode === LocalOperatorStartupMode.EXISTING_SERVER) {
5133
- updateCommand = "pip install --upgrade local-operator";
5134
- }
6788
+ const { canManageUpdate, updateCommand, remedy, detail, sourceBuild } = await this.resolveBackendUpdatePlan(startupMode);
5135
6789
  const updateInfo = {
5136
6790
  currentVersion: installedVersion,
5137
6791
  latestVersion,
5138
6792
  updateCommand,
5139
6793
  canManageUpdate,
5140
- startupMode
6794
+ startupMode,
6795
+ remedy,
6796
+ detail,
6797
+ sourceBuild,
6798
+ /* `silent` is the whole difference between the two callers: the periodic
6799
+ and start-up checks pass `true`, the IPC handlers behind the buttons
6800
+ pass `false`. The renderer needs to know which one it is answering,
6801
+ because the by-hand panel's instruction deliberately outlives a
6802
+ background check and is only ended by a check the user asked for
6803
+ (review U12, round 3). */
6804
+ manual: !silent
5141
6805
  };
5142
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
5143
- logger.info(
5144
- `Sending backend-update-available event with info: ${JSON.stringify(updateInfo)}`,
5145
- LogFileType.UPDATE_SERVICE
5146
- );
5147
- this.mainWindow.webContents.send(
5148
- "backend-update-available",
5149
- updateInfo
5150
- );
5151
- } else {
5152
- logger.error(
5153
- "Cannot send backend-update-available event: mainWindow is not available or destroyed",
5154
- LogFileType.UPDATE_SERVICE
5155
- );
5156
- }
6806
+ logger.info(
6807
+ `Sending backend-update-available event with info: ${JSON.stringify(updateInfo)}`,
6808
+ LogFileType.UPDATE_SERVICE
6809
+ );
6810
+ this.sendToRenderer("backend-update-available", updateInfo);
5157
6811
  return updateInfo;
5158
6812
  }
5159
6813
  logger.info(
@@ -5185,7 +6839,76 @@ class UpdateService {
5185
6839
  * Update the backend using pip and restart the backend service
5186
6840
  * @returns Promise resolving to true if update was successful, false otherwise
5187
6841
  */
5188
- async updateBackend() {
6842
+ /** The version the bundled environment has installed, via `pip show`. */
6843
+ async readBundledBackendVersion(pythonPath) {
6844
+ const probe = await runCommand(
6845
+ pythonPath,
6846
+ ["-m", "pip", "show", "local-operator"],
6847
+ { timeoutMs: 12e4 }
6848
+ );
6849
+ if (probe.exitCode !== 0) {
6850
+ logger.warn(
6851
+ `pip show local-operator exited ${probe.exitCode}: ${(probe.stderr || probe.stdout).trim()}`,
6852
+ LogFileType.UPDATE_SERVICE
6853
+ );
6854
+ return null;
6855
+ }
6856
+ return parsePipShowVersion(probe.stdout);
6857
+ }
6858
+ /**
6859
+ * Bring the previous server back after a failed upgrade, and say whether it
6860
+ * actually came back.
6861
+ *
6862
+ * A failure here must not leave the user with no backend at all: whatever pip
6863
+ * did or did not do, the environment usually still holds a working install, so
6864
+ * start it again and then ask /health rather than assuming. The answer is not
6865
+ * cosmetic: `pip install --upgrade` uninstalls before it installs, so a
6866
+ * mid-install failure can leave the package absent, and telling the user "the
6867
+ * previous server is still running" when it is not is a claim we had no
6868
+ * evidence for (review R6).
6869
+ */
6870
+ async restartBackendAfterFailedUpgrade() {
6871
+ try {
6872
+ await this.backendService?.start();
6873
+ const healthy = await this.checkBackendHealth();
6874
+ logger.info(
6875
+ healthy ? "Restarted the previous backend after a failed update" : "The backend did not answer its health check after a failed update",
6876
+ LogFileType.UPDATE_SERVICE
6877
+ );
6878
+ return healthy;
6879
+ } catch (error) {
6880
+ logger.error(
6881
+ "Could not restart the backend after a failed update:",
6882
+ LogFileType.UPDATE_SERVICE,
6883
+ error
6884
+ );
6885
+ return false;
6886
+ }
6887
+ }
6888
+ /**
6889
+ * Poll /health until it reports `targetVersion`, bounded by a timeout.
6890
+ *
6891
+ * Returns null when the version never matched (or could not be read), which
6892
+ * the caller reports as a failure rather than as a completed update.
6893
+ */
6894
+ async waitForBackendVersion(target2, timeoutMs = 6e4, intervalMs = 2e3) {
6895
+ const deadline = Date.now() + timeoutMs;
6896
+ let last = null;
6897
+ while (Date.now() < deadline) {
6898
+ const version2 = await this.getInstalledBackendVersion();
6899
+ if (version2 && version2 !== "Unknown") {
6900
+ last = version2;
6901
+ if (target2 == null || version2 === target2) return version2;
6902
+ }
6903
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
6904
+ }
6905
+ logger.warn(
6906
+ `Backend version poll timed out after ${timeoutMs}ms (target ${target2 ?? "any"}, last seen ${last ?? "none"})`,
6907
+ LogFileType.UPDATE_SERVICE
6908
+ );
6909
+ return null;
6910
+ }
6911
+ async updateBackend(targetVersion) {
5189
6912
  logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
5190
6913
  try {
5191
6914
  if (!this.backendService) {
@@ -5216,18 +6939,28 @@ class UpdateService {
5216
6939
  }
5217
6940
  return false;
5218
6941
  case LocalOperatorStartupMode.EXISTING_SERVER:
6942
+ case LocalOperatorStartupMode.GLOBAL_INSTALL: {
6943
+ const plan = await this.resolveBackendUpdatePlan(startupMode);
5219
6944
  logger.info(
5220
- "Cannot manage update for existing server. User must update manually.",
6945
+ `Cannot manage the update for ${startupMode}: ${plan.detail}`,
5221
6946
  LogFileType.UPDATE_SERVICE
5222
6947
  );
5223
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
5224
- this.mainWindow.webContents.send("backend-update-manual-required", {
5225
- message: "Please update the local-operator package manually using pip.",
5226
- command: "pip install --upgrade local-operator"
5227
- });
5228
- }
6948
+ const installed = await this.getInstalledBackendVersion();
6949
+ this.sendToRenderer("backend-update-manual-required", {
6950
+ message: plan.remedy,
6951
+ command: plan.updateCommand,
6952
+ detail: plan.detail,
6953
+ // The caller's target when it named one (the "Update server"
6954
+ // button does), otherwise the published release the last check
6955
+ // read: the compatibility banner calls this with no target at
6956
+ // all, and the panel's version sentence then had nothing to
6957
+ // render on the path that actually produces it (review U17).
6958
+ latestVersion: targetVersion ?? this.lastPublishedBackendVersion ?? null,
6959
+ currentVersion: installed && installed !== "Unknown" ? installed : null,
6960
+ sourceBuild: plan.sourceBuild
6961
+ });
5229
6962
  return false;
5230
- case LocalOperatorStartupMode.GLOBAL_INSTALL:
6963
+ }
5231
6964
  case LocalOperatorStartupMode.APP_BUNDLED_VENV:
5232
6965
  break;
5233
6966
  default:
@@ -5249,75 +6982,66 @@ class UpdateService {
5249
6982
  LogFileType.UPDATE_SERVICE
5250
6983
  );
5251
6984
  }
5252
- const execAsync = node_util.promisify(node_child_process.exec);
5253
6985
  let pythonPath = "";
5254
- let pipCommand = "";
5255
- if (startupMode === LocalOperatorStartupMode.APP_BUNDLED_VENV) {
5256
- try {
5257
- if (electron.app.isPackaged) {
5258
- if (process.platform === "darwin") {
5259
- pythonPath = path.join(
5260
- this.backendService?.getVenvPath(),
5261
- "bin",
5262
- "python3"
5263
- );
5264
- } else if (process.platform === "win32") {
5265
- pythonPath = path.join(
5266
- this.backendService?.getVenvPath(),
5267
- "Scripts",
5268
- "python.exe"
5269
- );
5270
- } else if (process.platform === "linux") {
5271
- pythonPath = path.join(
5272
- this.backendService?.getVenvPath(),
5273
- "bin",
5274
- "python3"
5275
- );
5276
- }
5277
- if (pythonPath) {
5278
- logger.info(
5279
- `Using bundled Python at: ${pythonPath}`,
5280
- LogFileType.UPDATE_SERVICE
5281
- );
5282
- pipCommand = `"${pythonPath}" -m pip install --upgrade local-operator`;
5283
- }
5284
- }
5285
- } catch (error) {
5286
- logger.warn(
5287
- "Error finding bundled Python path:",
5288
- LogFileType.UPDATE_SERVICE,
5289
- error
5290
- );
5291
- pythonPath = "";
5292
- }
5293
- } else if (startupMode === LocalOperatorStartupMode.GLOBAL_INSTALL) {
5294
- try {
5295
- const { stdout } = await execAsync("which python3 || which python");
5296
- pythonPath = stdout.trim();
5297
- logger.info(
5298
- `Using system Python at: ${pythonPath}`,
5299
- LogFileType.UPDATE_SERVICE
5300
- );
5301
- pipCommand = `"${pythonPath}" -m pip install --upgrade local-operator`;
5302
- } catch (error) {
5303
- logger.warn(
5304
- "Could not find system Python:",
5305
- LogFileType.UPDATE_SERVICE,
5306
- error
5307
- );
5308
- }
6986
+ const venvPath = this.backendService?.getVenvPath();
6987
+ if (venvPath && electron.app.isPackaged) {
6988
+ const venvBinDir = process.platform === "win32" ? "Scripts" : "bin";
6989
+ const pythonName = process.platform === "win32" ? "python.exe" : "python3";
6990
+ pythonPath = path.join(venvPath, venvBinDir, pythonName);
5309
6991
  }
5310
- if (!pipCommand) {
5311
- logger.warn("Using fallback pip command", LogFileType.UPDATE_SERVICE);
5312
- pipCommand = "pip install --upgrade local-operator";
6992
+ if (!pythonPath || !fs.existsSync(pythonPath)) {
6993
+ logger.error(
6994
+ `Cannot update the bundled backend: no Python at ${pythonPath || "an unknown path"}`,
6995
+ LogFileType.UPDATE_SERVICE
6996
+ );
6997
+ await this.restartBackendAfterFailedUpgrade();
6998
+ this.sendToRenderer(
6999
+ "backend-update-error",
7000
+ "The bundled server's Python environment could not be found, so the update did not run. Please reinstall the application."
7001
+ );
7002
+ return false;
5313
7003
  }
7004
+ const pip = buildPipUpgradeCommand(pythonPath);
7005
+ const versionBefore = await this.readBundledBackendVersion(pythonPath);
7006
+ logger.info(
7007
+ `Executing pip command: ${pip.display}`,
7008
+ LogFileType.UPDATE_SERVICE
7009
+ );
7010
+ const pipRun = await runCommand(pip.command, pip.args, {
7011
+ timeoutMs: 15 * 60 * 1e3
7012
+ });
5314
7013
  logger.info(
5315
- `Executing pip command: ${pipCommand}`,
7014
+ `pip stdout:
7015
+ ${pipRun.stdout.trim()}`,
5316
7016
  LogFileType.UPDATE_SERVICE
5317
7017
  );
5318
- await execAsync(pipCommand);
7018
+ if (pipRun.stderr.trim().length > 0) {
7019
+ logger.warn(
7020
+ `pip stderr:
7021
+ ${pipRun.stderr.trim()}`,
7022
+ LogFileType.UPDATE_SERVICE
7023
+ );
7024
+ }
7025
+ const versionAfter = await this.readBundledBackendVersion(pythonPath);
7026
+ const upgradeLanded = pipRun.exitCode === 0 && didUpgradeLand({
7027
+ before: versionBefore,
7028
+ after: versionAfter,
7029
+ target: targetVersion ?? null
7030
+ });
7031
+ if (!upgradeLanded) {
7032
+ logger.error(
7033
+ `Backend upgrade did not land: pip exited ${pipRun.exitCode}; version ${versionBefore ?? "unknown"} -> ${versionAfter ?? "unknown"}`,
7034
+ LogFileType.UPDATE_SERVICE
7035
+ );
7036
+ const serverIsBack = await this.restartBackendAfterFailedUpgrade();
7037
+ this.sendToRenderer(
7038
+ "backend-update-error",
7039
+ pipRun.exitCode !== 0 ? serverIsBack ? "The server update failed to install. The previously installed server is running again; see the update service log for pip's output." : "The server update failed to install and the server did not come back up. Restart Local Operator, and see the update service log for pip's output." : `The server update ran but the installed version did not change (still ${versionAfter ?? "unknown"}), so it is reported as failed.`
7040
+ );
7041
+ return false;
7042
+ }
5319
7043
  logger.info(
5320
- "Backend package updated successfully via pip",
7044
+ `Backend package upgraded: ${versionBefore ?? "unknown"} -> ${versionAfter}`,
5321
7045
  LogFileType.UPDATE_SERVICE
5322
7046
  );
5323
7047
  logger.info(
@@ -5382,6 +7106,24 @@ class UpdateService {
5382
7106
  }
5383
7107
  return false;
5384
7108
  }
7109
+ const reportedVersion = await this.waitForBackendVersion(
7110
+ targetVersion ?? null
7111
+ );
7112
+ if (reportedVersion == null) {
7113
+ logger.error(
7114
+ `Backend did not report version ${targetVersion ?? "the new one"} after the update`,
7115
+ LogFileType.UPDATE_SERVICE
7116
+ );
7117
+ this.sendToRenderer(
7118
+ "backend-update-error",
7119
+ `The server restarted but did not report ${targetVersion ? `version ${targetVersion}` : "the updated version"}. Check the update service log, then try again.`
7120
+ );
7121
+ return false;
7122
+ }
7123
+ logger.info(
7124
+ `Backend reports version ${reportedVersion} after the update`,
7125
+ LogFileType.UPDATE_SERVICE
7126
+ );
5385
7127
  logger.info(
5386
7128
  "Backend update and restart completed successfully",
5387
7129
  LogFileType.UPDATE_SERVICE
@@ -5461,6 +7203,14 @@ class UpdateService {
5461
7203
  * @param err The error object from autoUpdater
5462
7204
  * @returns True if the error should be filtered, false if it should be shown to the user
5463
7205
  */
7206
+ /**
7207
+ * Whether an error from the updater is a genuine availability problem.
7208
+ *
7209
+ * Only availability checks may be filtered. A download, verification or
7210
+ * install failure means something the user asked for did not happen, and
7211
+ * reporting it as "no update available" (the previous behaviour) is how the
7212
+ * operator's failed install produced no message at all.
7213
+ */
5464
7214
  shouldFilterUpdateError(err) {
5465
7215
  const errorMessage = err.message || "";
5466
7216
  logger.info(