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.
- package/out/main/index.js +2481 -346
- package/out/preload/index.js +51 -3
- package/out/renderer/assets/{_basePickBy-BJr_fQtB.js → _basePickBy-BvMhn6vs.js} +1 -1
- package/out/renderer/assets/{_baseUniq-CYir-14_.js → _baseUniq-BK8pe4uQ.js} +1 -1
- package/out/renderer/assets/{agent-details-page-D6PQzszA.js → agent-details-page-CBSLlAOk.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-BU8BMGm-.js → agent-hub-page-CrP4o6ZW.js} +1 -1
- package/out/renderer/assets/{agents-page-3jWcFYcZ.js → agents-page-C6OY5Mpg.js} +2 -2
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CCErX8Tf.js → architectureDiagram-IEHRJDOE-D8MpYATY.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-CUvjtfSj.js → blockDiagram-JOT3LUYC-B_I3ZONm.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-VeJwMCKK.js → c4Diagram-VJAJSXHY-DfXBiy8U.js} +1 -1
- package/out/renderer/assets/channel-QCRg_PDl.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-3Bq4xtvZ.js → chunk-4BMEZGHF-BJ8Xa2j3.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-B3y7xV7i.js → chunk-A2AXSNBT-C0NnqsE4.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-EVfEpH4f.js → chunk-AEK57VVT-BtRuzQyQ.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-DMvU3WoU.js → chunk-D6G4REZN-BauYOOAw.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-IvUVaxD4.js → chunk-RZ5BOZE2-BzhuQ5O_.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-qRGLrBmi.js → chunk-XZIHB7SX-Ct2eCcqa.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-Bi3kPe4h.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-Bi3kPe4h.js +1 -0
- package/out/renderer/assets/clone-A5TnVgxX.js +1 -0
- package/out/renderer/assets/{compact-pagination-C9MHaQh8.js → compact-pagination-Bou_Wr8n.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-mkOM0foV.js → dagre-OKDRZEBW-CTPq15Dg.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-nPhdvd43.js → diagram-SSKATNLV-C3Osiilj.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-DwSuRePG.js → diagram-VNBRO52H-Dz1J2itu.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-H_hDC8d0.js → erDiagram-Q7BY3M3F-hZV4173D.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-COn1sttR.js → flowDiagram-4HSFHLVR-DJ26Mgv9.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-DbxYzA4C.js → ganttDiagram-APWFNJXF-CetzfqU-.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-GJA88tpY.js → gitGraphDiagram-7IBYFJ6S-p6uBXV2t.js} +1 -1
- package/out/renderer/assets/{graph-CDkZGsbw.js → graph-DkNd5Vg_.js} +1 -1
- package/out/renderer/assets/icon-2KHv58kf.css +1 -0
- package/out/renderer/assets/{index-Coj74S4F.js → index-CH2sMRee.js} +1 -1
- package/out/renderer/assets/{index-CVSo1eIn.js → index-CsQcGUS4.js} +299 -298
- package/out/renderer/assets/{index-DyRL2cJn.js → index-DA5ogML6.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-DCDmAfuD.js → infoDiagram-PH2N3AL5-JCL1S2jO.js} +1 -1
- package/out/renderer/assets/{installer-JsKFFZ0N.js → installer-CwpKGBgz.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-D5zuKm0h.js → journeyDiagram-U35MCT3I-bsiOJp5O.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-D28NqnN2.js → kanban-definition-NDS4AKOZ-DqSB69IY.js} +1 -1
- package/out/renderer/assets/{layout-B__Skqp8.js → layout-DOCr1qf_.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-d_nYp_Kx.js → legacy-agents-page-BjfTT7Fq.js} +1 -1
- package/out/renderer/assets/{mermaid.core-BHfHBA6R.js → mermaid.core-B5-LsuoJ.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-DKRrQTAb.js → mindmap-definition-ALO5MXBD-6EZdR0xM.js} +1 -1
- package/out/renderer/assets/{page-header-JYwkpfbT.js → page-header-C-9zjakn.js} +1 -1
- package/out/renderer/assets/{parseISO-D2eiqdi5.js → parseISO-Dwm8HpJw.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-B1BtzilZ.js → pieDiagram-IB7DONF6-DJpMDsKb.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-2ZnW_kzE.js → quadrantDiagram-7GDLP6J5-DijmJtPz.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-BxdSoZJX.js → radar-MK3ICKWK-BTOUZlXe.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-Bi1-KXYI.js → radient-auth-buttons-Dwc9qPW2.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-6W-9ZOiF.js → requirementDiagram-KVF5MWMF-D3lSHecQ.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-DsCO-xHN.js → sankeyDiagram-QLVOVGJD-0iNyBkZV.js} +1 -1
- package/out/renderer/assets/{schedules-page-BGkrdg_o.js → schedules-page-DardPLDn.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-DTsOsVHj.js → sequenceDiagram-X6HHIX6F-CY9L6HEa.js} +1 -1
- package/out/renderer/assets/{settings-page-UqW8Dofa.js → settings-page-CjgBGEXW.js} +29 -29
- package/out/renderer/assets/{square-pen-DOIqaj9i.js → square-pen-2Yxvf_S9.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-Hgu5z66Y.js → stateDiagram-DGXRK772-DJ_q4gER.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CM2f7MUh.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-BkaQadIG.js → timeline-definition-BDJGKUSR-D0Y8Jbrj.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-BSrvgpki.js → use-agent-like-mutation-BXxHLNnI.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-DuWwCccB.js → xychartDiagram-VJFVF3MP-Bj6ktAuR.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +5 -3
- package/out/renderer/assets/channel-BtWQQlRJ.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-C298zC-3.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-C298zC-3.js +0 -1
- package/out/renderer/assets/clone-dCQCshht.js +0 -1
- package/out/renderer/assets/icon-DM6KYqqV.css +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-BrBwSEFe.js +0 -1
- /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
|
|
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
|
|
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
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4044
|
-
const
|
|
4045
|
-
const
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
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
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
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
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
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
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
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
|
-
|
|
4165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
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
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
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
|
-
|
|
4219
|
-
|
|
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
|
|
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
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
});
|
|
4233
|
-
}
|
|
6076
|
+
this.sendToRenderer("update-not-available", {
|
|
6077
|
+
version: electron.app.getVersion()
|
|
6078
|
+
});
|
|
4234
6079
|
} else {
|
|
4235
|
-
|
|
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(
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
error
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
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
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
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(
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
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
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
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
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
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("
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
)
|
|
4351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4731
|
-
this.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6945
|
+
`Cannot manage the update for ${startupMode}: ${plan.detail}`,
|
|
4839
6946
|
LogFileType.UPDATE_SERVICE
|
|
4840
6947
|
);
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4873
|
-
if (
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
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 (!
|
|
4929
|
-
logger.
|
|
4930
|
-
|
|
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
|
-
`
|
|
7014
|
+
`pip stdout:
|
|
7015
|
+
${pipRun.stdout.trim()}`,
|
|
4934
7016
|
LogFileType.UPDATE_SERVICE
|
|
4935
7017
|
);
|
|
4936
|
-
|
|
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
|
-
|
|
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));
|