local-operator-ui 0.21.2 → 0.21.4
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 +503 -27
- package/out/preload/index.js +23 -0
- package/out/renderer/assets/{_basePickBy-BrhuNY3Y.js → _basePickBy-DCkhfufs.js} +1 -1
- package/out/renderer/assets/{_baseUniq-d4I8BFB7.js → _baseUniq-CbXRn6Sz.js} +1 -1
- package/out/renderer/assets/{agent-details-page-DvPCamZn.js → agent-details-page-CzZG1eTh.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-MU5bf3FU.js → agent-hub-page-BUlhFKrQ.js} +1 -1
- package/out/renderer/assets/{agents-page-DiUW3c4r.js → agents-page-BChq9qh8.js} +2 -2
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CmxQBEkE.js → architectureDiagram-IEHRJDOE-CUcZIqGz.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-jEzBjOVv.js → blockDiagram-JOT3LUYC-C8iWDIl7.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-DPNdf3uy.js → c4Diagram-VJAJSXHY-Rf7kjwb8.js} +1 -1
- package/out/renderer/assets/channel-C0H6vNPZ.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-BjZRVy55.js → chunk-4BMEZGHF-B8xwSed9.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-CjiEaNik.js → chunk-A2AXSNBT-Cd443Eh9.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-B8sYsVL2.js → chunk-AEK57VVT-CfWs2qUA.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-B-1n06LH.js → chunk-D6G4REZN-DQS_tD7n.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-C-fq3iE3.js → chunk-RZ5BOZE2-GGjWUC8K.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-fVK9vXqX.js → chunk-XZIHB7SX-C_z5Swpc.js} +1 -1
- package/out/renderer/assets/{classDiagram-GIVACNV2-D38qMrkN.js → classDiagram-GIVACNV2-CK43jL1V.js} +1 -1
- package/out/renderer/assets/{classDiagram-v2-COTLJTTW-D38qMrkN.js → classDiagram-v2-COTLJTTW-CK43jL1V.js} +1 -1
- package/out/renderer/assets/clone-C7ZRecHq.js +1 -0
- package/out/renderer/assets/{compact-pagination-CQOvYQ3X.js → compact-pagination-BoBK24RF.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-DzX-_BZ7.js → dagre-OKDRZEBW-BeC8pWE9.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-6c7R-Rr_.js → diagram-SSKATNLV-Bxa_2UeV.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-BWcpjsqm.js → diagram-VNBRO52H-CadMYaoj.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-DGKLStxs.js → erDiagram-Q7BY3M3F-MaHtoLsT.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-B9Gacztw.js → flowDiagram-4HSFHLVR-Dthy2ic-.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-C38tm_md.js → ganttDiagram-APWFNJXF-CAk8SxD7.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-CHDB5uvl.js → gitGraphDiagram-7IBYFJ6S-Dva-2htw.js} +1 -1
- package/out/renderer/assets/{graph-DlWrFssJ.js → graph-s_OViwzG.js} +1 -1
- package/out/renderer/assets/{index-DXagGyV_.js → index--bextIso.js} +1 -1
- package/out/renderer/assets/{index-xyhhbm6O.js → index-CFAVcqcg.js} +299 -299
- package/out/renderer/assets/{index-6zYm2tzs.js → index-CsCp5pB0.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-1b_kOmTI.js → infoDiagram-PH2N3AL5-i9VhPGfZ.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-_u0C1LKL.js → journeyDiagram-U35MCT3I-DjN1JRcf.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-CL7zIGOQ.js → kanban-definition-NDS4AKOZ-DeBvWNw2.js} +1 -1
- package/out/renderer/assets/{layout-B7_UlWhh.js → layout-D9rlSDZl.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-DjdyFsle.js → legacy-agents-page-E3hHPrtT.js} +1 -1
- package/out/renderer/assets/{mermaid.core-DMGv0X4h.js → mermaid.core-CXUoJGge.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BdJz0phC.js → mindmap-definition-ALO5MXBD-CsbDlfvY.js} +1 -1
- package/out/renderer/assets/{parseISO-DHNn7dbD.js → parseISO-BvU3ISTP.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-BXygMPGV.js → pieDiagram-IB7DONF6-wWJJMpcD.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DSS3rJGy.js → quadrantDiagram-7GDLP6J5-B1gEaGpX.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-D7mJxb8L.js → radar-MK3ICKWK-BsQyOA66.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-BXjrjp9_.js → radient-auth-buttons-Dzwx2_bO.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DvtM0SO-.js → requirementDiagram-KVF5MWMF-DFGVsPwn.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-NmbhpHXe.js → sankeyDiagram-QLVOVGJD-Cpr12W3J.js} +1 -1
- package/out/renderer/assets/{schedules-page-DUipMSf5.js → schedules-page-DLGJUTRX.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-CDMHZ85H.js → sequenceDiagram-X6HHIX6F-CY6u9Tii.js} +1 -1
- package/out/renderer/assets/{settings-page-gbcFa5F4.js → settings-page-zkuTaAfc.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-1j6mgPBe.js → stateDiagram-DGXRK772-DvrY4Q_D.js} +1 -1
- package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-DwGmbNs9.js → stateDiagram-v2-YXO3MK2T-B5oCdb35.js} +1 -1
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-Dovu1lXn.js → timeline-definition-BDJGKUSR-B8_J7X8q.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-BcrQ-k2a.js → use-agent-like-mutation-BowqVPFo.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CuwUIYlx.js → xychartDiagram-VJFVF3MP-D7x_UntQ.js} +1 -1
- package/out/renderer/index.html +1 -1
- package/package.json +2 -2
- package/out/renderer/assets/channel-BgDmT1Me.js +0 -1
- package/out/renderer/assets/clone-DeRiLxFW.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -4427,6 +4427,9 @@ const PENDING_INSTALL_MARKER_FILE = "pending-update-install.json";
|
|
|
4427
4427
|
const INSTALL_DISK_SLACK_BYTES = 256 * 1024 * 1024;
|
|
4428
4428
|
const DOWNLOAD_PAGE_URL = "https://local-operator.com/download";
|
|
4429
4429
|
const WATCHDOG_TIMEOUT_SECONDS = 600;
|
|
4430
|
+
const WATCHDOG_HARD_TIMEOUT_SECONDS = 1800;
|
|
4431
|
+
const PENDING_INSTALL_RECENCY_MARGIN_SECONDS = 300;
|
|
4432
|
+
const PENDING_INSTALL_RECENCY_SECONDS = WATCHDOG_HARD_TIMEOUT_SECONDS + PENDING_INSTALL_RECENCY_MARGIN_SECONDS;
|
|
4430
4433
|
const PLIST_READ_TIMEOUT_SECONDS = 5;
|
|
4431
4434
|
const WATCHDOG_TOKEN = "local-operator-update-watchdog";
|
|
4432
4435
|
const BAD_BUNDLE_MARKERS = [
|
|
@@ -4783,7 +4786,7 @@ function recordInstallFailure(dir, input) {
|
|
|
4783
4786
|
const previous = readLastInstallAttempt(dir);
|
|
4784
4787
|
const record = {
|
|
4785
4788
|
targetVersion: input.payload.targetVersion,
|
|
4786
|
-
runningVersion: previous?.runningVersion
|
|
4789
|
+
runningVersion: input.runningVersion || previous?.runningVersion || "unknown",
|
|
4787
4790
|
startedAt: input.startedAt,
|
|
4788
4791
|
detectedAt: input.detectedAt,
|
|
4789
4792
|
detail: input.payload.detail,
|
|
@@ -4821,12 +4824,45 @@ function evaluatePendingInstall(input) {
|
|
|
4821
4824
|
}
|
|
4822
4825
|
const order = compareVersions(marker.targetVersion, input.runningVersion);
|
|
4823
4826
|
if (order !== null && order < 0) return { kind: "stale", marker };
|
|
4827
|
+
if (input.installInFlight === true) return { kind: "in-flight", marker };
|
|
4824
4828
|
return { kind: "failed", marker };
|
|
4825
4829
|
}
|
|
4826
|
-
function
|
|
4830
|
+
function pendingInstallAgeSeconds(marker, now = Date.now()) {
|
|
4831
|
+
if (!marker.startedAt) return null;
|
|
4832
|
+
const started = Date.parse(marker.startedAt);
|
|
4833
|
+
if (Number.isNaN(started)) return null;
|
|
4834
|
+
return (now - started) / 1e3;
|
|
4835
|
+
}
|
|
4836
|
+
function isInstallInFlight(input) {
|
|
4837
|
+
if (!input.marker || !input.jobLoaded) return false;
|
|
4838
|
+
const age = pendingInstallAgeSeconds(input.marker, input.now);
|
|
4839
|
+
if (age === null) return false;
|
|
4840
|
+
return age >= 0 && age <= PENDING_INSTALL_RECENCY_SECONDS;
|
|
4841
|
+
}
|
|
4842
|
+
function launchdJobLoaded(jobLabel, probe) {
|
|
4843
|
+
if (!jobLabel) return false;
|
|
4844
|
+
return probe(jobLabel) === 0;
|
|
4845
|
+
}
|
|
4846
|
+
function installStartedText(marker) {
|
|
4827
4847
|
const started = marker.startedAt ? new Date(marker.startedAt) : null;
|
|
4828
|
-
|
|
4848
|
+
return started && !Number.isNaN(started.getTime()) ? started.toLocaleString() : "at an unknown time";
|
|
4849
|
+
}
|
|
4850
|
+
function installFailurePayload(marker, runningVersion, options = {}) {
|
|
4851
|
+
const startedText = installStartedText(marker);
|
|
4829
4852
|
const logText = options.shipItLogPath ? ` Squirrel's own log is at ${options.shipItLogPath}.` : "";
|
|
4853
|
+
if (options.cancelledByRelaunch === true) {
|
|
4854
|
+
return {
|
|
4855
|
+
message: `The update to version ${marker.targetVersion} was cancelled because Local Operator was opened while the update was installing. Version ${runningVersion} is still running.`,
|
|
4856
|
+
remedy: {
|
|
4857
|
+
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.",
|
|
4858
|
+
url: DOWNLOAD_PAGE_URL
|
|
4859
|
+
},
|
|
4860
|
+
detail: `Install started ${startedText} from ${marker.artifactPath || "an unknown artifact"}. Squirrel cancels an install when an instance of the app is running.${logText}`,
|
|
4861
|
+
targetVersion: marker.targetVersion,
|
|
4862
|
+
attempts: options.attempts ?? 1,
|
|
4863
|
+
cancelledByRelaunch: true
|
|
4864
|
+
};
|
|
4865
|
+
}
|
|
4830
4866
|
return {
|
|
4831
4867
|
message: `The update to version ${marker.targetVersion} didn't finish, so version ${runningVersion} is still running.`,
|
|
4832
4868
|
remedy: {
|
|
@@ -4838,6 +4874,13 @@ function installFailurePayload(marker, runningVersion, options = {}) {
|
|
|
4838
4874
|
attempts: options.attempts ?? 1
|
|
4839
4875
|
};
|
|
4840
4876
|
}
|
|
4877
|
+
function installInFlightPayload(marker, runningVersion) {
|
|
4878
|
+
return {
|
|
4879
|
+
targetVersion: marker.targetVersion,
|
|
4880
|
+
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.`,
|
|
4881
|
+
detail: `Install started ${installStartedText(marker)} from ${marker.artifactPath || "an unknown artifact"}, while version ${runningVersion} was running.`
|
|
4882
|
+
};
|
|
4883
|
+
}
|
|
4841
4884
|
function shipItJobLabel(bundleId) {
|
|
4842
4885
|
return `${bundleId}.ShipIt`;
|
|
4843
4886
|
}
|
|
@@ -4853,14 +4896,17 @@ function watchdogSwapTarget(input) {
|
|
|
4853
4896
|
return input.target;
|
|
4854
4897
|
}
|
|
4855
4898
|
function watchdogSignals(platform) {
|
|
4899
|
+
if (platform !== "darwin") return { jobProbe: null, plistReader: null };
|
|
4856
4900
|
return { jobProbe: "launchctl", plistReader: "/usr/bin/plutil" };
|
|
4857
4901
|
}
|
|
4858
4902
|
function buildWatchdogPlan(input) {
|
|
4859
|
-
const signals = input.signals ?? watchdogSignals();
|
|
4903
|
+
const signals = input.signals ?? watchdogSignals(input.platform);
|
|
4860
4904
|
const timeoutSeconds = input.timeoutSeconds ?? WATCHDOG_TIMEOUT_SECONDS;
|
|
4905
|
+
const hardTimeoutSeconds = input.hardTimeoutSeconds ?? WATCHDOG_HARD_TIMEOUT_SECONDS;
|
|
4861
4906
|
const intervalSeconds = input.intervalSeconds ?? 3;
|
|
4862
4907
|
const settleSeconds = input.settleSeconds ?? 5;
|
|
4863
4908
|
const appearSeconds = input.appearSeconds ?? 30;
|
|
4909
|
+
const announceSeconds = input.announceSeconds ?? 5;
|
|
4864
4910
|
const plistTimeoutSeconds = input.plistReadTimeoutSeconds ?? PLIST_READ_TIMEOUT_SECONDS;
|
|
4865
4911
|
const script = `#!/bin/sh
|
|
4866
4912
|
# ${WATCHDOG_TOKEN}
|
|
@@ -4891,12 +4937,30 @@ function buildWatchdogPlan(input) {
|
|
|
4891
4937
|
# one beyond it: the new app is in place and ShipIt has nothing left to do.
|
|
4892
4938
|
# This is what makes an early exit safe rather than a guess, and it is the
|
|
4893
4939
|
# path a failed-but-swapped install takes.
|
|
4894
|
-
# (c)
|
|
4895
|
-
#
|
|
4896
|
-
#
|
|
4897
|
-
#
|
|
4898
|
-
#
|
|
4899
|
-
#
|
|
4940
|
+
# (c) a bound arrived with no decision. Trying is still better than leaving the
|
|
4941
|
+
# user with nothing, and the bound is what makes the previous version's
|
|
4942
|
+
# silent exit 0 impossible.
|
|
4943
|
+
#
|
|
4944
|
+
# (c) has a HOLD in front of it whenever the job is known and still loaded, and
|
|
4945
|
+
# that hold is the 2026-09-13 incident written into the script. At the soft bound
|
|
4946
|
+
# the install can be perfectly alive - that day it was, and it needed another
|
|
4947
|
+
# four minutes to move and code-verify a 1 GiB bundle on a host at load ~95 - and
|
|
4948
|
+
# Squirrel asks "is any instance of the target app running?" one last time before
|
|
4949
|
+
# it swaps. A launch there is not a rescue, it is the kill: the install aborts
|
|
4950
|
+
# (App Still Running, SQRLInstallerErrorDomain -9) and this script's own relaunch
|
|
4951
|
+
# is the whole cause of the failure the user is then told about. So while the job
|
|
4952
|
+
# is still loaded the script holds, says the update is still installing, and
|
|
4953
|
+
# starts the app only at the hard bound - where a job that old is better assumed
|
|
4954
|
+
# hung than live. (c) is therefore the ONE path that can start the app while a
|
|
4955
|
+
# swap is still in flight - an accepted risk, now taken at the hard bound rather
|
|
4956
|
+
# than at the soft one.
|
|
4957
|
+
#
|
|
4958
|
+
# It also says what is happening while it waits, because the silence is what cost
|
|
4959
|
+
# the operator the install: the window vanishes, nothing says the install takes
|
|
4960
|
+
# minutes, and reopening the app is the natural thing to do - which is exactly
|
|
4961
|
+
# what aborts it. Notifying is best-effort in every sense (notifications muted,
|
|
4962
|
+
# osascript blocked, no notifier at all), so no notification failure may change
|
|
4963
|
+
# what this script decides or what it exits with.
|
|
4900
4964
|
#
|
|
4901
4965
|
# With no job label to ask about, the job cannot be consulted at all, so the swap
|
|
4902
4966
|
# state is the whole signal: the script waits for (b) rather than spending an
|
|
@@ -4919,10 +4983,35 @@ if [ -z "$APP_PID" ] || [ -z "$BUNDLE" ]; then
|
|
|
4919
4983
|
fi
|
|
4920
4984
|
now() { date +%s; }
|
|
4921
4985
|
app_running() { kill -0 "$APP_PID" 2>/dev/null; }
|
|
4922
|
-
#
|
|
4923
|
-
#
|
|
4924
|
-
#
|
|
4925
|
-
|
|
4986
|
+
# Tell the user what is going on, in the only way available: the app is dead
|
|
4987
|
+
# while this runs, so this is osascript and nothing else - no app process, no
|
|
4988
|
+
# updater. Backgrounded and status-dropped, because a notification is best-effort
|
|
4989
|
+
# in every sense: muted Notification Center, osascript blocked by policy, or no
|
|
4990
|
+
# notifier at all must never change what this script decides, when it retries, or
|
|
4991
|
+
# what it exits with. The text travels as its own argument rather than inside the
|
|
4992
|
+
# AppleScript, so no word of it needs escaping.
|
|
4993
|
+
notify() {
|
|
4994
|
+
[ -n "$1" ] || return 0
|
|
4995
|
+
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 &
|
|
4996
|
+
}
|
|
4997
|
+
# launchctl list <label> exits 0 when the job is loaded and 113 when launchd has
|
|
4998
|
+
# no such job; every other status is not an answer at all. An unanswerable probe
|
|
4999
|
+
# is read as "still loaded" - the safe direction - because the one decision this
|
|
5000
|
+
# feeds here is whether to start the app into a live install, and a launchctl
|
|
5001
|
+
# that failed for some other reason (not on PATH, a transient launchd error) must
|
|
5002
|
+
# not be what starts it. The other direction is not licensed by it: the hard
|
|
5003
|
+
# bound still ends the wait, so an unanswered probe costs time rather than the
|
|
5004
|
+
# install. A definite 113 still means the install declared itself over, which is
|
|
5005
|
+
# the one answer that ends the wait early. With no probe at all there is no
|
|
5006
|
+
# launchd to ask, and this answers "cannot ask" rather than "not loaded":
|
|
5007
|
+
# job_known below requires the probe for the same reason.
|
|
5008
|
+
shipit_loaded() {
|
|
5009
|
+
[ -n "$SHIPIT_PROBE" ] && [ -n "$SHIPIT_JOB" ] || return 1
|
|
5010
|
+
"$SHIPIT_PROBE" list "$SHIPIT_JOB" >/dev/null 2>&1
|
|
5011
|
+
job_status=$?
|
|
5012
|
+
[ "$job_status" -eq 113 ] && return 1
|
|
5013
|
+
return 0
|
|
5014
|
+
}
|
|
4926
5015
|
# (b), the swap's own state: is the app at the target path AT OR BEYOND the
|
|
4927
5016
|
# version this update was for? plutil rather than \`defaults read\`, which reads
|
|
4928
5017
|
# through a preference domain and can answer from a stale cache; a half-written
|
|
@@ -5048,11 +5137,22 @@ decided() {
|
|
|
5048
5137
|
return 1
|
|
5049
5138
|
}
|
|
5050
5139
|
deadline=$(( $(now) + ${timeoutSeconds} ))
|
|
5140
|
+
hard_deadline=$(( $(now) + ${hardTimeoutSeconds} ))
|
|
5051
5141
|
# 1. The install only starts once the old app has exited.
|
|
5052
5142
|
while app_running; do
|
|
5053
5143
|
if [ "$(now)" -ge "$deadline" ]; then break; fi
|
|
5054
5144
|
sleep ${intervalSeconds}
|
|
5055
5145
|
done
|
|
5146
|
+
# 1b. The app is gone, so an install is at work: say so a few seconds in, so the
|
|
5147
|
+
# window that just vanished comes with an explanation. This is the moment the
|
|
5148
|
+
# operator of 2026-09-13 was left guessing, and reopening the app four
|
|
5149
|
+
# minutes later is what aborted their install. Skipped when the app is still
|
|
5150
|
+
# running - a quit the user cancelled, where nothing is being installed and
|
|
5151
|
+
# nothing should be claimed.
|
|
5152
|
+
if ! app_running; then
|
|
5153
|
+
sleep ${announceSeconds}
|
|
5154
|
+
notify "Installing the update. Keep Local Operator closed until it opens again by itself — this can take a few minutes."
|
|
5155
|
+
fi
|
|
5056
5156
|
# 2. ShipIt's job is submitted as part of the quit, so it may not be loaded the
|
|
5057
5157
|
# instant the app is gone: give it a bounded window to appear before treating
|
|
5058
5158
|
# "no job" as "the install is over". Skipped when there is no label to ask
|
|
@@ -5066,12 +5166,48 @@ if [ "$job_known" -eq 1 ]; then
|
|
|
5066
5166
|
done
|
|
5067
5167
|
fi
|
|
5068
5168
|
# 3. Wait for the install to be decided - by the job going, or by the swap
|
|
5069
|
-
# landing
|
|
5169
|
+
# landing. The soft bound ends this wait only when there is no loaded job
|
|
5170
|
+
# answering for the install: see the hold below.
|
|
5171
|
+
holding=0
|
|
5070
5172
|
while :; do
|
|
5071
5173
|
if decided; then break; fi
|
|
5072
|
-
if [ "$(now)" -ge "$deadline" ]; then
|
|
5174
|
+
if [ "$(now)" -ge "$deadline" ]; then
|
|
5175
|
+
if [ "$job_known" -eq 1 ] && shipit_loaded; then
|
|
5176
|
+
holding=1
|
|
5177
|
+
notify "The update is still installing. Keep Local Operator closed; it will open again when the install finishes."
|
|
5178
|
+
fi
|
|
5179
|
+
break
|
|
5180
|
+
fi
|
|
5073
5181
|
sleep ${intervalSeconds}
|
|
5074
5182
|
done
|
|
5183
|
+
# 3b. The hold. The soft bound arrived with the install's job still loaded, so
|
|
5184
|
+
# the install is alive and a launch here is what aborts it: Squirrel's last
|
|
5185
|
+
# question before it swaps is whether any instance of the app is running,
|
|
5186
|
+
# and on 2026-09-13 our own relaunch was the instance that answered yes. The
|
|
5187
|
+
# job going or the swap landing still ends this at once - both are decisions
|
|
5188
|
+
# the install made - and otherwise the hard bound does, which is where
|
|
5189
|
+
# "better a live swap than no app at all" takes over.
|
|
5190
|
+
#
|
|
5191
|
+
# The hard bound's notice names the action the panel will ask for instead of
|
|
5192
|
+
# a different one. What the user sees next is the app coming back with the
|
|
5193
|
+
# in-flight panel, whose copy is "quit and leave it closed until the app
|
|
5194
|
+
# opens again by itself" - so a notice that ended "check for updates when it
|
|
5195
|
+
# is back" told them the install was over at the exact moment the app was
|
|
5196
|
+
# about to say it was still running, and two authorities seconds apart with
|
|
5197
|
+
# opposite instructions is worse than either alone (UX U10). The notice now
|
|
5198
|
+
# states what is happening and hands over to that panel rather than
|
|
5199
|
+
# pre-empting it: the instruction the user acts on is the panel's, and it is
|
|
5200
|
+
# the correct one - quitting again is what lets the swap finish.
|
|
5201
|
+
if [ "$holding" -eq 1 ]; then
|
|
5202
|
+
while :; do
|
|
5203
|
+
if decided; then break; fi
|
|
5204
|
+
if [ "$(now)" -ge "$hard_deadline" ]; then
|
|
5205
|
+
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."
|
|
5206
|
+
break
|
|
5207
|
+
fi
|
|
5208
|
+
sleep ${intervalSeconds}
|
|
5209
|
+
done
|
|
5210
|
+
fi
|
|
5075
5211
|
# Let Squirrel's own relaunch (which happens as the job finishes) land first.
|
|
5076
5212
|
sleep ${settleSeconds}
|
|
5077
5213
|
# 4. Nothing to do if Squirrel relaunched the app or the user started it. This is
|
|
@@ -5096,9 +5232,46 @@ exit 0
|
|
|
5096
5232
|
LO_UPDATE_WATCHDOG_SHIPIT_PROBE: signals.jobProbe ?? "",
|
|
5097
5233
|
LO_UPDATE_WATCHDOG_PLIST_READER: signals.plistReader ?? ""
|
|
5098
5234
|
},
|
|
5099
|
-
timeoutSeconds
|
|
5235
|
+
timeoutSeconds,
|
|
5236
|
+
hardTimeoutSeconds
|
|
5237
|
+
};
|
|
5238
|
+
}
|
|
5239
|
+
function removeStagedTree(input) {
|
|
5240
|
+
const attempts = input.attempts ?? 3;
|
|
5241
|
+
const delayMs = input.delayMs ?? 250;
|
|
5242
|
+
const sleep = input.sleep ?? defaultSleep;
|
|
5243
|
+
let error = null;
|
|
5244
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
5245
|
+
try {
|
|
5246
|
+
input.remove(input.path);
|
|
5247
|
+
} catch (thrown) {
|
|
5248
|
+
error = String(thrown);
|
|
5249
|
+
}
|
|
5250
|
+
if (!input.exists(input.path)) {
|
|
5251
|
+
return { removed: true, attempts: attempt, error: null };
|
|
5252
|
+
}
|
|
5253
|
+
if (attempt < attempts) sleep(delayMs);
|
|
5254
|
+
}
|
|
5255
|
+
return {
|
|
5256
|
+
removed: false,
|
|
5257
|
+
attempts,
|
|
5258
|
+
error: error ?? `the staged tree at ${input.path} is still there`
|
|
5100
5259
|
};
|
|
5101
5260
|
}
|
|
5261
|
+
function defaultSleep(ms) {
|
|
5262
|
+
if (ms <= 0) return;
|
|
5263
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
5264
|
+
}
|
|
5265
|
+
function reapStagedTree(path2) {
|
|
5266
|
+
const removal = removeStagedTree({
|
|
5267
|
+
path: path2,
|
|
5268
|
+
remove: (target2) => fs.rmSync(target2, { recursive: true, force: true }),
|
|
5269
|
+
exists: (target2) => fs.existsSync(target2)
|
|
5270
|
+
});
|
|
5271
|
+
if (!removal.removed) {
|
|
5272
|
+
throw new Error(removal.error ?? `could not remove ${path2}`);
|
|
5273
|
+
}
|
|
5274
|
+
}
|
|
5102
5275
|
function reapFailedInstall(input) {
|
|
5103
5276
|
const result = {
|
|
5104
5277
|
jobLabel: null,
|
|
@@ -5414,6 +5587,7 @@ const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
|
|
|
5414
5587
|
const VERSION_CLEAN_REGEX = /^v/i;
|
|
5415
5588
|
const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
|
|
5416
5589
|
const BUNDLE_ID_REGEX = /^[\w.-]+$/;
|
|
5590
|
+
const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
|
|
5417
5591
|
const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
|
|
5418
5592
|
const SEAL_PROBE_TIMEOUT_MS = 45e3;
|
|
5419
5593
|
function versionSuffix(version) {
|
|
@@ -5498,6 +5672,38 @@ class UpdateService {
|
|
|
5498
5672
|
/** A failed install detected from the marker on this start, if any. */
|
|
5499
5673
|
pendingInstallFailure = null;
|
|
5500
5674
|
installFailureDelivered = false;
|
|
5675
|
+
/**
|
|
5676
|
+
* An install this process found still running, if any.
|
|
5677
|
+
*
|
|
5678
|
+
* The opposite fact to `pendingInstallFailure`, and kept apart from it: this
|
|
5679
|
+
* one means the update is still in progress and the user has to leave the app
|
|
5680
|
+
* closed for it to finish. Nothing may be cleared, reaped or reported while
|
|
5681
|
+
* it is up.
|
|
5682
|
+
*/
|
|
5683
|
+
pendingInstallInFlight = null;
|
|
5684
|
+
installInFlightDelivered = false;
|
|
5685
|
+
/**
|
|
5686
|
+
* True once this process has seen its own install run.
|
|
5687
|
+
*
|
|
5688
|
+
* It is what lets a later failure name the real cause: an install this app
|
|
5689
|
+
* watched start and then not finish, on a machine where the app was open for
|
|
5690
|
+
* part of it, was cancelled by that relaunch (Squirrel asks whether any
|
|
5691
|
+
* instance is running before it swaps), and saying "the update didn't finish"
|
|
5692
|
+
* there sends the user back to retry the one thing that cannot work.
|
|
5693
|
+
*/
|
|
5694
|
+
installWasInFlight = false;
|
|
5695
|
+
/** The pending re-check of a live install, while the app is open. */
|
|
5696
|
+
installInFlightTimer = null;
|
|
5697
|
+
/**
|
|
5698
|
+
* True once a quit following an in-flight install has made sure the app will
|
|
5699
|
+
* come back.
|
|
5700
|
+
*
|
|
5701
|
+
* The two callers are the same quit seen twice - `window-all-closed` decides
|
|
5702
|
+
* and `before-quit` runs as it goes through - so without this the launchd
|
|
5703
|
+
* probe, the `ps` read and the "no watchdog is running" log all happen twice
|
|
5704
|
+
* for one quit.
|
|
5705
|
+
*/
|
|
5706
|
+
inFlightQuitWatchdogEnsured = false;
|
|
5501
5707
|
/**
|
|
5502
5708
|
* True while `quit-and-install` is running its pre-flight.
|
|
5503
5709
|
*
|
|
@@ -5615,12 +5821,27 @@ class UpdateService {
|
|
|
5615
5821
|
* update did not happen. Everything the user learns afterwards starts here:
|
|
5616
5822
|
* the notice, the durable record it can be found in later, and the cleanup of
|
|
5617
5823
|
* what Squirrel left running.
|
|
5824
|
+
*
|
|
5825
|
+
* The one case that must NOT be read as a failure is the install that is still
|
|
5826
|
+
* running, and it is the 2026-09-13 incident: the app comes back mid-install
|
|
5827
|
+
* on the old version, because the swap has not happened yet. That used to
|
|
5828
|
+
* clear the marker, remove the very job doing the installing, and tell the
|
|
5829
|
+
* user the update had failed - two seconds before ShipIt aborted the install
|
|
5830
|
+
* on its own final check. So the job is probed first, a live install is left
|
|
5831
|
+
* alone entirely, and the re-check below reports what really happens to it.
|
|
5618
5832
|
*/
|
|
5619
5833
|
recoverPendingInstall() {
|
|
5620
5834
|
const marker = readPendingInstallMarker(this.markerDir());
|
|
5621
5835
|
const outcome = evaluatePendingInstall({
|
|
5622
5836
|
marker,
|
|
5623
|
-
runningVersion: electron.app.getVersion()
|
|
5837
|
+
runningVersion: electron.app.getVersion(),
|
|
5838
|
+
// Probed once per evaluation, and only on macOS: a loaded job plus a
|
|
5839
|
+
// current marker is what makes "still on the old version" an install in
|
|
5840
|
+
// progress rather than an install that failed.
|
|
5841
|
+
installInFlight: isInstallInFlight({
|
|
5842
|
+
marker,
|
|
5843
|
+
jobLoaded: this.shipItInstallJobLoaded()
|
|
5844
|
+
})
|
|
5624
5845
|
});
|
|
5625
5846
|
switch (outcome.kind) {
|
|
5626
5847
|
case "none":
|
|
@@ -5641,27 +5862,53 @@ class UpdateService {
|
|
|
5641
5862
|
this.reapWatchdog(outcome.marker, true);
|
|
5642
5863
|
clearPendingInstallMarker(this.markerDir());
|
|
5643
5864
|
return;
|
|
5865
|
+
case "in-flight": {
|
|
5866
|
+
this.installWasInFlight = true;
|
|
5867
|
+
logger.info(
|
|
5868
|
+
`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.`,
|
|
5869
|
+
LogFileType.UPDATE_SERVICE
|
|
5870
|
+
);
|
|
5871
|
+
this.pendingInstallInFlight = installInFlightPayload(
|
|
5872
|
+
outcome.marker,
|
|
5873
|
+
electron.app.getVersion()
|
|
5874
|
+
);
|
|
5875
|
+
this.scheduleInstallInFlightDelivery();
|
|
5876
|
+
this.scheduleInstallInFlightRecheck();
|
|
5877
|
+
return;
|
|
5878
|
+
}
|
|
5644
5879
|
case "failed": {
|
|
5645
5880
|
const shipItLogPath = this.shipItLog();
|
|
5646
5881
|
const record = recordInstallFailure(this.markerDir(), {
|
|
5647
5882
|
payload: installFailurePayload(outcome.marker, electron.app.getVersion(), {
|
|
5648
|
-
shipItLogPath
|
|
5883
|
+
shipItLogPath,
|
|
5884
|
+
cancelledByRelaunch: this.installWasInFlight
|
|
5649
5885
|
}),
|
|
5886
|
+
// The record names the version that is running now, which is the same
|
|
5887
|
+
// answer the payload above was built from: Settings renders it as
|
|
5888
|
+
// "Version X is running", and it used to carry the previous failure's
|
|
5889
|
+
// version forward instead (QA Q1).
|
|
5890
|
+
runningVersion: electron.app.getVersion(),
|
|
5650
5891
|
startedAt: outcome.marker.startedAt || null,
|
|
5651
5892
|
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5652
5893
|
});
|
|
5653
5894
|
const payload = installFailurePayload(
|
|
5654
5895
|
outcome.marker,
|
|
5655
5896
|
electron.app.getVersion(),
|
|
5656
|
-
{
|
|
5897
|
+
{
|
|
5898
|
+
attempts: record.attempts,
|
|
5899
|
+
shipItLogPath,
|
|
5900
|
+
// An install this process watched run and then not finish, with the
|
|
5901
|
+
// app open for part of it, was cancelled by that relaunch.
|
|
5902
|
+
cancelledByRelaunch: this.installWasInFlight
|
|
5903
|
+
}
|
|
5657
5904
|
);
|
|
5658
5905
|
logger.error(
|
|
5659
5906
|
`Update marker: install of version ${outcome.marker.targetVersion} did not complete (attempt ${record.attempts}). ${payload.detail}`,
|
|
5660
5907
|
LogFileType.UPDATE_SERVICE
|
|
5661
5908
|
);
|
|
5662
5909
|
this.pendingInstallFailure = payload;
|
|
5910
|
+
this.stopInstallInFlightRecheck();
|
|
5663
5911
|
this.reapWatchdog(outcome.marker, false);
|
|
5664
|
-
clearPendingInstallMarker(this.markerDir());
|
|
5665
5912
|
this.reapFailedInstallLeftovers();
|
|
5666
5913
|
this.schedulePendingInstallFailureDelivery();
|
|
5667
5914
|
}
|
|
@@ -5742,7 +5989,13 @@ class UpdateService {
|
|
|
5742
5989
|
},
|
|
5743
5990
|
exists: (path2) => fs.existsSync(path2),
|
|
5744
5991
|
listDir: (dir) => fs.readdirSync(dir),
|
|
5745
|
-
|
|
5992
|
+
// Retried, because the draw this cleans up is a race: on 2026-09-13
|
|
5993
|
+
// ShipIt was moving this very tree into place while the reap walked
|
|
5994
|
+
// it, and the rmdir it lost surfaced as ENOTDIR - which `rmSync`
|
|
5995
|
+
// does not retry (see `removeStagedTree`). A tree that cannot be
|
|
5996
|
+
// removed after that is still reported, because it is ~1 GiB the
|
|
5997
|
+
// user's disk is paying for.
|
|
5998
|
+
removeDir: (dir) => reapStagedTree(dir),
|
|
5746
5999
|
log
|
|
5747
6000
|
});
|
|
5748
6001
|
if (result.errors.length > 0) {
|
|
@@ -5761,9 +6014,12 @@ class UpdateService {
|
|
|
5761
6014
|
/**
|
|
5762
6015
|
* Deliver the failed-install notice once the renderer can hear it.
|
|
5763
6016
|
*
|
|
5764
|
-
* The
|
|
5765
|
-
* on
|
|
5766
|
-
* a
|
|
6017
|
+
* The single delivery path for every install failure, read at start-up or
|
|
6018
|
+
* found on a re-check: the marker is read while the window is still loading
|
|
6019
|
+
* (and, on a re-check, the window is up but the React effect that subscribes
|
|
6020
|
+
* is not provably past), so the push happens on `did-finish-load` with a
|
|
6021
|
+
* delayed fallback. One path rather than two is what keeps "delivered"
|
|
6022
|
+
* meaning the same thing in both cases (review R2).
|
|
5767
6023
|
*/
|
|
5768
6024
|
schedulePendingInstallFailureDelivery() {
|
|
5769
6025
|
if (!this.pendingInstallFailure) return;
|
|
@@ -5780,11 +6036,190 @@ class UpdateService {
|
|
|
5780
6036
|
return;
|
|
5781
6037
|
}
|
|
5782
6038
|
this.installFailureDelivered = true;
|
|
6039
|
+
this.pendingInstallInFlight = null;
|
|
6040
|
+
clearPendingInstallMarker(this.markerDir());
|
|
5783
6041
|
logger.info(
|
|
5784
6042
|
"Reported a failed update install to the renderer",
|
|
5785
6043
|
LogFileType.UPDATE_SERVICE
|
|
5786
6044
|
);
|
|
5787
6045
|
}
|
|
6046
|
+
/**
|
|
6047
|
+
* A substituted answer to "is this app's install job loaded", or null.
|
|
6048
|
+
*
|
|
6049
|
+
* The production answer is the launchd probe in `shipItInstallJobLoaded`, and
|
|
6050
|
+
* it asks about a label read from the RUNNING BUNDLE (`shipItJob`). A test or a
|
|
6051
|
+
* harness process that is not that bundle has no label to ask about, so the
|
|
6052
|
+
* whole in-flight path - recovery's reading, the re-check, and the quit below -
|
|
6053
|
+
* is otherwise unreachable from a suite. This is the same shape as the
|
|
6054
|
+
* watchdog's own probe, which is invoked by name precisely so a harness can
|
|
6055
|
+
* substitute it: one seam for the whole answer, so what a test drives is the
|
|
6056
|
+
* rule production uses rather than a second copy of it. Production never sets
|
|
6057
|
+
* it.
|
|
6058
|
+
*/
|
|
6059
|
+
installJobLoadedProbe = null;
|
|
6060
|
+
/**
|
|
6061
|
+
* Whether this app's ShipIt install job is loaded, right now.
|
|
6062
|
+
*
|
|
6063
|
+
* This is the machine's own answer to "is an install in flight", and the only
|
|
6064
|
+
* answer that can tell a live install apart from the job a failed one left
|
|
6065
|
+
* behind. It is asked of launchd by label rather than inferred from a process
|
|
6066
|
+
* name, so nothing else on the machine can be mistaken for it. Off macOS there
|
|
6067
|
+
* is no launchd to ask, so the answer is a plain no and recovery stays
|
|
6068
|
+
* version-only there, exactly as it is today.
|
|
6069
|
+
*/
|
|
6070
|
+
shipItInstallJobLoaded() {
|
|
6071
|
+
if (this.installJobLoadedProbe) return this.installJobLoadedProbe();
|
|
6072
|
+
const jobProbe = watchdogSignals(process.platform).jobProbe;
|
|
6073
|
+
if (!jobProbe) return false;
|
|
6074
|
+
return launchdJobLoaded(this.shipItJob(), (label) => {
|
|
6075
|
+
const result = node_child_process.spawnSync(jobProbe, ["list", label], {
|
|
6076
|
+
encoding: "utf8",
|
|
6077
|
+
timeout: 5e3
|
|
6078
|
+
});
|
|
6079
|
+
return result.error || result.status == null ? null : result.status;
|
|
6080
|
+
});
|
|
6081
|
+
}
|
|
6082
|
+
/**
|
|
6083
|
+
* Deliver the still-installing notice once the renderer can hear it.
|
|
6084
|
+
*
|
|
6085
|
+
* Same shape and the same reason as the failure's delivery: the marker is read
|
|
6086
|
+
* while the window is still loading, and the component subscribes from a React
|
|
6087
|
+
* effect that can run after `did-finish-load`.
|
|
6088
|
+
*/
|
|
6089
|
+
scheduleInstallInFlightDelivery() {
|
|
6090
|
+
if (!this.pendingInstallInFlight || this.installInFlightDelivered) return;
|
|
6091
|
+
const deliver = () => this.deliverPendingInstallInFlight();
|
|
6092
|
+
const webContents = this.mainWindow?.webContents;
|
|
6093
|
+
if (webContents && !webContents.isDestroyed()) {
|
|
6094
|
+
webContents.once("did-finish-load", deliver);
|
|
6095
|
+
}
|
|
6096
|
+
setTimeout(deliver, 5e3);
|
|
6097
|
+
}
|
|
6098
|
+
deliverPendingInstallInFlight() {
|
|
6099
|
+
if (!this.pendingInstallInFlight || this.installInFlightDelivered) return;
|
|
6100
|
+
if (!this.sendToRenderer(
|
|
6101
|
+
"update-install-in-flight",
|
|
6102
|
+
this.pendingInstallInFlight
|
|
6103
|
+
)) {
|
|
6104
|
+
return;
|
|
6105
|
+
}
|
|
6106
|
+
this.installInFlightDelivered = true;
|
|
6107
|
+
logger.info(
|
|
6108
|
+
"Reported an install still in flight to the renderer",
|
|
6109
|
+
LogFileType.UPDATE_SERVICE
|
|
6110
|
+
);
|
|
6111
|
+
}
|
|
6112
|
+
/**
|
|
6113
|
+
* Re-check a live install while the app stays open, until it settles.
|
|
6114
|
+
*
|
|
6115
|
+
* The panel that says "the update is still installing" cannot be the end of
|
|
6116
|
+
* the story: ShipIt decides within a couple of minutes of the app coming back
|
|
6117
|
+
* (it asks whether any instance is running as its final validation), and the
|
|
6118
|
+
* user has to hear what that decision was. Re-evaluating is what produces it -
|
|
6119
|
+
* the job no longer loaded means the install is over, and the marker plus the
|
|
6120
|
+
* old running version then say truthfully that it was cancelled. The loop ends
|
|
6121
|
+
* by itself: once the marker is older than
|
|
6122
|
+
* `PENDING_INSTALL_RECENCY_SECONDS` nothing calls it current, the next
|
|
6123
|
+
* evaluation is a failure, and that branch stops the timer.
|
|
6124
|
+
*/
|
|
6125
|
+
scheduleInstallInFlightRecheck() {
|
|
6126
|
+
if (this.installInFlightTimer) return;
|
|
6127
|
+
this.installInFlightTimer = setTimeout(() => {
|
|
6128
|
+
this.installInFlightTimer = null;
|
|
6129
|
+
this.recoverPendingInstall();
|
|
6130
|
+
}, INSTALL_IN_FLIGHT_RECHECK_MS);
|
|
6131
|
+
this.installInFlightTimer.unref();
|
|
6132
|
+
}
|
|
6133
|
+
stopInstallInFlightRecheck() {
|
|
6134
|
+
if (!this.installInFlightTimer) return;
|
|
6135
|
+
clearTimeout(this.installInFlightTimer);
|
|
6136
|
+
this.installInFlightTimer = null;
|
|
6137
|
+
}
|
|
6138
|
+
/**
|
|
6139
|
+
* Make sure something will start the app again after a quit that is only
|
|
6140
|
+
* getting out of an install's way.
|
|
6141
|
+
*
|
|
6142
|
+
* The watchdog from the original install is usually still there - it holds
|
|
6143
|
+
* while the job is loaded now, rather than starting the app into the swap -
|
|
6144
|
+
* but it may have run out its own bounds, and the user may be quitting hours
|
|
6145
|
+
* later. Quitting without one would leave them with no app at all, which is
|
|
6146
|
+
* the outcome this whole change exists to remove. Nothing is started when ours
|
|
6147
|
+
* is alive, because two watchdogs relaunching at the same moment is a race
|
|
6148
|
+
* with nothing to gain. The marker is left exactly as it is: its start time is
|
|
6149
|
+
* what tells recovery this is still the same install.
|
|
6150
|
+
*/
|
|
6151
|
+
ensureWatchdogAfterInFlightQuit(marker) {
|
|
6152
|
+
const pid = marker.watchdogPid;
|
|
6153
|
+
const commandLine = pid == null ? null : readCommandOutput("/bin/ps", ["-o", "command=", "-p", String(pid)]);
|
|
6154
|
+
if (watchdogIsOurs({
|
|
6155
|
+
alive: commandLine != null,
|
|
6156
|
+
commandLine,
|
|
6157
|
+
installSucceeded: true
|
|
6158
|
+
})) {
|
|
6159
|
+
logger.info(
|
|
6160
|
+
`Relaunch watchdog ${pid} is still running; it will start the app when the install settles.`,
|
|
6161
|
+
LogFileType.UPDATE_SERVICE
|
|
6162
|
+
);
|
|
6163
|
+
return;
|
|
6164
|
+
}
|
|
6165
|
+
const launched = this.launchWatchdog(marker.targetVersion);
|
|
6166
|
+
logger.info(
|
|
6167
|
+
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.",
|
|
6168
|
+
LogFileType.UPDATE_SERVICE
|
|
6169
|
+
);
|
|
6170
|
+
}
|
|
6171
|
+
/**
|
|
6172
|
+
* The pending-install marker, if the machine says an install is live now.
|
|
6173
|
+
*
|
|
6174
|
+
* The marker is read first, because the ordinary answer to "is an update
|
|
6175
|
+
* installing" is no and a file read answers it; the launchd probe behind it is
|
|
6176
|
+
* a process spawn and is only worth paying for when there is a marker to ask
|
|
6177
|
+
* about.
|
|
6178
|
+
*/
|
|
6179
|
+
livePendingInstallMarker() {
|
|
6180
|
+
const marker = readPendingInstallMarker(this.markerDir());
|
|
6181
|
+
if (!marker) return null;
|
|
6182
|
+
return isInstallInFlight({
|
|
6183
|
+
marker,
|
|
6184
|
+
jobLoaded: this.shipItInstallJobLoaded()
|
|
6185
|
+
}) ? marker : null;
|
|
6186
|
+
}
|
|
6187
|
+
/**
|
|
6188
|
+
* Treat a quit as a quit for an install that is in flight, and make sure
|
|
6189
|
+
* something will start the app again.
|
|
6190
|
+
*
|
|
6191
|
+
* Two gestures reach this, and a user believes both are harmless: the macOS
|
|
6192
|
+
* close-the-last-window close, and any ordinary quit (Cmd+Q, the dock, the
|
|
6193
|
+
* menu). `window-all-closed` on macOS deliberately leaves the app running with
|
|
6194
|
+
* no window, which is right for every ordinary close - but a running instance
|
|
6195
|
+
* of this app is exactly what Squirrel's final validation aborts an install on,
|
|
6196
|
+
* so a user who closes the window during an install has cancelled their update
|
|
6197
|
+
* without being told, and reached the incident this whole change exists to
|
|
6198
|
+
* remove by the platform's most familiar gesture (UX U1). The relaunch promise
|
|
6199
|
+
* was also button-shaped: a plain quit while an install was in flight left it
|
|
6200
|
+
* resting on whatever watchdog the install happened to still have (UX U2).
|
|
6201
|
+
*
|
|
6202
|
+
* The probe is the machine's answer rather than the renderer's, so this holds
|
|
6203
|
+
* even when the panel was never drawn - the app can come back mid-install and
|
|
6204
|
+
* have its last window closed before the renderer has heard anything.
|
|
6205
|
+
*
|
|
6206
|
+
* Returns true when an install was in flight, so a caller that is deciding
|
|
6207
|
+
* whether to quit knows the close was taken over. Nothing here touches the
|
|
6208
|
+
* marker, the install's job or the failure record: this only gets the app out
|
|
6209
|
+
* of Squirrel's way and makes sure it comes back.
|
|
6210
|
+
*/
|
|
6211
|
+
quitForInFlightInstall(context) {
|
|
6212
|
+
const marker = this.livePendingInstallMarker();
|
|
6213
|
+
if (!marker) return false;
|
|
6214
|
+
if (this.inFlightQuitWatchdogEnsured) return true;
|
|
6215
|
+
this.inFlightQuitWatchdogEnsured = true;
|
|
6216
|
+
logger.info(
|
|
6217
|
+
`Quitting so the in-flight update install can finish (${context}).`,
|
|
6218
|
+
LogFileType.UPDATE_SERVICE
|
|
6219
|
+
);
|
|
6220
|
+
this.ensureWatchdogAfterInFlightQuit(marker);
|
|
6221
|
+
return true;
|
|
6222
|
+
}
|
|
5788
6223
|
/**
|
|
5789
6224
|
* Kill a watchdog still running from the recorded install attempt.
|
|
5790
6225
|
*
|
|
@@ -6092,7 +6527,13 @@ ${heal.removed.join("\n")}`,
|
|
|
6092
6527
|
targetVersion: watchdogSwapTarget({
|
|
6093
6528
|
target: targetVersion,
|
|
6094
6529
|
running: electron.app.getVersion()
|
|
6095
|
-
})
|
|
6530
|
+
}),
|
|
6531
|
+
// Stated rather than read off `process.platform` at the plan: this
|
|
6532
|
+
// watchdog exists for Squirrel.Mac's ShipIt, the guard above already
|
|
6533
|
+
// refuses it anywhere else, and the script's two probes (launchd's job
|
|
6534
|
+
// lookup, `plutil`) are generated for the platform the script runs on
|
|
6535
|
+
// rather than for whichever host planned it (see `watchdogSignals`).
|
|
6536
|
+
platform: "darwin"
|
|
6096
6537
|
});
|
|
6097
6538
|
try {
|
|
6098
6539
|
const child = node_child_process.spawn("sh", ["-c", plan.script], {
|
|
@@ -6102,7 +6543,7 @@ ${heal.removed.join("\n")}`,
|
|
|
6102
6543
|
});
|
|
6103
6544
|
child.unref();
|
|
6104
6545
|
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.`,
|
|
6546
|
+
`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
6547
|
LogFileType.UPDATE_SERVICE
|
|
6107
6548
|
);
|
|
6108
6549
|
return child.pid ?? null;
|
|
@@ -6125,6 +6566,7 @@ ${heal.removed.join("\n")}`,
|
|
|
6125
6566
|
clearInterval(this.updateCheckInterval);
|
|
6126
6567
|
this.updateCheckInterval = null;
|
|
6127
6568
|
}
|
|
6569
|
+
this.stopInstallInFlightRecheck();
|
|
6128
6570
|
electronUpdater.autoUpdater.removeAllListeners();
|
|
6129
6571
|
electron.ipcMain.removeHandler("check-for-updates");
|
|
6130
6572
|
electron.ipcMain.removeHandler("check-for-backend-updates");
|
|
@@ -6133,6 +6575,7 @@ ${heal.removed.join("\n")}`,
|
|
|
6133
6575
|
electron.ipcMain.removeHandler("update-backend");
|
|
6134
6576
|
electron.ipcMain.removeHandler("download-update");
|
|
6135
6577
|
electron.ipcMain.removeHandler("quit-and-install");
|
|
6578
|
+
electron.ipcMain.removeHandler("quit-for-update-install");
|
|
6136
6579
|
this.mainWindow = null;
|
|
6137
6580
|
}
|
|
6138
6581
|
/**
|
|
@@ -6497,6 +6940,19 @@ ${heal.removed.join("\n")}`,
|
|
|
6497
6940
|
electronUpdater.autoUpdater.quitAndInstall(false, true);
|
|
6498
6941
|
return true;
|
|
6499
6942
|
});
|
|
6943
|
+
electron.ipcMain.handle("quit-for-update-install", () => {
|
|
6944
|
+
const marker = readPendingInstallMarker(this.markerDir());
|
|
6945
|
+
logger.info(
|
|
6946
|
+
"Quitting so the in-flight update install can finish.",
|
|
6947
|
+
LogFileType.UPDATE_SERVICE
|
|
6948
|
+
);
|
|
6949
|
+
if (marker && !this.inFlightQuitWatchdogEnsured) {
|
|
6950
|
+
this.inFlightQuitWatchdogEnsured = true;
|
|
6951
|
+
this.ensureWatchdogAfterInFlightQuit(marker);
|
|
6952
|
+
}
|
|
6953
|
+
setImmediate(() => electron.app.quit());
|
|
6954
|
+
return true;
|
|
6955
|
+
});
|
|
6500
6956
|
}
|
|
6501
6957
|
/**
|
|
6502
6958
|
* Check for updates
|
|
@@ -7658,6 +8114,19 @@ function createApplicationMenu() {
|
|
|
7658
8114
|
...process.platform === "darwin" ? [
|
|
7659
8115
|
{ type: "separator" },
|
|
7660
8116
|
{ role: "front" },
|
|
8117
|
+
/*
|
|
8118
|
+
* `close` is what gives the window the standard Cmd+W, and on macOS
|
|
8119
|
+
* it is not implied by anything else here: the Window menu carried
|
|
8120
|
+
* `minimize`/`zoom`/`front` and no close item, so the gesture the
|
|
8121
|
+
* in-flight update panel invites ("quit and leave it closed until
|
|
8122
|
+
* the app opens again by itself") resolved to nothing at all - the
|
|
8123
|
+
* red button worked and the keyboard did not (UX U9). It belongs
|
|
8124
|
+
* here rather than in File because the app's own File menu holds a
|
|
8125
|
+
* lone `quit`, and this gesture has to reach the same
|
|
8126
|
+
* `window-all-closed` path the red button takes, which is the one
|
|
8127
|
+
* this change made safe for an install in flight.
|
|
8128
|
+
*/
|
|
8129
|
+
{ role: "close" },
|
|
7661
8130
|
{ type: "separator" },
|
|
7662
8131
|
{ role: "window" }
|
|
7663
8132
|
] : [{ role: "close" }]
|
|
@@ -7851,6 +8320,7 @@ if (windowLaunch.mode !== "normal") {
|
|
|
7851
8320
|
console.log(`[window-mode] ${describeWindowLaunch(windowLaunch)}`);
|
|
7852
8321
|
}
|
|
7853
8322
|
let mainWindow = null;
|
|
8323
|
+
let activeUpdateService = null;
|
|
7854
8324
|
const zoomInFromEvent = () => {
|
|
7855
8325
|
if (mainWindow) {
|
|
7856
8326
|
const webContents = mainWindow.webContents;
|
|
@@ -8251,6 +8721,7 @@ electron.app.whenReady().then(async () => {
|
|
|
8251
8721
|
updateService = null;
|
|
8252
8722
|
}
|
|
8253
8723
|
updateService = new UpdateService(mainWindow, backendService);
|
|
8724
|
+
activeUpdateService = updateService;
|
|
8254
8725
|
mainWindow.on("closed", () => {
|
|
8255
8726
|
if (updateService) {
|
|
8256
8727
|
updateService.dispose();
|
|
@@ -8291,6 +8762,10 @@ electron.app.whenReady().then(async () => {
|
|
|
8291
8762
|
});
|
|
8292
8763
|
electron.app.on("window-all-closed", () => {
|
|
8293
8764
|
if (process.platform === "darwin") {
|
|
8765
|
+
if (activeUpdateService?.quitForInFlightInstall("last window closed")) {
|
|
8766
|
+
electron.app.quit();
|
|
8767
|
+
return;
|
|
8768
|
+
}
|
|
8294
8769
|
logger.info(
|
|
8295
8770
|
"All windows closed, but keeping app active (macOS platform)",
|
|
8296
8771
|
LogFileType.BACKEND
|
|
@@ -8469,6 +8944,7 @@ electron.app.on("will-quit", async (event) => {
|
|
|
8469
8944
|
electron.app.on("before-quit", () => {
|
|
8470
8945
|
logger.info("App is about to quit", LogFileType.BACKEND);
|
|
8471
8946
|
electron.globalShortcut.unregisterAll();
|
|
8947
|
+
activeUpdateService?.quitForInFlightInstall("app quit");
|
|
8472
8948
|
});
|
|
8473
8949
|
process.on("exit", () => {
|
|
8474
8950
|
logger.info(
|