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.
Files changed (58) hide show
  1. package/out/main/index.js +503 -27
  2. package/out/preload/index.js +23 -0
  3. package/out/renderer/assets/{_basePickBy-BrhuNY3Y.js → _basePickBy-DCkhfufs.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-d4I8BFB7.js → _baseUniq-CbXRn6Sz.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-DvPCamZn.js → agent-details-page-CzZG1eTh.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-MU5bf3FU.js → agent-hub-page-BUlhFKrQ.js} +1 -1
  7. package/out/renderer/assets/{agents-page-DiUW3c4r.js → agents-page-BChq9qh8.js} +2 -2
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CmxQBEkE.js → architectureDiagram-IEHRJDOE-CUcZIqGz.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-jEzBjOVv.js → blockDiagram-JOT3LUYC-C8iWDIl7.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-DPNdf3uy.js → c4Diagram-VJAJSXHY-Rf7kjwb8.js} +1 -1
  11. package/out/renderer/assets/channel-C0H6vNPZ.js +1 -0
  12. package/out/renderer/assets/{chunk-4BMEZGHF-BjZRVy55.js → chunk-4BMEZGHF-B8xwSed9.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-CjiEaNik.js → chunk-A2AXSNBT-Cd443Eh9.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-B8sYsVL2.js → chunk-AEK57VVT-CfWs2qUA.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-B-1n06LH.js → chunk-D6G4REZN-DQS_tD7n.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-C-fq3iE3.js → chunk-RZ5BOZE2-GGjWUC8K.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-fVK9vXqX.js → chunk-XZIHB7SX-C_z5Swpc.js} +1 -1
  18. package/out/renderer/assets/{classDiagram-GIVACNV2-D38qMrkN.js → classDiagram-GIVACNV2-CK43jL1V.js} +1 -1
  19. package/out/renderer/assets/{classDiagram-v2-COTLJTTW-D38qMrkN.js → classDiagram-v2-COTLJTTW-CK43jL1V.js} +1 -1
  20. package/out/renderer/assets/clone-C7ZRecHq.js +1 -0
  21. package/out/renderer/assets/{compact-pagination-CQOvYQ3X.js → compact-pagination-BoBK24RF.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-DzX-_BZ7.js → dagre-OKDRZEBW-BeC8pWE9.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-6c7R-Rr_.js → diagram-SSKATNLV-Bxa_2UeV.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-BWcpjsqm.js → diagram-VNBRO52H-CadMYaoj.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-DGKLStxs.js → erDiagram-Q7BY3M3F-MaHtoLsT.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-B9Gacztw.js → flowDiagram-4HSFHLVR-Dthy2ic-.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-C38tm_md.js → ganttDiagram-APWFNJXF-CAk8SxD7.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-CHDB5uvl.js → gitGraphDiagram-7IBYFJ6S-Dva-2htw.js} +1 -1
  29. package/out/renderer/assets/{graph-DlWrFssJ.js → graph-s_OViwzG.js} +1 -1
  30. package/out/renderer/assets/{index-DXagGyV_.js → index--bextIso.js} +1 -1
  31. package/out/renderer/assets/{index-xyhhbm6O.js → index-CFAVcqcg.js} +299 -299
  32. package/out/renderer/assets/{index-6zYm2tzs.js → index-CsCp5pB0.js} +1 -1
  33. package/out/renderer/assets/{infoDiagram-PH2N3AL5-1b_kOmTI.js → infoDiagram-PH2N3AL5-i9VhPGfZ.js} +1 -1
  34. package/out/renderer/assets/{journeyDiagram-U35MCT3I-_u0C1LKL.js → journeyDiagram-U35MCT3I-DjN1JRcf.js} +1 -1
  35. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-CL7zIGOQ.js → kanban-definition-NDS4AKOZ-DeBvWNw2.js} +1 -1
  36. package/out/renderer/assets/{layout-B7_UlWhh.js → layout-D9rlSDZl.js} +1 -1
  37. package/out/renderer/assets/{legacy-agents-page-DjdyFsle.js → legacy-agents-page-E3hHPrtT.js} +1 -1
  38. package/out/renderer/assets/{mermaid.core-DMGv0X4h.js → mermaid.core-CXUoJGge.js} +5 -5
  39. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BdJz0phC.js → mindmap-definition-ALO5MXBD-CsbDlfvY.js} +1 -1
  40. package/out/renderer/assets/{parseISO-DHNn7dbD.js → parseISO-BvU3ISTP.js} +1 -1
  41. package/out/renderer/assets/{pieDiagram-IB7DONF6-BXygMPGV.js → pieDiagram-IB7DONF6-wWJJMpcD.js} +1 -1
  42. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DSS3rJGy.js → quadrantDiagram-7GDLP6J5-B1gEaGpX.js} +1 -1
  43. package/out/renderer/assets/{radar-MK3ICKWK-D7mJxb8L.js → radar-MK3ICKWK-BsQyOA66.js} +1 -1
  44. package/out/renderer/assets/{radient-auth-buttons-BXjrjp9_.js → radient-auth-buttons-Dzwx2_bO.js} +1 -1
  45. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DvtM0SO-.js → requirementDiagram-KVF5MWMF-DFGVsPwn.js} +1 -1
  46. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-NmbhpHXe.js → sankeyDiagram-QLVOVGJD-Cpr12W3J.js} +1 -1
  47. package/out/renderer/assets/{schedules-page-DUipMSf5.js → schedules-page-DLGJUTRX.js} +1 -1
  48. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-CDMHZ85H.js → sequenceDiagram-X6HHIX6F-CY6u9Tii.js} +1 -1
  49. package/out/renderer/assets/{settings-page-gbcFa5F4.js → settings-page-zkuTaAfc.js} +1 -1
  50. package/out/renderer/assets/{stateDiagram-DGXRK772-1j6mgPBe.js → stateDiagram-DGXRK772-DvrY4Q_D.js} +1 -1
  51. package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-DwGmbNs9.js → stateDiagram-v2-YXO3MK2T-B5oCdb35.js} +1 -1
  52. package/out/renderer/assets/{timeline-definition-BDJGKUSR-Dovu1lXn.js → timeline-definition-BDJGKUSR-B8_J7X8q.js} +1 -1
  53. package/out/renderer/assets/{use-agent-like-mutation-BcrQ-k2a.js → use-agent-like-mutation-BowqVPFo.js} +1 -1
  54. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CuwUIYlx.js → xychartDiagram-VJFVF3MP-D7x_UntQ.js} +1 -1
  55. package/out/renderer/index.html +1 -1
  56. package/package.json +2 -2
  57. package/out/renderer/assets/channel-BgDmT1Me.js +0 -1
  58. 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 ?? "unknown",
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 installFailurePayload(marker, runningVersion, options = {}) {
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
- const startedText = started && !Number.isNaN(started.getTime()) ? started.toLocaleString() : "at an unknown time";
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) the bound arrived with no decision. Trying is still better than leaving
4895
- # the user with nothing, and the bound is what makes the previous
4896
- # version's silent exit 0 impossible. It is ~2.4x the slowest ShipIt
4897
- # attempt measured on this machine (255s from request to verdict for a
4898
- # 1 GiB app), and it is the ONE path that can start the app while a swap
4899
- # is still in flight - an accepted risk, not an oversight.
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
- # launchctl list <label> exits 113 when the job is not loaded, 0 when it is.
4923
- # With no probe there is no launchd to ask, and this answers "cannot ask" rather
4924
- # than "not loaded": job_known below requires the probe for the same reason.
4925
- shipit_loaded() { [ -n "$SHIPIT_PROBE" ] && [ -n "$SHIPIT_JOB" ] && "$SHIPIT_PROBE" list "$SHIPIT_JOB" >/dev/null 2>&1; }
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 - and never past the bound.
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 break; fi
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
- { attempts: record.attempts, shipItLogPath }
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
- removeDir: (dir) => fs.rmSync(dir, { recursive: true, force: true }),
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 marker is read while the window is still loading, so the push happens
5765
- * on `did-finish-load` with a delayed fallback: the component subscribes from
5766
- * a React effect, which may run after the load event.
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(