local-operator-ui 0.17.1 → 0.17.3

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 (68) hide show
  1. package/out/main/index.js +1955 -183
  2. package/out/preload/index.js +51 -3
  3. package/out/renderer/assets/{_basePickBy-C3hLSrXE.js → _basePickBy-CznFfatx.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-DvVdHboz.js → _baseUniq-BjMURw0K.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-6irHwYok.js → agent-details-page-ZDJPFc2J.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-t3NZyvhe.js → agent-hub-page-BCTiysl5.js} +1 -1
  7. package/out/renderer/assets/{agents-page-Cu3tV_JM.js → agents-page-DMVIzax-.js} +2 -2
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-Dy32lq21.js → architectureDiagram-IEHRJDOE-B1dNRm1E.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-D9CCHreU.js → blockDiagram-JOT3LUYC-jqbb6gXH.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-D6JYul4W.js → c4Diagram-VJAJSXHY-CFq9qrnD.js} +1 -1
  11. package/out/renderer/assets/channel-B0cyEtqf.js +1 -0
  12. package/out/renderer/assets/{chunk-4BMEZGHF-CWZ3r21W.js → chunk-4BMEZGHF-gZvJoH2i.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-_jdJLEL4.js → chunk-A2AXSNBT-BkTvsCLM.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-BRqxxp9X.js → chunk-AEK57VVT-h-_7Epgx.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-mvuW_LPO.js → chunk-D6G4REZN-CtrPKMTC.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-B2kzBW8d.js → chunk-RZ5BOZE2-C0RFP5w9.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-CqZmhFu_.js → chunk-XZIHB7SX-C-kSNRUr.js} +1 -1
  18. package/out/renderer/assets/classDiagram-GIVACNV2-Dqwik2D7.js +1 -0
  19. package/out/renderer/assets/classDiagram-v2-COTLJTTW-Dqwik2D7.js +1 -0
  20. package/out/renderer/assets/clone-CL2wO1Ym.js +1 -0
  21. package/out/renderer/assets/{compact-pagination-BJa5bxJe.js → compact-pagination-DS6ecmly.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-BLi9lW3T.js → dagre-OKDRZEBW-Dv_Zo3IY.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-DQa3WYjU.js → diagram-SSKATNLV-D5ST2KPg.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-C99A8WuH.js → diagram-VNBRO52H-haGCbMfM.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-DLWwdshb.js → erDiagram-Q7BY3M3F-C92fuMik.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-CHDQ3e9O.js → flowDiagram-4HSFHLVR-Bt4sRBki.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-G4Rf9Yhs.js → ganttDiagram-APWFNJXF-c0Y2OgXm.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-2JN_jG8T.js → gitGraphDiagram-7IBYFJ6S-B9TUU4Qi.js} +1 -1
  29. package/out/renderer/assets/{graph-BR85xHbv.js → graph-CO7rQdXD.js} +1 -1
  30. package/out/renderer/assets/icon-CqmmCSFR.css +1 -0
  31. package/out/renderer/assets/{index-_UzVA1oU.js → index-B6ufqJu6.js} +302 -302
  32. package/out/renderer/assets/{index-BIyDfbOQ.js → index-CHRG8Ler.js} +1 -1
  33. package/out/renderer/assets/{index-BuhuPWhs.js → index-Dt1yeMBX.js} +1 -1
  34. package/out/renderer/assets/{infoDiagram-PH2N3AL5-xVzynFeN.js → infoDiagram-PH2N3AL5-DroR83ys.js} +1 -1
  35. package/out/renderer/assets/{installer-CwpKGBgz.js → installer-CL3b2q9D.js} +1 -1
  36. package/out/renderer/assets/{journeyDiagram-U35MCT3I-C8-eHlWx.js → journeyDiagram-U35MCT3I-z6YQbkvW.js} +1 -1
  37. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-C2Oyzo6d.js → kanban-definition-NDS4AKOZ-DX3Ux1JM.js} +1 -1
  38. package/out/renderer/assets/{layout-mlSyc_4q.js → layout-CGW8X3Ld.js} +1 -1
  39. package/out/renderer/assets/{legacy-agents-page-CTGA2lul.js → legacy-agents-page-DOL46A-I.js} +1 -1
  40. package/out/renderer/assets/{mermaid.core-BvFuUEpc.js → mermaid.core-BD15O0c3.js} +5 -5
  41. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-DWnloutf.js → mindmap-definition-ALO5MXBD-BAJ-8QKu.js} +1 -1
  42. package/out/renderer/assets/{page-header-C-9zjakn.js → page-header-QKjtBvcy.js} +1 -1
  43. package/out/renderer/assets/{parseISO-Bp3fMoKU.js → parseISO-CXOJUx7e.js} +1 -1
  44. package/out/renderer/assets/{pieDiagram-IB7DONF6-B990Tpnk.js → pieDiagram-IB7DONF6-NFj3sXlT.js} +1 -1
  45. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BYeOekBR.js → quadrantDiagram-7GDLP6J5-CJB8XQQb.js} +1 -1
  46. package/out/renderer/assets/{radar-MK3ICKWK-DcxkUK5Z.js → radar-MK3ICKWK-DR5CTIYI.js} +1 -1
  47. package/out/renderer/assets/{radient-auth-buttons-CK9Icvbq.js → radient-auth-buttons-Cgr7rndT.js} +1 -1
  48. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D_tf_1lk.js → requirementDiagram-KVF5MWMF-B4Eet6_a.js} +1 -1
  49. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-DlDfWN8k.js → sankeyDiagram-QLVOVGJD-CI-VcjXP.js} +1 -1
  50. package/out/renderer/assets/{schedules-page-AQ9gMlkR.js → schedules-page-KeO7FZ7D.js} +1 -1
  51. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-wr3-RcoT.js → sequenceDiagram-X6HHIX6F-CAg0HbTN.js} +1 -1
  52. package/out/renderer/assets/{settings-page-BSjdxTFW.js → settings-page-D7ptJm1c.js} +29 -29
  53. package/out/renderer/assets/{square-pen-2Yxvf_S9.js → square-pen-uKlnlrn1.js} +1 -1
  54. package/out/renderer/assets/{stateDiagram-DGXRK772-BLD6Vmgd.js → stateDiagram-DGXRK772-CsaG4cMW.js} +1 -1
  55. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DbebtGKF.js +1 -0
  56. package/out/renderer/assets/{timeline-definition-BDJGKUSR-B-JeF04j.js → timeline-definition-BDJGKUSR-D_Pz88Zq.js} +1 -1
  57. package/out/renderer/assets/{use-agent-like-mutation-tstZNIUn.js → use-agent-like-mutation-DYvn_qmk.js} +1 -1
  58. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-DuFpzjW6.js → xychartDiagram-VJFVF3MP-BO5MIl1S.js} +1 -1
  59. package/out/renderer/index.html +3 -3
  60. package/out/renderer/installer.html +3 -3
  61. package/package.json +5 -3
  62. package/out/renderer/assets/channel-CLzqtgQj.js +0 -1
  63. package/out/renderer/assets/classDiagram-GIVACNV2-Bu04i3t4.js +0 -1
  64. package/out/renderer/assets/classDiagram-v2-COTLJTTW-Bu04i3t4.js +0 -1
  65. package/out/renderer/assets/clone-DHy7FuoP.js +0 -1
  66. package/out/renderer/assets/icon-2KHv58kf.css +0 -1
  67. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CsGkljza.js +0 -1
  68. /package/out/renderer/assets/{icon-2OdwuA9u.js → icon-DRXQp5yV.js} +0 -0
package/out/main/index.js CHANGED
@@ -4422,9 +4422,932 @@ 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 watchdogSignals(platform) {
4750
+ return { jobProbe: "launchctl", plistReader: "/usr/bin/plutil" };
4751
+ }
4752
+ function buildWatchdogPlan(input) {
4753
+ const signals = input.signals ?? watchdogSignals();
4754
+ const timeoutSeconds = input.timeoutSeconds ?? WATCHDOG_TIMEOUT_SECONDS;
4755
+ const intervalSeconds = input.intervalSeconds ?? 3;
4756
+ const settleSeconds = input.settleSeconds ?? 5;
4757
+ const appearSeconds = input.appearSeconds ?? 30;
4758
+ const plistTimeoutSeconds = input.plistReadTimeoutSeconds ?? PLIST_READ_TIMEOUT_SECONDS;
4759
+ const script = `#!/bin/sh
4760
+ # ${WATCHDOG_TOKEN}
4761
+ #
4762
+ # Why this exists: a failed Squirrel.Mac install quits the app and never brings
4763
+ # it back - ShipIt logs an installation error, leaves no exit status and does not
4764
+ # run the relaunch - so the user is left with no app and no message (operator
4765
+ # report, 2026-09-11, errSecCSBadBundleFormat -67028).
4766
+ #
4767
+ # How it decides, and why each signal is the one it is:
4768
+ # - "the app has exited" is asked of the app's own pid (kill -0), captured by
4769
+ # the app before it quit. A name probe cannot answer it on macOS: pgrep -f
4770
+ # does not report its own ancestors, and this script is the app's child.
4771
+ # - "the install is over" is asked of BOTH the ShipIt launchd job, by label,
4772
+ # AND the state of the swap on disk. The job is submitted as part of the quit
4773
+ # and unloaded when an install settles, so it is the signal that separates a
4774
+ # hung install from an unrelated process that merely has the word in its
4775
+ # command line - but it is not sufficient on its own: the 0.17.0 failure
4776
+ # left its job loaded and respawning for hours (runs=3114), so waiting only
4777
+ # on the job meant waiting for the bound with the user staring at nothing
4778
+ # (review R11). The version in the target bundle's own Info.plist is what
4779
+ # says the swap landed, whether or not the job ever goes away.
4780
+ #
4781
+ # It starts the app in exactly one situation - the app is not running - and it
4782
+ # reaches that point on three paths:
4783
+ # (a) ShipIt's job is gone: the install is decided. The conservative case.
4784
+ # (b) the bundle at the target path reports the version this update was for, or
4785
+ # one beyond it: the new app is in place and ShipIt has nothing left to do.
4786
+ # This is what makes an early exit safe rather than a guess, and it is the
4787
+ # path a failed-but-swapped install takes.
4788
+ # (c) the bound arrived with no decision. Trying is still better than leaving
4789
+ # the user with nothing, and the bound is what makes the previous
4790
+ # version's silent exit 0 impossible. It is ~2.4x the slowest ShipIt
4791
+ # attempt measured on this machine (255s from request to verdict for a
4792
+ # 1 GiB app), and it is the ONE path that can start the app while a swap
4793
+ # is still in flight - an accepted risk, not an oversight.
4794
+ #
4795
+ # With no job label to ask about, the job cannot be consulted at all, so the swap
4796
+ # state is the whole signal: the script waits for (b) rather than spending an
4797
+ # appear window on a job it cannot see and then relaunching on no evidence.
4798
+ set -u
4799
+ APP_PID="\${LO_UPDATE_WATCHDOG_APP_PID:-}"
4800
+ BUNDLE="\${LO_UPDATE_WATCHDOG_APP_BUNDLE:-}"
4801
+ NAME="\${LO_UPDATE_WATCHDOG_APP_NAME:-}"
4802
+ SHIPIT_JOB="\${LO_UPDATE_WATCHDOG_SHIPIT_JOB:-}"
4803
+ TARGET_VERSION="\${LO_UPDATE_WATCHDOG_TARGET_VERSION:-}"
4804
+ # The two probes, from the platform the plan was built for. Empty means the
4805
+ # platform has no such tool and the signal is unavailable - not "the job is not
4806
+ # loaded" and not "the swap has not landed". shipit_loaded, swap_landed and
4807
+ # job_known each decline on it rather than answering, so a host without them
4808
+ # waits out the bound and still relaunches.
4809
+ SHIPIT_PROBE="\${LO_UPDATE_WATCHDOG_SHIPIT_PROBE:-}"
4810
+ PLIST_READER="\${LO_UPDATE_WATCHDOG_PLIST_READER:-}"
4811
+ if [ -z "$APP_PID" ] || [ -z "$BUNDLE" ]; then
4812
+ exit 1
4813
+ fi
4814
+ now() { date +%s; }
4815
+ app_running() { kill -0 "$APP_PID" 2>/dev/null; }
4816
+ # launchctl list <label> exits 113 when the job is not loaded, 0 when it is.
4817
+ # With no probe there is no launchd to ask, and this answers "cannot ask" rather
4818
+ # than "not loaded": job_known below requires the probe for the same reason.
4819
+ shipit_loaded() { [ -n "$SHIPIT_PROBE" ] && [ -n "$SHIPIT_JOB" ] && "$SHIPIT_PROBE" list "$SHIPIT_JOB" >/dev/null 2>&1; }
4820
+ # (b), the swap's own state: is the app at the target path AT OR BEYOND the
4821
+ # version this update was for? plutil rather than \`defaults read\`, which reads
4822
+ # through a preference domain and can answer from a stale cache; a half-written
4823
+ # plist and a missing one both read as "not landed yet", which is the safe
4824
+ # direction.
4825
+ #
4826
+ # At or beyond rather than an exact match, because this is the same question the
4827
+ # renderer's clear rule asks ("is the server no longer behind the version the
4828
+ # panel named?"): a release that moves on between the offer and the check must
4829
+ # not leave the app waiting out the whole bound for a bundle already past the
4830
+ # target (review Q6).
4831
+ version_at_least() {
4832
+ # An absent version is not a version. This is the ONE input the two halves
4833
+ # answer differently on, deliberately: the renderer pads a missing side with
4834
+ # zero (\`left[index] ?? 0\`), so it reads an empty target as "at least 0",
4835
+ # while a rule that can start a relaunch must not call a version nobody
4836
+ # reported "landed". Both callers prove the operand non-empty first - the
4837
+ # script's \`swap_landed\` declines an empty target outright and the read
4838
+ # answers nothing rather than a version - so no path reaches this with one.
4839
+ [ -n "$1" ] && [ -n "$2" ] || return 1
4840
+ # Exact match first, as the renderer's rule does: a pre-release pair that
4841
+ # matches exactly IS the version that was waited for, and the digit checks
4842
+ # below cannot order a suffix at all. The caller strips a leading \`v\` before
4843
+ # calling, which is the other half of what the renderer's rule does itself.
4844
+ [ "$1" = "$2" ] && return 0
4845
+ # Anything else carrying a suffix is not orderable here, and the renderer's
4846
+ # rule refuses the same inputs for the same reason ("keeps the instruction on
4847
+ # screen rather than clearing over a real gap"). Answered before the digit
4848
+ # walk, because a suffix the unrelated leading numeric components never reach
4849
+ # would otherwise be ignored - \`1.0.0\` against \`0.18.0-rc1\` compared as
4850
+ # landed here and as not-beyond there. Not-landed is the safe direction in
4851
+ # this script: the job and the bound still decide, and the app still comes
4852
+ # back.
4853
+ case "$1$2" in *-*) return 1 ;; esac
4854
+ _va="$1"
4855
+ _vb="$2"
4856
+ while :; do
4857
+ case "$_va" in
4858
+ *.*) _ha="\${_va%%.*}"; _va="\${_va#*.}" ;;
4859
+ *) _ha="$_va"; _va="" ;;
4860
+ esac
4861
+ case "$_vb" in
4862
+ *.*) _hb="\${_vb%%.*}"; _vb="\${_vb#*.}" ;;
4863
+ *) _hb="$_vb"; _vb="" ;;
4864
+ esac
4865
+ # A component with no digits in it is a pre-release suffix, which this
4866
+ # cannot order: it reads as "not landed", so the job and the bound stay
4867
+ # the deciders. Same shape as the renderer's rule.
4868
+ case "$_ha" in *[!0-9]*) return 1 ;; esac
4869
+ case "$_hb" in *[!0-9]*) return 1 ;; esac
4870
+ # A side that has run out contributes zero, which is what the renderer's
4871
+ # \`left[index] ?? 0\` does, so unequally wide dotted versions order the
4872
+ # same way in both halves of "at or beyond". Without this, \`0.18.0.1\`
4873
+ # against \`0.18.0\` read as not-landed here and as landed there (review
4874
+ # round 4, M3): the safe direction is not a reason to leave two
4875
+ # implementations of one rule disagreeing.
4876
+ [ -n "$_ha" ] || _ha=0
4877
+ [ -n "$_hb" ] || _hb=0
4878
+ if [ "$_ha" -gt "$_hb" ]; then return 0; fi
4879
+ if [ "$_ha" -lt "$_hb" ]; then return 1; fi
4880
+ if [ -z "$_va" ] && [ -z "$_vb" ]; then return 0; fi
4881
+ done
4882
+ }
4883
+ # Read the bundle's version under a hard time bound. This sits inside the poll
4884
+ # loop, and an unbounded read of a path on a mount that has stopped answering
4885
+ # would outlive the script's own deadline and leave the user with no app - the
4886
+ # very outcome this script exists to undo. macOS ships no \`timeout(1)\`, so the
4887
+ # bound is a background read the script can stop waiting on (review R17).
4888
+ bundle_version() {
4889
+ _plist="$BUNDLE/Contents/Info.plist"
4890
+ # A plist that is not readable yet - the ordinary state while a swap is in
4891
+ # flight - answers without starting a read at all, so the bound below is only
4892
+ # ever spent on a read that started and did not answer.
4893
+ [ -r "$_plist" ] || return 1
4894
+ _stamp="$$-$(now)"
4895
+ _out="\${TMPDIR:-/tmp}/lo-update-watchdog-version-$_stamp.out"
4896
+ # The pid the bound kills IS the reader: \`exec\` replaces the subshell rather
4897
+ # than wrapping the reader, so nothing is reparented when the kill lands. The
4898
+ # previous shape killed a subshell whose whole job was to drop a completion
4899
+ # marker, and the read that subshell had forked was left blocked under pid 1 -
4900
+ # one more per poll, for as long as the mount stayed dead (review round 4,
4901
+ # Q7). Completion is read off the answer itself instead: the reader writes the
4902
+ # version into this file and nothing else does, so bytes in it mean the read
4903
+ # answered, and a read that fails writes none and is stopped by the same
4904
+ # deadline.
4905
+ ( exec "$PLIST_READER" -extract CFBundleShortVersionString raw -o - "$_plist" ) >"$_out" 2>/dev/null &
4906
+ _read_pid=$!
4907
+ _waited=0
4908
+ while [ ! -s "$_out" ]; do
4909
+ if [ "$_waited" -ge ${plistTimeoutSeconds} ]; then
4910
+ kill -9 "$_read_pid" 2>/dev/null
4911
+ rm -f "$_out"
4912
+ return 1
4913
+ fi
4914
+ sleep 1
4915
+ _waited=$((_waited + 1))
4916
+ done
4917
+ _version="$(cat "$_out" 2>/dev/null)"
4918
+ rm -f "$_out"
4919
+ [ -n "$_version" ] || return 1
4920
+ printf '%s
4921
+ ' "$_version"
4922
+ }
4923
+ swap_landed() {
4924
+ # A platform with no plist reader cannot ask this question at all, and the
4925
+ # answer it must not give is "not landed yet" either: the signal is declined
4926
+ # and the job and the bound decide instead. \`bundle_version\` is only ever
4927
+ # reached from here.
4928
+ [ -n "$PLIST_READER" ] || return 1
4929
+ [ -n "$TARGET_VERSION" ] || return 1
4930
+ installed=$(bundle_version) || return 1
4931
+ installed=\${installed#v}
4932
+ [ -n "$installed" ] || return 1
4933
+ target=\${TARGET_VERSION#v}
4934
+ [ "$installed" = "$target" ] && return 0
4935
+ version_at_least "$installed" "$target"
4936
+ }
4937
+ job_known=0
4938
+ [ -n "$SHIPIT_PROBE" ] && [ -n "$SHIPIT_JOB" ] && job_known=1
4939
+ decided() {
4940
+ if [ "$job_known" -eq 1 ] && ! shipit_loaded; then return 0; fi
4941
+ swap_landed && return 0
4942
+ return 1
4943
+ }
4944
+ deadline=$(( $(now) + ${timeoutSeconds} ))
4945
+ # 1. The install only starts once the old app has exited.
4946
+ while app_running; do
4947
+ if [ "$(now)" -ge "$deadline" ]; then break; fi
4948
+ sleep ${intervalSeconds}
4949
+ done
4950
+ # 2. ShipIt's job is submitted as part of the quit, so it may not be loaded the
4951
+ # instant the app is gone: give it a bounded window to appear before treating
4952
+ # "no job" as "the install is over". Skipped when there is no label to ask
4953
+ # about: an appear window for an unaskable job is 30s of pretending, and it
4954
+ # used to end by relaunching with no evidence about the install at all.
4955
+ if [ "$job_known" -eq 1 ]; then
4956
+ appear_deadline=$(( $(now) + ${appearSeconds} ))
4957
+ while ! shipit_loaded; do
4958
+ if [ "$(now)" -ge "$appear_deadline" ] || [ "$(now)" -ge "$deadline" ]; then break; fi
4959
+ sleep ${intervalSeconds}
4960
+ done
4961
+ fi
4962
+ # 3. Wait for the install to be decided - by the job going, or by the swap
4963
+ # landing - and never past the bound.
4964
+ while :; do
4965
+ if decided; then break; fi
4966
+ if [ "$(now)" -ge "$deadline" ]; then break; fi
4967
+ sleep ${intervalSeconds}
4968
+ done
4969
+ # Let Squirrel's own relaunch (which happens as the job finishes) land first.
4970
+ sleep ${settleSeconds}
4971
+ # 4. Nothing to do if Squirrel relaunched the app or the user started it. This is
4972
+ # what makes the script a no-op when the install succeeded, by construction
4973
+ # rather than by claim.
4974
+ if app_running; then exit 0; fi
4975
+ if [ -n "$NAME" ] && [ -x "$BUNDLE/Contents/MacOS/$NAME" ]; then
4976
+ open -a "$BUNDLE" >/dev/null 2>&1 || "$BUNDLE/Contents/MacOS/$NAME" >/dev/null 2>&1 &
4977
+ fi
4978
+ exit 0
4979
+ `;
4980
+ return {
4981
+ script,
4982
+ env: {
4983
+ LO_UPDATE_WATCHDOG_APP_PID: String(input.appPid),
4984
+ LO_UPDATE_WATCHDOG_APP_BUNDLE: input.appBundlePath,
4985
+ LO_UPDATE_WATCHDOG_APP_NAME: input.executableName,
4986
+ LO_UPDATE_WATCHDOG_SHIPIT_JOB: input.shipItJob ?? "",
4987
+ LO_UPDATE_WATCHDOG_TARGET_VERSION: input.targetVersion ?? "",
4988
+ // Empty off macOS, which the script reads as "this signal is not available
4989
+ // here" rather than as a negative answer (see `watchdogSignals`).
4990
+ LO_UPDATE_WATCHDOG_SHIPIT_PROBE: signals.jobProbe ?? "",
4991
+ LO_UPDATE_WATCHDOG_PLIST_READER: signals.plistReader ?? ""
4992
+ },
4993
+ timeoutSeconds
4994
+ };
4995
+ }
4996
+ function reapFailedInstall(input) {
4997
+ const result = {
4998
+ jobLabel: null,
4999
+ jobRemoved: false,
5000
+ removedStaging: [],
5001
+ errors: []
5002
+ };
5003
+ if (!input.bundleId) {
5004
+ input.log(
5005
+ "Skipped cleaning up the failed install: the app's bundle id could not be read."
5006
+ );
5007
+ return result;
5008
+ }
5009
+ const label = shipItJobLabel(input.bundleId);
5010
+ result.jobLabel = label;
5011
+ try {
5012
+ const removal = input.removeJob(label);
5013
+ result.jobRemoved = !removal.notFound;
5014
+ input.log(
5015
+ removal.notFound ? `Leftover install job ${label} was not loaded.` : `Removed the leftover install job ${label}.`
5016
+ );
5017
+ } catch (error) {
5018
+ result.errors.push(`launchctl remove ${label}: ${String(error)}`);
5019
+ input.log(
5020
+ `Could not remove the leftover install job ${label}: ${String(error)}`
5021
+ );
5022
+ }
5023
+ const cacheDir = input.cacheRoot ? shipItCacheDir(input.cacheRoot, input.bundleId) : null;
5024
+ if (!cacheDir) return result;
5025
+ try {
5026
+ if (!input.exists(cacheDir)) return result;
5027
+ for (const entry of input.listDir(cacheDir)) {
5028
+ if (!entry.startsWith("update.")) continue;
5029
+ const staging = path.join(cacheDir, entry);
5030
+ try {
5031
+ input.removeDir(staging);
5032
+ result.removedStaging.push(staging);
5033
+ input.log(`Removed the staged update left behind at ${staging}.`);
5034
+ } catch (error) {
5035
+ result.errors.push(`${staging}: ${String(error)}`);
5036
+ input.log(`Could not remove ${staging}: ${String(error)}`);
5037
+ }
5038
+ }
5039
+ } catch (error) {
5040
+ result.errors.push(`${cacheDir}: ${String(error)}`);
5041
+ input.log(`Could not inspect ${cacheDir}: ${String(error)}`);
5042
+ }
5043
+ return result;
5044
+ }
5045
+ function watchdogIsOurs(input) {
5046
+ if (!input.alive || !input.installSucceeded) return false;
5047
+ return (input.commandLine ?? "").includes(WATCHDOG_TOKEN);
5048
+ }
5049
+ const UV_TOOL_LIST_ENTRY_REGEX = /(^|\n)local-operator v\d/i;
5050
+ const PIPX_LIST_ENTRY_REGEX = /(^|\n)\s*package local-operator\b/i;
5051
+ function uvToolListNamesLocalOperator(output) {
5052
+ return UV_TOOL_LIST_ENTRY_REGEX.test(output ?? "");
5053
+ }
5054
+ function pipxListNamesLocalOperator(output) {
5055
+ return PIPX_LIST_ENTRY_REGEX.test(output ?? "");
5056
+ }
5057
+ const PYTHON_LIB_DIR = /^python\d/;
5058
+ const LOCAL_OPERATOR_DIST_INFO = /^local[-_]operator[-_]/;
5059
+ function resolveDistributionMarkers(prefix) {
5060
+ const sitePackages = [];
5061
+ try {
5062
+ for (const entry of fs.readdirSync(path.join(prefix, "lib"))) {
5063
+ if (!PYTHON_LIB_DIR.test(entry)) continue;
5064
+ const candidate = path.join(prefix, "lib", entry, "site-packages");
5065
+ if (fs.existsSync(candidate)) sitePackages.push(candidate);
5066
+ }
5067
+ } catch {
5068
+ }
5069
+ const windowsSitePackages = path.join(prefix, "Lib", "site-packages");
5070
+ if (fs.existsSync(windowsSitePackages)) sitePackages.push(windowsSitePackages);
5071
+ let installer = null;
5072
+ let editable = false;
5073
+ for (const dir of sitePackages) {
5074
+ let entries;
5075
+ try {
5076
+ entries = fs.readdirSync(dir);
5077
+ } catch {
5078
+ continue;
5079
+ }
5080
+ for (const entry of entries) {
5081
+ if (!LOCAL_OPERATOR_DIST_INFO.test(entry)) continue;
5082
+ if (!entry.endsWith(".dist-info")) continue;
5083
+ const distInfo = path.join(dir, entry);
5084
+ try {
5085
+ const installerText = fs.readFileSync(path.join(distInfo, "INSTALLER"), "utf8");
5086
+ installer = installerText.trim().toLowerCase() || installer;
5087
+ } catch {
5088
+ }
5089
+ try {
5090
+ const payload = JSON.parse(
5091
+ fs.readFileSync(path.join(distInfo, "direct_url.json"), "utf8")
5092
+ );
5093
+ if (payload.editable === true || payload.dir_info?.editable === true) {
5094
+ editable = true;
5095
+ }
5096
+ } catch {
5097
+ }
5098
+ }
5099
+ }
5100
+ return { installer, editable };
5101
+ }
5102
+ const TRAILING_SLASHES = /\/+$/;
5103
+ function commandSearchDirs(env, home) {
5104
+ const dirs = [];
5105
+ const add = (dir) => {
5106
+ if (!dir) return;
5107
+ const trimmed = dir.replace(TRAILING_SLASHES, "");
5108
+ if (trimmed.length === 0 || dirs.includes(trimmed)) return;
5109
+ dirs.push(trimmed);
5110
+ };
5111
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) add(dir);
5112
+ add(env.UV_TOOL_BIN_DIR);
5113
+ add(env.XDG_BIN_HOME);
5114
+ if (env.XDG_DATA_HOME) add(path.join(env.XDG_DATA_HOME, "..", "bin"));
5115
+ add(path.join(home, ".local", "bin"));
5116
+ add("/opt/homebrew/bin");
5117
+ add("/usr/local/bin");
5118
+ add("/usr/bin");
5119
+ add("/bin");
5120
+ return dirs;
5121
+ }
5122
+ function uvToolBinDirs(env, home, listDir, platform) {
5123
+ 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"));
5124
+ const scriptDir = platform === "win32" ? "Scripts" : "bin";
5125
+ const dirs = [];
5126
+ try {
5127
+ for (const entry of listDir(root)) {
5128
+ dirs.push(path.join(root, entry, scriptDir));
5129
+ }
5130
+ } catch {
5131
+ }
5132
+ return dirs;
5133
+ }
5134
+ const WINDOWS_DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
5135
+ function windowsCommandExtensions(env) {
5136
+ const raw = (env.PATHEXT ?? "").trim();
5137
+ const parts = (raw.length > 0 ? raw : WINDOWS_DEFAULT_PATHEXT).split(";");
5138
+ const extensions = [];
5139
+ for (const part of parts) {
5140
+ const extension = part.trim();
5141
+ if (extension.length === 0 || extensions.includes(extension)) continue;
5142
+ extensions.push(extension);
5143
+ }
5144
+ return extensions;
5145
+ }
5146
+ function commandCandidates(dir, name, env, platform) {
5147
+ const candidates = [path.join(dir, name)];
5148
+ if (platform !== "win32") return candidates;
5149
+ for (const extension of windowsCommandExtensions(env)) {
5150
+ candidates.push(path.join(dir, name + extension));
5151
+ }
5152
+ return candidates;
5153
+ }
5154
+ function resolveCommandPath(name, options = {}) {
5155
+ const env = options.env ?? process.env;
5156
+ const home = options.home ?? os.homedir();
5157
+ const platform = options.platform ?? process.platform;
5158
+ const exists = options.exists ?? fs.existsSync;
5159
+ const listDir = options.listDir ?? ((dir) => {
5160
+ try {
5161
+ return fs.readdirSync(dir);
5162
+ } catch {
5163
+ return [];
5164
+ }
5165
+ });
5166
+ const dirs = [
5167
+ ...commandSearchDirs(env, home),
5168
+ ...uvToolBinDirs(env, home, listDir, platform)
5169
+ ];
5170
+ for (const dir of dirs) {
5171
+ for (const candidate of commandCandidates(dir, name, env, platform)) {
5172
+ try {
5173
+ if (exists(candidate)) return candidate;
5174
+ } catch {
5175
+ }
5176
+ }
5177
+ }
5178
+ return null;
5179
+ }
5180
+ function readInstallIdentity(shimPath) {
5181
+ if (!shimPath) return { path: null };
5182
+ let realPath = null;
5183
+ try {
5184
+ realPath = fs.realpathSync(shimPath);
5185
+ } catch {
5186
+ realPath = null;
5187
+ }
5188
+ let shebang = null;
5189
+ try {
5190
+ const firstLine = fs.readFileSync(shimPath, "utf8").split("\n", 1)[0] ?? "";
5191
+ shebang = firstLine.startsWith("#!") ? firstLine : null;
5192
+ } catch {
5193
+ shebang = null;
5194
+ }
5195
+ const executable = realPath ?? shimPath;
5196
+ const prefix = path.dirname(path.dirname(executable));
5197
+ const existing = (candidate) => fs.existsSync(candidate) ? candidate : null;
5198
+ const markers = resolveDistributionMarkers(prefix);
5199
+ return {
5200
+ path: shimPath,
5201
+ realPath,
5202
+ shebang,
5203
+ uvReceipt: existing(path.join(prefix, "uv-receipt.toml")),
5204
+ venvPrefix: fs.existsSync(path.join(prefix, "pyvenv.cfg")) ? prefix : null,
5205
+ installer: markers.installer,
5206
+ editable: markers.editable
5207
+ };
5208
+ }
5209
+ function classifyGlobalInstall(identity) {
5210
+ if (!identity.path) return "global-unknown";
5211
+ if (identity.editable) return "editable";
5212
+ const haystack = [
5213
+ identity.path,
5214
+ identity.realPath ?? "",
5215
+ identity.shebang ?? ""
5216
+ ].join("\n").toLowerCase();
5217
+ if (haystack.includes("/uv/tools/")) return "uv-tool";
5218
+ if (identity.uvReceipt) return "uv-tool";
5219
+ if (uvToolListNamesLocalOperator(identity.uvToolList)) return "uv-tool";
5220
+ if (haystack.includes("/pipx/venvs/") || haystack.includes("/pipx/") && haystack.includes("venvs"))
5221
+ return "pipx";
5222
+ if (pipxListNamesLocalOperator(identity.pipxList)) return "pipx";
5223
+ if (identity.venvPrefix) return "pip";
5224
+ if (identity.installer === "pip") return "pip";
5225
+ return "global-unknown";
5226
+ }
5227
+ function resolveGlobalInstallPlan(input) {
5228
+ const kind = classifyGlobalInstall(input.identity);
5229
+ const sourceBuild = kind === "editable" || kind === "uv-tool" && Boolean(input.lopUpdatePath);
5230
+ 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";
5231
+ if (kind === "editable") {
5232
+ return {
5233
+ canManageUpdate: false,
5234
+ updateCommand: "",
5235
+ 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.",
5236
+ detail,
5237
+ sourceBuild: true
5238
+ };
5239
+ }
5240
+ if (kind === "uv-tool") {
5241
+ const useLopUpdate = Boolean(input.lopUpdatePath);
5242
+ return {
5243
+ canManageUpdate: false,
5244
+ updateCommand: useLopUpdate ? "lop-update" : "uv tool upgrade local-operator",
5245
+ 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:",
5246
+ detail,
5247
+ sourceBuild
5248
+ };
5249
+ }
5250
+ if (kind === "pipx") {
5251
+ return {
5252
+ canManageUpdate: false,
5253
+ updateCommand: "pipx upgrade local-operator",
5254
+ remedy: "The server is a pipx install, so update it from your terminal:",
5255
+ detail,
5256
+ sourceBuild: false
5257
+ };
5258
+ }
5259
+ if (kind === "pip") {
5260
+ return {
5261
+ canManageUpdate: false,
5262
+ updateCommand: "pip install --upgrade local-operator",
5263
+ remedy: "The server is a pip install, so update it from your terminal:",
5264
+ detail,
5265
+ sourceBuild: false
5266
+ };
5267
+ }
5268
+ return {
5269
+ canManageUpdate: false,
5270
+ updateCommand: "",
5271
+ 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:",
5272
+ detail,
5273
+ sourceBuild: false
5274
+ };
5275
+ }
5276
+ function buildPipUpgradeCommand(pythonPath) {
5277
+ const args = [
5278
+ "-m",
5279
+ "pip",
5280
+ "install",
5281
+ "--upgrade",
5282
+ "--no-input",
5283
+ "--disable-pip-version-check",
5284
+ "local-operator"
5285
+ ];
5286
+ return {
5287
+ command: pythonPath,
5288
+ args,
5289
+ display: `"${pythonPath}" ${args.join(" ")}`
5290
+ };
5291
+ }
5292
+ const PIP_SHOW_VERSION_REGEX = /^Version:\s*(.+)$/m;
5293
+ function parsePipShowVersion(stdout) {
5294
+ const match = stdout.match(PIP_SHOW_VERSION_REGEX);
5295
+ if (!match) return null;
5296
+ const version2 = match[1].trim();
5297
+ return version2.length > 0 ? version2 : null;
5298
+ }
5299
+ function didUpgradeLand(input) {
5300
+ const { before, after, target: target2 } = input;
5301
+ if (after == null) return false;
5302
+ if (before == null) {
5303
+ return target2 != null && after.trim() === target2.trim();
5304
+ }
5305
+ return before.trim() !== after.trim();
5306
+ }
4425
5307
  const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
4426
5308
  const VERSION_CLEAN_REGEX = /^v/i;
4427
5309
  const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
5310
+ const BUNDLE_ID_REGEX = /^[\w.-]+$/;
5311
+ const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
5312
+ const SEAL_PROBE_TIMEOUT_MS = 45e3;
5313
+ function versionSuffix(version2) {
5314
+ return version2 ? ` to version ${version2}` : "";
5315
+ }
5316
+ function runCommand(command, args, options = {}) {
5317
+ return new Promise((resolve) => {
5318
+ node_child_process.execFile(
5319
+ command,
5320
+ args,
5321
+ { timeout: options.timeoutMs ?? 2e4, maxBuffer: 4 * 1024 * 1024 },
5322
+ (error, stdout, stderr) => {
5323
+ const code = error?.code;
5324
+ const killed = error?.killed === true;
5325
+ const out = stdout?.toString() ?? "";
5326
+ const err = stderr?.toString() ?? "";
5327
+ resolve({
5328
+ exitCode: error ? typeof code === "number" ? code : 1 : 0,
5329
+ stdout: out,
5330
+ stderr: err,
5331
+ // A timeout (killed) or a process that failed to start (a string
5332
+ // code such as ENOENT) produced no verdict about the bundle. A
5333
+ // numeric exit with output did.
5334
+ ran: !error || !killed && typeof code === "number" && `${out}${err}`.trim().length > 0
5335
+ });
5336
+ }
5337
+ );
5338
+ });
5339
+ }
5340
+ function readCommandOutput(command, args) {
5341
+ try {
5342
+ const output = node_child_process.execFileSync(command, args, {
5343
+ encoding: "utf8",
5344
+ timeout: 5e3
5345
+ });
5346
+ return output.trim();
5347
+ } catch {
5348
+ return null;
5349
+ }
5350
+ }
4428
5351
  class UpdateService {
4429
5352
  mainWindow = null;
4430
5353
  isDevMode;
@@ -4432,6 +5355,47 @@ class UpdateService {
4432
5355
  backendUrl;
4433
5356
  updateCheckInterval = null;
4434
5357
  backendService = null;
5358
+ /**
5359
+ * What the updater is doing right now.
5360
+ *
5361
+ * Only used to decide whether an `error` event is a failure the user has to
5362
+ * hear about: a download or an install that dies is not "no update
5363
+ * available", which is how `shouldFilterUpdateError` would otherwise read it.
5364
+ */
5365
+ updateStage = "idle";
5366
+ /** The last update the updater told us about, for its file metadata. */
5367
+ lastUpdateInfo = null;
5368
+ /**
5369
+ * The version the published release last answered with, or null.
5370
+ *
5371
+ * The by-hand prompt is produced from a click, not from a check, so it has no
5372
+ * version of its own to name - and the panel that tells the user to go and do
5373
+ * work is the one that most needs to say what they are working towards. The
5374
+ * periodic and start-up checks read this from the published release already,
5375
+ * so it is kept rather than fetched again on the click (review U17).
5376
+ */
5377
+ lastPublishedBackendVersion = null;
5378
+ /** Path of the artifact this run downloaded, when the updater reported one. */
5379
+ downloadedArtifactPath = null;
5380
+ /**
5381
+ * Artifacts that failed their size/sha512/disk check, by version.
5382
+ *
5383
+ * The 5-minute periodic check runs regardless of what the user dismissed, so
5384
+ * without this an update that cannot be verified is offered again every five
5385
+ * minutes for as long as the app is open.
5386
+ */
5387
+ failedVerificationVersions = /* @__PURE__ */ new Set();
5388
+ /** A failed install detected from the marker on this start, if any. */
5389
+ pendingInstallFailure = null;
5390
+ installFailureDelivered = false;
5391
+ /**
5392
+ * True while `quit-and-install` is running its pre-flight.
5393
+ *
5394
+ * The pre-flight can take seconds over a large bundle and artifact, and the
5395
+ * whole point of it is that a second request must not run beside the first (see
5396
+ * the handler).
5397
+ */
5398
+ installPreflightInFlight = false;
4435
5399
  /**
4436
5400
  * Initialize the update service
4437
5401
  * @param mainWindow - The main application window
@@ -4455,8 +5419,9 @@ class UpdateService {
4455
5419
  debug: (message) => logger.debug(message, LogFileType.UPDATE_SERVICE)
4456
5420
  };
4457
5421
  electronUpdater.autoUpdater.autoDownload = false;
4458
- electronUpdater.autoUpdater.autoInstallOnAppQuit = true;
5422
+ electronUpdater.autoUpdater.autoInstallOnAppQuit = false;
4459
5423
  this.setupUpdateEvents();
5424
+ this.recoverPendingInstall();
4460
5425
  this.startPeriodicUpdateChecks();
4461
5426
  }
4462
5427
  /**
@@ -4479,6 +5444,517 @@ class UpdateService {
4479
5444
  LogFileType.UPDATE_SERVICE
4480
5445
  );
4481
5446
  }
5447
+ // -----------------------------------------------------------------------
5448
+ // Install robustness
5449
+ // -----------------------------------------------------------------------
5450
+ /**
5451
+ * Where the pending-update marker lives.
5452
+ *
5453
+ * userData, not the app bundle: the marker has to survive the bundle being
5454
+ * replaced (that is exactly the case it describes) and it must not be inside
5455
+ * the thing ShipIt is swapping.
5456
+ */
5457
+ markerDir() {
5458
+ return electron.app.getPath("userData");
5459
+ }
5460
+ sendToRenderer(channel, payload) {
5461
+ const webContents = this.mainWindow?.webContents;
5462
+ if (!webContents || webContents.isDestroyed()) return false;
5463
+ webContents.send(channel, payload);
5464
+ return true;
5465
+ }
5466
+ /**
5467
+ * Report a refusal to start an install, with the reason and the remedy.
5468
+ *
5469
+ * Sent rather than thrown: the caller is an IPC handler the renderer fires and
5470
+ * forgets, and the whole point of these checks is that the user gets an
5471
+ * explanation instead of an app that quits and never returns.
5472
+ */
5473
+ sendInstallBlock(block, version2) {
5474
+ logger.error(
5475
+ `Update refused (${block.code}): ${block.detail}`,
5476
+ LogFileType.UPDATE_SERVICE
5477
+ );
5478
+ this.sendToRenderer("update-install-blocked", {
5479
+ code: block.code,
5480
+ version: version2 ?? null,
5481
+ message: block.message,
5482
+ remedy: block.remedy,
5483
+ detail: block.detail
5484
+ });
5485
+ }
5486
+ /**
5487
+ * Read the marker a previous run may have left, and act on it.
5488
+ *
5489
+ * A failed Squirrel install leaves no exit status and never relaunches the
5490
+ * app, so the marker plus the running version is the only record that the
5491
+ * update did not happen. Everything the user learns afterwards starts here:
5492
+ * the notice, the durable record it can be found in later, and the cleanup of
5493
+ * what Squirrel left running.
5494
+ */
5495
+ recoverPendingInstall() {
5496
+ const marker = readPendingInstallMarker(this.markerDir());
5497
+ const outcome = evaluatePendingInstall({
5498
+ marker,
5499
+ runningVersion: electron.app.getVersion()
5500
+ });
5501
+ switch (outcome.kind) {
5502
+ case "none":
5503
+ return;
5504
+ case "succeeded":
5505
+ logger.info(
5506
+ `Update marker: install of version ${outcome.marker.targetVersion} succeeded (running ${electron.app.getVersion()}).`,
5507
+ LogFileType.UPDATE_SERVICE
5508
+ );
5509
+ this.reapWatchdog(outcome.marker, true);
5510
+ clearPendingInstallMarker(this.markerDir());
5511
+ return;
5512
+ case "stale":
5513
+ logger.info(
5514
+ `Update marker: ignored a marker for version ${outcome.marker.targetVersion}; version ${electron.app.getVersion()} is running, so it was superseded.`,
5515
+ LogFileType.UPDATE_SERVICE
5516
+ );
5517
+ this.reapWatchdog(outcome.marker, true);
5518
+ clearPendingInstallMarker(this.markerDir());
5519
+ return;
5520
+ case "failed": {
5521
+ const shipItLogPath = this.shipItLog();
5522
+ const record = recordInstallFailure(this.markerDir(), {
5523
+ payload: installFailurePayload(outcome.marker, electron.app.getVersion(), {
5524
+ shipItLogPath
5525
+ }),
5526
+ startedAt: outcome.marker.startedAt || null,
5527
+ detectedAt: (/* @__PURE__ */ new Date()).toISOString()
5528
+ });
5529
+ const payload = installFailurePayload(
5530
+ outcome.marker,
5531
+ electron.app.getVersion(),
5532
+ { attempts: record.attempts, shipItLogPath }
5533
+ );
5534
+ logger.error(
5535
+ `Update marker: install of version ${outcome.marker.targetVersion} did not complete (attempt ${record.attempts}). ${payload.detail}`,
5536
+ LogFileType.UPDATE_SERVICE
5537
+ );
5538
+ this.pendingInstallFailure = payload;
5539
+ this.reapWatchdog(outcome.marker, false);
5540
+ clearPendingInstallMarker(this.markerDir());
5541
+ this.reapFailedInstallLeftovers();
5542
+ this.schedulePendingInstallFailureDelivery();
5543
+ }
5544
+ }
5545
+ }
5546
+ /**
5547
+ * This app's bundle identifier, as macOS records it.
5548
+ *
5549
+ * Read from the running bundle rather than from `package.json`: the running
5550
+ * app is the only thing whose ShipIt job and cache directory this process may
5551
+ * touch, and a build whose `appId` drifted would otherwise have us cleaning up
5552
+ * some other app's leftovers.
5553
+ */
5554
+ bundleIdentifier() {
5555
+ const bundlePath = appBundleFromExecutable(process.execPath);
5556
+ if (!bundlePath) return null;
5557
+ const value = readCommandOutput("/usr/bin/defaults", [
5558
+ "read",
5559
+ path.join(bundlePath, "Contents", "Info.plist"),
5560
+ "CFBundleIdentifier"
5561
+ ]);
5562
+ return value && BUNDLE_ID_REGEX.test(value) ? value : null;
5563
+ }
5564
+ /** The launchd job Squirrel's install runs under, by label. */
5565
+ shipItJob() {
5566
+ const bundleId = this.bundleIdentifier();
5567
+ return bundleId ? shipItJobLabel(bundleId) : null;
5568
+ }
5569
+ /**
5570
+ * Squirrel's own stderr log for this app, when it exists.
5571
+ *
5572
+ * It is the only record of why an install failed: the app is not running
5573
+ * while ShipIt works, so the failure notice cannot quote a reason the way the
5574
+ * pre-flight refusal can. The log is preserved by `reapFailedInstallLeftovers`
5575
+ * for exactly this reason, so the details line points at the thing that is
5576
+ * still there afterwards (review U14).
5577
+ */
5578
+ shipItLog() {
5579
+ const bundleId = this.bundleIdentifier();
5580
+ if (!bundleId) return null;
5581
+ const candidate = path.join(
5582
+ shipItCacheDir(path.join(os.homedir(), "Library", "Caches"), bundleId),
5583
+ "ShipIt_stderr.log"
5584
+ );
5585
+ return fs.existsSync(candidate) ? candidate : null;
5586
+ }
5587
+ /**
5588
+ * Remove the launchd job and the staged update a failed install left behind.
5589
+ *
5590
+ * Why: the 0.17.0 failure left `com.local-operator.ShipIt` loaded in the
5591
+ * user's launchd domain, respawning every ~2.5 s (runs=3114,
5592
+ * LastExitStatus=256) and writing 3.4 MB of "Could not read update request" to
5593
+ * its stderr log, with the staged tree beside it. Squirrel only retires that
5594
+ * job when an install finishes, so a failed one leaves it running forever - it
5595
+ * was removed by hand during round 1 of the review, and the product must not
5596
+ * need that.
5597
+ */
5598
+ reapFailedInstallLeftovers() {
5599
+ if (process.platform !== "darwin") return;
5600
+ const log = (message) => logger.info(message, LogFileType.UPDATE_SERVICE);
5601
+ try {
5602
+ const result = reapFailedInstall({
5603
+ bundleId: this.bundleIdentifier(),
5604
+ cacheRoot: path.join(os.homedir(), "Library", "Caches"),
5605
+ removeJob: (label) => {
5606
+ const result2 = node_child_process.spawnSync("/bin/launchctl", ["remove", label], {
5607
+ encoding: "utf8",
5608
+ timeout: 5e3
5609
+ });
5610
+ const output = `${result2.stdout ?? ""}${result2.stderr ?? ""}`.trim();
5611
+ const notFound = LAUNCHCTL_NOT_LOADED_REGEX.test(output) || output === "" && result2.status !== 0;
5612
+ if (result2.status !== 0 && !notFound) {
5613
+ throw new Error(
5614
+ output || `launchctl remove exited ${result2.status}`
5615
+ );
5616
+ }
5617
+ return { notFound, output };
5618
+ },
5619
+ exists: (path2) => fs.existsSync(path2),
5620
+ listDir: (dir) => fs.readdirSync(dir),
5621
+ removeDir: (dir) => fs.rmSync(dir, { recursive: true, force: true }),
5622
+ log
5623
+ });
5624
+ if (result.errors.length > 0) {
5625
+ logger.warn(
5626
+ `Failed to clean up the leftover install: ${result.errors.join("; ")}`,
5627
+ LogFileType.UPDATE_SERVICE
5628
+ );
5629
+ }
5630
+ } catch (error) {
5631
+ logger.warn(
5632
+ `Could not clean up the leftover install: ${String(error)}`,
5633
+ LogFileType.UPDATE_SERVICE
5634
+ );
5635
+ }
5636
+ }
5637
+ /**
5638
+ * Deliver the failed-install notice once the renderer can hear it.
5639
+ *
5640
+ * The marker is read while the window is still loading, so the push happens
5641
+ * on `did-finish-load` with a delayed fallback: the component subscribes from
5642
+ * a React effect, which may run after the load event.
5643
+ */
5644
+ schedulePendingInstallFailureDelivery() {
5645
+ if (!this.pendingInstallFailure) return;
5646
+ const deliver = () => this.deliverPendingInstallFailure();
5647
+ const webContents = this.mainWindow?.webContents;
5648
+ if (webContents && !webContents.isDestroyed()) {
5649
+ webContents.once("did-finish-load", deliver);
5650
+ }
5651
+ setTimeout(deliver, 5e3);
5652
+ }
5653
+ deliverPendingInstallFailure() {
5654
+ if (!this.pendingInstallFailure || this.installFailureDelivered) return;
5655
+ if (!this.sendToRenderer("update-install-failed", this.pendingInstallFailure)) {
5656
+ return;
5657
+ }
5658
+ this.installFailureDelivered = true;
5659
+ logger.info(
5660
+ "Reported a failed update install to the renderer",
5661
+ LogFileType.UPDATE_SERVICE
5662
+ );
5663
+ }
5664
+ /**
5665
+ * Kill a watchdog still running from the recorded install attempt.
5666
+ *
5667
+ * Killing by pid alone would be unsafe after a pid reuse, so the process's
5668
+ * command line has to still carry the script's sentinel.
5669
+ */
5670
+ reapWatchdog(marker, installSucceeded) {
5671
+ const pid = marker.watchdogPid;
5672
+ if (pid == null) return;
5673
+ let commandLine = null;
5674
+ let alive = false;
5675
+ commandLine = readCommandOutput("/bin/ps", [
5676
+ "-o",
5677
+ "command=",
5678
+ "-p",
5679
+ String(pid)
5680
+ ]);
5681
+ alive = commandLine != null;
5682
+ if (!watchdogIsOurs({ alive, commandLine, installSucceeded })) {
5683
+ if (alive && !installSucceeded) {
5684
+ logger.info(
5685
+ `Relaunch watchdog ${pid} is still running; leaving it to finish or hit its deadline.`,
5686
+ LogFileType.UPDATE_SERVICE
5687
+ );
5688
+ }
5689
+ return;
5690
+ }
5691
+ try {
5692
+ process.kill(pid, "SIGTERM");
5693
+ logger.info(
5694
+ `Reaped relaunch watchdog ${pid} left over from the previous install.`,
5695
+ LogFileType.UPDATE_SERVICE
5696
+ );
5697
+ } catch (error) {
5698
+ logger.warn(
5699
+ `Could not reap relaunch watchdog ${pid}: ${String(error)}`,
5700
+ LogFileType.UPDATE_SERVICE
5701
+ );
5702
+ }
5703
+ }
5704
+ /**
5705
+ * Ask `codesign` whether the installed bundle is a sealed code object.
5706
+ *
5707
+ * This is the exact question ShipIt asks of the same bundle before it
5708
+ * installs (`SecStaticCodeCreateWithPath` in SQRLInstaller): on 2026-09-11 it
5709
+ * answered -67028 errSecCSBadBundleFormat because a Finder copy was replacing
5710
+ * the app at that moment, ShipIt quit, and nothing relaunched the app.
5711
+ * Refusing the install here keeps the user with a working app instead.
5712
+ *
5713
+ * Three deliberate properties, all of them from round 1's findings:
5714
+ *
5715
+ * - `--strict` is gone. It does not test the seal - `--verify` does that, and
5716
+ * it is what catches the mid-replacement -67028 - it additionally rejects
5717
+ * bundles carrying FinderInfo/detritus xattrs that ShipIt itself tolerates,
5718
+ * so it can only refuse installs Squirrel would have performed (R5).
5719
+ * - The timeout is generous because the bundle is ~1 GiB; measured warm at
5720
+ * 6.1 s, and a cold or slow volume is not a reason to refuse.
5721
+ * - A probe that could not run is retried once and then *allowed to proceed*.
5722
+ * "We could not ask" is not "the bundle is bad", and treating it as one is
5723
+ * how a transient hiccup became a permanent reinstall message (R5).
5724
+ */
5725
+ async probeInstalledBundleSeal(version2) {
5726
+ if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
5727
+ const bundlePath = appBundleFromExecutable(process.execPath);
5728
+ if (!bundlePath) {
5729
+ logger.warn(
5730
+ `Could not derive an app bundle from ${process.execPath}; skipping the seal pre-flight.`,
5731
+ LogFileType.UPDATE_SERVICE
5732
+ );
5733
+ return null;
5734
+ }
5735
+ const probeBundle = () => runCommand(
5736
+ "/usr/bin/codesign",
5737
+ ["--verify", "--deep", "--verbose=2", bundlePath],
5738
+ { timeoutMs: SEAL_PROBE_TIMEOUT_MS }
5739
+ );
5740
+ let seal = evaluateBundleSeal(await probeBundle());
5741
+ if (seal.kind === "unavailable") {
5742
+ logger.warn(
5743
+ `Seal check did not complete, retrying once: ${seal.detail}`,
5744
+ LogFileType.UPDATE_SERVICE
5745
+ );
5746
+ seal = evaluateBundleSeal(await probeBundle());
5747
+ }
5748
+ if (seal.kind === "unavailable") {
5749
+ logger.warn(
5750
+ `Seal check could not run twice, continuing with the install: ${seal.detail}`,
5751
+ LogFileType.UPDATE_SERVICE
5752
+ );
5753
+ return null;
5754
+ }
5755
+ if (seal.kind === "sealed") {
5756
+ logger.info(
5757
+ `Installed bundle passed its seal check: ${bundlePath}`,
5758
+ LogFileType.UPDATE_SERVICE
5759
+ );
5760
+ return null;
5761
+ }
5762
+ logger.error(
5763
+ `Installed bundle failed its seal check: ${seal.detail}`,
5764
+ LogFileType.UPDATE_SERVICE
5765
+ );
5766
+ return installedBundleSealBlock(bundlePath, seal.detail, version2);
5767
+ }
5768
+ /** File names the updater's metadata knows this update by. */
5769
+ stagedArtifactCandidates(info) {
5770
+ const names = /* @__PURE__ */ new Set();
5771
+ if (info?.path) names.add(path.basename(info.path));
5772
+ for (const file of info?.files ?? []) {
5773
+ if (file.url) names.add(path.basename(decodeURIComponent(file.url)));
5774
+ }
5775
+ if (this.downloadedArtifactPath) {
5776
+ names.add(path.basename(this.downloadedArtifactPath));
5777
+ }
5778
+ return [...names];
5779
+ }
5780
+ /**
5781
+ * electron-updater exposes no public path for the staged file; the private
5782
+ * download helper is authoritative for a download this run performed, and a
5783
+ * null simply falls back to the updater's pending cache directory.
5784
+ */
5785
+ downloadHelper() {
5786
+ const updater = electronUpdater.autoUpdater;
5787
+ return updater.downloadedUpdateHelper ?? null;
5788
+ }
5789
+ /** Free bytes on the volume holding `dir`, or null when it cannot be read. */
5790
+ freeBytesAt(dir) {
5791
+ try {
5792
+ const stats = fs.statfsSync(dir);
5793
+ return stats.bavail * stats.bsize;
5794
+ } catch (error) {
5795
+ logger.warn(
5796
+ `Could not read free space for ${dir}: ${String(error)}`,
5797
+ LogFileType.UPDATE_SERVICE
5798
+ );
5799
+ return null;
5800
+ }
5801
+ }
5802
+ static async sha512Base64(filePath) {
5803
+ const hash = node_crypto.createHash("sha512");
5804
+ const stream = fs.createReadStream(filePath);
5805
+ for await (const chunk of stream) {
5806
+ hash.update(chunk);
5807
+ }
5808
+ return hash.digest("base64");
5809
+ }
5810
+ /**
5811
+ * Verify the staged artifact against the metadata the updater parsed.
5812
+ *
5813
+ * electron-updater checks a file's sha512 while downloading it, but a staged
5814
+ * file reused on a later start is only checked for existence (`"check here
5815
+ * only existence, not checksum"` in its own DownloadedUpdateHelper), and the
5816
+ * install's real footprint - a full copy of the app - is never checked
5817
+ * against the free space available.
5818
+ */
5819
+ async ensureStagedArtifactVerified(info) {
5820
+ const helper = this.downloadHelper();
5821
+ const pendingDir = helper?.cacheDirForPendingUpdate ?? null;
5822
+ const candidates = this.stagedArtifactCandidates(info);
5823
+ const artifactPath = resolveStagedArtifactPath({
5824
+ downloadHelperFile: this.downloadedArtifactPath ?? helper?.file ?? null,
5825
+ pendingDir: pendingDir ?? "",
5826
+ candidateNames: candidates,
5827
+ listDir: (dir) => {
5828
+ try {
5829
+ return fs.readdirSync(dir);
5830
+ } catch {
5831
+ return [];
5832
+ }
5833
+ }
5834
+ });
5835
+ if (!artifactPath) {
5836
+ return {
5837
+ code: "download-verification-failed",
5838
+ message: `The downloaded update${versionSuffix(info?.version)} could not be found on disk, so it wasn't installed.`,
5839
+ remedy: {
5840
+ text: "Check for updates again to re-download the release."
5841
+ },
5842
+ detail: `No staged artifact matching ${candidates.join(", ") || "the update metadata"}.`
5843
+ };
5844
+ }
5845
+ const files = info?.files ?? [];
5846
+ const metadata = matchArtifactMetadata(
5847
+ artifactPath,
5848
+ files,
5849
+ info?.path ?? null
5850
+ );
5851
+ let actualSize;
5852
+ let actualSha512;
5853
+ try {
5854
+ actualSize = fs.statSync(artifactPath).size;
5855
+ actualSha512 = await UpdateService.sha512Base64(artifactPath);
5856
+ } catch (error) {
5857
+ return {
5858
+ code: "download-verification-failed",
5859
+ message: `The downloaded update${versionSuffix(info?.version)} could not be read, so it wasn't installed.`,
5860
+ remedy: {
5861
+ text: "Check for updates again to re-download the release."
5862
+ },
5863
+ detail: `${artifactPath}: ${String(error)}`
5864
+ };
5865
+ }
5866
+ const bundlePath = appBundleFromExecutable(process.execPath);
5867
+ const installedBundleSize = bundlePath ? measureDirectoryBytes(bundlePath) : null;
5868
+ if (installedBundleSize == null) {
5869
+ logger.warn(
5870
+ `Could not measure the installed app at ${bundlePath ?? "(no bundle path)"}; the free-space guard falls back to the artifact's own size.`,
5871
+ LogFileType.UPDATE_SERVICE
5872
+ );
5873
+ }
5874
+ const freeCandidates = [
5875
+ bundlePath ? this.freeBytesAt(path__namespace.dirname(bundlePath)) : null,
5876
+ this.freeBytesAt(path__namespace.dirname(artifactPath))
5877
+ ].filter((value) => value != null);
5878
+ const freeBytes = freeCandidates.length > 0 ? Math.min(...freeCandidates) : Number.MAX_SAFE_INTEGER;
5879
+ const verdict = verifyStagedArtifact({
5880
+ filePath: artifactPath,
5881
+ actualSize,
5882
+ actualSha512,
5883
+ metadata,
5884
+ freeBytes,
5885
+ installedBundleSize,
5886
+ version: info?.version ?? null
5887
+ });
5888
+ if (verdict.ok) {
5889
+ const required = requiredDiskBytes({
5890
+ artifactSize: verdict.size,
5891
+ installedBundleSize: installedBundleSize ?? 0
5892
+ });
5893
+ logger.info(
5894
+ `Staged artifact verified: ${artifactPath} (${verdict.size} bytes, sha512 matches, ${required} bytes required free, ${installedBundleSize ?? "unknown"} byte app).`,
5895
+ LogFileType.UPDATE_SERVICE
5896
+ );
5897
+ return null;
5898
+ }
5899
+ return verdict.block;
5900
+ }
5901
+ /**
5902
+ * Everything that has to hold before the app is allowed to quit.
5903
+ *
5904
+ * Returns the reason to refuse, or null to go ahead.
5905
+ */
5906
+ async runInstallPreflight(info) {
5907
+ const sealBlock = await this.probeInstalledBundleSeal(
5908
+ info?.version ?? null
5909
+ );
5910
+ if (sealBlock) return sealBlock;
5911
+ return this.ensureStagedArtifactVerified(info);
5912
+ }
5913
+ /**
5914
+ * Start the detached relaunch watchdog.
5915
+ *
5916
+ * Spawned detached so it survives this process exiting, with stdio ignored so
5917
+ * it holds no pipe open and cannot keep the app alive.
5918
+ */
5919
+ launchWatchdog(targetVersion) {
5920
+ if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
5921
+ const bundlePath = appBundleFromExecutable(process.execPath);
5922
+ if (!bundlePath) return null;
5923
+ const plan = buildWatchdogPlan({
5924
+ appBundlePath: bundlePath,
5925
+ executableName: path.basename(process.execPath),
5926
+ // The app's own pid, captured here - before the quit - because the
5927
+ // watchdog cannot ask a name about its own ancestor (review R1).
5928
+ appPid: process.pid,
5929
+ shipItJob: this.shipItJob(),
5930
+ // What "the install is over" is measured against on disk (review R11):
5931
+ // the updater's advertised version, and nothing that is already in place
5932
+ // (review R15).
5933
+ targetVersion: watchdogSwapTarget({
5934
+ target: targetVersion,
5935
+ running: electron.app.getVersion()
5936
+ })
5937
+ });
5938
+ try {
5939
+ const child = node_child_process.spawn("sh", ["-c", plan.script], {
5940
+ detached: true,
5941
+ stdio: "ignore",
5942
+ env: { ...process.env, ...plan.env }
5943
+ });
5944
+ child.unref();
5945
+ logger.info(
5946
+ `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.`,
5947
+ LogFileType.UPDATE_SERVICE
5948
+ );
5949
+ return child.pid ?? null;
5950
+ } catch (error) {
5951
+ logger.error(
5952
+ `Could not start the update relaunch watchdog: ${String(error)}`,
5953
+ LogFileType.UPDATE_SERVICE
5954
+ );
5955
+ return null;
5956
+ }
5957
+ }
4482
5958
  /**
4483
5959
  * Clean up resources when the service is no longer needed
4484
5960
  */
@@ -4494,6 +5970,7 @@ class UpdateService {
4494
5970
  electron.ipcMain.removeHandler("check-for-updates");
4495
5971
  electron.ipcMain.removeHandler("check-for-backend-updates");
4496
5972
  electron.ipcMain.removeHandler("check-for-all-updates");
5973
+ electron.ipcMain.removeHandler("get-last-install-attempt");
4497
5974
  electron.ipcMain.removeHandler("update-backend");
4498
5975
  electron.ipcMain.removeHandler("download-update");
4499
5976
  electron.ipcMain.removeHandler("quit-and-install");
@@ -4585,38 +6062,44 @@ class UpdateService {
4585
6062
  setupUpdateEvents() {
4586
6063
  electronUpdater.autoUpdater.on("update-available", (info) => {
4587
6064
  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);
6065
+ this.lastUpdateInfo = info;
6066
+ if (this.failedVerificationVersions.has(info.version)) {
6067
+ logger.warn(
6068
+ `Not offering version ${info.version} again: its artifact already failed verification in this session.`,
6069
+ LogFileType.UPDATE_SERVICE
6070
+ );
6071
+ return;
4590
6072
  }
6073
+ this.sendToRenderer("update-available", info);
4591
6074
  });
4592
6075
  electronUpdater.autoUpdater.on("update-not-available", (info) => {
4593
6076
  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
- }
6077
+ this.sendToRenderer("update-not-available", info);
4597
6078
  });
4598
- electronUpdater.autoUpdater.on("update-downloaded", (info) => {
6079
+ electronUpdater.autoUpdater.on("update-downloaded", async (info) => {
4599
6080
  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);
6081
+ this.lastUpdateInfo = info;
6082
+ const block = await this.ensureStagedArtifactVerified(info);
6083
+ if (block) {
6084
+ this.failedVerificationVersions.add(info.version);
6085
+ this.sendInstallBlock(block, info.version);
6086
+ return;
4602
6087
  }
6088
+ this.sendToRenderer("update-downloaded", info);
4603
6089
  });
4604
6090
  electronUpdater.autoUpdater.on("error", (err) => {
4605
6091
  logger.error("Update error:", LogFileType.UPDATE_SERVICE, err);
4606
- const shouldFilter = this.shouldFilterUpdateError(err);
6092
+ const actionable = this.updateStage !== "idle";
6093
+ const shouldFilter = !actionable && this.shouldFilterUpdateError(err);
4607
6094
  if (shouldFilter) {
4608
6095
  logger.info(
4609
6096
  "Error filtering result: Reporting as no updates available"
4610
6097
  );
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
- }
6098
+ this.sendToRenderer("update-not-available", {
6099
+ version: electron.app.getVersion()
6100
+ });
4616
6101
  } 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
- }
6102
+ this.sendToRenderer("update-error", err.message);
4620
6103
  }
4621
6104
  });
4622
6105
  electronUpdater.autoUpdater.on("download-progress", (progressObj) => {
@@ -4640,38 +6123,74 @@ class UpdateService {
4640
6123
  });
4641
6124
  }
4642
6125
  /**
4643
- * Set up IPC handlers for update-related actions
6126
+ * Set up IPC handlers for update-related actions
6127
+ */
6128
+ /**
6129
+ * The last install that did not complete, if any.
6130
+ *
6131
+ * Read from disk rather than from `this.pendingInstallFailure` so it survives
6132
+ * a dismiss: the panel that reports a failure is not the only place the user
6133
+ * can find out what happened, which is what made Dismiss an information loss
6134
+ * (reviews U1, D3).
6135
+ */
6136
+ lastInstallAttempt() {
6137
+ return readLastInstallAttempt(this.markerDir());
6138
+ }
6139
+ /**
6140
+ * Note that a check was asked for, and let an explicit one re-offer a release
6141
+ * whose artifact failed verification.
6142
+ *
6143
+ * Why the distinction exists: every refusal records its version in
6144
+ * `failedVerificationVersions` so the 5-minute timer cannot re-offer an
6145
+ * artifact that will fail again. That record used to apply to user-initiated
6146
+ * checks too, which made two of the panels' own labelled remedies inert - the
6147
+ * user freed disk space or cleared the bad download, pressed "Check for
6148
+ * updates" as instructed, and got silence until the app restarted (reviews
6149
+ * R3, U3). A user who asks again gets a fresh answer; the timer does not.
4644
6150
  */
6151
+ onCheckRequested(options) {
6152
+ if (!options?.manual) return;
6153
+ if (this.failedVerificationVersions.size === 0) return;
6154
+ logger.info(
6155
+ `Explicit check requested; re-offering ${[...this.failedVerificationVersions].join(", ")} after a failed verification.`,
6156
+ LogFileType.UPDATE_SERVICE
6157
+ );
6158
+ this.failedVerificationVersions.clear();
6159
+ }
4645
6160
  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
6161
+ electron.ipcMain.handle(
6162
+ "check-for-updates",
6163
+ async (_event, options) => {
6164
+ logger.info("Checking for UI updates...", LogFileType.UPDATE_SERVICE);
6165
+ this.onCheckRequested(options);
6166
+ try {
6167
+ return await electronUpdater.autoUpdater.checkForUpdates();
6168
+ } catch (error) {
6169
+ logger.error(
6170
+ "Error checking for UI updates:",
6171
+ LogFileType.UPDATE_SERVICE,
6172
+ error
4661
6173
  );
4662
- return {
4663
- updateInfo: {
4664
- version: electron.app.getVersion()
4665
- },
4666
- versionInfo: {
4667
- version: electron.app.getVersion()
4668
- },
4669
- cancellationToken: null
4670
- };
6174
+ const shouldFilter = this.shouldFilterUpdateError(error);
6175
+ if (shouldFilter) {
6176
+ logger.info(
6177
+ "Error filtering result in IPC handler: Reporting as no updates available",
6178
+ LogFileType.UPDATE_SERVICE
6179
+ );
6180
+ return {
6181
+ updateInfo: {
6182
+ version: electron.app.getVersion()
6183
+ },
6184
+ versionInfo: {
6185
+ version: electron.app.getVersion()
6186
+ },
6187
+ cancellationToken: null
6188
+ };
6189
+ }
6190
+ throw error;
4671
6191
  }
4672
- throw error;
4673
6192
  }
4674
- });
6193
+ );
4675
6194
  electron.ipcMain.handle("check-for-backend-updates", async () => {
4676
6195
  logger.info(
4677
6196
  "Checking for backend updates...",
@@ -4688,55 +6207,70 @@ class UpdateService {
4688
6207
  throw error;
4689
6208
  }
4690
6209
  });
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
6210
+ electron.ipcMain.handle(
6211
+ "check-for-all-updates",
6212
+ async (_event, options) => {
6213
+ logger.info(
6214
+ "Checking for all updates (UI and backend)...",
6215
+ LogFileType.UPDATE_SERVICE
4703
6216
  );
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
6217
+ this.onCheckRequested(options);
6218
+ try {
6219
+ return await this.checkForAllUpdates();
6220
+ } catch (error) {
6221
+ logger.error(
6222
+ "Error checking for all updates:",
6223
+ LogFileType.UPDATE_SERVICE,
6224
+ error
4709
6225
  );
4710
- return {
4711
- updateInfo: {
4712
- version: electron.app.getVersion()
4713
- },
4714
- versionInfo: {
4715
- version: electron.app.getVersion()
4716
- },
4717
- cancellationToken: null
4718
- };
6226
+ const shouldFilter = this.shouldFilterUpdateError(error);
6227
+ if (shouldFilter) {
6228
+ logger.info(
6229
+ "Error filtering result in check-for-all-updates: Reporting as no updates available",
6230
+ LogFileType.UPDATE_SERVICE
6231
+ );
6232
+ return {
6233
+ updateInfo: {
6234
+ version: electron.app.getVersion()
6235
+ },
6236
+ versionInfo: {
6237
+ version: electron.app.getVersion()
6238
+ },
6239
+ cancellationToken: null
6240
+ };
6241
+ }
6242
+ throw error;
4719
6243
  }
4720
- throw error;
4721
6244
  }
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;
6245
+ );
6246
+ electron.ipcMain.handle("get-last-install-attempt", () => this.lastInstallAttempt());
6247
+ electron.ipcMain.handle(
6248
+ "update-backend",
6249
+ async (_event, targetVersion) => {
6250
+ logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
6251
+ try {
6252
+ return await this.updateBackend(
6253
+ typeof targetVersion === "string" ? targetVersion : void 0
6254
+ );
6255
+ } catch (error) {
6256
+ logger.error(
6257
+ "Error updating backend:",
6258
+ LogFileType.UPDATE_SERVICE,
6259
+ error
6260
+ );
6261
+ throw error;
6262
+ }
4734
6263
  }
4735
- });
6264
+ );
4736
6265
  electron.ipcMain.handle("download-update", async () => {
4737
6266
  logger.info("Downloading update...", LogFileType.UPDATE_SERVICE);
6267
+ this.updateStage = "downloading";
4738
6268
  try {
4739
- return await electronUpdater.autoUpdater.downloadUpdate();
6269
+ const downloadedPaths = await electronUpdater.autoUpdater.downloadUpdate();
6270
+ if (Array.isArray(downloadedPaths) && downloadedPaths.length > 0) {
6271
+ this.downloadedArtifactPath = downloadedPaths[0];
6272
+ }
6273
+ return downloadedPaths;
4740
6274
  } catch (error) {
4741
6275
  logger.error(
4742
6276
  "Error downloading update:",
@@ -4759,15 +6293,50 @@ class UpdateService {
4759
6293
  };
4760
6294
  }
4761
6295
  throw error;
6296
+ } finally {
6297
+ this.updateStage = "idle";
4762
6298
  }
4763
6299
  });
4764
- electron.ipcMain.handle("quit-and-install", () => {
6300
+ electron.ipcMain.handle("quit-and-install", async () => {
6301
+ if (this.installPreflightInFlight) {
6302
+ logger.info(
6303
+ "Ignoring a second install request while the pre-flight is still running.",
6304
+ LogFileType.UPDATE_SERVICE
6305
+ );
6306
+ return false;
6307
+ }
6308
+ logger.info(
6309
+ "Preparing to quit and install the update...",
6310
+ LogFileType.UPDATE_SERVICE
6311
+ );
6312
+ this.installPreflightInFlight = true;
6313
+ this.updateStage = "installing";
6314
+ let block = null;
6315
+ try {
6316
+ block = await this.runInstallPreflight(this.lastUpdateInfo);
6317
+ } finally {
6318
+ this.installPreflightInFlight = false;
6319
+ }
6320
+ if (block) {
6321
+ this.updateStage = "idle";
6322
+ this.sendInstallBlock(block, this.lastUpdateInfo?.version ?? void 0);
6323
+ return false;
6324
+ }
6325
+ const targetVersion = this.lastUpdateInfo?.version ?? electron.app.getVersion();
6326
+ const watchdogPid = this.launchWatchdog(targetVersion);
6327
+ const marker = writePendingInstallMarker(this.markerDir(), {
6328
+ targetVersion,
6329
+ artifactPath: this.downloadedArtifactPath ?? this.downloadHelper()?.file ?? "unknown",
6330
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
6331
+ watchdogPid
6332
+ });
4765
6333
  logger.info(
4766
- "Quitting and installing update...",
6334
+ `Pending install marker written for version ${marker.targetVersion} (watchdog pid ${watchdogPid ?? "none"}).`,
4767
6335
  LogFileType.UPDATE_SERVICE
4768
6336
  );
4769
6337
  this.backendService?.setAutoUpdating(true);
4770
6338
  electronUpdater.autoUpdater.quitAndInstall(false, true);
6339
+ return true;
4771
6340
  });
4772
6341
  }
4773
6342
  /**
@@ -5075,6 +6644,103 @@ class UpdateService {
5075
6644
  * @param silent - Whether to suppress notifications on no update
5076
6645
  * @returns Promise resolving to update info or null if no update is available
5077
6646
  */
6647
+ /**
6648
+ * Decide what the backend update prompt may offer for a startup mode.
6649
+ *
6650
+ * GLOBAL_INSTALL is the mode that produced the operator's report: the app
6651
+ * offered `pip install --upgrade local-operator` for a server that was a uv
6652
+ * tool install (0.54.17, read from /health) with `canManageUpdate: true`. pip
6653
+ * is the wrong installer for that environment - uv owns it, and the
6654
+ * environment lives outside anything the app may write to.
6655
+ *
6656
+ * EXISTING_SERVER takes the same classification as GLOBAL_INSTALL, because it
6657
+ * is the same question: a server the app attaches to but does not own was
6658
+ * still being told to run pip, three lines below the code that had already
6659
+ * learned better (reviews U4, D4).
6660
+ */
6661
+ async resolveBackendUpdatePlan(startupMode) {
6662
+ if (startupMode === LocalOperatorStartupMode.EXISTING_SERVER || startupMode === LocalOperatorStartupMode.GLOBAL_INSTALL) {
6663
+ const plan = resolveGlobalInstallPlan({
6664
+ identity: this.resolveInstallIdentity(),
6665
+ lopUpdatePath: this.resolveLopUpdatePath()
6666
+ });
6667
+ logger.info(
6668
+ `External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\``,
6669
+ LogFileType.UPDATE_SERVICE
6670
+ );
6671
+ return {
6672
+ canManageUpdate: plan.canManageUpdate,
6673
+ updateCommand: plan.updateCommand,
6674
+ remedy: plan.remedy,
6675
+ detail: plan.detail,
6676
+ sourceBuild: plan.sourceBuild
6677
+ };
6678
+ }
6679
+ return {
6680
+ canManageUpdate: true,
6681
+ updateCommand: "pip install --upgrade local-operator",
6682
+ remedy: "Updating the server will improve AI functionality.",
6683
+ detail: `The app started this server itself (${startupMode}).`,
6684
+ sourceBuild: false
6685
+ };
6686
+ }
6687
+ /**
6688
+ * Path the `local-operator` shim resolves to, or null when it is not installed.
6689
+ *
6690
+ * Sync on purpose: it runs once per backend check, and the answer decides
6691
+ * which installer the prompt names, so the caller wants it before it builds
6692
+ * the message rather than a promise to settle afterwards.
6693
+ *
6694
+ * It does NOT ask the shell's `which` on macOS: the app is normally started
6695
+ * by Finder or `open`, whose PATH is the launchd default and does not contain
6696
+ * `~/.local/bin` - so the uv tool install this whole classification exists to
6697
+ * describe was invisible, and its remedy came out with no command in it.
6698
+ * `resolveCommandPath` searches the standard install locations for the same
6699
+ * markers (see `commandSearchDirs`). Windows keeps `where`, which already
6700
+ * searches the machine's and the user's own install locations.
6701
+ */
6702
+ resolveLocalOperatorPath() {
6703
+ if (process.platform === "win32") {
6704
+ const first = readCommandOutput("where", ["local-operator"])?.split("\n")[0]?.trim();
6705
+ return first && first.length > 0 ? first : null;
6706
+ }
6707
+ return resolveCommandPath("local-operator");
6708
+ }
6709
+ /**
6710
+ * Path the `lop-update` shim resolves to, or null when this machine has none.
6711
+ *
6712
+ * The same problem as the shim above, for the remedy rather than the
6713
+ * classification: `lop-update` is what the source-build wording tells the user
6714
+ * to run, and it lives in `~/.local/bin`, which a Finder launch cannot see
6715
+ * through PATH alone - so the plan fell back to the pip/pipx wording for an
6716
+ * install that is neither. It is a shell script, so there is no Windows
6717
+ * counterpart to look for.
6718
+ */
6719
+ resolveLopUpdatePath() {
6720
+ if (process.platform === "win32") return null;
6721
+ return resolveCommandPath("lop-update");
6722
+ }
6723
+ /**
6724
+ * Everything `classifyGlobalInstall` needs to tell a uv tool install from a
6725
+ * pipx one from an ordinary pip venv from the checkout a developer runs.
6726
+ *
6727
+ * The cheap signals are gathered first - `readInstallIdentity` reads the
6728
+ * shim's path, its resolved target, its shebang and the dist-info's own
6729
+ * markers - and the two CLI probes are skipped when a string test already
6730
+ * answered: spawning `uv` and `pipx` on every backend check for a question
6731
+ * three string tests settled is work nobody asked for.
6732
+ */
6733
+ resolveInstallIdentity() {
6734
+ const identity = readInstallIdentity(this.resolveLocalOperatorPath());
6735
+ if (identity.path && classifyGlobalInstall(identity) !== "global-unknown") {
6736
+ return identity;
6737
+ }
6738
+ const uv = resolveCommandPath("uv");
6739
+ const pipx = resolveCommandPath("pipx");
6740
+ identity.uvToolList = uv ? readCommandOutput(uv, ["tool", "list"]) : null;
6741
+ identity.pipxList = pipx ? readCommandOutput(pipx, ["list"]) : null;
6742
+ return identity;
6743
+ }
5078
6744
  async checkForBackendUpdates(silent = false) {
5079
6745
  logger.info(
5080
6746
  `Checking for backend updates... (silent mode: ${silent})`,
@@ -5104,20 +6770,34 @@ class UpdateService {
5104
6770
  }
5105
6771
  const installedVersion = await this.getInstalledBackendVersion();
5106
6772
  const latestVersion = await this.getLatestPypiVersion();
6773
+ if (latestVersion) this.lastPublishedBackendVersion = latestVersion;
5107
6774
  if (!installedVersion || !latestVersion) {
5108
6775
  logger.error(
5109
6776
  "Unable to determine backend versions.",
5110
6777
  LogFileType.UPDATE_SERVICE
5111
6778
  );
5112
- if (!silent && this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
5113
- this.mainWindow.webContents.send(
6779
+ if (!silent) {
6780
+ this.sendToRenderer(
5114
6781
  "backend-update-error",
5115
6782
  "Unable to determine backend version."
5116
6783
  );
5117
6784
  }
5118
6785
  return null;
5119
6786
  }
5120
- const shouldUpdate = installedVersion === "Unknown" || this.isNewerVersion(latestVersion, installedVersion);
6787
+ if (installedVersion === "Unknown") {
6788
+ logger.error(
6789
+ "The installed backend version could not be determined from the health endpoint.",
6790
+ LogFileType.UPDATE_SERVICE
6791
+ );
6792
+ if (!silent) {
6793
+ this.sendToRenderer(
6794
+ "backend-update-error",
6795
+ "The installed server version could not be determined, so no update was offered. Restart the app to try again."
6796
+ );
6797
+ }
6798
+ return null;
6799
+ }
6800
+ const shouldUpdate = this.isNewerVersion(latestVersion, installedVersion);
5121
6801
  logger.info(
5122
6802
  `Installed backend version: ${installedVersion}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Startup mode: ${startupMode}`,
5123
6803
  LogFileType.UPDATE_SERVICE
@@ -5127,33 +6807,29 @@ class UpdateService {
5127
6807
  `New backend version available: ${latestVersion} (installed: ${installedVersion})`,
5128
6808
  LogFileType.UPDATE_SERVICE
5129
6809
  );
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
- }
6810
+ const { canManageUpdate, updateCommand, remedy, detail, sourceBuild } = await this.resolveBackendUpdatePlan(startupMode);
5135
6811
  const updateInfo = {
5136
6812
  currentVersion: installedVersion,
5137
6813
  latestVersion,
5138
6814
  updateCommand,
5139
6815
  canManageUpdate,
5140
- startupMode
6816
+ startupMode,
6817
+ remedy,
6818
+ detail,
6819
+ sourceBuild,
6820
+ /* `silent` is the whole difference between the two callers: the periodic
6821
+ and start-up checks pass `true`, the IPC handlers behind the buttons
6822
+ pass `false`. The renderer needs to know which one it is answering,
6823
+ because the by-hand panel's instruction deliberately outlives a
6824
+ background check and is only ended by a check the user asked for
6825
+ (review U12, round 3). */
6826
+ manual: !silent
5141
6827
  };
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
- }
6828
+ logger.info(
6829
+ `Sending backend-update-available event with info: ${JSON.stringify(updateInfo)}`,
6830
+ LogFileType.UPDATE_SERVICE
6831
+ );
6832
+ this.sendToRenderer("backend-update-available", updateInfo);
5157
6833
  return updateInfo;
5158
6834
  }
5159
6835
  logger.info(
@@ -5185,7 +6861,76 @@ class UpdateService {
5185
6861
  * Update the backend using pip and restart the backend service
5186
6862
  * @returns Promise resolving to true if update was successful, false otherwise
5187
6863
  */
5188
- async updateBackend() {
6864
+ /** The version the bundled environment has installed, via `pip show`. */
6865
+ async readBundledBackendVersion(pythonPath) {
6866
+ const probe = await runCommand(
6867
+ pythonPath,
6868
+ ["-m", "pip", "show", "local-operator"],
6869
+ { timeoutMs: 12e4 }
6870
+ );
6871
+ if (probe.exitCode !== 0) {
6872
+ logger.warn(
6873
+ `pip show local-operator exited ${probe.exitCode}: ${(probe.stderr || probe.stdout).trim()}`,
6874
+ LogFileType.UPDATE_SERVICE
6875
+ );
6876
+ return null;
6877
+ }
6878
+ return parsePipShowVersion(probe.stdout);
6879
+ }
6880
+ /**
6881
+ * Bring the previous server back after a failed upgrade, and say whether it
6882
+ * actually came back.
6883
+ *
6884
+ * A failure here must not leave the user with no backend at all: whatever pip
6885
+ * did or did not do, the environment usually still holds a working install, so
6886
+ * start it again and then ask /health rather than assuming. The answer is not
6887
+ * cosmetic: `pip install --upgrade` uninstalls before it installs, so a
6888
+ * mid-install failure can leave the package absent, and telling the user "the
6889
+ * previous server is still running" when it is not is a claim we had no
6890
+ * evidence for (review R6).
6891
+ */
6892
+ async restartBackendAfterFailedUpgrade() {
6893
+ try {
6894
+ await this.backendService?.start();
6895
+ const healthy = await this.checkBackendHealth();
6896
+ logger.info(
6897
+ healthy ? "Restarted the previous backend after a failed update" : "The backend did not answer its health check after a failed update",
6898
+ LogFileType.UPDATE_SERVICE
6899
+ );
6900
+ return healthy;
6901
+ } catch (error) {
6902
+ logger.error(
6903
+ "Could not restart the backend after a failed update:",
6904
+ LogFileType.UPDATE_SERVICE,
6905
+ error
6906
+ );
6907
+ return false;
6908
+ }
6909
+ }
6910
+ /**
6911
+ * Poll /health until it reports `targetVersion`, bounded by a timeout.
6912
+ *
6913
+ * Returns null when the version never matched (or could not be read), which
6914
+ * the caller reports as a failure rather than as a completed update.
6915
+ */
6916
+ async waitForBackendVersion(target2, timeoutMs = 6e4, intervalMs = 2e3) {
6917
+ const deadline = Date.now() + timeoutMs;
6918
+ let last = null;
6919
+ while (Date.now() < deadline) {
6920
+ const version2 = await this.getInstalledBackendVersion();
6921
+ if (version2 && version2 !== "Unknown") {
6922
+ last = version2;
6923
+ if (target2 == null || version2 === target2) return version2;
6924
+ }
6925
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
6926
+ }
6927
+ logger.warn(
6928
+ `Backend version poll timed out after ${timeoutMs}ms (target ${target2 ?? "any"}, last seen ${last ?? "none"})`,
6929
+ LogFileType.UPDATE_SERVICE
6930
+ );
6931
+ return null;
6932
+ }
6933
+ async updateBackend(targetVersion) {
5189
6934
  logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
5190
6935
  try {
5191
6936
  if (!this.backendService) {
@@ -5216,18 +6961,28 @@ class UpdateService {
5216
6961
  }
5217
6962
  return false;
5218
6963
  case LocalOperatorStartupMode.EXISTING_SERVER:
6964
+ case LocalOperatorStartupMode.GLOBAL_INSTALL: {
6965
+ const plan = await this.resolveBackendUpdatePlan(startupMode);
5219
6966
  logger.info(
5220
- "Cannot manage update for existing server. User must update manually.",
6967
+ `Cannot manage the update for ${startupMode}: ${plan.detail}`,
5221
6968
  LogFileType.UPDATE_SERVICE
5222
6969
  );
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
- }
6970
+ const installed = await this.getInstalledBackendVersion();
6971
+ this.sendToRenderer("backend-update-manual-required", {
6972
+ message: plan.remedy,
6973
+ command: plan.updateCommand,
6974
+ detail: plan.detail,
6975
+ // The caller's target when it named one (the "Update server"
6976
+ // button does), otherwise the published release the last check
6977
+ // read: the compatibility banner calls this with no target at
6978
+ // all, and the panel's version sentence then had nothing to
6979
+ // render on the path that actually produces it (review U17).
6980
+ latestVersion: targetVersion ?? this.lastPublishedBackendVersion ?? null,
6981
+ currentVersion: installed && installed !== "Unknown" ? installed : null,
6982
+ sourceBuild: plan.sourceBuild
6983
+ });
5229
6984
  return false;
5230
- case LocalOperatorStartupMode.GLOBAL_INSTALL:
6985
+ }
5231
6986
  case LocalOperatorStartupMode.APP_BUNDLED_VENV:
5232
6987
  break;
5233
6988
  default:
@@ -5249,75 +7004,66 @@ class UpdateService {
5249
7004
  LogFileType.UPDATE_SERVICE
5250
7005
  );
5251
7006
  }
5252
- const execAsync = node_util.promisify(node_child_process.exec);
5253
7007
  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
- }
7008
+ const venvPath = this.backendService?.getVenvPath();
7009
+ if (venvPath && electron.app.isPackaged) {
7010
+ const venvBinDir = process.platform === "win32" ? "Scripts" : "bin";
7011
+ const pythonName = process.platform === "win32" ? "python.exe" : "python3";
7012
+ pythonPath = path.join(venvPath, venvBinDir, pythonName);
5309
7013
  }
5310
- if (!pipCommand) {
5311
- logger.warn("Using fallback pip command", LogFileType.UPDATE_SERVICE);
5312
- pipCommand = "pip install --upgrade local-operator";
7014
+ if (!pythonPath || !fs.existsSync(pythonPath)) {
7015
+ logger.error(
7016
+ `Cannot update the bundled backend: no Python at ${pythonPath || "an unknown path"}`,
7017
+ LogFileType.UPDATE_SERVICE
7018
+ );
7019
+ await this.restartBackendAfterFailedUpgrade();
7020
+ this.sendToRenderer(
7021
+ "backend-update-error",
7022
+ "The bundled server's Python environment could not be found, so the update did not run. Please reinstall the application."
7023
+ );
7024
+ return false;
5313
7025
  }
7026
+ const pip = buildPipUpgradeCommand(pythonPath);
7027
+ const versionBefore = await this.readBundledBackendVersion(pythonPath);
7028
+ logger.info(
7029
+ `Executing pip command: ${pip.display}`,
7030
+ LogFileType.UPDATE_SERVICE
7031
+ );
7032
+ const pipRun = await runCommand(pip.command, pip.args, {
7033
+ timeoutMs: 15 * 60 * 1e3
7034
+ });
5314
7035
  logger.info(
5315
- `Executing pip command: ${pipCommand}`,
7036
+ `pip stdout:
7037
+ ${pipRun.stdout.trim()}`,
5316
7038
  LogFileType.UPDATE_SERVICE
5317
7039
  );
5318
- await execAsync(pipCommand);
7040
+ if (pipRun.stderr.trim().length > 0) {
7041
+ logger.warn(
7042
+ `pip stderr:
7043
+ ${pipRun.stderr.trim()}`,
7044
+ LogFileType.UPDATE_SERVICE
7045
+ );
7046
+ }
7047
+ const versionAfter = await this.readBundledBackendVersion(pythonPath);
7048
+ const upgradeLanded = pipRun.exitCode === 0 && didUpgradeLand({
7049
+ before: versionBefore,
7050
+ after: versionAfter,
7051
+ target: targetVersion ?? null
7052
+ });
7053
+ if (!upgradeLanded) {
7054
+ logger.error(
7055
+ `Backend upgrade did not land: pip exited ${pipRun.exitCode}; version ${versionBefore ?? "unknown"} -> ${versionAfter ?? "unknown"}`,
7056
+ LogFileType.UPDATE_SERVICE
7057
+ );
7058
+ const serverIsBack = await this.restartBackendAfterFailedUpgrade();
7059
+ this.sendToRenderer(
7060
+ "backend-update-error",
7061
+ 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.`
7062
+ );
7063
+ return false;
7064
+ }
5319
7065
  logger.info(
5320
- "Backend package updated successfully via pip",
7066
+ `Backend package upgraded: ${versionBefore ?? "unknown"} -> ${versionAfter}`,
5321
7067
  LogFileType.UPDATE_SERVICE
5322
7068
  );
5323
7069
  logger.info(
@@ -5382,6 +7128,24 @@ class UpdateService {
5382
7128
  }
5383
7129
  return false;
5384
7130
  }
7131
+ const reportedVersion = await this.waitForBackendVersion(
7132
+ targetVersion ?? null
7133
+ );
7134
+ if (reportedVersion == null) {
7135
+ logger.error(
7136
+ `Backend did not report version ${targetVersion ?? "the new one"} after the update`,
7137
+ LogFileType.UPDATE_SERVICE
7138
+ );
7139
+ this.sendToRenderer(
7140
+ "backend-update-error",
7141
+ `The server restarted but did not report ${targetVersion ? `version ${targetVersion}` : "the updated version"}. Check the update service log, then try again.`
7142
+ );
7143
+ return false;
7144
+ }
7145
+ logger.info(
7146
+ `Backend reports version ${reportedVersion} after the update`,
7147
+ LogFileType.UPDATE_SERVICE
7148
+ );
5385
7149
  logger.info(
5386
7150
  "Backend update and restart completed successfully",
5387
7151
  LogFileType.UPDATE_SERVICE
@@ -5461,6 +7225,14 @@ class UpdateService {
5461
7225
  * @param err The error object from autoUpdater
5462
7226
  * @returns True if the error should be filtered, false if it should be shown to the user
5463
7227
  */
7228
+ /**
7229
+ * Whether an error from the updater is a genuine availability problem.
7230
+ *
7231
+ * Only availability checks may be filtered. A download, verification or
7232
+ * install failure means something the user asked for did not happen, and
7233
+ * reporting it as "no update available" (the previous behaviour) is how the
7234
+ * operator's failed install produced no message at all.
7235
+ */
5464
7236
  shouldFilterUpdateError(err) {
5465
7237
  const errorMessage = err.message || "";
5466
7238
  logger.info(