local-operator-ui 0.17.0 → 0.17.2

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 (68) hide show
  1. package/out/main/index.js +2481 -346
  2. package/out/preload/index.js +51 -3
  3. package/out/renderer/assets/{_basePickBy-BJr_fQtB.js → _basePickBy-BvMhn6vs.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-CYir-14_.js → _baseUniq-BK8pe4uQ.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-D6PQzszA.js → agent-details-page-CBSLlAOk.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-BU8BMGm-.js → agent-hub-page-CrP4o6ZW.js} +1 -1
  7. package/out/renderer/assets/{agents-page-3jWcFYcZ.js → agents-page-C6OY5Mpg.js} +2 -2
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CCErX8Tf.js → architectureDiagram-IEHRJDOE-D8MpYATY.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CUvjtfSj.js → blockDiagram-JOT3LUYC-B_I3ZONm.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-VeJwMCKK.js → c4Diagram-VJAJSXHY-DfXBiy8U.js} +1 -1
  11. package/out/renderer/assets/channel-QCRg_PDl.js +1 -0
  12. package/out/renderer/assets/{chunk-4BMEZGHF-3Bq4xtvZ.js → chunk-4BMEZGHF-BJ8Xa2j3.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-B3y7xV7i.js → chunk-A2AXSNBT-C0NnqsE4.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-EVfEpH4f.js → chunk-AEK57VVT-BtRuzQyQ.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-DMvU3WoU.js → chunk-D6G4REZN-BauYOOAw.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-IvUVaxD4.js → chunk-RZ5BOZE2-BzhuQ5O_.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-qRGLrBmi.js → chunk-XZIHB7SX-Ct2eCcqa.js} +1 -1
  18. package/out/renderer/assets/classDiagram-GIVACNV2-Bi3kPe4h.js +1 -0
  19. package/out/renderer/assets/classDiagram-v2-COTLJTTW-Bi3kPe4h.js +1 -0
  20. package/out/renderer/assets/clone-A5TnVgxX.js +1 -0
  21. package/out/renderer/assets/{compact-pagination-C9MHaQh8.js → compact-pagination-Bou_Wr8n.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-mkOM0foV.js → dagre-OKDRZEBW-CTPq15Dg.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-nPhdvd43.js → diagram-SSKATNLV-C3Osiilj.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-DwSuRePG.js → diagram-VNBRO52H-Dz1J2itu.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-H_hDC8d0.js → erDiagram-Q7BY3M3F-hZV4173D.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-COn1sttR.js → flowDiagram-4HSFHLVR-DJ26Mgv9.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-DbxYzA4C.js → ganttDiagram-APWFNJXF-CetzfqU-.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-GJA88tpY.js → gitGraphDiagram-7IBYFJ6S-p6uBXV2t.js} +1 -1
  29. package/out/renderer/assets/{graph-CDkZGsbw.js → graph-DkNd5Vg_.js} +1 -1
  30. package/out/renderer/assets/icon-2KHv58kf.css +1 -0
  31. package/out/renderer/assets/{index-Coj74S4F.js → index-CH2sMRee.js} +1 -1
  32. package/out/renderer/assets/{index-CVSo1eIn.js → index-CsQcGUS4.js} +299 -298
  33. package/out/renderer/assets/{index-DyRL2cJn.js → index-DA5ogML6.js} +1 -1
  34. package/out/renderer/assets/{infoDiagram-PH2N3AL5-DCDmAfuD.js → infoDiagram-PH2N3AL5-JCL1S2jO.js} +1 -1
  35. package/out/renderer/assets/{installer-JsKFFZ0N.js → installer-CwpKGBgz.js} +1 -1
  36. package/out/renderer/assets/{journeyDiagram-U35MCT3I-D5zuKm0h.js → journeyDiagram-U35MCT3I-bsiOJp5O.js} +1 -1
  37. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-D28NqnN2.js → kanban-definition-NDS4AKOZ-DqSB69IY.js} +1 -1
  38. package/out/renderer/assets/{layout-B__Skqp8.js → layout-DOCr1qf_.js} +1 -1
  39. package/out/renderer/assets/{legacy-agents-page-d_nYp_Kx.js → legacy-agents-page-BjfTT7Fq.js} +1 -1
  40. package/out/renderer/assets/{mermaid.core-BHfHBA6R.js → mermaid.core-B5-LsuoJ.js} +5 -5
  41. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-DKRrQTAb.js → mindmap-definition-ALO5MXBD-6EZdR0xM.js} +1 -1
  42. package/out/renderer/assets/{page-header-JYwkpfbT.js → page-header-C-9zjakn.js} +1 -1
  43. package/out/renderer/assets/{parseISO-D2eiqdi5.js → parseISO-Dwm8HpJw.js} +1 -1
  44. package/out/renderer/assets/{pieDiagram-IB7DONF6-B1BtzilZ.js → pieDiagram-IB7DONF6-DJpMDsKb.js} +1 -1
  45. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-2ZnW_kzE.js → quadrantDiagram-7GDLP6J5-DijmJtPz.js} +1 -1
  46. package/out/renderer/assets/{radar-MK3ICKWK-BxdSoZJX.js → radar-MK3ICKWK-BTOUZlXe.js} +1 -1
  47. package/out/renderer/assets/{radient-auth-buttons-Bi1-KXYI.js → radient-auth-buttons-Dwc9qPW2.js} +1 -1
  48. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-6W-9ZOiF.js → requirementDiagram-KVF5MWMF-D3lSHecQ.js} +1 -1
  49. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-DsCO-xHN.js → sankeyDiagram-QLVOVGJD-0iNyBkZV.js} +1 -1
  50. package/out/renderer/assets/{schedules-page-BGkrdg_o.js → schedules-page-DardPLDn.js} +1 -1
  51. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-DTsOsVHj.js → sequenceDiagram-X6HHIX6F-CY9L6HEa.js} +1 -1
  52. package/out/renderer/assets/{settings-page-UqW8Dofa.js → settings-page-CjgBGEXW.js} +29 -29
  53. package/out/renderer/assets/{square-pen-DOIqaj9i.js → square-pen-2Yxvf_S9.js} +1 -1
  54. package/out/renderer/assets/{stateDiagram-DGXRK772-Hgu5z66Y.js → stateDiagram-DGXRK772-DJ_q4gER.js} +1 -1
  55. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CM2f7MUh.js +1 -0
  56. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BkaQadIG.js → timeline-definition-BDJGKUSR-D0Y8Jbrj.js} +1 -1
  57. package/out/renderer/assets/{use-agent-like-mutation-BSrvgpki.js → use-agent-like-mutation-BXxHLNnI.js} +1 -1
  58. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-DuWwCccB.js → xychartDiagram-VJFVF3MP-Bj6ktAuR.js} +1 -1
  59. package/out/renderer/index.html +3 -3
  60. package/out/renderer/installer.html +3 -3
  61. package/package.json +5 -3
  62. package/out/renderer/assets/channel-BtWQQlRJ.js +0 -1
  63. package/out/renderer/assets/classDiagram-GIVACNV2-C298zC-3.js +0 -1
  64. package/out/renderer/assets/classDiagram-v2-COTLJTTW-C298zC-3.js +0 -1
  65. package/out/renderer/assets/clone-dCQCshht.js +0 -1
  66. package/out/renderer/assets/icon-DM6KYqqV.css +0 -1
  67. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-BrBwSEFe.js +0 -1
  68. /package/out/renderer/assets/{icon-D9xnujmI.js → icon-2OdwuA9u.js} +0 -0
package/out/main/index.js CHANGED
@@ -457,6 +457,17 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
457
457
  sessionId,
458
458
  completionToken: zod.z.string().uuid()
459
459
  }).strict(),
460
+ // Cross-surface delivery claim, NOT a read receipt. `claim_delivery`
461
+ // serialises the observers that can see one completion (a TUI, this app) so
462
+ // exactly one of them toasts it. It deliberately never advances the read
463
+ // watermark: routing a notification must not clear the sidebar's unseen mark
464
+ // for a session the user never opened, which is why this is its own op and
465
+ // not a reuse of `sessions.seen`.
466
+ zod.z.object({
467
+ op: zod.z.literal("sessions.notified"),
468
+ sessionId,
469
+ completionToken: zod.z.string().uuid()
470
+ }).strict(),
460
471
  zod.z.object({
461
472
  op: zod.z.literal("sessions.watch"),
462
473
  sessionId,
@@ -917,6 +928,12 @@ function desktopEndpoint(request) {
917
928
  method: "POST",
918
929
  body: { completion_token: request.completionToken }
919
930
  };
931
+ case "sessions.notified":
932
+ return {
933
+ path: `/v1/desktop/sessions/${request.sessionId}/notified`,
934
+ method: "POST",
935
+ body: { completion_token: request.completionToken }
936
+ };
920
937
  case "sessions.watch":
921
938
  return {
922
939
  path: `/v1/desktop/sessions/${request.sessionId}/watch`,
@@ -1835,6 +1852,31 @@ class BackendServiceManager {
1835
1852
  /** Survives relay recreation so notifications never silently detach
1836
1853
  * when the backend URL rotates. */
1837
1854
  streamObserver = null;
1855
+ /**
1856
+ * Called every time the backend becomes reachable and authenticated.
1857
+ *
1858
+ * The desktop token is minted inside `start()`, so anything that must query
1859
+ * the backend cannot be issued from `app.whenReady()` — at that point the
1860
+ * port is dead on the ordinary self-managed cold start and the request is
1861
+ * lost. This fires after the health check passes, which is the first moment
1862
+ * `requestDesktop` can succeed.
1863
+ *
1864
+ * It fires AGAIN on every restart and on external-backend discovery, on
1865
+ * purpose: the backend underneath a running app can be replaced by a
1866
+ * different version, and a capability read taken once at startup would
1867
+ * outlive the backend it described.
1868
+ */
1869
+ backendReadyObserver = null;
1870
+ onBackendReady(observer) {
1871
+ this.backendReadyObserver = observer;
1872
+ }
1873
+ notifyBackendReady() {
1874
+ try {
1875
+ this.backendReadyObserver?.();
1876
+ } catch (error) {
1877
+ logger.error("Backend-ready observer threw:", LogFileType.BACKEND, error);
1878
+ }
1879
+ }
1838
1880
  getStreamRelay() {
1839
1881
  if (!this.streamRelay || this.streamRelayUrl !== this.backendUrl) {
1840
1882
  this.streamRelay?.dispose();
@@ -2182,6 +2224,7 @@ class BackendServiceManager {
2182
2224
  LogFileType.BACKEND
2183
2225
  );
2184
2226
  this.isExternalBackend = true;
2227
+ this.notifyBackendReady();
2185
2228
  return true;
2186
2229
  }
2187
2230
  } catch (error) {
@@ -2211,6 +2254,7 @@ class BackendServiceManager {
2211
2254
  LogFileType.BACKEND
2212
2255
  );
2213
2256
  this.isExternalBackend = true;
2257
+ this.notifyBackendReady();
2214
2258
  return true;
2215
2259
  }
2216
2260
  }
@@ -2244,6 +2288,7 @@ class BackendServiceManager {
2244
2288
  "Backend Service Manager is disabled. Skipping backend start.",
2245
2289
  LogFileType.BACKEND
2246
2290
  );
2291
+ this.notifyBackendReady();
2247
2292
  return true;
2248
2293
  }
2249
2294
  if (await this.checkExistingBackend()) {
@@ -2379,6 +2424,7 @@ class BackendServiceManager {
2379
2424
  if (await this.checkHealth()) {
2380
2425
  this.isRunning = true;
2381
2426
  this.startHealthCheck();
2427
+ this.notifyBackendReady();
2382
2428
  return true;
2383
2429
  }
2384
2430
  await new Promise((resolve) => setTimeout(resolve, 1e3));
@@ -3938,6 +3984,21 @@ function registerDesktopIPC(window, expectedUrl, request, streams, media, notifi
3938
3984
  }
3939
3985
  const NOTIFY_TTL_MS = 10 * 60 * 1e3;
3940
3986
  const MAX_DEDUPE_KEYS = 2048;
3987
+ const CONTRACT_PROBE_ATTEMPTS = 3;
3988
+ const CONTRACT_PROBE_RETRY_MS = 250;
3989
+ const MAX_BODY_CHARS = 240;
3990
+ const TRAILING_COLONS = /:+$/;
3991
+ const GATE_BODIES = {
3992
+ ask: "Waiting for your answer",
3993
+ approval: "Waiting for approval"
3994
+ };
3995
+ function gateBody(kind, gateTitle, gateDetail) {
3996
+ let subject = (gateDetail ?? "").trim();
3997
+ if (subject && gateTitle && !subject.toLowerCase().startsWith(gateTitle.toLowerCase())) {
3998
+ subject = `${gateTitle}: ${subject}`.trim().replace(TRAILING_COLONS, "").trim();
3999
+ }
4000
+ return subject || GATE_BODIES[kind] || GATE_BODIES.approval;
4001
+ }
3941
4002
  class DesktopNotifier {
3942
4003
  constructor(window, request) {
3943
4004
  this.window = window;
@@ -3948,9 +4009,163 @@ class DesktopNotifier {
3948
4009
  epochs = /* @__PURE__ */ new Map();
3949
4010
  /** Window state per (window id) used to decide whether a toast is needed. */
3950
4011
  windows = /* @__PURE__ */ new Map();
4012
+ /**
4013
+ * `features.notification_contract` from `/v1/capabilities`; 0 means legacy.
4014
+ *
4015
+ * A HARD switch, not a heuristic. At >= 1 the backend owns every completion
4016
+ * toast and the legacy event path must stay silent or one turn produces two
4017
+ * banners. Absent or unfetchable stays 0 on purpose: a missing capability
4018
+ * response is a transient HTTP failure far more often than it is an old
4019
+ * backend, and failing toward silence would lose completions outright.
4020
+ */
4021
+ notificationContract = 0;
4022
+ /**
4023
+ * The capability read in flight, or the last one that settled.
4024
+ *
4025
+ * `null` only before the first read is ever issued.
4026
+ */
4027
+ contractProbe = null;
4028
+ /**
4029
+ * Generation of the newest capability read, so the LAST-STARTED one wins.
4030
+ *
4031
+ * Probes are not idempotent and they overlap: the backend-ready hook can
4032
+ * fire twice in a tick (external-backend discovery and `start()` both
4033
+ * reported ready), a health-check restart can fire it while an earlier probe
4034
+ * is still retrying, and `awaitContract` starts one of its own. Without this
4035
+ * the shared fields are written by whichever probe SETTLES last, which on a
4036
+ * transient blip is the older read — a successful answer clobbered by a
4037
+ * stale failure, decided by timing rather than by recency (review round 2,
4038
+ * R2-2).
4039
+ *
4040
+ * A superseded probe therefore writes nothing at all and returns. It is not
4041
+ * cancelled — its in-flight `fetch` is left to settle on its own, because
4042
+ * the only cost is one wasted response and the alternative is threading an
4043
+ * `AbortSignal` through the desktop transport for no user-visible gain.
4044
+ */
4045
+ contractGeneration = 0;
4046
+ /**
4047
+ * Whether a capability read has ever come back with a real HTTP answer.
4048
+ *
4049
+ * This is the distinction that closes the double-banner window BY
4050
+ * CONSTRUCTION. A `notificationContract` of 0 has three causes that look
4051
+ * identical on the field alone: the backend answered and has no contract
4052
+ * (old backend, legacy path is correct), nobody has asked yet, and the ask
4053
+ * never reached a backend at all. Only the first is an answer. The other two
4054
+ * are the cold start the review reproduced — the read went to a port nothing
4055
+ * was listening on, was swallowed, and the legacy `agent_end` toast then
4056
+ * fired alongside the composed frame, two banners for one turn.
4057
+ *
4058
+ * So the legacy path re-asks until it has a genuine answer rather than
4059
+ * reading an unanswered 0 as "no contract". Failing toward the legacy path
4060
+ * rather than toward silence (design 4.3) is preserved: once a bounded probe
4061
+ * really has failed at the moment the toast is due, the toast is raised.
4062
+ *
4063
+ * ONCE TRUE THIS AND `notificationContract` MOVE ONLY TOGETHER. A failed
4064
+ * probe that reset the value while leaving this true produced a pair meaning
4065
+ * "we were told there is no contract" when in fact we were told nothing, and
4066
+ * `awaitContract` then declined to re-ask — the double banner returned and
4067
+ * STAYED, every turn, until the next backend bounce (review round 2, R2-1).
4068
+ */
4069
+ contractAnswered = false;
3951
4070
  get canNotify() {
3952
4071
  return electron.Notification.isSupported();
3953
4072
  }
4073
+ /**
4074
+ * Read `features.notification_contract` from the connected backend.
4075
+ *
4076
+ * Call this every time the backend becomes reachable — first start, a health
4077
+ * check restarting it, external-backend discovery rotating the URL. It is
4078
+ * wired to `BackendServiceManager.onBackendReady` for exactly that reason:
4079
+ * issuing it earlier (inside `app.whenReady()`) queries a port nothing is
4080
+ * listening on yet on the ordinary self-managed cold start, and a swallowed
4081
+ * failure there leaves the gate on the legacy path against a backend that
4082
+ * composes — two banners for one turn.
4083
+ *
4084
+ * Re-reading is also the downgrade story the design names (4.3): an app
4085
+ * pointed from a new backend at an old one re-reads 0 and gets its legacy
4086
+ * completion signal back. The previous one-way latch could not do that.
4087
+ *
4088
+ * A throw or a non-200 after the bounded retry leaves the gate at 0, which
4089
+ * is the legacy path rather than silence: see `notificationContract`.
4090
+ */
4091
+ refreshNotificationContract() {
4092
+ const generation = ++this.contractGeneration;
4093
+ const probe = this.probeNotificationContract(generation);
4094
+ this.contractProbe = probe;
4095
+ return probe;
4096
+ }
4097
+ async probeNotificationContract(generation) {
4098
+ for (let attempt = 0; attempt < CONTRACT_PROBE_ATTEMPTS; attempt++) {
4099
+ let response = null;
4100
+ try {
4101
+ response = await this.request({ op: "capabilities" });
4102
+ } catch {
4103
+ }
4104
+ if (generation !== this.contractGeneration) return;
4105
+ if (response?.status === 200) {
4106
+ const body = response.body;
4107
+ this.applyNotificationContract(
4108
+ body?.result?.features?.notification_contract
4109
+ );
4110
+ this.contractAnswered = true;
4111
+ return;
4112
+ }
4113
+ if (attempt + 1 < CONTRACT_PROBE_ATTEMPTS) {
4114
+ await new Promise(
4115
+ (resolve) => setTimeout(resolve, CONTRACT_PROBE_RETRY_MS)
4116
+ );
4117
+ if (generation !== this.contractGeneration) return;
4118
+ }
4119
+ }
4120
+ }
4121
+ /**
4122
+ * Block until a capability read has ANSWERED against the current backend,
4123
+ * or until a fresh bounded probe has failed here and now.
4124
+ *
4125
+ * Issuing the probe itself when none has answered is what makes the legacy
4126
+ * path safe by construction rather than by call-site discipline. The failure
4127
+ * the review reproduced is a read fired before the backend existed: it is
4128
+ * not enough to move that call later, because any read can lose its race
4129
+ * with a backend that is still starting, and a swallowed one leaves a 0 that
4130
+ * reads exactly like an old backend. Re-asking at the moment the answer is
4131
+ * actually needed cannot be beaten by a slow start.
4132
+ *
4133
+ * Loops rather than awaiting once because a backend rotation can start a
4134
+ * new probe while we are waiting on the old one, and the legacy path must
4135
+ * never be decided on the previous backend's answer.
4136
+ */
4137
+ async awaitContract() {
4138
+ if (!this.contractAnswered) this.refreshNotificationContract();
4139
+ let awaited = null;
4140
+ while (this.contractProbe !== awaited) {
4141
+ awaited = this.contractProbe;
4142
+ await awaited;
4143
+ }
4144
+ }
4145
+ /**
4146
+ * Record the contract version a backend reported.
4147
+ *
4148
+ * Being told the value directly IS an answer, so this also stops the legacy
4149
+ * path issuing a probe of its own and overwriting it.
4150
+ *
4151
+ * Bumps the generation for the same reason a probe does: this is the newest
4152
+ * reading of the backend, so an older probe still in flight must not settle
4153
+ * on top of it.
4154
+ */
4155
+ setNotificationContract(version2) {
4156
+ this.contractGeneration++;
4157
+ this.applyNotificationContract(version2);
4158
+ this.contractAnswered = true;
4159
+ }
4160
+ /**
4161
+ * Normalise a reported contract value. Both callers are ANSWERS and both
4162
+ * set `contractAnswered` themselves; nothing else may write this field.
4163
+ * That invariant is the fix for R2-1, so a third caller that resets the
4164
+ * value without answering re-opens it.
4165
+ */
4166
+ applyNotificationContract(version2) {
4167
+ this.notificationContract = typeof version2 === "number" && Number.isFinite(version2) && version2 > 0 ? version2 : 0;
4168
+ }
3954
4169
  /**
3955
4170
  * Renderer heartbeat. Forwarded to the backend as the watch lease for the
3956
4171
  * renderer's active subscription; `can_notify` is asserted only when main
@@ -3979,21 +4194,103 @@ class DesktopNotifier {
3979
4194
  if (frame.type === "snapshot") {
3980
4195
  this.epochs.set(sessionId2, frame.payload.frontend.epoch);
3981
4196
  const gate = frame.payload.frontend.snapshot.pending_gate;
3982
- if (gate) this.gate(sessionId2, gate.request_id, gate.title, gate.detail);
4197
+ if (gate) this.gate(sessionId2, gate);
3983
4198
  return;
3984
4199
  }
3985
4200
  if (frame.type === "frontend.update") {
3986
4201
  this.epochs.set(sessionId2, frame.payload.epoch);
3987
4202
  const gate = frame.payload.changes.pending_gate;
3988
- if (gate) this.gate(sessionId2, gate.request_id, gate.title, gate.detail);
4203
+ if (gate) this.gate(sessionId2, gate);
4204
+ return;
4205
+ }
4206
+ if (frame.type === "notification") {
4207
+ void this.composed(sessionId2, frame.payload);
3989
4208
  return;
3990
4209
  }
3991
4210
  if (frame.type === "event") {
3992
- const eventType = String(frame.payload.type ?? "");
3993
- if (eventType === "agent_end" || eventType === "turn_end") {
3994
- this.turn(sessionId2, frame.seq);
4211
+ if (String(frame.payload.type ?? "") !== "agent_end") return;
4212
+ void this.legacyTurn(sessionId2, frame.seq);
4213
+ }
4214
+ }
4215
+ /**
4216
+ * The legacy completion toast, raised only against a backend that composes
4217
+ * none of its own.
4218
+ *
4219
+ * DEFERRED, never raced. A backend advertising `notification_contract` owns
4220
+ * every completion toast, so this must stay silent against it or the user
4221
+ * gets two banners for one turn: the legacy one here plus the composed frame.
4222
+ * `agent_end` is published the moment the turn's last model call returns,
4223
+ * while the composed frame follows from the backend's 1 s attention poll
4224
+ * (design 6.1) — so the event reliably arrives FIRST, and any check that
4225
+ * reacts to a frame already in hand closes the window one frame too late.
4226
+ * Waiting for the capability read to settle is what closes it by
4227
+ * construction. The wait is bounded by `CONTRACT_PROBE_*` and only ever
4228
+ * applies before the first answer of a run.
4229
+ *
4230
+ * `agent_end` survives on this path only because dropping it too would leave
4231
+ * an old backend with no completion signal at all. It is still wrong (it
4232
+ * arrives while `task` children run), which is exactly what the
4233
+ * backend-composed path fixes. The event itself is NEVER filtered out of the
4234
+ * stream: the transcript reducer settles on it.
4235
+ */
4236
+ async legacyTurn(sessionId2, seq) {
4237
+ await this.awaitContract();
4238
+ if (this.notificationContract > 0) return;
4239
+ this.turn(sessionId2, seq);
4240
+ }
4241
+ /**
4242
+ * Deliver one backend-composed notification.
4243
+ *
4244
+ * The order of these four checks is load-bearing and is the whole of 7.3.
4245
+ * Dedupe and the focus gate run BEFORE the claim so a toast we are not going
4246
+ * to show never burns the cross-surface claim; the claim runs immediately
4247
+ * before delivery so the claimant really is the deliverer.
4248
+ */
4249
+ async composed(sessionId2, n) {
4250
+ if (!this.claim(n.dedupe_key)) return;
4251
+ if (n.focus_policy === "when_unfocused" && this.anyFocused()) return;
4252
+ if (n.completion_token) {
4253
+ const outcome = await this.claimDelivery(sessionId2, n.completion_token);
4254
+ if (outcome !== "won") {
4255
+ if (outcome === "failed") this.delivered.delete(n.dedupe_key);
4256
+ return;
3995
4257
  }
3996
4258
  }
4259
+ this.show(
4260
+ sessionId2,
4261
+ n.title,
4262
+ n.status,
4263
+ n.body,
4264
+ n.body_is_snippet,
4265
+ n.body_is_failure ?? false
4266
+ );
4267
+ }
4268
+ /**
4269
+ * Ask the backend whether this surface owns this completion's toast.
4270
+ *
4271
+ * Fails CLOSED on the TOAST: neither `lost` nor `failed` shows anything,
4272
+ * because the failure mode of failing open is the duplicate banner on every
4273
+ * surface this whole mechanism exists to prevent.
4274
+ *
4275
+ * The two non-winning outcomes are still reported separately, because they
4276
+ * differ in who owns the completion afterwards. `lost` means the backend
4277
+ * answered and another surface has it. `failed` means the backend was never
4278
+ * reached or never answered cleanly, so nothing was claimed anywhere and the
4279
+ * completion is still owed to somebody — see the caller.
4280
+ */
4281
+ async claimDelivery(sessionId2, completionToken) {
4282
+ try {
4283
+ const response = await this.request({
4284
+ op: "sessions.notified",
4285
+ sessionId: sessionId2,
4286
+ completionToken
4287
+ });
4288
+ if (response.status !== 200) return "failed";
4289
+ const body = response.body;
4290
+ return body?.result?.claimed === true ? "won" : "lost";
4291
+ } catch {
4292
+ return "failed";
4293
+ }
3997
4294
  }
3998
4295
  anyFocused() {
3999
4296
  for (const state of this.windows.values()) {
@@ -4001,16 +4298,59 @@ class DesktopNotifier {
4001
4298
  }
4002
4299
  return false;
4003
4300
  }
4004
- gate(sessionId2, requestId2, title, detail) {
4301
+ /**
4302
+ * Raise the banner for a pending approval or question.
4303
+ *
4304
+ * A gate is worth a toast even when the window is focused: the user may be
4305
+ * reading another conversation in the same window.
4306
+ *
4307
+ * When the backend supplies `session_name`, the banner takes the SHAPE of a
4308
+ * completion banner — session name as the title, the gate's own title
4309
+ * leading the detail. Without it, a gate named only its category while
4310
+ * completions named their session, so the banner a user can safely ignore
4311
+ * identified itself and the banner holding a run hostage did not; with two
4312
+ * or three sessions running, that one cannot be triaged without clicking,
4313
+ * which is the action the user was deciding whether to take.
4314
+ *
4315
+ * The name is rendered ONLY as the backend sent it. `session_name` is
4316
+ * additive and optional: it is absent on an older backend and empty when the
4317
+ * `session_names_in_notifications()` privacy flag is off. The backend owns
4318
+ * that decision because only it can read the flag, so this app must never
4319
+ * resolve the name from a snapshot instead — doing so would leak a name the
4320
+ * user opted out of, on every gate, forever.
4321
+ */
4322
+ gate(sessionId2, gate) {
4323
+ const { request_id: requestId2, kind, title, detail } = gate;
4005
4324
  const key = `gate:${sessionId2}:${this.epochs.get(sessionId2) ?? ""}:${requestId2}`;
4006
4325
  if (!this.claim(key)) return;
4007
- this.show(sessionId2, title || "Approval needed", detail);
4326
+ const fallback = kind === "ask" ? "Question" : "Approval needed";
4327
+ const sessionName = gate.session_name?.trim() || "";
4328
+ if (!sessionName) {
4329
+ this.show(
4330
+ sessionId2,
4331
+ title.trim() || fallback,
4332
+ "",
4333
+ gateBody(kind, "", detail)
4334
+ );
4335
+ return;
4336
+ }
4337
+ this.show(
4338
+ sessionId2,
4339
+ sessionName,
4340
+ "",
4341
+ gateBody(kind, title.trim() || fallback, detail)
4342
+ );
4008
4343
  }
4344
+ /**
4345
+ * Legacy completion toast: canned copy, reached only against a backend that
4346
+ * does not advertise `notification_contract`. Keyed on `session:epoch:seq`
4347
+ * as it always was — an old backend mints no `dedupe_key` to key on.
4348
+ */
4009
4349
  turn(sessionId2, seq) {
4010
4350
  const key = `turn:${sessionId2}:${this.epochs.get(sessionId2) ?? ""}:${seq}`;
4011
4351
  if (!this.claim(key)) return;
4012
4352
  if (this.anyFocused()) return;
4013
- this.show(sessionId2, "Turn complete", "The agent finished its turn.");
4353
+ this.show(sessionId2, "Turn complete", "", "The agent finished its turn.");
4014
4354
  }
4015
4355
  claim(key) {
4016
4356
  const now = Date.now();
@@ -4023,10 +4363,52 @@ class DesktopNotifier {
4023
4363
  }
4024
4364
  return true;
4025
4365
  }
4026
- show(sessionId2, title, body) {
4366
+ /**
4367
+ * Render one toast.
4368
+ *
4369
+ * `status` leads the body rather than riding `subtitle`. Electron's
4370
+ * `subtitle` option is documented macOS-only, so on Windows and Linux the
4371
+ * state category would simply vanish and those users would read a bare
4372
+ * snippet with no indication of whether the turn succeeded or failed. One
4373
+ * shape that degrades on no platform beats a second, platform-forked one.
4374
+ * The strings themselves are the backend's, unchanged; only the join is ours.
4375
+ *
4376
+ * The design doc's stated reason for keeping the status out of the title —
4377
+ * "macOS clips a title at ~43 characters" — is FALSE on a current macOS
4378
+ * banner: rendered frames show an 80-character title wrapping across two
4379
+ * lines, not clipping (design review round 1). The real constraint is a
4380
+ * total budget of roughly five text lines for title and body together, after
4381
+ * which macOS truncates the BODY with its own ellipsis. That is why the join
4382
+ * is spent sparingly below.
4383
+ *
4384
+ * WHETHER THE BODY NAMES THE OUTCOME ITSELF decides whether the status earns
4385
+ * its place, and the backend sends two flags for the two kinds of body that
4386
+ * do not. A model-written snippet describes the work, and the session's own
4387
+ * recorded failure text describes the cause ("anthropic: 429
4388
+ * rate_limit_error - credit balance too low"); neither says whether the turn
4389
+ * succeeded, so the category in front of it is the only thing that does. The
4390
+ * house bodies ("Task complete", "Stopped with an error") already name the
4391
+ * state, so prefixing them renders "Complete — Task complete": one fact
4392
+ * asserted twice in the two lines a banner gets, which is what every user
4393
+ * with the session-name privacy flag off would have received.
4394
+ *
4395
+ * Reading only `body_is_snippet` split the world in two where the backend
4396
+ * made it three, and it inverted the intent of the failure text (design
4397
+ * round 1, D4): a raw 429 envelope under a session name reads as routine log
4398
+ * noise, so the ONE banner that demands action was the one that never said
4399
+ * anything had gone wrong — and it was worse with the privacy flag ON (the
4400
+ * default, which is what produces the failure text) than off, where the user
4401
+ * at least got "Stopped with an error" (QA round 1, Q-1).
4402
+ *
4403
+ * These are the backend's own flags for exactly this difference, so this app
4404
+ * still re-words nothing (design 8.4). Gates pass `status = ""` and are
4405
+ * unaffected either way.
4406
+ */
4407
+ show(sessionId2, title, status, body, isSnippet = false, isFailure = false) {
4408
+ const lead = (isSnippet || isFailure) && status ? `${status} — ` : "";
4027
4409
  const notification = new electron.Notification({
4028
4410
  title,
4029
- body: body.slice(0, 240),
4411
+ body: `${lead}${body}`.slice(0, MAX_BODY_CHARS),
4030
4412
  silent: false
4031
4413
  });
4032
4414
  notification.on("click", () => {
@@ -4040,201 +4422,1662 @@ class DesktopNotifier {
4040
4422
  notification.show();
4041
4423
  }
4042
4424
  }
4043
- const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
4044
- const VERSION_CLEAN_REGEX = /^v/i;
4045
- const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
4046
- class UpdateService {
4047
- mainWindow = null;
4048
- isDevMode;
4049
- isNpxInstall;
4050
- backendUrl;
4051
- updateCheckInterval = null;
4052
- backendService = null;
4053
- /**
4054
- * Initialize the update service
4055
- * @param mainWindow - The main application window
4056
- * @param backendService - Optional backend service manager for restarting the backend after updates
4057
- */
4058
- constructor(mainWindow2, backendService2) {
4059
- this.mainWindow = mainWindow2;
4060
- this.backendService = backendService2 || null;
4061
- this.isDevMode = !electron.app.isPackaged || Boolean(process.env.ELECTRON_RENDERER_URL);
4062
- const execPath = process.execPath;
4063
- this.isNpxInstall = execPath.includes("node_modules/.bin") || execPath.includes("node_modules\\.bin");
4064
- this.backendUrl = apiConfig.baseUrl.replace("localhost", "127.0.0.1");
4065
- logger.info(
4066
- `Update service initialized. Dev mode: ${this.isDevMode}, NPX install: ${this.isNpxInstall}`,
4067
- LogFileType.UPDATE_SERVICE
4068
- );
4069
- electronUpdater.autoUpdater.logger = {
4070
- info: (message) => logger.info(message, LogFileType.UPDATE_SERVICE),
4071
- warn: (message) => logger.warn(message, LogFileType.UPDATE_SERVICE),
4072
- error: (message) => logger.error(message, LogFileType.UPDATE_SERVICE),
4073
- debug: (message) => logger.debug(message, LogFileType.UPDATE_SERVICE)
4425
+ const PENDING_INSTALL_MARKER_FILE = "pending-update-install.json";
4426
+ const INSTALL_DISK_SLACK_BYTES = 256 * 1024 * 1024;
4427
+ const DOWNLOAD_PAGE_URL = "https://local-operator.com/download";
4428
+ const WATCHDOG_TIMEOUT_SECONDS = 600;
4429
+ const PLIST_READ_TIMEOUT_SECONDS = 5;
4430
+ const WATCHDOG_TOKEN = "local-operator-update-watchdog";
4431
+ const BAD_BUNDLE_MARKERS = [
4432
+ "errSecCSBadBundleFormat",
4433
+ "-67028",
4434
+ "not a valid code object",
4435
+ "code object is not signed at all",
4436
+ "a sealed resource is missing or invalid",
4437
+ "errSecCSUnsigned",
4438
+ "-67062"
4439
+ ];
4440
+ function appBundleFromExecutable(execPath) {
4441
+ const marker = `${path.join("Contents", "MacOS")}/`;
4442
+ const index = execPath.lastIndexOf(marker);
4443
+ if (index <= 0) return null;
4444
+ return execPath.slice(0, index - 1);
4445
+ }
4446
+ function evaluateBundleSeal(probe) {
4447
+ const output = `${probe.stdout}
4448
+ ${probe.stderr}`.trim();
4449
+ if (probe.ran === false) {
4450
+ return {
4451
+ kind: "unavailable",
4452
+ detail: `codesign --verify did not complete: ${output || "no output"}`
4074
4453
  };
4075
- electronUpdater.autoUpdater.autoDownload = false;
4076
- electronUpdater.autoUpdater.autoInstallOnAppQuit = true;
4077
- this.setupUpdateEvents();
4078
- this.startPeriodicUpdateChecks();
4079
- }
4080
- /**
4081
- * Start periodic update checks
4082
- * Checks for updates every 5 minutes
4083
- */
4084
- startPeriodicUpdateChecks() {
4085
- if (this.updateCheckInterval) {
4086
- clearInterval(this.updateCheckInterval);
4087
- }
4088
- this.updateCheckInterval = setInterval(() => {
4089
- logger.info(
4090
- "Running scheduled update check (every 5 minutes)",
4091
- LogFileType.UPDATE_SERVICE
4092
- );
4093
- this.checkForAllUpdates(true);
4094
- }, 3e5);
4095
- logger.info(
4096
- "Periodic update checks scheduled (every 5 minutes)",
4097
- LogFileType.UPDATE_SERVICE
4098
- );
4099
4454
  }
4100
- /**
4101
- * Clean up resources when the service is no longer needed
4102
- */
4103
- /**
4104
- * Clean up resources, event listeners, and IPC handlers when the service is no longer needed
4105
- */
4106
- dispose() {
4107
- if (this.updateCheckInterval) {
4108
- clearInterval(this.updateCheckInterval);
4109
- this.updateCheckInterval = null;
4455
+ if (probe.exitCode === 0) return { kind: "sealed" };
4456
+ const marker = BAD_BUNDLE_MARKERS.find(
4457
+ (candidate) => output.includes(candidate)
4458
+ );
4459
+ return {
4460
+ kind: "unsealed",
4461
+ detail: marker ? `${marker}: ${output || "no output"}` : `codesign --verify exited ${probe.exitCode}: ${output || "no output"}`
4462
+ };
4463
+ }
4464
+ function installedBundleSealBlock(appBundlePath, detail, version2) {
4465
+ return {
4466
+ code: "installed-bundle-not-sealed",
4467
+ message: version2 ? `This install of Local Operator can't be updated in place, so the update to version ${version2} was stopped before the app quit.` : "This install of Local Operator can't be updated in place, so the update was stopped before the app quit.",
4468
+ remedy: {
4469
+ text: "Quit Local Operator, then download a fresh copy and replace the app in Applications.",
4470
+ url: DOWNLOAD_PAGE_URL
4471
+ },
4472
+ detail: `${appBundlePath}: ${detail}`
4473
+ };
4474
+ }
4475
+ function metadataBasenames(filePath, updatePath) {
4476
+ const names = [path.basename(filePath)];
4477
+ if (updatePath) names.push(path.basename(updatePath));
4478
+ return names;
4479
+ }
4480
+ function matchArtifactMetadata(filePath, files, updatePath) {
4481
+ const names = metadataBasenames(filePath, updatePath);
4482
+ for (const name of names) {
4483
+ for (const file of files) {
4484
+ let url = file.url;
4485
+ try {
4486
+ url = decodeURIComponent(url);
4487
+ } catch {
4488
+ }
4489
+ if (path.basename(url) === name) return file;
4110
4490
  }
4111
- electronUpdater.autoUpdater.removeAllListeners();
4112
- electron.ipcMain.removeHandler("check-for-updates");
4113
- electron.ipcMain.removeHandler("check-for-backend-updates");
4114
- electron.ipcMain.removeHandler("check-for-all-updates");
4115
- electron.ipcMain.removeHandler("update-backend");
4116
- electron.ipcMain.removeHandler("download-update");
4117
- electron.ipcMain.removeHandler("quit-and-install");
4118
- this.mainWindow = null;
4119
4491
  }
4120
- /**
4121
- * Register the backend service for proper shutdown when app quits
4122
- * This ensures that restarted backend services are properly shut down
4123
- */
4124
- registerBackendShutdown() {
4125
- if (!this.backendService || this.backendService.isUsingExternalBackend()) {
4126
- return;
4127
- }
4128
- const shutdownHandler = async () => {
4129
- logger.info(
4130
- "Shutting down backend service before app quit...",
4131
- LogFileType.UPDATE_SERVICE
4132
- );
4492
+ return null;
4493
+ }
4494
+ function requiredDiskBytes(input) {
4495
+ return input.artifactSize + input.installedBundleSize * 2 + INSTALL_DISK_SLACK_BYTES;
4496
+ }
4497
+ function formatGiB(bytes) {
4498
+ return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
4499
+ }
4500
+ function measureDirectoryBytes(root) {
4501
+ try {
4502
+ const stats = fs.lstatSync(root);
4503
+ if (!stats.isDirectory()) return stats.size;
4504
+ let total = 0;
4505
+ for (const entry of fs.readdirSync(root)) {
4506
+ const child = path.join(root, entry);
4507
+ let childStats;
4133
4508
  try {
4134
- await this.backendService?.stop(false);
4135
- logger.info(
4136
- "Backend service successfully shut down before app quit",
4137
- LogFileType.UPDATE_SERVICE
4138
- );
4139
- } catch (error) {
4140
- logger.error(
4141
- "Error shutting down backend service before app quit:",
4142
- LogFileType.UPDATE_SERVICE,
4143
- error
4144
- );
4145
- try {
4146
- logger.info(
4147
- "Attempting forced shutdown of backend service...",
4148
- LogFileType.UPDATE_SERVICE
4149
- );
4150
- await this.forceTerminateBackendProcess();
4151
- logger.info(
4152
- "Forced shutdown of backend service completed",
4153
- LogFileType.UPDATE_SERVICE
4154
- );
4155
- } catch (forceError) {
4156
- logger.error(
4157
- "Error during forced shutdown of backend service:",
4158
- LogFileType.UPDATE_SERVICE,
4159
- forceError
4160
- );
4161
- }
4509
+ childStats = fs.lstatSync(child);
4510
+ } catch {
4511
+ continue;
4162
4512
  }
4163
- };
4164
- electron.app.removeAllListeners("before-quit");
4165
- electron.app.once("before-quit", shutdownHandler);
4166
- electron.app.once("will-quit", shutdownHandler);
4167
- logger.info(
4168
- "Registered backend service for proper shutdown on app quit",
4169
- LogFileType.UPDATE_SERVICE
4170
- );
4171
- }
4172
- /**
4173
- * Force terminate the backend process using platform-specific commands
4174
- * This is a last resort method when normal termination fails
4175
- */
4176
- async forceTerminateBackendProcess() {
4177
- if (!this.backendService) {
4178
- return;
4179
- }
4180
- const execAsync = node_util.promisify(node_child_process.exec);
4181
- try {
4182
- if (process.platform === "win32") {
4183
- await execAsync('taskkill /f /im "local-operator serve" /t');
4184
- await execAsync(
4185
- `wmic process where "commandline like '%local-operator serve%'" call terminate`
4186
- );
4513
+ if (childStats.isDirectory()) {
4514
+ const nested = measureDirectoryBytes(child);
4515
+ if (nested != null) total += nested;
4187
4516
  } else {
4188
- await execAsync('pkill -f "local-operator serve"');
4189
- await new Promise((resolve) => setTimeout(resolve, 1e3));
4190
- await execAsync('pkill -9 -f "local-operator serve"');
4517
+ total += childStats.size;
4191
4518
  }
4192
- } catch (error) {
4193
- logger.warn(
4194
- "Error during force termination (this may be normal if process was already terminated):",
4195
- LogFileType.UPDATE_SERVICE,
4196
- error
4197
- );
4198
4519
  }
4520
+ return total;
4521
+ } catch {
4522
+ return null;
4199
4523
  }
4200
- /**
4201
- * Set up event handlers for the autoUpdater
4202
- */
4203
- setupUpdateEvents() {
4204
- electronUpdater.autoUpdater.on("update-available", (info) => {
4205
- logger.info("Update available:", LogFileType.UPDATE_SERVICE, info);
4206
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4207
- this.mainWindow.webContents.send("update-available", info);
4524
+ }
4525
+ function versionClause(version2) {
4526
+ return version2 ? ` to version ${version2}` : "";
4527
+ }
4528
+ function verifyStagedArtifact(input) {
4529
+ const name = path.basename(input.filePath);
4530
+ const version2 = input.version ?? null;
4531
+ if (input.metadata == null) {
4532
+ return {
4533
+ ok: false,
4534
+ block: {
4535
+ code: "artifact-metadata-missing",
4536
+ message: `The downloaded update${versionClause(version2)} isn't listed in the release metadata, so it wasn't installed.`,
4537
+ remedy: {
4538
+ text: "Check for updates again to re-download the release."
4539
+ },
4540
+ detail: `${name} has no matching entry in the update metadata.`
4208
4541
  }
4209
- });
4210
- electronUpdater.autoUpdater.on("update-not-available", (info) => {
4211
- logger.info("No update available:", LogFileType.UPDATE_SERVICE, info);
4212
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4213
- this.mainWindow.webContents.send("update-not-available", info);
4542
+ };
4543
+ }
4544
+ const expectedSize = input.metadata.size ?? null;
4545
+ if (expectedSize != null && input.actualSize !== expectedSize) {
4546
+ return {
4547
+ ok: false,
4548
+ block: {
4549
+ code: "download-verification-failed",
4550
+ message: `The downloaded update${versionClause(version2)} doesn't match the published release, so it wasn't installed.`,
4551
+ remedy: {
4552
+ text: "Check for updates again to re-download the release."
4553
+ },
4554
+ detail: `${name} is ${input.actualSize} bytes; the release lists ${expectedSize}.`
4555
+ }
4556
+ };
4557
+ }
4558
+ const expectedSha = input.metadata.sha512 ?? null;
4559
+ if (expectedSha != null && input.actualSha512 !== expectedSha) {
4560
+ return {
4561
+ ok: false,
4562
+ block: {
4563
+ code: "download-verification-failed",
4564
+ message: `The downloaded update${versionClause(version2)} doesn't match the published release, so it wasn't installed.`,
4565
+ remedy: {
4566
+ text: "Check for updates again to re-download the release."
4567
+ },
4568
+ detail: `${name} sha512 ${input.actualSha512} does not match the release's ${expectedSha}.`
4569
+ }
4570
+ };
4571
+ }
4572
+ const artifactSize = expectedSize ?? input.actualSize;
4573
+ const installedBundleSize = input.installedBundleSize ?? 0;
4574
+ const needed = requiredDiskBytes({ artifactSize, installedBundleSize });
4575
+ if (input.freeBytes < needed) {
4576
+ const footprint = installedBundleSize ? `${formatGiB(artifactSize)} artifact + two ${formatGiB(
4577
+ installedBundleSize
4578
+ )} app copies + ${formatGiB(INSTALL_DISK_SLACK_BYTES)} slack` : `${formatGiB(artifactSize)} artifact + ${formatGiB(INSTALL_DISK_SLACK_BYTES)} slack (the installed app could not be measured)`;
4579
+ return {
4580
+ ok: false,
4581
+ block: {
4582
+ code: "insufficient-disk-space",
4583
+ message: `There isn't enough free disk space to install the update${versionClause(version2)}, so it wasn't installed.`,
4584
+ remedy: {
4585
+ text: `Free up about ${formatGiB(needed - input.freeBytes)}, then check for updates again.`
4586
+ },
4587
+ detail: `Install needs ${formatGiB(needed)} (${footprint}); ${formatGiB(input.freeBytes)} free.`
4588
+ }
4589
+ };
4590
+ }
4591
+ return { ok: true, sha512: input.actualSha512, size: input.actualSize };
4592
+ }
4593
+ function resolveStagedArtifactPath(input) {
4594
+ if (input.downloadHelperFile && fs.existsSync(input.downloadHelperFile)) {
4595
+ return input.downloadHelperFile;
4596
+ }
4597
+ if (input.candidateNames.length === 0 || !fs.existsSync(input.pendingDir)) {
4598
+ return null;
4599
+ }
4600
+ const entries = input.listDir(input.pendingDir);
4601
+ for (const name of input.candidateNames) {
4602
+ const match = entries.find((entry) => entry === name);
4603
+ if (match) return path.join(input.pendingDir, match);
4604
+ }
4605
+ return null;
4606
+ }
4607
+ function pendingInstallMarkerPath(dir) {
4608
+ return path.join(dir, PENDING_INSTALL_MARKER_FILE);
4609
+ }
4610
+ function writePendingInstallMarker(dir, marker) {
4611
+ fs.mkdirSync(dir, { recursive: true });
4612
+ const target2 = pendingInstallMarkerPath(dir);
4613
+ const temp = `${target2}.tmp`;
4614
+ fs.writeFileSync(temp, `${JSON.stringify(marker, null, 2)}
4615
+ `, "utf8");
4616
+ fs.renameSync(temp, target2);
4617
+ return marker;
4618
+ }
4619
+ function parsePendingInstallMarker(raw) {
4620
+ try {
4621
+ const parsed = JSON.parse(raw);
4622
+ if (typeof parsed?.targetVersion !== "string" || parsed.targetVersion.length === 0) {
4623
+ return null;
4624
+ }
4625
+ return {
4626
+ targetVersion: parsed.targetVersion,
4627
+ artifactPath: typeof parsed.artifactPath === "string" ? parsed.artifactPath : "",
4628
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : "",
4629
+ watchdogPid: typeof parsed.watchdogPid === "number" ? parsed.watchdogPid : null
4630
+ };
4631
+ } catch {
4632
+ return null;
4633
+ }
4634
+ }
4635
+ function readPendingInstallMarker(dir) {
4636
+ const path2 = pendingInstallMarkerPath(dir);
4637
+ if (!fs.existsSync(path2)) return null;
4638
+ try {
4639
+ return parsePendingInstallMarker(fs.readFileSync(path2, "utf8"));
4640
+ } catch {
4641
+ return null;
4642
+ }
4643
+ }
4644
+ function clearPendingInstallMarker(dir) {
4645
+ const path2 = pendingInstallMarkerPath(dir);
4646
+ if (!fs.existsSync(path2)) return false;
4647
+ fs.rmSync(path2, { force: true });
4648
+ return true;
4649
+ }
4650
+ const LAST_INSTALL_ATTEMPT_FILE = "last-update-install.json";
4651
+ function lastInstallAttemptPath(dir) {
4652
+ return path.join(dir, LAST_INSTALL_ATTEMPT_FILE);
4653
+ }
4654
+ function readLastInstallAttempt(dir) {
4655
+ const path2 = lastInstallAttemptPath(dir);
4656
+ if (!fs.existsSync(path2)) return null;
4657
+ try {
4658
+ const parsed = JSON.parse(
4659
+ fs.readFileSync(path2, "utf8")
4660
+ );
4661
+ if (typeof parsed?.targetVersion !== "string" || parsed.targetVersion.length === 0) {
4662
+ return null;
4663
+ }
4664
+ return {
4665
+ targetVersion: parsed.targetVersion,
4666
+ runningVersion: typeof parsed.runningVersion === "string" ? parsed.runningVersion : "unknown",
4667
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null,
4668
+ detectedAt: typeof parsed.detectedAt === "string" ? parsed.detectedAt : "",
4669
+ detail: typeof parsed.detail === "string" ? parsed.detail : "",
4670
+ attempts: typeof parsed.attempts === "number" && parsed.attempts > 0 ? parsed.attempts : 1
4671
+ };
4672
+ } catch {
4673
+ return null;
4674
+ }
4675
+ }
4676
+ function recordInstallFailure(dir, input) {
4677
+ const previous = readLastInstallAttempt(dir);
4678
+ const record = {
4679
+ targetVersion: input.payload.targetVersion,
4680
+ runningVersion: previous?.runningVersion ?? "unknown",
4681
+ startedAt: input.startedAt,
4682
+ detectedAt: input.detectedAt,
4683
+ detail: input.payload.detail,
4684
+ attempts: previous && previous.targetVersion === input.payload.targetVersion ? previous.attempts + 1 : 1
4685
+ };
4686
+ fs.mkdirSync(dir, { recursive: true });
4687
+ const target2 = lastInstallAttemptPath(dir);
4688
+ const temp = `${target2}.tmp`;
4689
+ fs.writeFileSync(temp, `${JSON.stringify(record, null, 2)}
4690
+ `, "utf8");
4691
+ fs.renameSync(temp, target2);
4692
+ return record;
4693
+ }
4694
+ const VERSION_TRIPLE_REGEX = /^v?(\d+)\.(\d+)\.(\d+)/;
4695
+ function compareVersions(a, b) {
4696
+ const parse = (value) => {
4697
+ const match = VERSION_TRIPLE_REGEX.exec(value.trim());
4698
+ if (!match) return null;
4699
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
4700
+ };
4701
+ const left = parse(a);
4702
+ const right = parse(b);
4703
+ if (!left || !right) return null;
4704
+ for (let index = 0; index < 3; index++) {
4705
+ if (left[index] !== right[index])
4706
+ return left[index] < right[index] ? -1 : 1;
4707
+ }
4708
+ return 0;
4709
+ }
4710
+ function evaluatePendingInstall(input) {
4711
+ const { marker } = input;
4712
+ if (marker == null) return { kind: "none" };
4713
+ if (marker.targetVersion === input.runningVersion) {
4714
+ return { kind: "succeeded", marker };
4715
+ }
4716
+ const order = compareVersions(marker.targetVersion, input.runningVersion);
4717
+ if (order !== null && order < 0) return { kind: "stale", marker };
4718
+ return { kind: "failed", marker };
4719
+ }
4720
+ function installFailurePayload(marker, runningVersion, options = {}) {
4721
+ const started = marker.startedAt ? new Date(marker.startedAt) : null;
4722
+ const startedText = started && !Number.isNaN(started.getTime()) ? started.toLocaleString() : "at an unknown time";
4723
+ const logText = options.shipItLogPath ? ` Squirrel's own log is at ${options.shipItLogPath}.` : "";
4724
+ return {
4725
+ message: `The update to version ${marker.targetVersion} didn't finish, so version ${runningVersion} is still running.`,
4726
+ remedy: {
4727
+ text: "Quit Local Operator and replace it in Applications with a fresh copy, or update again from the app.",
4728
+ url: DOWNLOAD_PAGE_URL
4729
+ },
4730
+ detail: `Install started ${startedText} from ${marker.artifactPath || "an unknown artifact"}.${logText}`,
4731
+ targetVersion: marker.targetVersion,
4732
+ attempts: options.attempts ?? 1
4733
+ };
4734
+ }
4735
+ function shipItJobLabel(bundleId) {
4736
+ return `${bundleId}.ShipIt`;
4737
+ }
4738
+ function shipItCacheDir(cacheRoot, bundleId) {
4739
+ return path.join(cacheRoot, shipItJobLabel(bundleId));
4740
+ }
4741
+ function watchdogSwapTarget(input) {
4742
+ if (!input.target) return null;
4743
+ if (!input.running) return input.target;
4744
+ const order = compareVersions(input.target, input.running);
4745
+ if (order !== null && order <= 0) return null;
4746
+ if (input.target === input.running) return null;
4747
+ return input.target;
4748
+ }
4749
+ function buildWatchdogPlan(input) {
4750
+ const timeoutSeconds = input.timeoutSeconds ?? WATCHDOG_TIMEOUT_SECONDS;
4751
+ const intervalSeconds = input.intervalSeconds ?? 3;
4752
+ const settleSeconds = input.settleSeconds ?? 5;
4753
+ const appearSeconds = input.appearSeconds ?? 30;
4754
+ const plistTimeoutSeconds = input.plistReadTimeoutSeconds ?? PLIST_READ_TIMEOUT_SECONDS;
4755
+ const script = `#!/bin/sh
4756
+ # ${WATCHDOG_TOKEN}
4757
+ #
4758
+ # Why this exists: a failed Squirrel.Mac install quits the app and never brings
4759
+ # it back - ShipIt logs an installation error, leaves no exit status and does not
4760
+ # run the relaunch - so the user is left with no app and no message (operator
4761
+ # report, 2026-09-11, errSecCSBadBundleFormat -67028).
4762
+ #
4763
+ # How it decides, and why each signal is the one it is:
4764
+ # - "the app has exited" is asked of the app's own pid (kill -0), captured by
4765
+ # the app before it quit. A name probe cannot answer it on macOS: pgrep -f
4766
+ # does not report its own ancestors, and this script is the app's child.
4767
+ # - "the install is over" is asked of BOTH the ShipIt launchd job, by label,
4768
+ # AND the state of the swap on disk. The job is submitted as part of the quit
4769
+ # and unloaded when an install settles, so it is the signal that separates a
4770
+ # hung install from an unrelated process that merely has the word in its
4771
+ # command line - but it is not sufficient on its own: the 0.17.0 failure
4772
+ # left its job loaded and respawning for hours (runs=3114), so waiting only
4773
+ # on the job meant waiting for the bound with the user staring at nothing
4774
+ # (review R11). The version in the target bundle's own Info.plist is what
4775
+ # says the swap landed, whether or not the job ever goes away.
4776
+ #
4777
+ # It starts the app in exactly one situation - the app is not running - and it
4778
+ # reaches that point on three paths:
4779
+ # (a) ShipIt's job is gone: the install is decided. The conservative case.
4780
+ # (b) the bundle at the target path reports the version this update was for, or
4781
+ # one beyond it: the new app is in place and ShipIt has nothing left to do.
4782
+ # This is what makes an early exit safe rather than a guess, and it is the
4783
+ # path a failed-but-swapped install takes.
4784
+ # (c) the bound arrived with no decision. Trying is still better than leaving
4785
+ # the user with nothing, and the bound is what makes the previous
4786
+ # version's silent exit 0 impossible. It is ~2.4x the slowest ShipIt
4787
+ # attempt measured on this machine (255s from request to verdict for a
4788
+ # 1 GiB app), and it is the ONE path that can start the app while a swap
4789
+ # is still in flight - an accepted risk, not an oversight.
4790
+ #
4791
+ # With no job label to ask about, the job cannot be consulted at all, so the swap
4792
+ # state is the whole signal: the script waits for (b) rather than spending an
4793
+ # appear window on a job it cannot see and then relaunching on no evidence.
4794
+ set -u
4795
+ APP_PID="\${LO_UPDATE_WATCHDOG_APP_PID:-}"
4796
+ BUNDLE="\${LO_UPDATE_WATCHDOG_APP_BUNDLE:-}"
4797
+ NAME="\${LO_UPDATE_WATCHDOG_APP_NAME:-}"
4798
+ SHIPIT_JOB="\${LO_UPDATE_WATCHDOG_SHIPIT_JOB:-}"
4799
+ TARGET_VERSION="\${LO_UPDATE_WATCHDOG_TARGET_VERSION:-}"
4800
+ if [ -z "$APP_PID" ] || [ -z "$BUNDLE" ]; then
4801
+ exit 1
4802
+ fi
4803
+ now() { date +%s; }
4804
+ app_running() { kill -0 "$APP_PID" 2>/dev/null; }
4805
+ # launchctl list <label> exits 113 when the job is not loaded, 0 when it is.
4806
+ shipit_loaded() { [ -n "$SHIPIT_JOB" ] && launchctl list "$SHIPIT_JOB" >/dev/null 2>&1; }
4807
+ # (b), the swap's own state: is the app at the target path AT OR BEYOND the
4808
+ # version this update was for? plutil rather than \`defaults read\`, which reads
4809
+ # through a preference domain and can answer from a stale cache; a half-written
4810
+ # plist and a missing one both read as "not landed yet", which is the safe
4811
+ # direction.
4812
+ #
4813
+ # At or beyond rather than an exact match, because this is the same question the
4814
+ # renderer's clear rule asks ("is the server no longer behind the version the
4815
+ # panel named?"): a release that moves on between the offer and the check must
4816
+ # not leave the app waiting out the whole bound for a bundle already past the
4817
+ # target (review Q6).
4818
+ version_at_least() {
4819
+ # An absent version is not a version. This is the ONE input the two halves
4820
+ # answer differently on, deliberately: the renderer pads a missing side with
4821
+ # zero (\`left[index] ?? 0\`), so it reads an empty target as "at least 0",
4822
+ # while a rule that can start a relaunch must not call a version nobody
4823
+ # reported "landed". Both callers prove the operand non-empty first - the
4824
+ # script's \`swap_landed\` declines an empty target outright and the read
4825
+ # answers nothing rather than a version - so no path reaches this with one.
4826
+ [ -n "$1" ] && [ -n "$2" ] || return 1
4827
+ # Exact match first, as the renderer's rule does: a pre-release pair that
4828
+ # matches exactly IS the version that was waited for, and the digit checks
4829
+ # below cannot order a suffix at all. The caller strips a leading \`v\` before
4830
+ # calling, which is the other half of what the renderer's rule does itself.
4831
+ [ "$1" = "$2" ] && return 0
4832
+ # Anything else carrying a suffix is not orderable here, and the renderer's
4833
+ # rule refuses the same inputs for the same reason ("keeps the instruction on
4834
+ # screen rather than clearing over a real gap"). Answered before the digit
4835
+ # walk, because a suffix the unrelated leading numeric components never reach
4836
+ # would otherwise be ignored - \`1.0.0\` against \`0.18.0-rc1\` compared as
4837
+ # landed here and as not-beyond there. Not-landed is the safe direction in
4838
+ # this script: the job and the bound still decide, and the app still comes
4839
+ # back.
4840
+ case "$1$2" in *-*) return 1 ;; esac
4841
+ _va="$1"
4842
+ _vb="$2"
4843
+ while :; do
4844
+ case "$_va" in
4845
+ *.*) _ha="\${_va%%.*}"; _va="\${_va#*.}" ;;
4846
+ *) _ha="$_va"; _va="" ;;
4847
+ esac
4848
+ case "$_vb" in
4849
+ *.*) _hb="\${_vb%%.*}"; _vb="\${_vb#*.}" ;;
4850
+ *) _hb="$_vb"; _vb="" ;;
4851
+ esac
4852
+ # A component with no digits in it is a pre-release suffix, which this
4853
+ # cannot order: it reads as "not landed", so the job and the bound stay
4854
+ # the deciders. Same shape as the renderer's rule.
4855
+ case "$_ha" in *[!0-9]*) return 1 ;; esac
4856
+ case "$_hb" in *[!0-9]*) return 1 ;; esac
4857
+ # A side that has run out contributes zero, which is what the renderer's
4858
+ # \`left[index] ?? 0\` does, so unequally wide dotted versions order the
4859
+ # same way in both halves of "at or beyond". Without this, \`0.18.0.1\`
4860
+ # against \`0.18.0\` read as not-landed here and as landed there (review
4861
+ # round 4, M3): the safe direction is not a reason to leave two
4862
+ # implementations of one rule disagreeing.
4863
+ [ -n "$_ha" ] || _ha=0
4864
+ [ -n "$_hb" ] || _hb=0
4865
+ if [ "$_ha" -gt "$_hb" ]; then return 0; fi
4866
+ if [ "$_ha" -lt "$_hb" ]; then return 1; fi
4867
+ if [ -z "$_va" ] && [ -z "$_vb" ]; then return 0; fi
4868
+ done
4869
+ }
4870
+ # Read the bundle's version under a hard time bound. This sits inside the poll
4871
+ # loop, and an unbounded read of a path on a mount that has stopped answering
4872
+ # would outlive the script's own deadline and leave the user with no app - the
4873
+ # very outcome this script exists to undo. macOS ships no \`timeout(1)\`, so the
4874
+ # bound is a background read the script can stop waiting on (review R17).
4875
+ bundle_version() {
4876
+ _plist="$BUNDLE/Contents/Info.plist"
4877
+ # A plist that is not readable yet - the ordinary state while a swap is in
4878
+ # flight - answers without starting a read at all, so the bound below is only
4879
+ # ever spent on a read that started and did not answer.
4880
+ [ -r "$_plist" ] || return 1
4881
+ _stamp="$$-$(now)"
4882
+ _out="\${TMPDIR:-/tmp}/lo-update-watchdog-version-$_stamp.out"
4883
+ # The pid the bound kills IS the reader: \`exec\` replaces the subshell rather
4884
+ # than wrapping plutil, so nothing is reparented when the kill lands. The
4885
+ # previous shape killed a subshell whose whole job was to drop a completion
4886
+ # marker, and the read that subshell had forked was left blocked under pid 1 -
4887
+ # one more per poll, for as long as the mount stayed dead (review round 4,
4888
+ # Q7). Completion is read off the answer itself instead: plutil writes the
4889
+ # version into this file and nothing else does, so bytes in it mean the read
4890
+ # answered, and a read that fails writes none and is stopped by the same
4891
+ # deadline.
4892
+ ( exec /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$_plist" ) >"$_out" 2>/dev/null &
4893
+ _read_pid=$!
4894
+ _waited=0
4895
+ while [ ! -s "$_out" ]; do
4896
+ if [ "$_waited" -ge ${plistTimeoutSeconds} ]; then
4897
+ kill -9 "$_read_pid" 2>/dev/null
4898
+ rm -f "$_out"
4899
+ return 1
4900
+ fi
4901
+ sleep 1
4902
+ _waited=$((_waited + 1))
4903
+ done
4904
+ _version="$(cat "$_out" 2>/dev/null)"
4905
+ rm -f "$_out"
4906
+ [ -n "$_version" ] || return 1
4907
+ printf '%s
4908
+ ' "$_version"
4909
+ }
4910
+ swap_landed() {
4911
+ [ -n "$TARGET_VERSION" ] || return 1
4912
+ installed=$(bundle_version) || return 1
4913
+ installed=\${installed#v}
4914
+ [ -n "$installed" ] || return 1
4915
+ target=\${TARGET_VERSION#v}
4916
+ [ "$installed" = "$target" ] && return 0
4917
+ version_at_least "$installed" "$target"
4918
+ }
4919
+ job_known=0
4920
+ [ -n "$SHIPIT_JOB" ] && job_known=1
4921
+ decided() {
4922
+ if [ "$job_known" -eq 1 ] && ! shipit_loaded; then return 0; fi
4923
+ swap_landed && return 0
4924
+ return 1
4925
+ }
4926
+ deadline=$(( $(now) + ${timeoutSeconds} ))
4927
+ # 1. The install only starts once the old app has exited.
4928
+ while app_running; do
4929
+ if [ "$(now)" -ge "$deadline" ]; then break; fi
4930
+ sleep ${intervalSeconds}
4931
+ done
4932
+ # 2. ShipIt's job is submitted as part of the quit, so it may not be loaded the
4933
+ # instant the app is gone: give it a bounded window to appear before treating
4934
+ # "no job" as "the install is over". Skipped when there is no label to ask
4935
+ # about: an appear window for an unaskable job is 30s of pretending, and it
4936
+ # used to end by relaunching with no evidence about the install at all.
4937
+ if [ "$job_known" -eq 1 ]; then
4938
+ appear_deadline=$(( $(now) + ${appearSeconds} ))
4939
+ while ! shipit_loaded; do
4940
+ if [ "$(now)" -ge "$appear_deadline" ] || [ "$(now)" -ge "$deadline" ]; then break; fi
4941
+ sleep ${intervalSeconds}
4942
+ done
4943
+ fi
4944
+ # 3. Wait for the install to be decided - by the job going, or by the swap
4945
+ # landing - and never past the bound.
4946
+ while :; do
4947
+ if decided; then break; fi
4948
+ if [ "$(now)" -ge "$deadline" ]; then break; fi
4949
+ sleep ${intervalSeconds}
4950
+ done
4951
+ # Let Squirrel's own relaunch (which happens as the job finishes) land first.
4952
+ sleep ${settleSeconds}
4953
+ # 4. Nothing to do if Squirrel relaunched the app or the user started it. This is
4954
+ # what makes the script a no-op when the install succeeded, by construction
4955
+ # rather than by claim.
4956
+ if app_running; then exit 0; fi
4957
+ if [ -n "$NAME" ] && [ -x "$BUNDLE/Contents/MacOS/$NAME" ]; then
4958
+ open -a "$BUNDLE" >/dev/null 2>&1 || "$BUNDLE/Contents/MacOS/$NAME" >/dev/null 2>&1 &
4959
+ fi
4960
+ exit 0
4961
+ `;
4962
+ return {
4963
+ script,
4964
+ env: {
4965
+ LO_UPDATE_WATCHDOG_APP_PID: String(input.appPid),
4966
+ LO_UPDATE_WATCHDOG_APP_BUNDLE: input.appBundlePath,
4967
+ LO_UPDATE_WATCHDOG_APP_NAME: input.executableName,
4968
+ LO_UPDATE_WATCHDOG_SHIPIT_JOB: input.shipItJob ?? "",
4969
+ LO_UPDATE_WATCHDOG_TARGET_VERSION: input.targetVersion ?? ""
4970
+ },
4971
+ timeoutSeconds
4972
+ };
4973
+ }
4974
+ function reapFailedInstall(input) {
4975
+ const result = {
4976
+ jobLabel: null,
4977
+ jobRemoved: false,
4978
+ removedStaging: [],
4979
+ errors: []
4980
+ };
4981
+ if (!input.bundleId) {
4982
+ input.log(
4983
+ "Skipped cleaning up the failed install: the app's bundle id could not be read."
4984
+ );
4985
+ return result;
4986
+ }
4987
+ const label = shipItJobLabel(input.bundleId);
4988
+ result.jobLabel = label;
4989
+ try {
4990
+ const removal = input.removeJob(label);
4991
+ result.jobRemoved = !removal.notFound;
4992
+ input.log(
4993
+ removal.notFound ? `Leftover install job ${label} was not loaded.` : `Removed the leftover install job ${label}.`
4994
+ );
4995
+ } catch (error) {
4996
+ result.errors.push(`launchctl remove ${label}: ${String(error)}`);
4997
+ input.log(
4998
+ `Could not remove the leftover install job ${label}: ${String(error)}`
4999
+ );
5000
+ }
5001
+ const cacheDir = input.cacheRoot ? shipItCacheDir(input.cacheRoot, input.bundleId) : null;
5002
+ if (!cacheDir) return result;
5003
+ try {
5004
+ if (!input.exists(cacheDir)) return result;
5005
+ for (const entry of input.listDir(cacheDir)) {
5006
+ if (!entry.startsWith("update.")) continue;
5007
+ const staging = path.join(cacheDir, entry);
5008
+ try {
5009
+ input.removeDir(staging);
5010
+ result.removedStaging.push(staging);
5011
+ input.log(`Removed the staged update left behind at ${staging}.`);
5012
+ } catch (error) {
5013
+ result.errors.push(`${staging}: ${String(error)}`);
5014
+ input.log(`Could not remove ${staging}: ${String(error)}`);
5015
+ }
5016
+ }
5017
+ } catch (error) {
5018
+ result.errors.push(`${cacheDir}: ${String(error)}`);
5019
+ input.log(`Could not inspect ${cacheDir}: ${String(error)}`);
5020
+ }
5021
+ return result;
5022
+ }
5023
+ function watchdogIsOurs(input) {
5024
+ if (!input.alive || !input.installSucceeded) return false;
5025
+ return (input.commandLine ?? "").includes(WATCHDOG_TOKEN);
5026
+ }
5027
+ const UV_TOOL_LIST_ENTRY_REGEX = /(^|\n)local-operator v\d/i;
5028
+ const PIPX_LIST_ENTRY_REGEX = /(^|\n)\s*package local-operator\b/i;
5029
+ function uvToolListNamesLocalOperator(output) {
5030
+ return UV_TOOL_LIST_ENTRY_REGEX.test(output ?? "");
5031
+ }
5032
+ function pipxListNamesLocalOperator(output) {
5033
+ return PIPX_LIST_ENTRY_REGEX.test(output ?? "");
5034
+ }
5035
+ const PYTHON_LIB_DIR = /^python\d/;
5036
+ const LOCAL_OPERATOR_DIST_INFO = /^local[-_]operator[-_]/;
5037
+ function resolveDistributionMarkers(prefix) {
5038
+ const sitePackages = [];
5039
+ try {
5040
+ for (const entry of fs.readdirSync(path.join(prefix, "lib"))) {
5041
+ if (!PYTHON_LIB_DIR.test(entry)) continue;
5042
+ const candidate = path.join(prefix, "lib", entry, "site-packages");
5043
+ if (fs.existsSync(candidate)) sitePackages.push(candidate);
5044
+ }
5045
+ } catch {
5046
+ }
5047
+ const windowsSitePackages = path.join(prefix, "Lib", "site-packages");
5048
+ if (fs.existsSync(windowsSitePackages)) sitePackages.push(windowsSitePackages);
5049
+ let installer = null;
5050
+ let editable = false;
5051
+ for (const dir of sitePackages) {
5052
+ let entries;
5053
+ try {
5054
+ entries = fs.readdirSync(dir);
5055
+ } catch {
5056
+ continue;
5057
+ }
5058
+ for (const entry of entries) {
5059
+ if (!LOCAL_OPERATOR_DIST_INFO.test(entry)) continue;
5060
+ if (!entry.endsWith(".dist-info")) continue;
5061
+ const distInfo = path.join(dir, entry);
5062
+ try {
5063
+ const installerText = fs.readFileSync(path.join(distInfo, "INSTALLER"), "utf8");
5064
+ installer = installerText.trim().toLowerCase() || installer;
5065
+ } catch {
5066
+ }
5067
+ try {
5068
+ const payload = JSON.parse(
5069
+ fs.readFileSync(path.join(distInfo, "direct_url.json"), "utf8")
5070
+ );
5071
+ if (payload.editable === true || payload.dir_info?.editable === true) {
5072
+ editable = true;
5073
+ }
5074
+ } catch {
5075
+ }
5076
+ }
5077
+ }
5078
+ return { installer, editable };
5079
+ }
5080
+ const TRAILING_SLASHES = /\/+$/;
5081
+ function commandSearchDirs(env, home) {
5082
+ const dirs = [];
5083
+ const add = (dir) => {
5084
+ if (!dir) return;
5085
+ const trimmed = dir.replace(TRAILING_SLASHES, "");
5086
+ if (trimmed.length === 0 || dirs.includes(trimmed)) return;
5087
+ dirs.push(trimmed);
5088
+ };
5089
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) add(dir);
5090
+ add(env.UV_TOOL_BIN_DIR);
5091
+ add(env.XDG_BIN_HOME);
5092
+ if (env.XDG_DATA_HOME) add(path.join(env.XDG_DATA_HOME, "..", "bin"));
5093
+ add(path.join(home, ".local", "bin"));
5094
+ add("/opt/homebrew/bin");
5095
+ add("/usr/local/bin");
5096
+ add("/usr/bin");
5097
+ add("/bin");
5098
+ return dirs;
5099
+ }
5100
+ function uvToolBinDirs(env, home, listDir, platform) {
5101
+ const root = env.UV_TOOL_DIR ?? (env.XDG_DATA_HOME ? path.join(env.XDG_DATA_HOME, "uv", "tools") : path.join(home, ".local", "share", "uv", "tools"));
5102
+ const scriptDir = platform === "win32" ? "Scripts" : "bin";
5103
+ const dirs = [];
5104
+ try {
5105
+ for (const entry of listDir(root)) {
5106
+ dirs.push(path.join(root, entry, scriptDir));
5107
+ }
5108
+ } catch {
5109
+ }
5110
+ return dirs;
5111
+ }
5112
+ const WINDOWS_DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
5113
+ function windowsCommandExtensions(env) {
5114
+ const raw = (env.PATHEXT ?? "").trim();
5115
+ const parts = (raw.length > 0 ? raw : WINDOWS_DEFAULT_PATHEXT).split(";");
5116
+ const extensions = [];
5117
+ for (const part of parts) {
5118
+ const extension = part.trim();
5119
+ if (extension.length === 0 || extensions.includes(extension)) continue;
5120
+ extensions.push(extension);
5121
+ }
5122
+ return extensions;
5123
+ }
5124
+ function commandCandidates(dir, name, env, platform) {
5125
+ const candidates = [path.join(dir, name)];
5126
+ if (platform !== "win32") return candidates;
5127
+ for (const extension of windowsCommandExtensions(env)) {
5128
+ candidates.push(path.join(dir, name + extension));
5129
+ }
5130
+ return candidates;
5131
+ }
5132
+ function resolveCommandPath(name, options = {}) {
5133
+ const env = options.env ?? process.env;
5134
+ const home = options.home ?? os.homedir();
5135
+ const platform = options.platform ?? process.platform;
5136
+ const exists = options.exists ?? fs.existsSync;
5137
+ const listDir = options.listDir ?? ((dir) => {
5138
+ try {
5139
+ return fs.readdirSync(dir);
5140
+ } catch {
5141
+ return [];
5142
+ }
5143
+ });
5144
+ const dirs = [
5145
+ ...commandSearchDirs(env, home),
5146
+ ...uvToolBinDirs(env, home, listDir, platform)
5147
+ ];
5148
+ for (const dir of dirs) {
5149
+ for (const candidate of commandCandidates(dir, name, env, platform)) {
5150
+ try {
5151
+ if (exists(candidate)) return candidate;
5152
+ } catch {
5153
+ }
5154
+ }
5155
+ }
5156
+ return null;
5157
+ }
5158
+ function readInstallIdentity(shimPath) {
5159
+ if (!shimPath) return { path: null };
5160
+ let realPath = null;
5161
+ try {
5162
+ realPath = fs.realpathSync(shimPath);
5163
+ } catch {
5164
+ realPath = null;
5165
+ }
5166
+ let shebang = null;
5167
+ try {
5168
+ const firstLine = fs.readFileSync(shimPath, "utf8").split("\n", 1)[0] ?? "";
5169
+ shebang = firstLine.startsWith("#!") ? firstLine : null;
5170
+ } catch {
5171
+ shebang = null;
5172
+ }
5173
+ const executable = realPath ?? shimPath;
5174
+ const prefix = path.dirname(path.dirname(executable));
5175
+ const existing = (candidate) => fs.existsSync(candidate) ? candidate : null;
5176
+ const markers = resolveDistributionMarkers(prefix);
5177
+ return {
5178
+ path: shimPath,
5179
+ realPath,
5180
+ shebang,
5181
+ uvReceipt: existing(path.join(prefix, "uv-receipt.toml")),
5182
+ venvPrefix: fs.existsSync(path.join(prefix, "pyvenv.cfg")) ? prefix : null,
5183
+ installer: markers.installer,
5184
+ editable: markers.editable
5185
+ };
5186
+ }
5187
+ function classifyGlobalInstall(identity) {
5188
+ if (!identity.path) return "global-unknown";
5189
+ if (identity.editable) return "editable";
5190
+ const haystack = [
5191
+ identity.path,
5192
+ identity.realPath ?? "",
5193
+ identity.shebang ?? ""
5194
+ ].join("\n").toLowerCase();
5195
+ if (haystack.includes("/uv/tools/")) return "uv-tool";
5196
+ if (identity.uvReceipt) return "uv-tool";
5197
+ if (uvToolListNamesLocalOperator(identity.uvToolList)) return "uv-tool";
5198
+ if (haystack.includes("/pipx/venvs/") || haystack.includes("/pipx/") && haystack.includes("venvs"))
5199
+ return "pipx";
5200
+ if (pipxListNamesLocalOperator(identity.pipxList)) return "pipx";
5201
+ if (identity.venvPrefix) return "pip";
5202
+ if (identity.installer === "pip") return "pip";
5203
+ return "global-unknown";
5204
+ }
5205
+ function resolveGlobalInstallPlan(input) {
5206
+ const kind = classifyGlobalInstall(input.identity);
5207
+ const sourceBuild = kind === "editable" || kind === "uv-tool" && Boolean(input.lopUpdatePath);
5208
+ const detail = input.identity.path ? `local-operator resolves to ${input.identity.path}${input.identity.realPath && input.identity.realPath !== input.identity.path ? ` (${input.identity.realPath})` : ""}, classified as ${kind}${sourceBuild ? ", built from source on this machine, so it follows the checkout rather than the published release" : ""}` : "local-operator was not found on PATH";
5209
+ if (kind === "editable") {
5210
+ return {
5211
+ canManageUpdate: false,
5212
+ updateCommand: "",
5213
+ remedy: "The server is running from a source checkout on this machine rather than an installed copy, so update it with lop-update after your change is merged.",
5214
+ detail,
5215
+ sourceBuild: true
5216
+ };
5217
+ }
5218
+ if (kind === "uv-tool") {
5219
+ const useLopUpdate = Boolean(input.lopUpdatePath);
5220
+ return {
5221
+ canManageUpdate: false,
5222
+ updateCommand: useLopUpdate ? "lop-update" : "uv tool upgrade local-operator",
5223
+ remedy: useLopUpdate ? "The server is a uv tool install built from source on this machine, so update it from your terminal:" : "The server is a uv tool install, so update it from your terminal:",
5224
+ detail,
5225
+ sourceBuild
5226
+ };
5227
+ }
5228
+ if (kind === "pipx") {
5229
+ return {
5230
+ canManageUpdate: false,
5231
+ updateCommand: "pipx upgrade local-operator",
5232
+ remedy: "The server is a pipx install, so update it from your terminal:",
5233
+ detail,
5234
+ sourceBuild: false
5235
+ };
5236
+ }
5237
+ if (kind === "pip") {
5238
+ return {
5239
+ canManageUpdate: false,
5240
+ updateCommand: "pip install --upgrade local-operator",
5241
+ remedy: "The server is a pip install, so update it from your terminal:",
5242
+ detail,
5243
+ sourceBuild: false
5244
+ };
5245
+ }
5246
+ return {
5247
+ canManageUpdate: false,
5248
+ updateCommand: "",
5249
+ remedy: "The app could not tell how this server was installed, so update it with the tool you installed it with - uv, pipx or pip:",
5250
+ detail,
5251
+ sourceBuild: false
5252
+ };
5253
+ }
5254
+ function buildPipUpgradeCommand(pythonPath) {
5255
+ const args = [
5256
+ "-m",
5257
+ "pip",
5258
+ "install",
5259
+ "--upgrade",
5260
+ "--no-input",
5261
+ "--disable-pip-version-check",
5262
+ "local-operator"
5263
+ ];
5264
+ return {
5265
+ command: pythonPath,
5266
+ args,
5267
+ display: `"${pythonPath}" ${args.join(" ")}`
5268
+ };
5269
+ }
5270
+ const PIP_SHOW_VERSION_REGEX = /^Version:\s*(.+)$/m;
5271
+ function parsePipShowVersion(stdout) {
5272
+ const match = stdout.match(PIP_SHOW_VERSION_REGEX);
5273
+ if (!match) return null;
5274
+ const version2 = match[1].trim();
5275
+ return version2.length > 0 ? version2 : null;
5276
+ }
5277
+ function didUpgradeLand(input) {
5278
+ const { before, after, target: target2 } = input;
5279
+ if (after == null) return false;
5280
+ if (before == null) {
5281
+ return target2 != null && after.trim() === target2.trim();
5282
+ }
5283
+ return before.trim() !== after.trim();
5284
+ }
5285
+ const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
5286
+ const VERSION_CLEAN_REGEX = /^v/i;
5287
+ const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
5288
+ const BUNDLE_ID_REGEX = /^[\w.-]+$/;
5289
+ const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
5290
+ const SEAL_PROBE_TIMEOUT_MS = 45e3;
5291
+ function versionSuffix(version2) {
5292
+ return version2 ? ` to version ${version2}` : "";
5293
+ }
5294
+ function runCommand(command, args, options = {}) {
5295
+ return new Promise((resolve) => {
5296
+ node_child_process.execFile(
5297
+ command,
5298
+ args,
5299
+ { timeout: options.timeoutMs ?? 2e4, maxBuffer: 4 * 1024 * 1024 },
5300
+ (error, stdout, stderr) => {
5301
+ const code = error?.code;
5302
+ const killed = error?.killed === true;
5303
+ const out = stdout?.toString() ?? "";
5304
+ const err = stderr?.toString() ?? "";
5305
+ resolve({
5306
+ exitCode: error ? typeof code === "number" ? code : 1 : 0,
5307
+ stdout: out,
5308
+ stderr: err,
5309
+ // A timeout (killed) or a process that failed to start (a string
5310
+ // code such as ENOENT) produced no verdict about the bundle. A
5311
+ // numeric exit with output did.
5312
+ ran: !error || !killed && typeof code === "number" && `${out}${err}`.trim().length > 0
5313
+ });
5314
+ }
5315
+ );
5316
+ });
5317
+ }
5318
+ function readCommandOutput(command, args) {
5319
+ try {
5320
+ const output = node_child_process.execFileSync(command, args, {
5321
+ encoding: "utf8",
5322
+ timeout: 5e3
5323
+ });
5324
+ return output.trim();
5325
+ } catch {
5326
+ return null;
5327
+ }
5328
+ }
5329
+ class UpdateService {
5330
+ mainWindow = null;
5331
+ isDevMode;
5332
+ isNpxInstall;
5333
+ backendUrl;
5334
+ updateCheckInterval = null;
5335
+ backendService = null;
5336
+ /**
5337
+ * What the updater is doing right now.
5338
+ *
5339
+ * Only used to decide whether an `error` event is a failure the user has to
5340
+ * hear about: a download or an install that dies is not "no update
5341
+ * available", which is how `shouldFilterUpdateError` would otherwise read it.
5342
+ */
5343
+ updateStage = "idle";
5344
+ /** The last update the updater told us about, for its file metadata. */
5345
+ lastUpdateInfo = null;
5346
+ /**
5347
+ * The version the published release last answered with, or null.
5348
+ *
5349
+ * The by-hand prompt is produced from a click, not from a check, so it has no
5350
+ * version of its own to name - and the panel that tells the user to go and do
5351
+ * work is the one that most needs to say what they are working towards. The
5352
+ * periodic and start-up checks read this from the published release already,
5353
+ * so it is kept rather than fetched again on the click (review U17).
5354
+ */
5355
+ lastPublishedBackendVersion = null;
5356
+ /** Path of the artifact this run downloaded, when the updater reported one. */
5357
+ downloadedArtifactPath = null;
5358
+ /**
5359
+ * Artifacts that failed their size/sha512/disk check, by version.
5360
+ *
5361
+ * The 5-minute periodic check runs regardless of what the user dismissed, so
5362
+ * without this an update that cannot be verified is offered again every five
5363
+ * minutes for as long as the app is open.
5364
+ */
5365
+ failedVerificationVersions = /* @__PURE__ */ new Set();
5366
+ /** A failed install detected from the marker on this start, if any. */
5367
+ pendingInstallFailure = null;
5368
+ installFailureDelivered = false;
5369
+ /**
5370
+ * True while `quit-and-install` is running its pre-flight.
5371
+ *
5372
+ * The pre-flight can take seconds over a large bundle and artifact, and the
5373
+ * whole point of it is that a second request must not run beside the first (see
5374
+ * the handler).
5375
+ */
5376
+ installPreflightInFlight = false;
5377
+ /**
5378
+ * Initialize the update service
5379
+ * @param mainWindow - The main application window
5380
+ * @param backendService - Optional backend service manager for restarting the backend after updates
5381
+ */
5382
+ constructor(mainWindow2, backendService2) {
5383
+ this.mainWindow = mainWindow2;
5384
+ this.backendService = backendService2 || null;
5385
+ this.isDevMode = !electron.app.isPackaged || Boolean(process.env.ELECTRON_RENDERER_URL);
5386
+ const execPath = process.execPath;
5387
+ this.isNpxInstall = execPath.includes("node_modules/.bin") || execPath.includes("node_modules\\.bin");
5388
+ this.backendUrl = apiConfig.baseUrl.replace("localhost", "127.0.0.1");
5389
+ logger.info(
5390
+ `Update service initialized. Dev mode: ${this.isDevMode}, NPX install: ${this.isNpxInstall}`,
5391
+ LogFileType.UPDATE_SERVICE
5392
+ );
5393
+ electronUpdater.autoUpdater.logger = {
5394
+ info: (message) => logger.info(message, LogFileType.UPDATE_SERVICE),
5395
+ warn: (message) => logger.warn(message, LogFileType.UPDATE_SERVICE),
5396
+ error: (message) => logger.error(message, LogFileType.UPDATE_SERVICE),
5397
+ debug: (message) => logger.debug(message, LogFileType.UPDATE_SERVICE)
5398
+ };
5399
+ electronUpdater.autoUpdater.autoDownload = false;
5400
+ electronUpdater.autoUpdater.autoInstallOnAppQuit = false;
5401
+ this.setupUpdateEvents();
5402
+ this.recoverPendingInstall();
5403
+ this.startPeriodicUpdateChecks();
5404
+ }
5405
+ /**
5406
+ * Start periodic update checks
5407
+ * Checks for updates every 5 minutes
5408
+ */
5409
+ startPeriodicUpdateChecks() {
5410
+ if (this.updateCheckInterval) {
5411
+ clearInterval(this.updateCheckInterval);
5412
+ }
5413
+ this.updateCheckInterval = setInterval(() => {
5414
+ logger.info(
5415
+ "Running scheduled update check (every 5 minutes)",
5416
+ LogFileType.UPDATE_SERVICE
5417
+ );
5418
+ this.checkForAllUpdates(true);
5419
+ }, 3e5);
5420
+ logger.info(
5421
+ "Periodic update checks scheduled (every 5 minutes)",
5422
+ LogFileType.UPDATE_SERVICE
5423
+ );
5424
+ }
5425
+ // -----------------------------------------------------------------------
5426
+ // Install robustness
5427
+ // -----------------------------------------------------------------------
5428
+ /**
5429
+ * Where the pending-update marker lives.
5430
+ *
5431
+ * userData, not the app bundle: the marker has to survive the bundle being
5432
+ * replaced (that is exactly the case it describes) and it must not be inside
5433
+ * the thing ShipIt is swapping.
5434
+ */
5435
+ markerDir() {
5436
+ return electron.app.getPath("userData");
5437
+ }
5438
+ sendToRenderer(channel, payload) {
5439
+ const webContents = this.mainWindow?.webContents;
5440
+ if (!webContents || webContents.isDestroyed()) return false;
5441
+ webContents.send(channel, payload);
5442
+ return true;
5443
+ }
5444
+ /**
5445
+ * Report a refusal to start an install, with the reason and the remedy.
5446
+ *
5447
+ * Sent rather than thrown: the caller is an IPC handler the renderer fires and
5448
+ * forgets, and the whole point of these checks is that the user gets an
5449
+ * explanation instead of an app that quits and never returns.
5450
+ */
5451
+ sendInstallBlock(block, version2) {
5452
+ logger.error(
5453
+ `Update refused (${block.code}): ${block.detail}`,
5454
+ LogFileType.UPDATE_SERVICE
5455
+ );
5456
+ this.sendToRenderer("update-install-blocked", {
5457
+ code: block.code,
5458
+ version: version2 ?? null,
5459
+ message: block.message,
5460
+ remedy: block.remedy,
5461
+ detail: block.detail
5462
+ });
5463
+ }
5464
+ /**
5465
+ * Read the marker a previous run may have left, and act on it.
5466
+ *
5467
+ * A failed Squirrel install leaves no exit status and never relaunches the
5468
+ * app, so the marker plus the running version is the only record that the
5469
+ * update did not happen. Everything the user learns afterwards starts here:
5470
+ * the notice, the durable record it can be found in later, and the cleanup of
5471
+ * what Squirrel left running.
5472
+ */
5473
+ recoverPendingInstall() {
5474
+ const marker = readPendingInstallMarker(this.markerDir());
5475
+ const outcome = evaluatePendingInstall({
5476
+ marker,
5477
+ runningVersion: electron.app.getVersion()
5478
+ });
5479
+ switch (outcome.kind) {
5480
+ case "none":
5481
+ return;
5482
+ case "succeeded":
5483
+ logger.info(
5484
+ `Update marker: install of version ${outcome.marker.targetVersion} succeeded (running ${electron.app.getVersion()}).`,
5485
+ LogFileType.UPDATE_SERVICE
5486
+ );
5487
+ this.reapWatchdog(outcome.marker, true);
5488
+ clearPendingInstallMarker(this.markerDir());
5489
+ return;
5490
+ case "stale":
5491
+ logger.info(
5492
+ `Update marker: ignored a marker for version ${outcome.marker.targetVersion}; version ${electron.app.getVersion()} is running, so it was superseded.`,
5493
+ LogFileType.UPDATE_SERVICE
5494
+ );
5495
+ this.reapWatchdog(outcome.marker, true);
5496
+ clearPendingInstallMarker(this.markerDir());
5497
+ return;
5498
+ case "failed": {
5499
+ const shipItLogPath = this.shipItLog();
5500
+ const record = recordInstallFailure(this.markerDir(), {
5501
+ payload: installFailurePayload(outcome.marker, electron.app.getVersion(), {
5502
+ shipItLogPath
5503
+ }),
5504
+ startedAt: outcome.marker.startedAt || null,
5505
+ detectedAt: (/* @__PURE__ */ new Date()).toISOString()
5506
+ });
5507
+ const payload = installFailurePayload(
5508
+ outcome.marker,
5509
+ electron.app.getVersion(),
5510
+ { attempts: record.attempts, shipItLogPath }
5511
+ );
5512
+ logger.error(
5513
+ `Update marker: install of version ${outcome.marker.targetVersion} did not complete (attempt ${record.attempts}). ${payload.detail}`,
5514
+ LogFileType.UPDATE_SERVICE
5515
+ );
5516
+ this.pendingInstallFailure = payload;
5517
+ this.reapWatchdog(outcome.marker, false);
5518
+ clearPendingInstallMarker(this.markerDir());
5519
+ this.reapFailedInstallLeftovers();
5520
+ this.schedulePendingInstallFailureDelivery();
5521
+ }
5522
+ }
5523
+ }
5524
+ /**
5525
+ * This app's bundle identifier, as macOS records it.
5526
+ *
5527
+ * Read from the running bundle rather than from `package.json`: the running
5528
+ * app is the only thing whose ShipIt job and cache directory this process may
5529
+ * touch, and a build whose `appId` drifted would otherwise have us cleaning up
5530
+ * some other app's leftovers.
5531
+ */
5532
+ bundleIdentifier() {
5533
+ const bundlePath = appBundleFromExecutable(process.execPath);
5534
+ if (!bundlePath) return null;
5535
+ const value = readCommandOutput("/usr/bin/defaults", [
5536
+ "read",
5537
+ path.join(bundlePath, "Contents", "Info.plist"),
5538
+ "CFBundleIdentifier"
5539
+ ]);
5540
+ return value && BUNDLE_ID_REGEX.test(value) ? value : null;
5541
+ }
5542
+ /** The launchd job Squirrel's install runs under, by label. */
5543
+ shipItJob() {
5544
+ const bundleId = this.bundleIdentifier();
5545
+ return bundleId ? shipItJobLabel(bundleId) : null;
5546
+ }
5547
+ /**
5548
+ * Squirrel's own stderr log for this app, when it exists.
5549
+ *
5550
+ * It is the only record of why an install failed: the app is not running
5551
+ * while ShipIt works, so the failure notice cannot quote a reason the way the
5552
+ * pre-flight refusal can. The log is preserved by `reapFailedInstallLeftovers`
5553
+ * for exactly this reason, so the details line points at the thing that is
5554
+ * still there afterwards (review U14).
5555
+ */
5556
+ shipItLog() {
5557
+ const bundleId = this.bundleIdentifier();
5558
+ if (!bundleId) return null;
5559
+ const candidate = path.join(
5560
+ shipItCacheDir(path.join(os.homedir(), "Library", "Caches"), bundleId),
5561
+ "ShipIt_stderr.log"
5562
+ );
5563
+ return fs.existsSync(candidate) ? candidate : null;
5564
+ }
5565
+ /**
5566
+ * Remove the launchd job and the staged update a failed install left behind.
5567
+ *
5568
+ * Why: the 0.17.0 failure left `com.local-operator.ShipIt` loaded in the
5569
+ * user's launchd domain, respawning every ~2.5 s (runs=3114,
5570
+ * LastExitStatus=256) and writing 3.4 MB of "Could not read update request" to
5571
+ * its stderr log, with the staged tree beside it. Squirrel only retires that
5572
+ * job when an install finishes, so a failed one leaves it running forever - it
5573
+ * was removed by hand during round 1 of the review, and the product must not
5574
+ * need that.
5575
+ */
5576
+ reapFailedInstallLeftovers() {
5577
+ if (process.platform !== "darwin") return;
5578
+ const log = (message) => logger.info(message, LogFileType.UPDATE_SERVICE);
5579
+ try {
5580
+ const result = reapFailedInstall({
5581
+ bundleId: this.bundleIdentifier(),
5582
+ cacheRoot: path.join(os.homedir(), "Library", "Caches"),
5583
+ removeJob: (label) => {
5584
+ const result2 = node_child_process.spawnSync("/bin/launchctl", ["remove", label], {
5585
+ encoding: "utf8",
5586
+ timeout: 5e3
5587
+ });
5588
+ const output = `${result2.stdout ?? ""}${result2.stderr ?? ""}`.trim();
5589
+ const notFound = LAUNCHCTL_NOT_LOADED_REGEX.test(output) || output === "" && result2.status !== 0;
5590
+ if (result2.status !== 0 && !notFound) {
5591
+ throw new Error(
5592
+ output || `launchctl remove exited ${result2.status}`
5593
+ );
5594
+ }
5595
+ return { notFound, output };
5596
+ },
5597
+ exists: (path2) => fs.existsSync(path2),
5598
+ listDir: (dir) => fs.readdirSync(dir),
5599
+ removeDir: (dir) => fs.rmSync(dir, { recursive: true, force: true }),
5600
+ log
5601
+ });
5602
+ if (result.errors.length > 0) {
5603
+ logger.warn(
5604
+ `Failed to clean up the leftover install: ${result.errors.join("; ")}`,
5605
+ LogFileType.UPDATE_SERVICE
5606
+ );
5607
+ }
5608
+ } catch (error) {
5609
+ logger.warn(
5610
+ `Could not clean up the leftover install: ${String(error)}`,
5611
+ LogFileType.UPDATE_SERVICE
5612
+ );
5613
+ }
5614
+ }
5615
+ /**
5616
+ * Deliver the failed-install notice once the renderer can hear it.
5617
+ *
5618
+ * The marker is read while the window is still loading, so the push happens
5619
+ * on `did-finish-load` with a delayed fallback: the component subscribes from
5620
+ * a React effect, which may run after the load event.
5621
+ */
5622
+ schedulePendingInstallFailureDelivery() {
5623
+ if (!this.pendingInstallFailure) return;
5624
+ const deliver = () => this.deliverPendingInstallFailure();
5625
+ const webContents = this.mainWindow?.webContents;
5626
+ if (webContents && !webContents.isDestroyed()) {
5627
+ webContents.once("did-finish-load", deliver);
5628
+ }
5629
+ setTimeout(deliver, 5e3);
5630
+ }
5631
+ deliverPendingInstallFailure() {
5632
+ if (!this.pendingInstallFailure || this.installFailureDelivered) return;
5633
+ if (!this.sendToRenderer("update-install-failed", this.pendingInstallFailure)) {
5634
+ return;
5635
+ }
5636
+ this.installFailureDelivered = true;
5637
+ logger.info(
5638
+ "Reported a failed update install to the renderer",
5639
+ LogFileType.UPDATE_SERVICE
5640
+ );
5641
+ }
5642
+ /**
5643
+ * Kill a watchdog still running from the recorded install attempt.
5644
+ *
5645
+ * Killing by pid alone would be unsafe after a pid reuse, so the process's
5646
+ * command line has to still carry the script's sentinel.
5647
+ */
5648
+ reapWatchdog(marker, installSucceeded) {
5649
+ const pid = marker.watchdogPid;
5650
+ if (pid == null) return;
5651
+ let commandLine = null;
5652
+ let alive = false;
5653
+ commandLine = readCommandOutput("/bin/ps", [
5654
+ "-o",
5655
+ "command=",
5656
+ "-p",
5657
+ String(pid)
5658
+ ]);
5659
+ alive = commandLine != null;
5660
+ if (!watchdogIsOurs({ alive, commandLine, installSucceeded })) {
5661
+ if (alive && !installSucceeded) {
5662
+ logger.info(
5663
+ `Relaunch watchdog ${pid} is still running; leaving it to finish or hit its deadline.`,
5664
+ LogFileType.UPDATE_SERVICE
5665
+ );
5666
+ }
5667
+ return;
5668
+ }
5669
+ try {
5670
+ process.kill(pid, "SIGTERM");
5671
+ logger.info(
5672
+ `Reaped relaunch watchdog ${pid} left over from the previous install.`,
5673
+ LogFileType.UPDATE_SERVICE
5674
+ );
5675
+ } catch (error) {
5676
+ logger.warn(
5677
+ `Could not reap relaunch watchdog ${pid}: ${String(error)}`,
5678
+ LogFileType.UPDATE_SERVICE
5679
+ );
5680
+ }
5681
+ }
5682
+ /**
5683
+ * Ask `codesign` whether the installed bundle is a sealed code object.
5684
+ *
5685
+ * This is the exact question ShipIt asks of the same bundle before it
5686
+ * installs (`SecStaticCodeCreateWithPath` in SQRLInstaller): on 2026-09-11 it
5687
+ * answered -67028 errSecCSBadBundleFormat because a Finder copy was replacing
5688
+ * the app at that moment, ShipIt quit, and nothing relaunched the app.
5689
+ * Refusing the install here keeps the user with a working app instead.
5690
+ *
5691
+ * Three deliberate properties, all of them from round 1's findings:
5692
+ *
5693
+ * - `--strict` is gone. It does not test the seal - `--verify` does that, and
5694
+ * it is what catches the mid-replacement -67028 - it additionally rejects
5695
+ * bundles carrying FinderInfo/detritus xattrs that ShipIt itself tolerates,
5696
+ * so it can only refuse installs Squirrel would have performed (R5).
5697
+ * - The timeout is generous because the bundle is ~1 GiB; measured warm at
5698
+ * 6.1 s, and a cold or slow volume is not a reason to refuse.
5699
+ * - A probe that could not run is retried once and then *allowed to proceed*.
5700
+ * "We could not ask" is not "the bundle is bad", and treating it as one is
5701
+ * how a transient hiccup became a permanent reinstall message (R5).
5702
+ */
5703
+ async probeInstalledBundleSeal(version2) {
5704
+ if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
5705
+ const bundlePath = appBundleFromExecutable(process.execPath);
5706
+ if (!bundlePath) {
5707
+ logger.warn(
5708
+ `Could not derive an app bundle from ${process.execPath}; skipping the seal pre-flight.`,
5709
+ LogFileType.UPDATE_SERVICE
5710
+ );
5711
+ return null;
5712
+ }
5713
+ const probeBundle = () => runCommand(
5714
+ "/usr/bin/codesign",
5715
+ ["--verify", "--deep", "--verbose=2", bundlePath],
5716
+ { timeoutMs: SEAL_PROBE_TIMEOUT_MS }
5717
+ );
5718
+ let seal = evaluateBundleSeal(await probeBundle());
5719
+ if (seal.kind === "unavailable") {
5720
+ logger.warn(
5721
+ `Seal check did not complete, retrying once: ${seal.detail}`,
5722
+ LogFileType.UPDATE_SERVICE
5723
+ );
5724
+ seal = evaluateBundleSeal(await probeBundle());
5725
+ }
5726
+ if (seal.kind === "unavailable") {
5727
+ logger.warn(
5728
+ `Seal check could not run twice, continuing with the install: ${seal.detail}`,
5729
+ LogFileType.UPDATE_SERVICE
5730
+ );
5731
+ return null;
5732
+ }
5733
+ if (seal.kind === "sealed") {
5734
+ logger.info(
5735
+ `Installed bundle passed its seal check: ${bundlePath}`,
5736
+ LogFileType.UPDATE_SERVICE
5737
+ );
5738
+ return null;
5739
+ }
5740
+ logger.error(
5741
+ `Installed bundle failed its seal check: ${seal.detail}`,
5742
+ LogFileType.UPDATE_SERVICE
5743
+ );
5744
+ return installedBundleSealBlock(bundlePath, seal.detail, version2);
5745
+ }
5746
+ /** File names the updater's metadata knows this update by. */
5747
+ stagedArtifactCandidates(info) {
5748
+ const names = /* @__PURE__ */ new Set();
5749
+ if (info?.path) names.add(path.basename(info.path));
5750
+ for (const file of info?.files ?? []) {
5751
+ if (file.url) names.add(path.basename(decodeURIComponent(file.url)));
5752
+ }
5753
+ if (this.downloadedArtifactPath) {
5754
+ names.add(path.basename(this.downloadedArtifactPath));
5755
+ }
5756
+ return [...names];
5757
+ }
5758
+ /**
5759
+ * electron-updater exposes no public path for the staged file; the private
5760
+ * download helper is authoritative for a download this run performed, and a
5761
+ * null simply falls back to the updater's pending cache directory.
5762
+ */
5763
+ downloadHelper() {
5764
+ const updater = electronUpdater.autoUpdater;
5765
+ return updater.downloadedUpdateHelper ?? null;
5766
+ }
5767
+ /** Free bytes on the volume holding `dir`, or null when it cannot be read. */
5768
+ freeBytesAt(dir) {
5769
+ try {
5770
+ const stats = fs.statfsSync(dir);
5771
+ return stats.bavail * stats.bsize;
5772
+ } catch (error) {
5773
+ logger.warn(
5774
+ `Could not read free space for ${dir}: ${String(error)}`,
5775
+ LogFileType.UPDATE_SERVICE
5776
+ );
5777
+ return null;
5778
+ }
5779
+ }
5780
+ static async sha512Base64(filePath) {
5781
+ const hash = node_crypto.createHash("sha512");
5782
+ const stream = fs.createReadStream(filePath);
5783
+ for await (const chunk of stream) {
5784
+ hash.update(chunk);
5785
+ }
5786
+ return hash.digest("base64");
5787
+ }
5788
+ /**
5789
+ * Verify the staged artifact against the metadata the updater parsed.
5790
+ *
5791
+ * electron-updater checks a file's sha512 while downloading it, but a staged
5792
+ * file reused on a later start is only checked for existence (`"check here
5793
+ * only existence, not checksum"` in its own DownloadedUpdateHelper), and the
5794
+ * install's real footprint - a full copy of the app - is never checked
5795
+ * against the free space available.
5796
+ */
5797
+ async ensureStagedArtifactVerified(info) {
5798
+ const helper = this.downloadHelper();
5799
+ const pendingDir = helper?.cacheDirForPendingUpdate ?? null;
5800
+ const candidates = this.stagedArtifactCandidates(info);
5801
+ const artifactPath = resolveStagedArtifactPath({
5802
+ downloadHelperFile: this.downloadedArtifactPath ?? helper?.file ?? null,
5803
+ pendingDir: pendingDir ?? "",
5804
+ candidateNames: candidates,
5805
+ listDir: (dir) => {
5806
+ try {
5807
+ return fs.readdirSync(dir);
5808
+ } catch {
5809
+ return [];
5810
+ }
5811
+ }
5812
+ });
5813
+ if (!artifactPath) {
5814
+ return {
5815
+ code: "download-verification-failed",
5816
+ message: `The downloaded update${versionSuffix(info?.version)} could not be found on disk, so it wasn't installed.`,
5817
+ remedy: {
5818
+ text: "Check for updates again to re-download the release."
5819
+ },
5820
+ detail: `No staged artifact matching ${candidates.join(", ") || "the update metadata"}.`
5821
+ };
5822
+ }
5823
+ const files = info?.files ?? [];
5824
+ const metadata = matchArtifactMetadata(
5825
+ artifactPath,
5826
+ files,
5827
+ info?.path ?? null
5828
+ );
5829
+ let actualSize;
5830
+ let actualSha512;
5831
+ try {
5832
+ actualSize = fs.statSync(artifactPath).size;
5833
+ actualSha512 = await UpdateService.sha512Base64(artifactPath);
5834
+ } catch (error) {
5835
+ return {
5836
+ code: "download-verification-failed",
5837
+ message: `The downloaded update${versionSuffix(info?.version)} could not be read, so it wasn't installed.`,
5838
+ remedy: {
5839
+ text: "Check for updates again to re-download the release."
5840
+ },
5841
+ detail: `${artifactPath}: ${String(error)}`
5842
+ };
5843
+ }
5844
+ const bundlePath = appBundleFromExecutable(process.execPath);
5845
+ const installedBundleSize = bundlePath ? measureDirectoryBytes(bundlePath) : null;
5846
+ if (installedBundleSize == null) {
5847
+ logger.warn(
5848
+ `Could not measure the installed app at ${bundlePath ?? "(no bundle path)"}; the free-space guard falls back to the artifact's own size.`,
5849
+ LogFileType.UPDATE_SERVICE
5850
+ );
5851
+ }
5852
+ const freeCandidates = [
5853
+ bundlePath ? this.freeBytesAt(path__namespace.dirname(bundlePath)) : null,
5854
+ this.freeBytesAt(path__namespace.dirname(artifactPath))
5855
+ ].filter((value) => value != null);
5856
+ const freeBytes = freeCandidates.length > 0 ? Math.min(...freeCandidates) : Number.MAX_SAFE_INTEGER;
5857
+ const verdict = verifyStagedArtifact({
5858
+ filePath: artifactPath,
5859
+ actualSize,
5860
+ actualSha512,
5861
+ metadata,
5862
+ freeBytes,
5863
+ installedBundleSize,
5864
+ version: info?.version ?? null
5865
+ });
5866
+ if (verdict.ok) {
5867
+ const required = requiredDiskBytes({
5868
+ artifactSize: verdict.size,
5869
+ installedBundleSize: installedBundleSize ?? 0
5870
+ });
5871
+ logger.info(
5872
+ `Staged artifact verified: ${artifactPath} (${verdict.size} bytes, sha512 matches, ${required} bytes required free, ${installedBundleSize ?? "unknown"} byte app).`,
5873
+ LogFileType.UPDATE_SERVICE
5874
+ );
5875
+ return null;
5876
+ }
5877
+ return verdict.block;
5878
+ }
5879
+ /**
5880
+ * Everything that has to hold before the app is allowed to quit.
5881
+ *
5882
+ * Returns the reason to refuse, or null to go ahead.
5883
+ */
5884
+ async runInstallPreflight(info) {
5885
+ const sealBlock = await this.probeInstalledBundleSeal(
5886
+ info?.version ?? null
5887
+ );
5888
+ if (sealBlock) return sealBlock;
5889
+ return this.ensureStagedArtifactVerified(info);
5890
+ }
5891
+ /**
5892
+ * Start the detached relaunch watchdog.
5893
+ *
5894
+ * Spawned detached so it survives this process exiting, with stdio ignored so
5895
+ * it holds no pipe open and cannot keep the app alive.
5896
+ */
5897
+ launchWatchdog(targetVersion) {
5898
+ if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
5899
+ const bundlePath = appBundleFromExecutable(process.execPath);
5900
+ if (!bundlePath) return null;
5901
+ const plan = buildWatchdogPlan({
5902
+ appBundlePath: bundlePath,
5903
+ executableName: path.basename(process.execPath),
5904
+ // The app's own pid, captured here - before the quit - because the
5905
+ // watchdog cannot ask a name about its own ancestor (review R1).
5906
+ appPid: process.pid,
5907
+ shipItJob: this.shipItJob(),
5908
+ // What "the install is over" is measured against on disk (review R11):
5909
+ // the updater's advertised version, and nothing that is already in place
5910
+ // (review R15).
5911
+ targetVersion: watchdogSwapTarget({
5912
+ target: targetVersion,
5913
+ running: electron.app.getVersion()
5914
+ })
5915
+ });
5916
+ try {
5917
+ const child = node_child_process.spawn("sh", ["-c", plan.script], {
5918
+ detached: true,
5919
+ stdio: "ignore",
5920
+ env: { ...process.env, ...plan.env }
5921
+ });
5922
+ child.unref();
5923
+ logger.info(
5924
+ `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.`,
5925
+ LogFileType.UPDATE_SERVICE
5926
+ );
5927
+ return child.pid ?? null;
5928
+ } catch (error) {
5929
+ logger.error(
5930
+ `Could not start the update relaunch watchdog: ${String(error)}`,
5931
+ LogFileType.UPDATE_SERVICE
5932
+ );
5933
+ return null;
5934
+ }
5935
+ }
5936
+ /**
5937
+ * Clean up resources when the service is no longer needed
5938
+ */
5939
+ /**
5940
+ * Clean up resources, event listeners, and IPC handlers when the service is no longer needed
5941
+ */
5942
+ dispose() {
5943
+ if (this.updateCheckInterval) {
5944
+ clearInterval(this.updateCheckInterval);
5945
+ this.updateCheckInterval = null;
5946
+ }
5947
+ electronUpdater.autoUpdater.removeAllListeners();
5948
+ electron.ipcMain.removeHandler("check-for-updates");
5949
+ electron.ipcMain.removeHandler("check-for-backend-updates");
5950
+ electron.ipcMain.removeHandler("check-for-all-updates");
5951
+ electron.ipcMain.removeHandler("get-last-install-attempt");
5952
+ electron.ipcMain.removeHandler("update-backend");
5953
+ electron.ipcMain.removeHandler("download-update");
5954
+ electron.ipcMain.removeHandler("quit-and-install");
5955
+ this.mainWindow = null;
5956
+ }
5957
+ /**
5958
+ * Register the backend service for proper shutdown when app quits
5959
+ * This ensures that restarted backend services are properly shut down
5960
+ */
5961
+ registerBackendShutdown() {
5962
+ if (!this.backendService || this.backendService.isUsingExternalBackend()) {
5963
+ return;
5964
+ }
5965
+ const shutdownHandler = async () => {
5966
+ logger.info(
5967
+ "Shutting down backend service before app quit...",
5968
+ LogFileType.UPDATE_SERVICE
5969
+ );
5970
+ try {
5971
+ await this.backendService?.stop(false);
5972
+ logger.info(
5973
+ "Backend service successfully shut down before app quit",
5974
+ LogFileType.UPDATE_SERVICE
5975
+ );
5976
+ } catch (error) {
5977
+ logger.error(
5978
+ "Error shutting down backend service before app quit:",
5979
+ LogFileType.UPDATE_SERVICE,
5980
+ error
5981
+ );
5982
+ try {
5983
+ logger.info(
5984
+ "Attempting forced shutdown of backend service...",
5985
+ LogFileType.UPDATE_SERVICE
5986
+ );
5987
+ await this.forceTerminateBackendProcess();
5988
+ logger.info(
5989
+ "Forced shutdown of backend service completed",
5990
+ LogFileType.UPDATE_SERVICE
5991
+ );
5992
+ } catch (forceError) {
5993
+ logger.error(
5994
+ "Error during forced shutdown of backend service:",
5995
+ LogFileType.UPDATE_SERVICE,
5996
+ forceError
5997
+ );
5998
+ }
5999
+ }
6000
+ };
6001
+ electron.app.removeAllListeners("before-quit");
6002
+ electron.app.once("before-quit", shutdownHandler);
6003
+ electron.app.once("will-quit", shutdownHandler);
6004
+ logger.info(
6005
+ "Registered backend service for proper shutdown on app quit",
6006
+ LogFileType.UPDATE_SERVICE
6007
+ );
6008
+ }
6009
+ /**
6010
+ * Force terminate the backend process using platform-specific commands
6011
+ * This is a last resort method when normal termination fails
6012
+ */
6013
+ async forceTerminateBackendProcess() {
6014
+ if (!this.backendService) {
6015
+ return;
6016
+ }
6017
+ const execAsync = node_util.promisify(node_child_process.exec);
6018
+ try {
6019
+ if (process.platform === "win32") {
6020
+ await execAsync('taskkill /f /im "local-operator serve" /t');
6021
+ await execAsync(
6022
+ `wmic process where "commandline like '%local-operator serve%'" call terminate`
6023
+ );
6024
+ } else {
6025
+ await execAsync('pkill -f "local-operator serve"');
6026
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
6027
+ await execAsync('pkill -9 -f "local-operator serve"');
6028
+ }
6029
+ } catch (error) {
6030
+ logger.warn(
6031
+ "Error during force termination (this may be normal if process was already terminated):",
6032
+ LogFileType.UPDATE_SERVICE,
6033
+ error
6034
+ );
6035
+ }
6036
+ }
6037
+ /**
6038
+ * Set up event handlers for the autoUpdater
6039
+ */
6040
+ setupUpdateEvents() {
6041
+ electronUpdater.autoUpdater.on("update-available", (info) => {
6042
+ logger.info("Update available:", LogFileType.UPDATE_SERVICE, info);
6043
+ this.lastUpdateInfo = info;
6044
+ if (this.failedVerificationVersions.has(info.version)) {
6045
+ logger.warn(
6046
+ `Not offering version ${info.version} again: its artifact already failed verification in this session.`,
6047
+ LogFileType.UPDATE_SERVICE
6048
+ );
6049
+ return;
4214
6050
  }
6051
+ this.sendToRenderer("update-available", info);
6052
+ });
6053
+ electronUpdater.autoUpdater.on("update-not-available", (info) => {
6054
+ logger.info("No update available:", LogFileType.UPDATE_SERVICE, info);
6055
+ this.sendToRenderer("update-not-available", info);
4215
6056
  });
4216
- electronUpdater.autoUpdater.on("update-downloaded", (info) => {
6057
+ electronUpdater.autoUpdater.on("update-downloaded", async (info) => {
4217
6058
  logger.info("Update downloaded:", LogFileType.UPDATE_SERVICE, info);
4218
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4219
- this.mainWindow.webContents.send("update-downloaded", info);
6059
+ this.lastUpdateInfo = info;
6060
+ const block = await this.ensureStagedArtifactVerified(info);
6061
+ if (block) {
6062
+ this.failedVerificationVersions.add(info.version);
6063
+ this.sendInstallBlock(block, info.version);
6064
+ return;
4220
6065
  }
6066
+ this.sendToRenderer("update-downloaded", info);
4221
6067
  });
4222
6068
  electronUpdater.autoUpdater.on("error", (err) => {
4223
6069
  logger.error("Update error:", LogFileType.UPDATE_SERVICE, err);
4224
- const shouldFilter = this.shouldFilterUpdateError(err);
6070
+ const actionable = this.updateStage !== "idle";
6071
+ const shouldFilter = !actionable && this.shouldFilterUpdateError(err);
4225
6072
  if (shouldFilter) {
4226
6073
  logger.info(
4227
6074
  "Error filtering result: Reporting as no updates available"
4228
6075
  );
4229
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4230
- this.mainWindow.webContents.send("update-not-available", {
4231
- version: electron.app.getVersion()
4232
- });
4233
- }
6076
+ this.sendToRenderer("update-not-available", {
6077
+ version: electron.app.getVersion()
6078
+ });
4234
6079
  } else {
4235
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4236
- this.mainWindow.webContents.send("update-error", err.message);
4237
- }
6080
+ this.sendToRenderer("update-error", err.message);
4238
6081
  }
4239
6082
  });
4240
6083
  electronUpdater.autoUpdater.on("download-progress", (progressObj) => {
@@ -4260,36 +6103,72 @@ class UpdateService {
4260
6103
  /**
4261
6104
  * Set up IPC handlers for update-related actions
4262
6105
  */
6106
+ /**
6107
+ * The last install that did not complete, if any.
6108
+ *
6109
+ * Read from disk rather than from `this.pendingInstallFailure` so it survives
6110
+ * a dismiss: the panel that reports a failure is not the only place the user
6111
+ * can find out what happened, which is what made Dismiss an information loss
6112
+ * (reviews U1, D3).
6113
+ */
6114
+ lastInstallAttempt() {
6115
+ return readLastInstallAttempt(this.markerDir());
6116
+ }
6117
+ /**
6118
+ * Note that a check was asked for, and let an explicit one re-offer a release
6119
+ * whose artifact failed verification.
6120
+ *
6121
+ * Why the distinction exists: every refusal records its version in
6122
+ * `failedVerificationVersions` so the 5-minute timer cannot re-offer an
6123
+ * artifact that will fail again. That record used to apply to user-initiated
6124
+ * checks too, which made two of the panels' own labelled remedies inert - the
6125
+ * user freed disk space or cleared the bad download, pressed "Check for
6126
+ * updates" as instructed, and got silence until the app restarted (reviews
6127
+ * R3, U3). A user who asks again gets a fresh answer; the timer does not.
6128
+ */
6129
+ onCheckRequested(options) {
6130
+ if (!options?.manual) return;
6131
+ if (this.failedVerificationVersions.size === 0) return;
6132
+ logger.info(
6133
+ `Explicit check requested; re-offering ${[...this.failedVerificationVersions].join(", ")} after a failed verification.`,
6134
+ LogFileType.UPDATE_SERVICE
6135
+ );
6136
+ this.failedVerificationVersions.clear();
6137
+ }
4263
6138
  setupIpcHandlers() {
4264
- electron.ipcMain.handle("check-for-updates", async () => {
4265
- logger.info("Checking for UI updates...", LogFileType.UPDATE_SERVICE);
4266
- try {
4267
- return await electronUpdater.autoUpdater.checkForUpdates();
4268
- } catch (error) {
4269
- logger.error(
4270
- "Error checking for UI updates:",
4271
- LogFileType.UPDATE_SERVICE,
4272
- error
4273
- );
4274
- const shouldFilter = this.shouldFilterUpdateError(error);
4275
- if (shouldFilter) {
4276
- logger.info(
4277
- "Error filtering result in IPC handler: Reporting as no updates available",
4278
- LogFileType.UPDATE_SERVICE
6139
+ electron.ipcMain.handle(
6140
+ "check-for-updates",
6141
+ async (_event, options) => {
6142
+ logger.info("Checking for UI updates...", LogFileType.UPDATE_SERVICE);
6143
+ this.onCheckRequested(options);
6144
+ try {
6145
+ return await electronUpdater.autoUpdater.checkForUpdates();
6146
+ } catch (error) {
6147
+ logger.error(
6148
+ "Error checking for UI updates:",
6149
+ LogFileType.UPDATE_SERVICE,
6150
+ error
4279
6151
  );
4280
- return {
4281
- updateInfo: {
4282
- version: electron.app.getVersion()
4283
- },
4284
- versionInfo: {
4285
- version: electron.app.getVersion()
4286
- },
4287
- cancellationToken: null
4288
- };
6152
+ const shouldFilter = this.shouldFilterUpdateError(error);
6153
+ if (shouldFilter) {
6154
+ logger.info(
6155
+ "Error filtering result in IPC handler: Reporting as no updates available",
6156
+ LogFileType.UPDATE_SERVICE
6157
+ );
6158
+ return {
6159
+ updateInfo: {
6160
+ version: electron.app.getVersion()
6161
+ },
6162
+ versionInfo: {
6163
+ version: electron.app.getVersion()
6164
+ },
6165
+ cancellationToken: null
6166
+ };
6167
+ }
6168
+ throw error;
4289
6169
  }
4290
- throw error;
4291
6170
  }
4292
- });
6171
+ );
4293
6172
  electron.ipcMain.handle("check-for-backend-updates", async () => {
4294
6173
  logger.info(
4295
6174
  "Checking for backend updates...",
@@ -4306,55 +6185,70 @@ class UpdateService {
4306
6185
  throw error;
4307
6186
  }
4308
6187
  });
4309
- electron.ipcMain.handle("check-for-all-updates", async () => {
4310
- logger.info(
4311
- "Checking for all updates (UI and backend)...",
4312
- LogFileType.UPDATE_SERVICE
4313
- );
4314
- try {
4315
- return await this.checkForAllUpdates();
4316
- } catch (error) {
4317
- logger.error(
4318
- "Error checking for all updates:",
4319
- LogFileType.UPDATE_SERVICE,
4320
- error
6188
+ electron.ipcMain.handle(
6189
+ "check-for-all-updates",
6190
+ async (_event, options) => {
6191
+ logger.info(
6192
+ "Checking for all updates (UI and backend)...",
6193
+ LogFileType.UPDATE_SERVICE
4321
6194
  );
4322
- const shouldFilter = this.shouldFilterUpdateError(error);
4323
- if (shouldFilter) {
4324
- logger.info(
4325
- "Error filtering result in check-for-all-updates: Reporting as no updates available",
4326
- LogFileType.UPDATE_SERVICE
6195
+ this.onCheckRequested(options);
6196
+ try {
6197
+ return await this.checkForAllUpdates();
6198
+ } catch (error) {
6199
+ logger.error(
6200
+ "Error checking for all updates:",
6201
+ LogFileType.UPDATE_SERVICE,
6202
+ error
4327
6203
  );
4328
- return {
4329
- updateInfo: {
4330
- version: electron.app.getVersion()
4331
- },
4332
- versionInfo: {
4333
- version: electron.app.getVersion()
4334
- },
4335
- cancellationToken: null
4336
- };
6204
+ const shouldFilter = this.shouldFilterUpdateError(error);
6205
+ if (shouldFilter) {
6206
+ logger.info(
6207
+ "Error filtering result in check-for-all-updates: Reporting as no updates available",
6208
+ LogFileType.UPDATE_SERVICE
6209
+ );
6210
+ return {
6211
+ updateInfo: {
6212
+ version: electron.app.getVersion()
6213
+ },
6214
+ versionInfo: {
6215
+ version: electron.app.getVersion()
6216
+ },
6217
+ cancellationToken: null
6218
+ };
6219
+ }
6220
+ throw error;
4337
6221
  }
4338
- throw error;
4339
6222
  }
4340
- });
4341
- electron.ipcMain.handle("update-backend", async () => {
4342
- logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
4343
- try {
4344
- return await this.updateBackend();
4345
- } catch (error) {
4346
- logger.error(
4347
- "Error updating backend:",
4348
- LogFileType.UPDATE_SERVICE,
4349
- error
4350
- );
4351
- throw error;
6223
+ );
6224
+ electron.ipcMain.handle("get-last-install-attempt", () => this.lastInstallAttempt());
6225
+ electron.ipcMain.handle(
6226
+ "update-backend",
6227
+ async (_event, targetVersion) => {
6228
+ logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
6229
+ try {
6230
+ return await this.updateBackend(
6231
+ typeof targetVersion === "string" ? targetVersion : void 0
6232
+ );
6233
+ } catch (error) {
6234
+ logger.error(
6235
+ "Error updating backend:",
6236
+ LogFileType.UPDATE_SERVICE,
6237
+ error
6238
+ );
6239
+ throw error;
6240
+ }
4352
6241
  }
4353
- });
6242
+ );
4354
6243
  electron.ipcMain.handle("download-update", async () => {
4355
6244
  logger.info("Downloading update...", LogFileType.UPDATE_SERVICE);
6245
+ this.updateStage = "downloading";
4356
6246
  try {
4357
- return await electronUpdater.autoUpdater.downloadUpdate();
6247
+ const downloadedPaths = await electronUpdater.autoUpdater.downloadUpdate();
6248
+ if (Array.isArray(downloadedPaths) && downloadedPaths.length > 0) {
6249
+ this.downloadedArtifactPath = downloadedPaths[0];
6250
+ }
6251
+ return downloadedPaths;
4358
6252
  } catch (error) {
4359
6253
  logger.error(
4360
6254
  "Error downloading update:",
@@ -4377,15 +6271,50 @@ class UpdateService {
4377
6271
  };
4378
6272
  }
4379
6273
  throw error;
6274
+ } finally {
6275
+ this.updateStage = "idle";
4380
6276
  }
4381
6277
  });
4382
- electron.ipcMain.handle("quit-and-install", () => {
6278
+ electron.ipcMain.handle("quit-and-install", async () => {
6279
+ if (this.installPreflightInFlight) {
6280
+ logger.info(
6281
+ "Ignoring a second install request while the pre-flight is still running.",
6282
+ LogFileType.UPDATE_SERVICE
6283
+ );
6284
+ return false;
6285
+ }
6286
+ logger.info(
6287
+ "Preparing to quit and install the update...",
6288
+ LogFileType.UPDATE_SERVICE
6289
+ );
6290
+ this.installPreflightInFlight = true;
6291
+ this.updateStage = "installing";
6292
+ let block = null;
6293
+ try {
6294
+ block = await this.runInstallPreflight(this.lastUpdateInfo);
6295
+ } finally {
6296
+ this.installPreflightInFlight = false;
6297
+ }
6298
+ if (block) {
6299
+ this.updateStage = "idle";
6300
+ this.sendInstallBlock(block, this.lastUpdateInfo?.version ?? void 0);
6301
+ return false;
6302
+ }
6303
+ const targetVersion = this.lastUpdateInfo?.version ?? electron.app.getVersion();
6304
+ const watchdogPid = this.launchWatchdog(targetVersion);
6305
+ const marker = writePendingInstallMarker(this.markerDir(), {
6306
+ targetVersion,
6307
+ artifactPath: this.downloadedArtifactPath ?? this.downloadHelper()?.file ?? "unknown",
6308
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
6309
+ watchdogPid
6310
+ });
4383
6311
  logger.info(
4384
- "Quitting and installing update...",
6312
+ `Pending install marker written for version ${marker.targetVersion} (watchdog pid ${watchdogPid ?? "none"}).`,
4385
6313
  LogFileType.UPDATE_SERVICE
4386
6314
  );
4387
6315
  this.backendService?.setAutoUpdating(true);
4388
6316
  electronUpdater.autoUpdater.quitAndInstall(false, true);
6317
+ return true;
4389
6318
  });
4390
6319
  }
4391
6320
  /**
@@ -4693,6 +6622,103 @@ class UpdateService {
4693
6622
  * @param silent - Whether to suppress notifications on no update
4694
6623
  * @returns Promise resolving to update info or null if no update is available
4695
6624
  */
6625
+ /**
6626
+ * Decide what the backend update prompt may offer for a startup mode.
6627
+ *
6628
+ * GLOBAL_INSTALL is the mode that produced the operator's report: the app
6629
+ * offered `pip install --upgrade local-operator` for a server that was a uv
6630
+ * tool install (0.54.17, read from /health) with `canManageUpdate: true`. pip
6631
+ * is the wrong installer for that environment - uv owns it, and the
6632
+ * environment lives outside anything the app may write to.
6633
+ *
6634
+ * EXISTING_SERVER takes the same classification as GLOBAL_INSTALL, because it
6635
+ * is the same question: a server the app attaches to but does not own was
6636
+ * still being told to run pip, three lines below the code that had already
6637
+ * learned better (reviews U4, D4).
6638
+ */
6639
+ async resolveBackendUpdatePlan(startupMode) {
6640
+ if (startupMode === LocalOperatorStartupMode.EXISTING_SERVER || startupMode === LocalOperatorStartupMode.GLOBAL_INSTALL) {
6641
+ const plan = resolveGlobalInstallPlan({
6642
+ identity: this.resolveInstallIdentity(),
6643
+ lopUpdatePath: this.resolveLopUpdatePath()
6644
+ });
6645
+ logger.info(
6646
+ `External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\``,
6647
+ LogFileType.UPDATE_SERVICE
6648
+ );
6649
+ return {
6650
+ canManageUpdate: plan.canManageUpdate,
6651
+ updateCommand: plan.updateCommand,
6652
+ remedy: plan.remedy,
6653
+ detail: plan.detail,
6654
+ sourceBuild: plan.sourceBuild
6655
+ };
6656
+ }
6657
+ return {
6658
+ canManageUpdate: true,
6659
+ updateCommand: "pip install --upgrade local-operator",
6660
+ remedy: "Updating the server will improve AI functionality.",
6661
+ detail: `The app started this server itself (${startupMode}).`,
6662
+ sourceBuild: false
6663
+ };
6664
+ }
6665
+ /**
6666
+ * Path the `local-operator` shim resolves to, or null when it is not installed.
6667
+ *
6668
+ * Sync on purpose: it runs once per backend check, and the answer decides
6669
+ * which installer the prompt names, so the caller wants it before it builds
6670
+ * the message rather than a promise to settle afterwards.
6671
+ *
6672
+ * It does NOT ask the shell's `which` on macOS: the app is normally started
6673
+ * by Finder or `open`, whose PATH is the launchd default and does not contain
6674
+ * `~/.local/bin` - so the uv tool install this whole classification exists to
6675
+ * describe was invisible, and its remedy came out with no command in it.
6676
+ * `resolveCommandPath` searches the standard install locations for the same
6677
+ * markers (see `commandSearchDirs`). Windows keeps `where`, which already
6678
+ * searches the machine's and the user's own install locations.
6679
+ */
6680
+ resolveLocalOperatorPath() {
6681
+ if (process.platform === "win32") {
6682
+ const first = readCommandOutput("where", ["local-operator"])?.split("\n")[0]?.trim();
6683
+ return first && first.length > 0 ? first : null;
6684
+ }
6685
+ return resolveCommandPath("local-operator");
6686
+ }
6687
+ /**
6688
+ * Path the `lop-update` shim resolves to, or null when this machine has none.
6689
+ *
6690
+ * The same problem as the shim above, for the remedy rather than the
6691
+ * classification: `lop-update` is what the source-build wording tells the user
6692
+ * to run, and it lives in `~/.local/bin`, which a Finder launch cannot see
6693
+ * through PATH alone - so the plan fell back to the pip/pipx wording for an
6694
+ * install that is neither. It is a shell script, so there is no Windows
6695
+ * counterpart to look for.
6696
+ */
6697
+ resolveLopUpdatePath() {
6698
+ if (process.platform === "win32") return null;
6699
+ return resolveCommandPath("lop-update");
6700
+ }
6701
+ /**
6702
+ * Everything `classifyGlobalInstall` needs to tell a uv tool install from a
6703
+ * pipx one from an ordinary pip venv from the checkout a developer runs.
6704
+ *
6705
+ * The cheap signals are gathered first - `readInstallIdentity` reads the
6706
+ * shim's path, its resolved target, its shebang and the dist-info's own
6707
+ * markers - and the two CLI probes are skipped when a string test already
6708
+ * answered: spawning `uv` and `pipx` on every backend check for a question
6709
+ * three string tests settled is work nobody asked for.
6710
+ */
6711
+ resolveInstallIdentity() {
6712
+ const identity = readInstallIdentity(this.resolveLocalOperatorPath());
6713
+ if (identity.path && classifyGlobalInstall(identity) !== "global-unknown") {
6714
+ return identity;
6715
+ }
6716
+ const uv = resolveCommandPath("uv");
6717
+ const pipx = resolveCommandPath("pipx");
6718
+ identity.uvToolList = uv ? readCommandOutput(uv, ["tool", "list"]) : null;
6719
+ identity.pipxList = pipx ? readCommandOutput(pipx, ["list"]) : null;
6720
+ return identity;
6721
+ }
4696
6722
  async checkForBackendUpdates(silent = false) {
4697
6723
  logger.info(
4698
6724
  `Checking for backend updates... (silent mode: ${silent})`,
@@ -4722,20 +6748,34 @@ class UpdateService {
4722
6748
  }
4723
6749
  const installedVersion = await this.getInstalledBackendVersion();
4724
6750
  const latestVersion = await this.getLatestPypiVersion();
6751
+ if (latestVersion) this.lastPublishedBackendVersion = latestVersion;
4725
6752
  if (!installedVersion || !latestVersion) {
4726
6753
  logger.error(
4727
6754
  "Unable to determine backend versions.",
4728
6755
  LogFileType.UPDATE_SERVICE
4729
6756
  );
4730
- if (!silent && this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4731
- this.mainWindow.webContents.send(
6757
+ if (!silent) {
6758
+ this.sendToRenderer(
4732
6759
  "backend-update-error",
4733
6760
  "Unable to determine backend version."
4734
6761
  );
4735
6762
  }
4736
6763
  return null;
4737
6764
  }
4738
- const shouldUpdate = installedVersion === "Unknown" || this.isNewerVersion(latestVersion, installedVersion);
6765
+ if (installedVersion === "Unknown") {
6766
+ logger.error(
6767
+ "The installed backend version could not be determined from the health endpoint.",
6768
+ LogFileType.UPDATE_SERVICE
6769
+ );
6770
+ if (!silent) {
6771
+ this.sendToRenderer(
6772
+ "backend-update-error",
6773
+ "The installed server version could not be determined, so no update was offered. Restart the app to try again."
6774
+ );
6775
+ }
6776
+ return null;
6777
+ }
6778
+ const shouldUpdate = this.isNewerVersion(latestVersion, installedVersion);
4739
6779
  logger.info(
4740
6780
  `Installed backend version: ${installedVersion}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Startup mode: ${startupMode}`,
4741
6781
  LogFileType.UPDATE_SERVICE
@@ -4745,33 +6785,29 @@ class UpdateService {
4745
6785
  `New backend version available: ${latestVersion} (installed: ${installedVersion})`,
4746
6786
  LogFileType.UPDATE_SERVICE
4747
6787
  );
4748
- const canManageUpdate = startupMode !== LocalOperatorStartupMode.EXISTING_SERVER;
4749
- let updateCommand = "pip install --upgrade local-operator";
4750
- if (startupMode === LocalOperatorStartupMode.EXISTING_SERVER) {
4751
- updateCommand = "pip install --upgrade local-operator";
4752
- }
6788
+ const { canManageUpdate, updateCommand, remedy, detail, sourceBuild } = await this.resolveBackendUpdatePlan(startupMode);
4753
6789
  const updateInfo = {
4754
6790
  currentVersion: installedVersion,
4755
6791
  latestVersion,
4756
6792
  updateCommand,
4757
6793
  canManageUpdate,
4758
- startupMode
6794
+ startupMode,
6795
+ remedy,
6796
+ detail,
6797
+ sourceBuild,
6798
+ /* `silent` is the whole difference between the two callers: the periodic
6799
+ and start-up checks pass `true`, the IPC handlers behind the buttons
6800
+ pass `false`. The renderer needs to know which one it is answering,
6801
+ because the by-hand panel's instruction deliberately outlives a
6802
+ background check and is only ended by a check the user asked for
6803
+ (review U12, round 3). */
6804
+ manual: !silent
4759
6805
  };
4760
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4761
- logger.info(
4762
- `Sending backend-update-available event with info: ${JSON.stringify(updateInfo)}`,
4763
- LogFileType.UPDATE_SERVICE
4764
- );
4765
- this.mainWindow.webContents.send(
4766
- "backend-update-available",
4767
- updateInfo
4768
- );
4769
- } else {
4770
- logger.error(
4771
- "Cannot send backend-update-available event: mainWindow is not available or destroyed",
4772
- LogFileType.UPDATE_SERVICE
4773
- );
4774
- }
6806
+ logger.info(
6807
+ `Sending backend-update-available event with info: ${JSON.stringify(updateInfo)}`,
6808
+ LogFileType.UPDATE_SERVICE
6809
+ );
6810
+ this.sendToRenderer("backend-update-available", updateInfo);
4775
6811
  return updateInfo;
4776
6812
  }
4777
6813
  logger.info(
@@ -4803,7 +6839,76 @@ class UpdateService {
4803
6839
  * Update the backend using pip and restart the backend service
4804
6840
  * @returns Promise resolving to true if update was successful, false otherwise
4805
6841
  */
4806
- async updateBackend() {
6842
+ /** The version the bundled environment has installed, via `pip show`. */
6843
+ async readBundledBackendVersion(pythonPath) {
6844
+ const probe = await runCommand(
6845
+ pythonPath,
6846
+ ["-m", "pip", "show", "local-operator"],
6847
+ { timeoutMs: 12e4 }
6848
+ );
6849
+ if (probe.exitCode !== 0) {
6850
+ logger.warn(
6851
+ `pip show local-operator exited ${probe.exitCode}: ${(probe.stderr || probe.stdout).trim()}`,
6852
+ LogFileType.UPDATE_SERVICE
6853
+ );
6854
+ return null;
6855
+ }
6856
+ return parsePipShowVersion(probe.stdout);
6857
+ }
6858
+ /**
6859
+ * Bring the previous server back after a failed upgrade, and say whether it
6860
+ * actually came back.
6861
+ *
6862
+ * A failure here must not leave the user with no backend at all: whatever pip
6863
+ * did or did not do, the environment usually still holds a working install, so
6864
+ * start it again and then ask /health rather than assuming. The answer is not
6865
+ * cosmetic: `pip install --upgrade` uninstalls before it installs, so a
6866
+ * mid-install failure can leave the package absent, and telling the user "the
6867
+ * previous server is still running" when it is not is a claim we had no
6868
+ * evidence for (review R6).
6869
+ */
6870
+ async restartBackendAfterFailedUpgrade() {
6871
+ try {
6872
+ await this.backendService?.start();
6873
+ const healthy = await this.checkBackendHealth();
6874
+ logger.info(
6875
+ healthy ? "Restarted the previous backend after a failed update" : "The backend did not answer its health check after a failed update",
6876
+ LogFileType.UPDATE_SERVICE
6877
+ );
6878
+ return healthy;
6879
+ } catch (error) {
6880
+ logger.error(
6881
+ "Could not restart the backend after a failed update:",
6882
+ LogFileType.UPDATE_SERVICE,
6883
+ error
6884
+ );
6885
+ return false;
6886
+ }
6887
+ }
6888
+ /**
6889
+ * Poll /health until it reports `targetVersion`, bounded by a timeout.
6890
+ *
6891
+ * Returns null when the version never matched (or could not be read), which
6892
+ * the caller reports as a failure rather than as a completed update.
6893
+ */
6894
+ async waitForBackendVersion(target2, timeoutMs = 6e4, intervalMs = 2e3) {
6895
+ const deadline = Date.now() + timeoutMs;
6896
+ let last = null;
6897
+ while (Date.now() < deadline) {
6898
+ const version2 = await this.getInstalledBackendVersion();
6899
+ if (version2 && version2 !== "Unknown") {
6900
+ last = version2;
6901
+ if (target2 == null || version2 === target2) return version2;
6902
+ }
6903
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
6904
+ }
6905
+ logger.warn(
6906
+ `Backend version poll timed out after ${timeoutMs}ms (target ${target2 ?? "any"}, last seen ${last ?? "none"})`,
6907
+ LogFileType.UPDATE_SERVICE
6908
+ );
6909
+ return null;
6910
+ }
6911
+ async updateBackend(targetVersion) {
4807
6912
  logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
4808
6913
  try {
4809
6914
  if (!this.backendService) {
@@ -4834,18 +6939,28 @@ class UpdateService {
4834
6939
  }
4835
6940
  return false;
4836
6941
  case LocalOperatorStartupMode.EXISTING_SERVER:
6942
+ case LocalOperatorStartupMode.GLOBAL_INSTALL: {
6943
+ const plan = await this.resolveBackendUpdatePlan(startupMode);
4837
6944
  logger.info(
4838
- "Cannot manage update for existing server. User must update manually.",
6945
+ `Cannot manage the update for ${startupMode}: ${plan.detail}`,
4839
6946
  LogFileType.UPDATE_SERVICE
4840
6947
  );
4841
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
4842
- this.mainWindow.webContents.send("backend-update-manual-required", {
4843
- message: "Please update the local-operator package manually using pip.",
4844
- command: "pip install --upgrade local-operator"
4845
- });
4846
- }
6948
+ const installed = await this.getInstalledBackendVersion();
6949
+ this.sendToRenderer("backend-update-manual-required", {
6950
+ message: plan.remedy,
6951
+ command: plan.updateCommand,
6952
+ detail: plan.detail,
6953
+ // The caller's target when it named one (the "Update server"
6954
+ // button does), otherwise the published release the last check
6955
+ // read: the compatibility banner calls this with no target at
6956
+ // all, and the panel's version sentence then had nothing to
6957
+ // render on the path that actually produces it (review U17).
6958
+ latestVersion: targetVersion ?? this.lastPublishedBackendVersion ?? null,
6959
+ currentVersion: installed && installed !== "Unknown" ? installed : null,
6960
+ sourceBuild: plan.sourceBuild
6961
+ });
4847
6962
  return false;
4848
- case LocalOperatorStartupMode.GLOBAL_INSTALL:
6963
+ }
4849
6964
  case LocalOperatorStartupMode.APP_BUNDLED_VENV:
4850
6965
  break;
4851
6966
  default:
@@ -4867,75 +6982,66 @@ class UpdateService {
4867
6982
  LogFileType.UPDATE_SERVICE
4868
6983
  );
4869
6984
  }
4870
- const execAsync = node_util.promisify(node_child_process.exec);
4871
6985
  let pythonPath = "";
4872
- let pipCommand = "";
4873
- if (startupMode === LocalOperatorStartupMode.APP_BUNDLED_VENV) {
4874
- try {
4875
- if (electron.app.isPackaged) {
4876
- if (process.platform === "darwin") {
4877
- pythonPath = path.join(
4878
- this.backendService?.getVenvPath(),
4879
- "bin",
4880
- "python3"
4881
- );
4882
- } else if (process.platform === "win32") {
4883
- pythonPath = path.join(
4884
- this.backendService?.getVenvPath(),
4885
- "Scripts",
4886
- "python.exe"
4887
- );
4888
- } else if (process.platform === "linux") {
4889
- pythonPath = path.join(
4890
- this.backendService?.getVenvPath(),
4891
- "bin",
4892
- "python3"
4893
- );
4894
- }
4895
- if (pythonPath) {
4896
- logger.info(
4897
- `Using bundled Python at: ${pythonPath}`,
4898
- LogFileType.UPDATE_SERVICE
4899
- );
4900
- pipCommand = `"${pythonPath}" -m pip install --upgrade local-operator`;
4901
- }
4902
- }
4903
- } catch (error) {
4904
- logger.warn(
4905
- "Error finding bundled Python path:",
4906
- LogFileType.UPDATE_SERVICE,
4907
- error
4908
- );
4909
- pythonPath = "";
4910
- }
4911
- } else if (startupMode === LocalOperatorStartupMode.GLOBAL_INSTALL) {
4912
- try {
4913
- const { stdout } = await execAsync("which python3 || which python");
4914
- pythonPath = stdout.trim();
4915
- logger.info(
4916
- `Using system Python at: ${pythonPath}`,
4917
- LogFileType.UPDATE_SERVICE
4918
- );
4919
- pipCommand = `"${pythonPath}" -m pip install --upgrade local-operator`;
4920
- } catch (error) {
4921
- logger.warn(
4922
- "Could not find system Python:",
4923
- LogFileType.UPDATE_SERVICE,
4924
- error
4925
- );
4926
- }
6986
+ const venvPath = this.backendService?.getVenvPath();
6987
+ if (venvPath && electron.app.isPackaged) {
6988
+ const venvBinDir = process.platform === "win32" ? "Scripts" : "bin";
6989
+ const pythonName = process.platform === "win32" ? "python.exe" : "python3";
6990
+ pythonPath = path.join(venvPath, venvBinDir, pythonName);
4927
6991
  }
4928
- if (!pipCommand) {
4929
- logger.warn("Using fallback pip command", LogFileType.UPDATE_SERVICE);
4930
- pipCommand = "pip install --upgrade local-operator";
6992
+ if (!pythonPath || !fs.existsSync(pythonPath)) {
6993
+ logger.error(
6994
+ `Cannot update the bundled backend: no Python at ${pythonPath || "an unknown path"}`,
6995
+ LogFileType.UPDATE_SERVICE
6996
+ );
6997
+ await this.restartBackendAfterFailedUpgrade();
6998
+ this.sendToRenderer(
6999
+ "backend-update-error",
7000
+ "The bundled server's Python environment could not be found, so the update did not run. Please reinstall the application."
7001
+ );
7002
+ return false;
4931
7003
  }
7004
+ const pip = buildPipUpgradeCommand(pythonPath);
7005
+ const versionBefore = await this.readBundledBackendVersion(pythonPath);
7006
+ logger.info(
7007
+ `Executing pip command: ${pip.display}`,
7008
+ LogFileType.UPDATE_SERVICE
7009
+ );
7010
+ const pipRun = await runCommand(pip.command, pip.args, {
7011
+ timeoutMs: 15 * 60 * 1e3
7012
+ });
4932
7013
  logger.info(
4933
- `Executing pip command: ${pipCommand}`,
7014
+ `pip stdout:
7015
+ ${pipRun.stdout.trim()}`,
4934
7016
  LogFileType.UPDATE_SERVICE
4935
7017
  );
4936
- await execAsync(pipCommand);
7018
+ if (pipRun.stderr.trim().length > 0) {
7019
+ logger.warn(
7020
+ `pip stderr:
7021
+ ${pipRun.stderr.trim()}`,
7022
+ LogFileType.UPDATE_SERVICE
7023
+ );
7024
+ }
7025
+ const versionAfter = await this.readBundledBackendVersion(pythonPath);
7026
+ const upgradeLanded = pipRun.exitCode === 0 && didUpgradeLand({
7027
+ before: versionBefore,
7028
+ after: versionAfter,
7029
+ target: targetVersion ?? null
7030
+ });
7031
+ if (!upgradeLanded) {
7032
+ logger.error(
7033
+ `Backend upgrade did not land: pip exited ${pipRun.exitCode}; version ${versionBefore ?? "unknown"} -> ${versionAfter ?? "unknown"}`,
7034
+ LogFileType.UPDATE_SERVICE
7035
+ );
7036
+ const serverIsBack = await this.restartBackendAfterFailedUpgrade();
7037
+ this.sendToRenderer(
7038
+ "backend-update-error",
7039
+ pipRun.exitCode !== 0 ? serverIsBack ? "The server update failed to install. The previously installed server is running again; see the update service log for pip's output." : "The server update failed to install and the server did not come back up. Restart Local Operator, and see the update service log for pip's output." : `The server update ran but the installed version did not change (still ${versionAfter ?? "unknown"}), so it is reported as failed.`
7040
+ );
7041
+ return false;
7042
+ }
4937
7043
  logger.info(
4938
- "Backend package updated successfully via pip",
7044
+ `Backend package upgraded: ${versionBefore ?? "unknown"} -> ${versionAfter}`,
4939
7045
  LogFileType.UPDATE_SERVICE
4940
7046
  );
4941
7047
  logger.info(
@@ -5000,6 +7106,24 @@ class UpdateService {
5000
7106
  }
5001
7107
  return false;
5002
7108
  }
7109
+ const reportedVersion = await this.waitForBackendVersion(
7110
+ targetVersion ?? null
7111
+ );
7112
+ if (reportedVersion == null) {
7113
+ logger.error(
7114
+ `Backend did not report version ${targetVersion ?? "the new one"} after the update`,
7115
+ LogFileType.UPDATE_SERVICE
7116
+ );
7117
+ this.sendToRenderer(
7118
+ "backend-update-error",
7119
+ `The server restarted but did not report ${targetVersion ? `version ${targetVersion}` : "the updated version"}. Check the update service log, then try again.`
7120
+ );
7121
+ return false;
7122
+ }
7123
+ logger.info(
7124
+ `Backend reports version ${reportedVersion} after the update`,
7125
+ LogFileType.UPDATE_SERVICE
7126
+ );
5003
7127
  logger.info(
5004
7128
  "Backend update and restart completed successfully",
5005
7129
  LogFileType.UPDATE_SERVICE
@@ -5079,6 +7203,14 @@ class UpdateService {
5079
7203
  * @param err The error object from autoUpdater
5080
7204
  * @returns True if the error should be filtered, false if it should be shown to the user
5081
7205
  */
7206
+ /**
7207
+ * Whether an error from the updater is a genuine availability problem.
7208
+ *
7209
+ * Only availability checks may be filtered. A download, verification or
7210
+ * install failure means something the user asked for did not happen, and
7211
+ * reporting it as "no update available" (the previous behaviour) is how the
7212
+ * operator's failed install produced no message at all.
7213
+ */
5082
7214
  shouldFilterUpdateError(err) {
5083
7215
  const errorMessage = err.message || "";
5084
7216
  logger.info(
@@ -5433,6 +7565,9 @@ electron.app.whenReady().then(async () => {
5433
7565
  (input) => backendService.requestDesktop(input)
5434
7566
  );
5435
7567
  const desktopNotifier = new DesktopNotifier(() => mainWindow, sendDesktop);
7568
+ backendService.onBackendReady(() => {
7569
+ void desktopNotifier.refreshNotificationContract();
7570
+ });
5436
7571
  backendService.observeStream((sessionId2, data) => {
5437
7572
  try {
5438
7573
  desktopNotifier.observe(sessionId2, JSON.parse(data));