local-operator-ui 0.21.3 → 0.22.0
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 +553 -28
- package/out/preload/index.js +23 -0
- package/out/renderer/assets/{_basePickBy-DQM99jb4.js → _basePickBy-BeQ9vtr8.js} +1 -1
- package/out/renderer/assets/{_baseUniq-BIAAeVMg.js → _baseUniq-jNQP-e40.js} +1 -1
- package/out/renderer/assets/{agent-details-page-dx1TniCr.js → agent-details-page-9uaeITDU.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-UNzJ0vXB.js → agent-hub-page-D00EYmFH.js} +1 -1
- package/out/renderer/assets/{agents-page-pXjc3qtt.js → agents-page-tvOD-Uab.js} +2 -2
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-DEcJz-Qe.js → architectureDiagram-IEHRJDOE-DiG6lB3k.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-Bes_3ToS.js → blockDiagram-JOT3LUYC-BwDJ8ME3.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-DyylFXCR.js → c4Diagram-VJAJSXHY-C6i03Pqk.js} +1 -1
- package/out/renderer/assets/channel-CJMp_ZIa.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-0o1VxPEs.js → chunk-4BMEZGHF-CTx_WrmV.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-CDzbxJOn.js → chunk-A2AXSNBT-Dg8PU2Hw.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-qofjYzAQ.js → chunk-AEK57VVT-CBp1W_io.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-DgUILlRY.js → chunk-D6G4REZN-DOqf6sBF.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-CZfeeyyY.js → chunk-RZ5BOZE2-c4NZDSCk.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-Caq1mgL3.js → chunk-XZIHB7SX-M80AzyRM.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CJU4ZrqI.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CJU4ZrqI.js +1 -0
- package/out/renderer/assets/clone-D9uKrDcZ.js +1 -0
- package/out/renderer/assets/{compact-pagination-DxteDqhR.js → compact-pagination-B8AHJW_h.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-1KFO0_lD.js → dagre-OKDRZEBW-CIHoQTiW.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-CR9_zeNN.js → diagram-SSKATNLV-CBv6-N1r.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-B6Q67mPl.js → diagram-VNBRO52H-oe2NlrCN.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-DiGAX-hF.js → erDiagram-Q7BY3M3F-BnkIqgqC.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-De5WE4bH.js → flowDiagram-4HSFHLVR-xUqJ9igl.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-85-hwrgI.js → ganttDiagram-APWFNJXF-BX1_Rgv5.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-BFeTzgs6.js → gitGraphDiagram-7IBYFJ6S-XPpNnM_f.js} +1 -1
- package/out/renderer/assets/{graph-BK_WLpDk.js → graph-Cp5inlbe.js} +1 -1
- package/out/renderer/assets/icon-DDxGQ4fY.css +1 -0
- package/out/renderer/assets/{icon-Bt3GeFFS.js → icon-D_ZYoi4t.js} +30 -30
- package/out/renderer/assets/{index-a_g7S4vR.js → index-C4_Ypk8f.js} +1 -1
- package/out/renderer/assets/{index-Ceh3b7sZ.js → index-CdXgOl0F.js} +1 -1
- package/out/renderer/assets/{index-BdDNkjKF.js → index-f1OMVN5Q.js} +330 -321
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-BuMhNfqC.js → infoDiagram-PH2N3AL5-NEuE_EoZ.js} +1 -1
- package/out/renderer/assets/{installer-Dk6RajAB.js → installer-d_BYw04I.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-BelmeZiv.js → journeyDiagram-U35MCT3I-1ZGyp-FK.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-C_9spP4K.js → kanban-definition-NDS4AKOZ-CHRRjxYu.js} +1 -1
- package/out/renderer/assets/{layout-DWuQFI8w.js → layout-owitWDQ9.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-BHwelD0E.js → legacy-agents-page-CPgqQdYZ.js} +4 -4
- package/out/renderer/assets/{mermaid.core-CJpjFJ4N.js → mermaid.core-ByEjDU8I.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-DCrJ6ZKP.js → mindmap-definition-ALO5MXBD-acZmrSZp.js} +1 -1
- package/out/renderer/assets/{page-header-6ahaMOFS.js → page-header-ENK--OpK.js} +1 -1
- package/out/renderer/assets/{parseISO-Cyr1Bs2X.js → parseISO-Cfsnb_ML.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-CKb1tBLa.js → pieDiagram-IB7DONF6-CBlmSuUy.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DJOZVw4s.js → quadrantDiagram-7GDLP6J5-FH2q_WHS.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-B8yUP2ME.js → radar-MK3ICKWK-B7OziLd9.js} +1 -1
- package/out/renderer/assets/radient-auth-buttons-CHSdmLM3.js +6 -0
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D3iYYrFv.js → requirementDiagram-KVF5MWMF-xIQ9gKv2.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-PuCrwrYp.js → sankeyDiagram-QLVOVGJD-BR57eoXB.js} +1 -1
- package/out/renderer/assets/{schedules-page-CQQP4oYf.js → schedules-page-CgpgOnly.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BmpKpkbC.js → sequenceDiagram-X6HHIX6F-ly8FBjif.js} +1 -1
- package/out/renderer/assets/settings-page-DzMBU4wg.js +159 -0
- package/out/renderer/assets/{square-pen-ztv62ABT.js → square-pen-BpQ_6Lgs.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-DCdWaFjm.js → stateDiagram-DGXRK772-D8q6PM3f.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CUS9Wd9Z.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-urwrEG1U.js → timeline-definition-BDJGKUSR-DMXdshQZ.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-BgCVEpYQ.js → use-agent-like-mutation-y0mt7F5Z.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-qKP1zXp4.js → xychartDiagram-VJFVF3MP-CHwXE1ES.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +2 -2
- package/out/renderer/assets/channel-DeUP4Ko6.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-C4_vEunV.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-C4_vEunV.js +0 -1
- package/out/renderer/assets/clone-CyPJi_jL.js +0 -1
- package/out/renderer/assets/icon-ByNY0u25.css +0 -1
- package/out/renderer/assets/radient-auth-buttons-DTCerGi4.js +0 -6
- package/out/renderer/assets/settings-page-frT3NZNd.js +0 -165
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CfqWebTJ.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -184,6 +184,27 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
184
184
|
beforeId: id$1.optional(),
|
|
185
185
|
limit: zod.z.number().int().min(1).max(500).optional()
|
|
186
186
|
}).strict(),
|
|
187
|
+
/*
|
|
188
|
+
* One child's durable transcript, for the run panel's reader
|
|
189
|
+
* (`docs/run-sidebar.md` § 10.1, § 10.3).
|
|
190
|
+
*
|
|
191
|
+
* Both ids are the same `^[a-f0-9]{12}$` the whole desktop surface already
|
|
192
|
+
* validates on, and NEITHER is a path: the child directory is resolved by the
|
|
193
|
+
* route from the id and from the parent's own roster, so the renderer cannot
|
|
194
|
+
* name a directory at all. That containment is the route's, not the caller's —
|
|
195
|
+
* a schema that accepted a path here would be the whole boundary.
|
|
196
|
+
*
|
|
197
|
+
* `beforeId` is an entry id (max 128 chars, the `id` shape) rather than an
|
|
198
|
+
* offset, matching `sessions.history`: file compaction replaces the JSONL
|
|
199
|
+
* atomically, so offsets become lies while ids stay meaningful.
|
|
200
|
+
*/
|
|
201
|
+
zod.z.object({
|
|
202
|
+
op: zod.z.literal("subagents.transcript"),
|
|
203
|
+
sessionId,
|
|
204
|
+
childId: sessionId,
|
|
205
|
+
beforeId: id$1.optional(),
|
|
206
|
+
limit: zod.z.number().int().min(1).max(500).optional()
|
|
207
|
+
}).strict(),
|
|
187
208
|
zod.z.object({
|
|
188
209
|
op: zod.z.literal("sessions.message"),
|
|
189
210
|
sessionId,
|
|
@@ -670,6 +691,16 @@ function desktopEndpoint(request) {
|
|
|
670
691
|
method: "GET"
|
|
671
692
|
};
|
|
672
693
|
}
|
|
694
|
+
case "subagents.transcript": {
|
|
695
|
+
const query = new URLSearchParams({
|
|
696
|
+
limit: String(request.limit ?? 100)
|
|
697
|
+
});
|
|
698
|
+
if (request.beforeId) query.set("before_id", request.beforeId);
|
|
699
|
+
return {
|
|
700
|
+
path: `/v1/desktop/sessions/${request.sessionId}/children/${request.childId}/transcript?${query}`,
|
|
701
|
+
method: "GET"
|
|
702
|
+
};
|
|
703
|
+
}
|
|
673
704
|
case "sessions.message":
|
|
674
705
|
return {
|
|
675
706
|
path: `/v1/desktop/sessions/${request.sessionId}/messages`,
|
|
@@ -1098,6 +1129,19 @@ const mediaRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
1098
1129
|
op: zod.z.literal("sessions.attachment"),
|
|
1099
1130
|
sessionId: zod.z.string().regex(/^[a-f0-9]{12}$/),
|
|
1100
1131
|
digest: zod.z.string().regex(/^[a-f0-9]{32}$/)
|
|
1132
|
+
}).strict(),
|
|
1133
|
+
// The child-scoped twin of the op above, for the run panel's reader: a
|
|
1134
|
+
// child's durable rows reference attachments in the same content-addressed
|
|
1135
|
+
// store, and the parent's route takes the session whose transcript holds the
|
|
1136
|
+
// reference — which a child session is not, so it refuses. `childId` is
|
|
1137
|
+
// shape-constrained here for the same reason `sessionId` is: the path is
|
|
1138
|
+
// built in main, and an unvalidated id would be renderer-controlled path
|
|
1139
|
+
// text.
|
|
1140
|
+
zod.z.object({
|
|
1141
|
+
op: zod.z.literal("subagents.attachment"),
|
|
1142
|
+
sessionId: zod.z.string().regex(/^[a-f0-9]{12}$/),
|
|
1143
|
+
childId: zod.z.string().regex(/^[a-f0-9]{12}$/),
|
|
1144
|
+
digest: zod.z.string().regex(/^[a-f0-9]{32}$/)
|
|
1101
1145
|
}).strict()
|
|
1102
1146
|
]);
|
|
1103
1147
|
function endpoint(request) {
|
|
@@ -1117,6 +1161,11 @@ function endpoint(request) {
|
|
|
1117
1161
|
path: `/v1/desktop/sessions/${request.sessionId}/attachments/${request.digest}`,
|
|
1118
1162
|
method: "GET"
|
|
1119
1163
|
};
|
|
1164
|
+
case "subagents.attachment":
|
|
1165
|
+
return {
|
|
1166
|
+
path: `/v1/desktop/sessions/${request.sessionId}/children/${request.childId}/attachments/${request.digest}`,
|
|
1167
|
+
method: "GET"
|
|
1168
|
+
};
|
|
1120
1169
|
}
|
|
1121
1170
|
}
|
|
1122
1171
|
async function requestDesktopMedia(input, bytes, backendUrl, token) {
|
|
@@ -1138,7 +1187,7 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
|
|
|
1138
1187
|
const target2 = endpoint(request);
|
|
1139
1188
|
let body;
|
|
1140
1189
|
let contentType;
|
|
1141
|
-
if (request.op === "agent.export" || request.op === "sessions.attachment") {
|
|
1190
|
+
if (request.op === "agent.export" || request.op === "sessions.attachment" || request.op === "subagents.attachment") {
|
|
1142
1191
|
body = void 0;
|
|
1143
1192
|
} else if (request.op === "speech.create" || request.op === "speech.agent") {
|
|
1144
1193
|
body = JSON.stringify(request.request);
|
|
@@ -4427,6 +4476,9 @@ const PENDING_INSTALL_MARKER_FILE = "pending-update-install.json";
|
|
|
4427
4476
|
const INSTALL_DISK_SLACK_BYTES = 256 * 1024 * 1024;
|
|
4428
4477
|
const DOWNLOAD_PAGE_URL = "https://local-operator.com/download";
|
|
4429
4478
|
const WATCHDOG_TIMEOUT_SECONDS = 600;
|
|
4479
|
+
const WATCHDOG_HARD_TIMEOUT_SECONDS = 1800;
|
|
4480
|
+
const PENDING_INSTALL_RECENCY_MARGIN_SECONDS = 300;
|
|
4481
|
+
const PENDING_INSTALL_RECENCY_SECONDS = WATCHDOG_HARD_TIMEOUT_SECONDS + PENDING_INSTALL_RECENCY_MARGIN_SECONDS;
|
|
4430
4482
|
const PLIST_READ_TIMEOUT_SECONDS = 5;
|
|
4431
4483
|
const WATCHDOG_TOKEN = "local-operator-update-watchdog";
|
|
4432
4484
|
const BAD_BUNDLE_MARKERS = [
|
|
@@ -4783,7 +4835,7 @@ function recordInstallFailure(dir, input) {
|
|
|
4783
4835
|
const previous = readLastInstallAttempt(dir);
|
|
4784
4836
|
const record = {
|
|
4785
4837
|
targetVersion: input.payload.targetVersion,
|
|
4786
|
-
runningVersion: previous?.runningVersion
|
|
4838
|
+
runningVersion: input.runningVersion || previous?.runningVersion || "unknown",
|
|
4787
4839
|
startedAt: input.startedAt,
|
|
4788
4840
|
detectedAt: input.detectedAt,
|
|
4789
4841
|
detail: input.payload.detail,
|
|
@@ -4821,12 +4873,45 @@ function evaluatePendingInstall(input) {
|
|
|
4821
4873
|
}
|
|
4822
4874
|
const order = compareVersions(marker.targetVersion, input.runningVersion);
|
|
4823
4875
|
if (order !== null && order < 0) return { kind: "stale", marker };
|
|
4876
|
+
if (input.installInFlight === true) return { kind: "in-flight", marker };
|
|
4824
4877
|
return { kind: "failed", marker };
|
|
4825
4878
|
}
|
|
4826
|
-
function
|
|
4879
|
+
function pendingInstallAgeSeconds(marker, now = Date.now()) {
|
|
4880
|
+
if (!marker.startedAt) return null;
|
|
4881
|
+
const started = Date.parse(marker.startedAt);
|
|
4882
|
+
if (Number.isNaN(started)) return null;
|
|
4883
|
+
return (now - started) / 1e3;
|
|
4884
|
+
}
|
|
4885
|
+
function isInstallInFlight(input) {
|
|
4886
|
+
if (!input.marker || !input.jobLoaded) return false;
|
|
4887
|
+
const age = pendingInstallAgeSeconds(input.marker, input.now);
|
|
4888
|
+
if (age === null) return false;
|
|
4889
|
+
return age >= 0 && age <= PENDING_INSTALL_RECENCY_SECONDS;
|
|
4890
|
+
}
|
|
4891
|
+
function launchdJobLoaded(jobLabel, probe) {
|
|
4892
|
+
if (!jobLabel) return false;
|
|
4893
|
+
return probe(jobLabel) === 0;
|
|
4894
|
+
}
|
|
4895
|
+
function installStartedText(marker) {
|
|
4827
4896
|
const started = marker.startedAt ? new Date(marker.startedAt) : null;
|
|
4828
|
-
|
|
4897
|
+
return started && !Number.isNaN(started.getTime()) ? started.toLocaleString() : "at an unknown time";
|
|
4898
|
+
}
|
|
4899
|
+
function installFailurePayload(marker, runningVersion, options = {}) {
|
|
4900
|
+
const startedText = installStartedText(marker);
|
|
4829
4901
|
const logText = options.shipItLogPath ? ` Squirrel's own log is at ${options.shipItLogPath}.` : "";
|
|
4902
|
+
if (options.cancelledByRelaunch === true) {
|
|
4903
|
+
return {
|
|
4904
|
+
message: `The update to version ${marker.targetVersion} was cancelled because Local Operator was opened while the update was installing. Version ${runningVersion} is still running.`,
|
|
4905
|
+
remedy: {
|
|
4906
|
+
text: "Quit Local Operator and replace it in Applications with a fresh copy, or update again from the app — and leave it closed until the update finishes.",
|
|
4907
|
+
url: DOWNLOAD_PAGE_URL
|
|
4908
|
+
},
|
|
4909
|
+
detail: `Install started ${startedText} from ${marker.artifactPath || "an unknown artifact"}. Squirrel cancels an install when an instance of the app is running.${logText}`,
|
|
4910
|
+
targetVersion: marker.targetVersion,
|
|
4911
|
+
attempts: options.attempts ?? 1,
|
|
4912
|
+
cancelledByRelaunch: true
|
|
4913
|
+
};
|
|
4914
|
+
}
|
|
4830
4915
|
return {
|
|
4831
4916
|
message: `The update to version ${marker.targetVersion} didn't finish, so version ${runningVersion} is still running.`,
|
|
4832
4917
|
remedy: {
|
|
@@ -4838,6 +4923,13 @@ function installFailurePayload(marker, runningVersion, options = {}) {
|
|
|
4838
4923
|
attempts: options.attempts ?? 1
|
|
4839
4924
|
};
|
|
4840
4925
|
}
|
|
4926
|
+
function installInFlightPayload(marker, runningVersion) {
|
|
4927
|
+
return {
|
|
4928
|
+
targetVersion: marker.targetVersion,
|
|
4929
|
+
message: `Version ${marker.targetVersion} can't finish installing while Local Operator is open — keeping it open cancels the install. Quit and leave it closed until the app opens again by itself.`,
|
|
4930
|
+
detail: `Install started ${installStartedText(marker)} from ${marker.artifactPath || "an unknown artifact"}, while version ${runningVersion} was running.`
|
|
4931
|
+
};
|
|
4932
|
+
}
|
|
4841
4933
|
function shipItJobLabel(bundleId) {
|
|
4842
4934
|
return `${bundleId}.ShipIt`;
|
|
4843
4935
|
}
|
|
@@ -4853,14 +4945,17 @@ function watchdogSwapTarget(input) {
|
|
|
4853
4945
|
return input.target;
|
|
4854
4946
|
}
|
|
4855
4947
|
function watchdogSignals(platform) {
|
|
4948
|
+
if (platform !== "darwin") return { jobProbe: null, plistReader: null };
|
|
4856
4949
|
return { jobProbe: "launchctl", plistReader: "/usr/bin/plutil" };
|
|
4857
4950
|
}
|
|
4858
4951
|
function buildWatchdogPlan(input) {
|
|
4859
|
-
const signals = input.signals ?? watchdogSignals();
|
|
4952
|
+
const signals = input.signals ?? watchdogSignals(input.platform);
|
|
4860
4953
|
const timeoutSeconds = input.timeoutSeconds ?? WATCHDOG_TIMEOUT_SECONDS;
|
|
4954
|
+
const hardTimeoutSeconds = input.hardTimeoutSeconds ?? WATCHDOG_HARD_TIMEOUT_SECONDS;
|
|
4861
4955
|
const intervalSeconds = input.intervalSeconds ?? 3;
|
|
4862
4956
|
const settleSeconds = input.settleSeconds ?? 5;
|
|
4863
4957
|
const appearSeconds = input.appearSeconds ?? 30;
|
|
4958
|
+
const announceSeconds = input.announceSeconds ?? 5;
|
|
4864
4959
|
const plistTimeoutSeconds = input.plistReadTimeoutSeconds ?? PLIST_READ_TIMEOUT_SECONDS;
|
|
4865
4960
|
const script = `#!/bin/sh
|
|
4866
4961
|
# ${WATCHDOG_TOKEN}
|
|
@@ -4891,12 +4986,30 @@ function buildWatchdogPlan(input) {
|
|
|
4891
4986
|
# one beyond it: the new app is in place and ShipIt has nothing left to do.
|
|
4892
4987
|
# This is what makes an early exit safe rather than a guess, and it is the
|
|
4893
4988
|
# path a failed-but-swapped install takes.
|
|
4894
|
-
# (c)
|
|
4895
|
-
#
|
|
4896
|
-
#
|
|
4897
|
-
#
|
|
4898
|
-
#
|
|
4899
|
-
#
|
|
4989
|
+
# (c) a bound arrived with no decision. Trying is still better than leaving the
|
|
4990
|
+
# user with nothing, and the bound is what makes the previous version's
|
|
4991
|
+
# silent exit 0 impossible.
|
|
4992
|
+
#
|
|
4993
|
+
# (c) has a HOLD in front of it whenever the job is known and still loaded, and
|
|
4994
|
+
# that hold is the 2026-09-13 incident written into the script. At the soft bound
|
|
4995
|
+
# the install can be perfectly alive - that day it was, and it needed another
|
|
4996
|
+
# four minutes to move and code-verify a 1 GiB bundle on a host at load ~95 - and
|
|
4997
|
+
# Squirrel asks "is any instance of the target app running?" one last time before
|
|
4998
|
+
# it swaps. A launch there is not a rescue, it is the kill: the install aborts
|
|
4999
|
+
# (App Still Running, SQRLInstallerErrorDomain -9) and this script's own relaunch
|
|
5000
|
+
# is the whole cause of the failure the user is then told about. So while the job
|
|
5001
|
+
# is still loaded the script holds, says the update is still installing, and
|
|
5002
|
+
# starts the app only at the hard bound - where a job that old is better assumed
|
|
5003
|
+
# hung than live. (c) is therefore the ONE path that can start the app while a
|
|
5004
|
+
# swap is still in flight - an accepted risk, now taken at the hard bound rather
|
|
5005
|
+
# than at the soft one.
|
|
5006
|
+
#
|
|
5007
|
+
# It also says what is happening while it waits, because the silence is what cost
|
|
5008
|
+
# the operator the install: the window vanishes, nothing says the install takes
|
|
5009
|
+
# minutes, and reopening the app is the natural thing to do - which is exactly
|
|
5010
|
+
# what aborts it. Notifying is best-effort in every sense (notifications muted,
|
|
5011
|
+
# osascript blocked, no notifier at all), so no notification failure may change
|
|
5012
|
+
# what this script decides or what it exits with.
|
|
4900
5013
|
#
|
|
4901
5014
|
# With no job label to ask about, the job cannot be consulted at all, so the swap
|
|
4902
5015
|
# state is the whole signal: the script waits for (b) rather than spending an
|
|
@@ -4919,10 +5032,35 @@ if [ -z "$APP_PID" ] || [ -z "$BUNDLE" ]; then
|
|
|
4919
5032
|
fi
|
|
4920
5033
|
now() { date +%s; }
|
|
4921
5034
|
app_running() { kill -0 "$APP_PID" 2>/dev/null; }
|
|
4922
|
-
#
|
|
4923
|
-
#
|
|
4924
|
-
#
|
|
4925
|
-
|
|
5035
|
+
# Tell the user what is going on, in the only way available: the app is dead
|
|
5036
|
+
# while this runs, so this is osascript and nothing else - no app process, no
|
|
5037
|
+
# updater. Backgrounded and status-dropped, because a notification is best-effort
|
|
5038
|
+
# in every sense: muted Notification Center, osascript blocked by policy, or no
|
|
5039
|
+
# notifier at all must never change what this script decides, when it retries, or
|
|
5040
|
+
# what it exits with. The text travels as its own argument rather than inside the
|
|
5041
|
+
# AppleScript, so no word of it needs escaping.
|
|
5042
|
+
notify() {
|
|
5043
|
+
[ -n "$1" ] || return 0
|
|
5044
|
+
osascript -e 'on run argv' -e 'display notification (item 1 of argv) with title (item 2 of argv)' -e 'end run' "$1" "Local Operator" >/dev/null 2>&1 &
|
|
5045
|
+
}
|
|
5046
|
+
# launchctl list <label> exits 0 when the job is loaded and 113 when launchd has
|
|
5047
|
+
# no such job; every other status is not an answer at all. An unanswerable probe
|
|
5048
|
+
# is read as "still loaded" - the safe direction - because the one decision this
|
|
5049
|
+
# feeds here is whether to start the app into a live install, and a launchctl
|
|
5050
|
+
# that failed for some other reason (not on PATH, a transient launchd error) must
|
|
5051
|
+
# not be what starts it. The other direction is not licensed by it: the hard
|
|
5052
|
+
# bound still ends the wait, so an unanswered probe costs time rather than the
|
|
5053
|
+
# install. A definite 113 still means the install declared itself over, which is
|
|
5054
|
+
# the one answer that ends the wait early. With no probe at all there is no
|
|
5055
|
+
# launchd to ask, and this answers "cannot ask" rather than "not loaded":
|
|
5056
|
+
# job_known below requires the probe for the same reason.
|
|
5057
|
+
shipit_loaded() {
|
|
5058
|
+
[ -n "$SHIPIT_PROBE" ] && [ -n "$SHIPIT_JOB" ] || return 1
|
|
5059
|
+
"$SHIPIT_PROBE" list "$SHIPIT_JOB" >/dev/null 2>&1
|
|
5060
|
+
job_status=$?
|
|
5061
|
+
[ "$job_status" -eq 113 ] && return 1
|
|
5062
|
+
return 0
|
|
5063
|
+
}
|
|
4926
5064
|
# (b), the swap's own state: is the app at the target path AT OR BEYOND the
|
|
4927
5065
|
# version this update was for? plutil rather than \`defaults read\`, which reads
|
|
4928
5066
|
# through a preference domain and can answer from a stale cache; a half-written
|
|
@@ -5048,11 +5186,22 @@ decided() {
|
|
|
5048
5186
|
return 1
|
|
5049
5187
|
}
|
|
5050
5188
|
deadline=$(( $(now) + ${timeoutSeconds} ))
|
|
5189
|
+
hard_deadline=$(( $(now) + ${hardTimeoutSeconds} ))
|
|
5051
5190
|
# 1. The install only starts once the old app has exited.
|
|
5052
5191
|
while app_running; do
|
|
5053
5192
|
if [ "$(now)" -ge "$deadline" ]; then break; fi
|
|
5054
5193
|
sleep ${intervalSeconds}
|
|
5055
5194
|
done
|
|
5195
|
+
# 1b. The app is gone, so an install is at work: say so a few seconds in, so the
|
|
5196
|
+
# window that just vanished comes with an explanation. This is the moment the
|
|
5197
|
+
# operator of 2026-09-13 was left guessing, and reopening the app four
|
|
5198
|
+
# minutes later is what aborted their install. Skipped when the app is still
|
|
5199
|
+
# running - a quit the user cancelled, where nothing is being installed and
|
|
5200
|
+
# nothing should be claimed.
|
|
5201
|
+
if ! app_running; then
|
|
5202
|
+
sleep ${announceSeconds}
|
|
5203
|
+
notify "Installing the update. Keep Local Operator closed until it opens again by itself — this can take a few minutes."
|
|
5204
|
+
fi
|
|
5056
5205
|
# 2. ShipIt's job is submitted as part of the quit, so it may not be loaded the
|
|
5057
5206
|
# instant the app is gone: give it a bounded window to appear before treating
|
|
5058
5207
|
# "no job" as "the install is over". Skipped when there is no label to ask
|
|
@@ -5066,12 +5215,48 @@ if [ "$job_known" -eq 1 ]; then
|
|
|
5066
5215
|
done
|
|
5067
5216
|
fi
|
|
5068
5217
|
# 3. Wait for the install to be decided - by the job going, or by the swap
|
|
5069
|
-
# landing
|
|
5218
|
+
# landing. The soft bound ends this wait only when there is no loaded job
|
|
5219
|
+
# answering for the install: see the hold below.
|
|
5220
|
+
holding=0
|
|
5070
5221
|
while :; do
|
|
5071
5222
|
if decided; then break; fi
|
|
5072
|
-
if [ "$(now)" -ge "$deadline" ]; then
|
|
5223
|
+
if [ "$(now)" -ge "$deadline" ]; then
|
|
5224
|
+
if [ "$job_known" -eq 1 ] && shipit_loaded; then
|
|
5225
|
+
holding=1
|
|
5226
|
+
notify "The update is still installing. Keep Local Operator closed; it will open again when the install finishes."
|
|
5227
|
+
fi
|
|
5228
|
+
break
|
|
5229
|
+
fi
|
|
5073
5230
|
sleep ${intervalSeconds}
|
|
5074
5231
|
done
|
|
5232
|
+
# 3b. The hold. The soft bound arrived with the install's job still loaded, so
|
|
5233
|
+
# the install is alive and a launch here is what aborts it: Squirrel's last
|
|
5234
|
+
# question before it swaps is whether any instance of the app is running,
|
|
5235
|
+
# and on 2026-09-13 our own relaunch was the instance that answered yes. The
|
|
5236
|
+
# job going or the swap landing still ends this at once - both are decisions
|
|
5237
|
+
# the install made - and otherwise the hard bound does, which is where
|
|
5238
|
+
# "better a live swap than no app at all" takes over.
|
|
5239
|
+
#
|
|
5240
|
+
# The hard bound's notice names the action the panel will ask for instead of
|
|
5241
|
+
# a different one. What the user sees next is the app coming back with the
|
|
5242
|
+
# in-flight panel, whose copy is "quit and leave it closed until the app
|
|
5243
|
+
# opens again by itself" - so a notice that ended "check for updates when it
|
|
5244
|
+
# is back" told them the install was over at the exact moment the app was
|
|
5245
|
+
# about to say it was still running, and two authorities seconds apart with
|
|
5246
|
+
# opposite instructions is worse than either alone (UX U10). The notice now
|
|
5247
|
+
# states what is happening and hands over to that panel rather than
|
|
5248
|
+
# pre-empting it: the instruction the user acts on is the panel's, and it is
|
|
5249
|
+
# the correct one - quitting again is what lets the swap finish.
|
|
5250
|
+
if [ "$holding" -eq 1 ]; then
|
|
5251
|
+
while :; do
|
|
5252
|
+
if decided; then break; fi
|
|
5253
|
+
if [ "$(now)" -ge "$hard_deadline" ]; then
|
|
5254
|
+
notify "The update is taking longer than expected. Local Operator is opening again so you are not left without it — if it says the update is still installing, quit it and leave it closed until the install finishes."
|
|
5255
|
+
break
|
|
5256
|
+
fi
|
|
5257
|
+
sleep ${intervalSeconds}
|
|
5258
|
+
done
|
|
5259
|
+
fi
|
|
5075
5260
|
# Let Squirrel's own relaunch (which happens as the job finishes) land first.
|
|
5076
5261
|
sleep ${settleSeconds}
|
|
5077
5262
|
# 4. Nothing to do if Squirrel relaunched the app or the user started it. This is
|
|
@@ -5096,9 +5281,46 @@ exit 0
|
|
|
5096
5281
|
LO_UPDATE_WATCHDOG_SHIPIT_PROBE: signals.jobProbe ?? "",
|
|
5097
5282
|
LO_UPDATE_WATCHDOG_PLIST_READER: signals.plistReader ?? ""
|
|
5098
5283
|
},
|
|
5099
|
-
timeoutSeconds
|
|
5284
|
+
timeoutSeconds,
|
|
5285
|
+
hardTimeoutSeconds
|
|
5100
5286
|
};
|
|
5101
5287
|
}
|
|
5288
|
+
function removeStagedTree(input) {
|
|
5289
|
+
const attempts = input.attempts ?? 3;
|
|
5290
|
+
const delayMs = input.delayMs ?? 250;
|
|
5291
|
+
const sleep = input.sleep ?? defaultSleep;
|
|
5292
|
+
let error = null;
|
|
5293
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
5294
|
+
try {
|
|
5295
|
+
input.remove(input.path);
|
|
5296
|
+
} catch (thrown) {
|
|
5297
|
+
error = String(thrown);
|
|
5298
|
+
}
|
|
5299
|
+
if (!input.exists(input.path)) {
|
|
5300
|
+
return { removed: true, attempts: attempt, error: null };
|
|
5301
|
+
}
|
|
5302
|
+
if (attempt < attempts) sleep(delayMs);
|
|
5303
|
+
}
|
|
5304
|
+
return {
|
|
5305
|
+
removed: false,
|
|
5306
|
+
attempts,
|
|
5307
|
+
error: error ?? `the staged tree at ${input.path} is still there`
|
|
5308
|
+
};
|
|
5309
|
+
}
|
|
5310
|
+
function defaultSleep(ms) {
|
|
5311
|
+
if (ms <= 0) return;
|
|
5312
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
5313
|
+
}
|
|
5314
|
+
function reapStagedTree(path2) {
|
|
5315
|
+
const removal = removeStagedTree({
|
|
5316
|
+
path: path2,
|
|
5317
|
+
remove: (target2) => fs.rmSync(target2, { recursive: true, force: true }),
|
|
5318
|
+
exists: (target2) => fs.existsSync(target2)
|
|
5319
|
+
});
|
|
5320
|
+
if (!removal.removed) {
|
|
5321
|
+
throw new Error(removal.error ?? `could not remove ${path2}`);
|
|
5322
|
+
}
|
|
5323
|
+
}
|
|
5102
5324
|
function reapFailedInstall(input) {
|
|
5103
5325
|
const result = {
|
|
5104
5326
|
jobLabel: null,
|
|
@@ -5414,6 +5636,7 @@ const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
|
|
|
5414
5636
|
const VERSION_CLEAN_REGEX = /^v/i;
|
|
5415
5637
|
const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
|
|
5416
5638
|
const BUNDLE_ID_REGEX = /^[\w.-]+$/;
|
|
5639
|
+
const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
|
|
5417
5640
|
const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
|
|
5418
5641
|
const SEAL_PROBE_TIMEOUT_MS = 45e3;
|
|
5419
5642
|
function versionSuffix(version) {
|
|
@@ -5498,6 +5721,38 @@ class UpdateService {
|
|
|
5498
5721
|
/** A failed install detected from the marker on this start, if any. */
|
|
5499
5722
|
pendingInstallFailure = null;
|
|
5500
5723
|
installFailureDelivered = false;
|
|
5724
|
+
/**
|
|
5725
|
+
* An install this process found still running, if any.
|
|
5726
|
+
*
|
|
5727
|
+
* The opposite fact to `pendingInstallFailure`, and kept apart from it: this
|
|
5728
|
+
* one means the update is still in progress and the user has to leave the app
|
|
5729
|
+
* closed for it to finish. Nothing may be cleared, reaped or reported while
|
|
5730
|
+
* it is up.
|
|
5731
|
+
*/
|
|
5732
|
+
pendingInstallInFlight = null;
|
|
5733
|
+
installInFlightDelivered = false;
|
|
5734
|
+
/**
|
|
5735
|
+
* True once this process has seen its own install run.
|
|
5736
|
+
*
|
|
5737
|
+
* It is what lets a later failure name the real cause: an install this app
|
|
5738
|
+
* watched start and then not finish, on a machine where the app was open for
|
|
5739
|
+
* part of it, was cancelled by that relaunch (Squirrel asks whether any
|
|
5740
|
+
* instance is running before it swaps), and saying "the update didn't finish"
|
|
5741
|
+
* there sends the user back to retry the one thing that cannot work.
|
|
5742
|
+
*/
|
|
5743
|
+
installWasInFlight = false;
|
|
5744
|
+
/** The pending re-check of a live install, while the app is open. */
|
|
5745
|
+
installInFlightTimer = null;
|
|
5746
|
+
/**
|
|
5747
|
+
* True once a quit following an in-flight install has made sure the app will
|
|
5748
|
+
* come back.
|
|
5749
|
+
*
|
|
5750
|
+
* The two callers are the same quit seen twice - `window-all-closed` decides
|
|
5751
|
+
* and `before-quit` runs as it goes through - so without this the launchd
|
|
5752
|
+
* probe, the `ps` read and the "no watchdog is running" log all happen twice
|
|
5753
|
+
* for one quit.
|
|
5754
|
+
*/
|
|
5755
|
+
inFlightQuitWatchdogEnsured = false;
|
|
5501
5756
|
/**
|
|
5502
5757
|
* True while `quit-and-install` is running its pre-flight.
|
|
5503
5758
|
*
|
|
@@ -5615,12 +5870,27 @@ class UpdateService {
|
|
|
5615
5870
|
* update did not happen. Everything the user learns afterwards starts here:
|
|
5616
5871
|
* the notice, the durable record it can be found in later, and the cleanup of
|
|
5617
5872
|
* what Squirrel left running.
|
|
5873
|
+
*
|
|
5874
|
+
* The one case that must NOT be read as a failure is the install that is still
|
|
5875
|
+
* running, and it is the 2026-09-13 incident: the app comes back mid-install
|
|
5876
|
+
* on the old version, because the swap has not happened yet. That used to
|
|
5877
|
+
* clear the marker, remove the very job doing the installing, and tell the
|
|
5878
|
+
* user the update had failed - two seconds before ShipIt aborted the install
|
|
5879
|
+
* on its own final check. So the job is probed first, a live install is left
|
|
5880
|
+
* alone entirely, and the re-check below reports what really happens to it.
|
|
5618
5881
|
*/
|
|
5619
5882
|
recoverPendingInstall() {
|
|
5620
5883
|
const marker = readPendingInstallMarker(this.markerDir());
|
|
5621
5884
|
const outcome = evaluatePendingInstall({
|
|
5622
5885
|
marker,
|
|
5623
|
-
runningVersion: electron.app.getVersion()
|
|
5886
|
+
runningVersion: electron.app.getVersion(),
|
|
5887
|
+
// Probed once per evaluation, and only on macOS: a loaded job plus a
|
|
5888
|
+
// current marker is what makes "still on the old version" an install in
|
|
5889
|
+
// progress rather than an install that failed.
|
|
5890
|
+
installInFlight: isInstallInFlight({
|
|
5891
|
+
marker,
|
|
5892
|
+
jobLoaded: this.shipItInstallJobLoaded()
|
|
5893
|
+
})
|
|
5624
5894
|
});
|
|
5625
5895
|
switch (outcome.kind) {
|
|
5626
5896
|
case "none":
|
|
@@ -5641,27 +5911,53 @@ class UpdateService {
|
|
|
5641
5911
|
this.reapWatchdog(outcome.marker, true);
|
|
5642
5912
|
clearPendingInstallMarker(this.markerDir());
|
|
5643
5913
|
return;
|
|
5914
|
+
case "in-flight": {
|
|
5915
|
+
this.installWasInFlight = true;
|
|
5916
|
+
logger.info(
|
|
5917
|
+
`Update marker: the install of version ${outcome.marker.targetVersion} is still running (its install job is loaded). Leaving the marker, the install job and the relaunch watchdog in place.`,
|
|
5918
|
+
LogFileType.UPDATE_SERVICE
|
|
5919
|
+
);
|
|
5920
|
+
this.pendingInstallInFlight = installInFlightPayload(
|
|
5921
|
+
outcome.marker,
|
|
5922
|
+
electron.app.getVersion()
|
|
5923
|
+
);
|
|
5924
|
+
this.scheduleInstallInFlightDelivery();
|
|
5925
|
+
this.scheduleInstallInFlightRecheck();
|
|
5926
|
+
return;
|
|
5927
|
+
}
|
|
5644
5928
|
case "failed": {
|
|
5645
5929
|
const shipItLogPath = this.shipItLog();
|
|
5646
5930
|
const record = recordInstallFailure(this.markerDir(), {
|
|
5647
5931
|
payload: installFailurePayload(outcome.marker, electron.app.getVersion(), {
|
|
5648
|
-
shipItLogPath
|
|
5932
|
+
shipItLogPath,
|
|
5933
|
+
cancelledByRelaunch: this.installWasInFlight
|
|
5649
5934
|
}),
|
|
5935
|
+
// The record names the version that is running now, which is the same
|
|
5936
|
+
// answer the payload above was built from: Settings renders it as
|
|
5937
|
+
// "Version X is running", and it used to carry the previous failure's
|
|
5938
|
+
// version forward instead (QA Q1).
|
|
5939
|
+
runningVersion: electron.app.getVersion(),
|
|
5650
5940
|
startedAt: outcome.marker.startedAt || null,
|
|
5651
5941
|
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5652
5942
|
});
|
|
5653
5943
|
const payload = installFailurePayload(
|
|
5654
5944
|
outcome.marker,
|
|
5655
5945
|
electron.app.getVersion(),
|
|
5656
|
-
{
|
|
5946
|
+
{
|
|
5947
|
+
attempts: record.attempts,
|
|
5948
|
+
shipItLogPath,
|
|
5949
|
+
// An install this process watched run and then not finish, with the
|
|
5950
|
+
// app open for part of it, was cancelled by that relaunch.
|
|
5951
|
+
cancelledByRelaunch: this.installWasInFlight
|
|
5952
|
+
}
|
|
5657
5953
|
);
|
|
5658
5954
|
logger.error(
|
|
5659
5955
|
`Update marker: install of version ${outcome.marker.targetVersion} did not complete (attempt ${record.attempts}). ${payload.detail}`,
|
|
5660
5956
|
LogFileType.UPDATE_SERVICE
|
|
5661
5957
|
);
|
|
5662
5958
|
this.pendingInstallFailure = payload;
|
|
5959
|
+
this.stopInstallInFlightRecheck();
|
|
5663
5960
|
this.reapWatchdog(outcome.marker, false);
|
|
5664
|
-
clearPendingInstallMarker(this.markerDir());
|
|
5665
5961
|
this.reapFailedInstallLeftovers();
|
|
5666
5962
|
this.schedulePendingInstallFailureDelivery();
|
|
5667
5963
|
}
|
|
@@ -5742,7 +6038,13 @@ class UpdateService {
|
|
|
5742
6038
|
},
|
|
5743
6039
|
exists: (path2) => fs.existsSync(path2),
|
|
5744
6040
|
listDir: (dir) => fs.readdirSync(dir),
|
|
5745
|
-
|
|
6041
|
+
// Retried, because the draw this cleans up is a race: on 2026-09-13
|
|
6042
|
+
// ShipIt was moving this very tree into place while the reap walked
|
|
6043
|
+
// it, and the rmdir it lost surfaced as ENOTDIR - which `rmSync`
|
|
6044
|
+
// does not retry (see `removeStagedTree`). A tree that cannot be
|
|
6045
|
+
// removed after that is still reported, because it is ~1 GiB the
|
|
6046
|
+
// user's disk is paying for.
|
|
6047
|
+
removeDir: (dir) => reapStagedTree(dir),
|
|
5746
6048
|
log
|
|
5747
6049
|
});
|
|
5748
6050
|
if (result.errors.length > 0) {
|
|
@@ -5761,9 +6063,12 @@ class UpdateService {
|
|
|
5761
6063
|
/**
|
|
5762
6064
|
* Deliver the failed-install notice once the renderer can hear it.
|
|
5763
6065
|
*
|
|
5764
|
-
* The
|
|
5765
|
-
* on
|
|
5766
|
-
* a
|
|
6066
|
+
* The single delivery path for every install failure, read at start-up or
|
|
6067
|
+
* found on a re-check: the marker is read while the window is still loading
|
|
6068
|
+
* (and, on a re-check, the window is up but the React effect that subscribes
|
|
6069
|
+
* is not provably past), so the push happens on `did-finish-load` with a
|
|
6070
|
+
* delayed fallback. One path rather than two is what keeps "delivered"
|
|
6071
|
+
* meaning the same thing in both cases (review R2).
|
|
5767
6072
|
*/
|
|
5768
6073
|
schedulePendingInstallFailureDelivery() {
|
|
5769
6074
|
if (!this.pendingInstallFailure) return;
|
|
@@ -5780,11 +6085,190 @@ class UpdateService {
|
|
|
5780
6085
|
return;
|
|
5781
6086
|
}
|
|
5782
6087
|
this.installFailureDelivered = true;
|
|
6088
|
+
this.pendingInstallInFlight = null;
|
|
6089
|
+
clearPendingInstallMarker(this.markerDir());
|
|
5783
6090
|
logger.info(
|
|
5784
6091
|
"Reported a failed update install to the renderer",
|
|
5785
6092
|
LogFileType.UPDATE_SERVICE
|
|
5786
6093
|
);
|
|
5787
6094
|
}
|
|
6095
|
+
/**
|
|
6096
|
+
* A substituted answer to "is this app's install job loaded", or null.
|
|
6097
|
+
*
|
|
6098
|
+
* The production answer is the launchd probe in `shipItInstallJobLoaded`, and
|
|
6099
|
+
* it asks about a label read from the RUNNING BUNDLE (`shipItJob`). A test or a
|
|
6100
|
+
* harness process that is not that bundle has no label to ask about, so the
|
|
6101
|
+
* whole in-flight path - recovery's reading, the re-check, and the quit below -
|
|
6102
|
+
* is otherwise unreachable from a suite. This is the same shape as the
|
|
6103
|
+
* watchdog's own probe, which is invoked by name precisely so a harness can
|
|
6104
|
+
* substitute it: one seam for the whole answer, so what a test drives is the
|
|
6105
|
+
* rule production uses rather than a second copy of it. Production never sets
|
|
6106
|
+
* it.
|
|
6107
|
+
*/
|
|
6108
|
+
installJobLoadedProbe = null;
|
|
6109
|
+
/**
|
|
6110
|
+
* Whether this app's ShipIt install job is loaded, right now.
|
|
6111
|
+
*
|
|
6112
|
+
* This is the machine's own answer to "is an install in flight", and the only
|
|
6113
|
+
* answer that can tell a live install apart from the job a failed one left
|
|
6114
|
+
* behind. It is asked of launchd by label rather than inferred from a process
|
|
6115
|
+
* name, so nothing else on the machine can be mistaken for it. Off macOS there
|
|
6116
|
+
* is no launchd to ask, so the answer is a plain no and recovery stays
|
|
6117
|
+
* version-only there, exactly as it is today.
|
|
6118
|
+
*/
|
|
6119
|
+
shipItInstallJobLoaded() {
|
|
6120
|
+
if (this.installJobLoadedProbe) return this.installJobLoadedProbe();
|
|
6121
|
+
const jobProbe = watchdogSignals(process.platform).jobProbe;
|
|
6122
|
+
if (!jobProbe) return false;
|
|
6123
|
+
return launchdJobLoaded(this.shipItJob(), (label) => {
|
|
6124
|
+
const result = node_child_process.spawnSync(jobProbe, ["list", label], {
|
|
6125
|
+
encoding: "utf8",
|
|
6126
|
+
timeout: 5e3
|
|
6127
|
+
});
|
|
6128
|
+
return result.error || result.status == null ? null : result.status;
|
|
6129
|
+
});
|
|
6130
|
+
}
|
|
6131
|
+
/**
|
|
6132
|
+
* Deliver the still-installing notice once the renderer can hear it.
|
|
6133
|
+
*
|
|
6134
|
+
* Same shape and the same reason as the failure's delivery: the marker is read
|
|
6135
|
+
* while the window is still loading, and the component subscribes from a React
|
|
6136
|
+
* effect that can run after `did-finish-load`.
|
|
6137
|
+
*/
|
|
6138
|
+
scheduleInstallInFlightDelivery() {
|
|
6139
|
+
if (!this.pendingInstallInFlight || this.installInFlightDelivered) return;
|
|
6140
|
+
const deliver = () => this.deliverPendingInstallInFlight();
|
|
6141
|
+
const webContents = this.mainWindow?.webContents;
|
|
6142
|
+
if (webContents && !webContents.isDestroyed()) {
|
|
6143
|
+
webContents.once("did-finish-load", deliver);
|
|
6144
|
+
}
|
|
6145
|
+
setTimeout(deliver, 5e3);
|
|
6146
|
+
}
|
|
6147
|
+
deliverPendingInstallInFlight() {
|
|
6148
|
+
if (!this.pendingInstallInFlight || this.installInFlightDelivered) return;
|
|
6149
|
+
if (!this.sendToRenderer(
|
|
6150
|
+
"update-install-in-flight",
|
|
6151
|
+
this.pendingInstallInFlight
|
|
6152
|
+
)) {
|
|
6153
|
+
return;
|
|
6154
|
+
}
|
|
6155
|
+
this.installInFlightDelivered = true;
|
|
6156
|
+
logger.info(
|
|
6157
|
+
"Reported an install still in flight to the renderer",
|
|
6158
|
+
LogFileType.UPDATE_SERVICE
|
|
6159
|
+
);
|
|
6160
|
+
}
|
|
6161
|
+
/**
|
|
6162
|
+
* Re-check a live install while the app stays open, until it settles.
|
|
6163
|
+
*
|
|
6164
|
+
* The panel that says "the update is still installing" cannot be the end of
|
|
6165
|
+
* the story: ShipIt decides within a couple of minutes of the app coming back
|
|
6166
|
+
* (it asks whether any instance is running as its final validation), and the
|
|
6167
|
+
* user has to hear what that decision was. Re-evaluating is what produces it -
|
|
6168
|
+
* the job no longer loaded means the install is over, and the marker plus the
|
|
6169
|
+
* old running version then say truthfully that it was cancelled. The loop ends
|
|
6170
|
+
* by itself: once the marker is older than
|
|
6171
|
+
* `PENDING_INSTALL_RECENCY_SECONDS` nothing calls it current, the next
|
|
6172
|
+
* evaluation is a failure, and that branch stops the timer.
|
|
6173
|
+
*/
|
|
6174
|
+
scheduleInstallInFlightRecheck() {
|
|
6175
|
+
if (this.installInFlightTimer) return;
|
|
6176
|
+
this.installInFlightTimer = setTimeout(() => {
|
|
6177
|
+
this.installInFlightTimer = null;
|
|
6178
|
+
this.recoverPendingInstall();
|
|
6179
|
+
}, INSTALL_IN_FLIGHT_RECHECK_MS);
|
|
6180
|
+
this.installInFlightTimer.unref();
|
|
6181
|
+
}
|
|
6182
|
+
stopInstallInFlightRecheck() {
|
|
6183
|
+
if (!this.installInFlightTimer) return;
|
|
6184
|
+
clearTimeout(this.installInFlightTimer);
|
|
6185
|
+
this.installInFlightTimer = null;
|
|
6186
|
+
}
|
|
6187
|
+
/**
|
|
6188
|
+
* Make sure something will start the app again after a quit that is only
|
|
6189
|
+
* getting out of an install's way.
|
|
6190
|
+
*
|
|
6191
|
+
* The watchdog from the original install is usually still there - it holds
|
|
6192
|
+
* while the job is loaded now, rather than starting the app into the swap -
|
|
6193
|
+
* but it may have run out its own bounds, and the user may be quitting hours
|
|
6194
|
+
* later. Quitting without one would leave them with no app at all, which is
|
|
6195
|
+
* the outcome this whole change exists to remove. Nothing is started when ours
|
|
6196
|
+
* is alive, because two watchdogs relaunching at the same moment is a race
|
|
6197
|
+
* with nothing to gain. The marker is left exactly as it is: its start time is
|
|
6198
|
+
* what tells recovery this is still the same install.
|
|
6199
|
+
*/
|
|
6200
|
+
ensureWatchdogAfterInFlightQuit(marker) {
|
|
6201
|
+
const pid = marker.watchdogPid;
|
|
6202
|
+
const commandLine = pid == null ? null : readCommandOutput("/bin/ps", ["-o", "command=", "-p", String(pid)]);
|
|
6203
|
+
if (watchdogIsOurs({
|
|
6204
|
+
alive: commandLine != null,
|
|
6205
|
+
commandLine,
|
|
6206
|
+
installSucceeded: true
|
|
6207
|
+
})) {
|
|
6208
|
+
logger.info(
|
|
6209
|
+
`Relaunch watchdog ${pid} is still running; it will start the app when the install settles.`,
|
|
6210
|
+
LogFileType.UPDATE_SERVICE
|
|
6211
|
+
);
|
|
6212
|
+
return;
|
|
6213
|
+
}
|
|
6214
|
+
const launched = this.launchWatchdog(marker.targetVersion);
|
|
6215
|
+
logger.info(
|
|
6216
|
+
launched ? `Started relaunch watchdog ${launched} for the install already in flight; it will start the app when the install's job goes.` : "No relaunch watchdog is running for the install in flight and none could be started; the app will need starting by hand after it quits.",
|
|
6217
|
+
LogFileType.UPDATE_SERVICE
|
|
6218
|
+
);
|
|
6219
|
+
}
|
|
6220
|
+
/**
|
|
6221
|
+
* The pending-install marker, if the machine says an install is live now.
|
|
6222
|
+
*
|
|
6223
|
+
* The marker is read first, because the ordinary answer to "is an update
|
|
6224
|
+
* installing" is no and a file read answers it; the launchd probe behind it is
|
|
6225
|
+
* a process spawn and is only worth paying for when there is a marker to ask
|
|
6226
|
+
* about.
|
|
6227
|
+
*/
|
|
6228
|
+
livePendingInstallMarker() {
|
|
6229
|
+
const marker = readPendingInstallMarker(this.markerDir());
|
|
6230
|
+
if (!marker) return null;
|
|
6231
|
+
return isInstallInFlight({
|
|
6232
|
+
marker,
|
|
6233
|
+
jobLoaded: this.shipItInstallJobLoaded()
|
|
6234
|
+
}) ? marker : null;
|
|
6235
|
+
}
|
|
6236
|
+
/**
|
|
6237
|
+
* Treat a quit as a quit for an install that is in flight, and make sure
|
|
6238
|
+
* something will start the app again.
|
|
6239
|
+
*
|
|
6240
|
+
* Two gestures reach this, and a user believes both are harmless: the macOS
|
|
6241
|
+
* close-the-last-window close, and any ordinary quit (Cmd+Q, the dock, the
|
|
6242
|
+
* menu). `window-all-closed` on macOS deliberately leaves the app running with
|
|
6243
|
+
* no window, which is right for every ordinary close - but a running instance
|
|
6244
|
+
* of this app is exactly what Squirrel's final validation aborts an install on,
|
|
6245
|
+
* so a user who closes the window during an install has cancelled their update
|
|
6246
|
+
* without being told, and reached the incident this whole change exists to
|
|
6247
|
+
* remove by the platform's most familiar gesture (UX U1). The relaunch promise
|
|
6248
|
+
* was also button-shaped: a plain quit while an install was in flight left it
|
|
6249
|
+
* resting on whatever watchdog the install happened to still have (UX U2).
|
|
6250
|
+
*
|
|
6251
|
+
* The probe is the machine's answer rather than the renderer's, so this holds
|
|
6252
|
+
* even when the panel was never drawn - the app can come back mid-install and
|
|
6253
|
+
* have its last window closed before the renderer has heard anything.
|
|
6254
|
+
*
|
|
6255
|
+
* Returns true when an install was in flight, so a caller that is deciding
|
|
6256
|
+
* whether to quit knows the close was taken over. Nothing here touches the
|
|
6257
|
+
* marker, the install's job or the failure record: this only gets the app out
|
|
6258
|
+
* of Squirrel's way and makes sure it comes back.
|
|
6259
|
+
*/
|
|
6260
|
+
quitForInFlightInstall(context) {
|
|
6261
|
+
const marker = this.livePendingInstallMarker();
|
|
6262
|
+
if (!marker) return false;
|
|
6263
|
+
if (this.inFlightQuitWatchdogEnsured) return true;
|
|
6264
|
+
this.inFlightQuitWatchdogEnsured = true;
|
|
6265
|
+
logger.info(
|
|
6266
|
+
`Quitting so the in-flight update install can finish (${context}).`,
|
|
6267
|
+
LogFileType.UPDATE_SERVICE
|
|
6268
|
+
);
|
|
6269
|
+
this.ensureWatchdogAfterInFlightQuit(marker);
|
|
6270
|
+
return true;
|
|
6271
|
+
}
|
|
5788
6272
|
/**
|
|
5789
6273
|
* Kill a watchdog still running from the recorded install attempt.
|
|
5790
6274
|
*
|
|
@@ -6092,7 +6576,13 @@ ${heal.removed.join("\n")}`,
|
|
|
6092
6576
|
targetVersion: watchdogSwapTarget({
|
|
6093
6577
|
target: targetVersion,
|
|
6094
6578
|
running: electron.app.getVersion()
|
|
6095
|
-
})
|
|
6579
|
+
}),
|
|
6580
|
+
// Stated rather than read off `process.platform` at the plan: this
|
|
6581
|
+
// watchdog exists for Squirrel.Mac's ShipIt, the guard above already
|
|
6582
|
+
// refuses it anywhere else, and the script's two probes (launchd's job
|
|
6583
|
+
// lookup, `plutil`) are generated for the platform the script runs on
|
|
6584
|
+
// rather than for whichever host planned it (see `watchdogSignals`).
|
|
6585
|
+
platform: "darwin"
|
|
6096
6586
|
});
|
|
6097
6587
|
try {
|
|
6098
6588
|
const child = node_child_process.spawn("sh", ["-c", plan.script], {
|
|
@@ -6102,7 +6592,7 @@ ${heal.removed.join("\n")}`,
|
|
|
6102
6592
|
});
|
|
6103
6593
|
child.unref();
|
|
6104
6594
|
logger.info(
|
|
6105
|
-
`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.`,
|
|
6595
|
+
`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 - or at the ${plan.hardTimeoutSeconds}s hard bound when the install's job is still loaded at the first, which is the case where starting the app would cancel the install.`,
|
|
6106
6596
|
LogFileType.UPDATE_SERVICE
|
|
6107
6597
|
);
|
|
6108
6598
|
return child.pid ?? null;
|
|
@@ -6125,6 +6615,7 @@ ${heal.removed.join("\n")}`,
|
|
|
6125
6615
|
clearInterval(this.updateCheckInterval);
|
|
6126
6616
|
this.updateCheckInterval = null;
|
|
6127
6617
|
}
|
|
6618
|
+
this.stopInstallInFlightRecheck();
|
|
6128
6619
|
electronUpdater.autoUpdater.removeAllListeners();
|
|
6129
6620
|
electron.ipcMain.removeHandler("check-for-updates");
|
|
6130
6621
|
electron.ipcMain.removeHandler("check-for-backend-updates");
|
|
@@ -6133,6 +6624,7 @@ ${heal.removed.join("\n")}`,
|
|
|
6133
6624
|
electron.ipcMain.removeHandler("update-backend");
|
|
6134
6625
|
electron.ipcMain.removeHandler("download-update");
|
|
6135
6626
|
electron.ipcMain.removeHandler("quit-and-install");
|
|
6627
|
+
electron.ipcMain.removeHandler("quit-for-update-install");
|
|
6136
6628
|
this.mainWindow = null;
|
|
6137
6629
|
}
|
|
6138
6630
|
/**
|
|
@@ -6497,6 +6989,19 @@ ${heal.removed.join("\n")}`,
|
|
|
6497
6989
|
electronUpdater.autoUpdater.quitAndInstall(false, true);
|
|
6498
6990
|
return true;
|
|
6499
6991
|
});
|
|
6992
|
+
electron.ipcMain.handle("quit-for-update-install", () => {
|
|
6993
|
+
const marker = readPendingInstallMarker(this.markerDir());
|
|
6994
|
+
logger.info(
|
|
6995
|
+
"Quitting so the in-flight update install can finish.",
|
|
6996
|
+
LogFileType.UPDATE_SERVICE
|
|
6997
|
+
);
|
|
6998
|
+
if (marker && !this.inFlightQuitWatchdogEnsured) {
|
|
6999
|
+
this.inFlightQuitWatchdogEnsured = true;
|
|
7000
|
+
this.ensureWatchdogAfterInFlightQuit(marker);
|
|
7001
|
+
}
|
|
7002
|
+
setImmediate(() => electron.app.quit());
|
|
7003
|
+
return true;
|
|
7004
|
+
});
|
|
6500
7005
|
}
|
|
6501
7006
|
/**
|
|
6502
7007
|
* Check for updates
|
|
@@ -7658,6 +8163,19 @@ function createApplicationMenu() {
|
|
|
7658
8163
|
...process.platform === "darwin" ? [
|
|
7659
8164
|
{ type: "separator" },
|
|
7660
8165
|
{ role: "front" },
|
|
8166
|
+
/*
|
|
8167
|
+
* `close` is what gives the window the standard Cmd+W, and on macOS
|
|
8168
|
+
* it is not implied by anything else here: the Window menu carried
|
|
8169
|
+
* `minimize`/`zoom`/`front` and no close item, so the gesture the
|
|
8170
|
+
* in-flight update panel invites ("quit and leave it closed until
|
|
8171
|
+
* the app opens again by itself") resolved to nothing at all - the
|
|
8172
|
+
* red button worked and the keyboard did not (UX U9). It belongs
|
|
8173
|
+
* here rather than in File because the app's own File menu holds a
|
|
8174
|
+
* lone `quit`, and this gesture has to reach the same
|
|
8175
|
+
* `window-all-closed` path the red button takes, which is the one
|
|
8176
|
+
* this change made safe for an install in flight.
|
|
8177
|
+
*/
|
|
8178
|
+
{ role: "close" },
|
|
7661
8179
|
{ type: "separator" },
|
|
7662
8180
|
{ role: "window" }
|
|
7663
8181
|
] : [{ role: "close" }]
|
|
@@ -7851,6 +8369,7 @@ if (windowLaunch.mode !== "normal") {
|
|
|
7851
8369
|
console.log(`[window-mode] ${describeWindowLaunch(windowLaunch)}`);
|
|
7852
8370
|
}
|
|
7853
8371
|
let mainWindow = null;
|
|
8372
|
+
let activeUpdateService = null;
|
|
7854
8373
|
const zoomInFromEvent = () => {
|
|
7855
8374
|
if (mainWindow) {
|
|
7856
8375
|
const webContents = mainWindow.webContents;
|
|
@@ -8251,6 +8770,7 @@ electron.app.whenReady().then(async () => {
|
|
|
8251
8770
|
updateService = null;
|
|
8252
8771
|
}
|
|
8253
8772
|
updateService = new UpdateService(mainWindow, backendService);
|
|
8773
|
+
activeUpdateService = updateService;
|
|
8254
8774
|
mainWindow.on("closed", () => {
|
|
8255
8775
|
if (updateService) {
|
|
8256
8776
|
updateService.dispose();
|
|
@@ -8291,6 +8811,10 @@ electron.app.whenReady().then(async () => {
|
|
|
8291
8811
|
});
|
|
8292
8812
|
electron.app.on("window-all-closed", () => {
|
|
8293
8813
|
if (process.platform === "darwin") {
|
|
8814
|
+
if (activeUpdateService?.quitForInFlightInstall("last window closed")) {
|
|
8815
|
+
electron.app.quit();
|
|
8816
|
+
return;
|
|
8817
|
+
}
|
|
8294
8818
|
logger.info(
|
|
8295
8819
|
"All windows closed, but keeping app active (macOS platform)",
|
|
8296
8820
|
LogFileType.BACKEND
|
|
@@ -8469,6 +8993,7 @@ electron.app.on("will-quit", async (event) => {
|
|
|
8469
8993
|
electron.app.on("before-quit", () => {
|
|
8470
8994
|
logger.info("App is about to quit", LogFileType.BACKEND);
|
|
8471
8995
|
electron.globalShortcut.unregisterAll();
|
|
8996
|
+
activeUpdateService?.quitForInFlightInstall("app quit");
|
|
8472
8997
|
});
|
|
8473
8998
|
process.on("exit", () => {
|
|
8474
8999
|
logger.info(
|