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.
- package/out/main/index.js +1955 -183
- package/out/preload/index.js +51 -3
- package/out/renderer/assets/{_basePickBy-C3hLSrXE.js → _basePickBy-CznFfatx.js} +1 -1
- package/out/renderer/assets/{_baseUniq-DvVdHboz.js → _baseUniq-BjMURw0K.js} +1 -1
- package/out/renderer/assets/{agent-details-page-6irHwYok.js → agent-details-page-ZDJPFc2J.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-t3NZyvhe.js → agent-hub-page-BCTiysl5.js} +1 -1
- package/out/renderer/assets/{agents-page-Cu3tV_JM.js → agents-page-DMVIzax-.js} +2 -2
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-Dy32lq21.js → architectureDiagram-IEHRJDOE-B1dNRm1E.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-D9CCHreU.js → blockDiagram-JOT3LUYC-jqbb6gXH.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-D6JYul4W.js → c4Diagram-VJAJSXHY-CFq9qrnD.js} +1 -1
- package/out/renderer/assets/channel-B0cyEtqf.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-CWZ3r21W.js → chunk-4BMEZGHF-gZvJoH2i.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-_jdJLEL4.js → chunk-A2AXSNBT-BkTvsCLM.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-BRqxxp9X.js → chunk-AEK57VVT-h-_7Epgx.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-mvuW_LPO.js → chunk-D6G4REZN-CtrPKMTC.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-B2kzBW8d.js → chunk-RZ5BOZE2-C0RFP5w9.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-CqZmhFu_.js → chunk-XZIHB7SX-C-kSNRUr.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-Dqwik2D7.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-Dqwik2D7.js +1 -0
- package/out/renderer/assets/clone-CL2wO1Ym.js +1 -0
- package/out/renderer/assets/{compact-pagination-BJa5bxJe.js → compact-pagination-DS6ecmly.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-BLi9lW3T.js → dagre-OKDRZEBW-Dv_Zo3IY.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-DQa3WYjU.js → diagram-SSKATNLV-D5ST2KPg.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-C99A8WuH.js → diagram-VNBRO52H-haGCbMfM.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-DLWwdshb.js → erDiagram-Q7BY3M3F-C92fuMik.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-CHDQ3e9O.js → flowDiagram-4HSFHLVR-Bt4sRBki.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-G4Rf9Yhs.js → ganttDiagram-APWFNJXF-c0Y2OgXm.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-2JN_jG8T.js → gitGraphDiagram-7IBYFJ6S-B9TUU4Qi.js} +1 -1
- package/out/renderer/assets/{graph-BR85xHbv.js → graph-CO7rQdXD.js} +1 -1
- package/out/renderer/assets/icon-CqmmCSFR.css +1 -0
- package/out/renderer/assets/{index-_UzVA1oU.js → index-B6ufqJu6.js} +302 -302
- package/out/renderer/assets/{index-BIyDfbOQ.js → index-CHRG8Ler.js} +1 -1
- package/out/renderer/assets/{index-BuhuPWhs.js → index-Dt1yeMBX.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-xVzynFeN.js → infoDiagram-PH2N3AL5-DroR83ys.js} +1 -1
- package/out/renderer/assets/{installer-CwpKGBgz.js → installer-CL3b2q9D.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-C8-eHlWx.js → journeyDiagram-U35MCT3I-z6YQbkvW.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-C2Oyzo6d.js → kanban-definition-NDS4AKOZ-DX3Ux1JM.js} +1 -1
- package/out/renderer/assets/{layout-mlSyc_4q.js → layout-CGW8X3Ld.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-CTGA2lul.js → legacy-agents-page-DOL46A-I.js} +1 -1
- package/out/renderer/assets/{mermaid.core-BvFuUEpc.js → mermaid.core-BD15O0c3.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-DWnloutf.js → mindmap-definition-ALO5MXBD-BAJ-8QKu.js} +1 -1
- package/out/renderer/assets/{page-header-C-9zjakn.js → page-header-QKjtBvcy.js} +1 -1
- package/out/renderer/assets/{parseISO-Bp3fMoKU.js → parseISO-CXOJUx7e.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-B990Tpnk.js → pieDiagram-IB7DONF6-NFj3sXlT.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BYeOekBR.js → quadrantDiagram-7GDLP6J5-CJB8XQQb.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-DcxkUK5Z.js → radar-MK3ICKWK-DR5CTIYI.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-CK9Icvbq.js → radient-auth-buttons-Cgr7rndT.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D_tf_1lk.js → requirementDiagram-KVF5MWMF-B4Eet6_a.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-DlDfWN8k.js → sankeyDiagram-QLVOVGJD-CI-VcjXP.js} +1 -1
- package/out/renderer/assets/{schedules-page-AQ9gMlkR.js → schedules-page-KeO7FZ7D.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-wr3-RcoT.js → sequenceDiagram-X6HHIX6F-CAg0HbTN.js} +1 -1
- package/out/renderer/assets/{settings-page-BSjdxTFW.js → settings-page-D7ptJm1c.js} +29 -29
- package/out/renderer/assets/{square-pen-2Yxvf_S9.js → square-pen-uKlnlrn1.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-BLD6Vmgd.js → stateDiagram-DGXRK772-CsaG4cMW.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DbebtGKF.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-B-JeF04j.js → timeline-definition-BDJGKUSR-D_Pz88Zq.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-tstZNIUn.js → use-agent-like-mutation-DYvn_qmk.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-DuFpzjW6.js → xychartDiagram-VJFVF3MP-BO5MIl1S.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +5 -3
- package/out/renderer/assets/channel-CLzqtgQj.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-Bu04i3t4.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-Bu04i3t4.js +0 -1
- package/out/renderer/assets/clone-DHy7FuoP.js +0 -1
- package/out/renderer/assets/icon-2KHv58kf.css +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CsGkljza.js +0 -1
- /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 =
|
|
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
|
-
|
|
4589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4601
|
-
|
|
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
|
|
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
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
});
|
|
4615
|
-
}
|
|
6098
|
+
this.sendToRenderer("update-not-available", {
|
|
6099
|
+
version: electron.app.getVersion()
|
|
6100
|
+
});
|
|
4616
6101
|
} else {
|
|
4617
|
-
|
|
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(
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
error
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
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
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
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(
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
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
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
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
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
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("
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
)
|
|
4733
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5113
|
-
this.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6967
|
+
`Cannot manage the update for ${startupMode}: ${plan.detail}`,
|
|
5221
6968
|
LogFileType.UPDATE_SERVICE
|
|
5222
6969
|
);
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5255
|
-
if (
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
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 (!
|
|
5311
|
-
logger.
|
|
5312
|
-
|
|
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
|
-
`
|
|
7036
|
+
`pip stdout:
|
|
7037
|
+
${pipRun.stdout.trim()}`,
|
|
5316
7038
|
LogFileType.UPDATE_SERVICE
|
|
5317
7039
|
);
|
|
5318
|
-
|
|
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
|
-
|
|
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(
|