local-operator-ui 0.21.3 → 0.22.0

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