local-operator-ui 0.23.1 → 0.23.3
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/bin/local-operator-ui.js +11 -1
- package/out/main/index.js +2636 -610
- package/out/preload/index.js +79 -0
- package/out/renderer/assets/{_basePickBy-mmFlh3ho.js → _basePickBy-BcgsHs9n.js} +1 -1
- package/out/renderer/assets/{_baseUniq-DAWnt4WG.js → _baseUniq-BpL7wRRP.js} +1 -1
- package/out/renderer/assets/{agent-details-page-CO72YkSX.js → agent-details-page-XeWCJu5r.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-DhYc5_gP.js → agent-hub-page-CnCjDZpC.js} +1 -1
- package/out/renderer/assets/{agents-page-BopOzax_.js → agents-page-Bdno1dWD.js} +2 -2
- package/out/renderer/assets/{arc-BgHsUFIt.js → arc-BoPsrKtE.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-BWxIiACo.js → architectureDiagram-IEHRJDOE-C2WpBBPk.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-CoJDUfWc.js → blockDiagram-JOT3LUYC-CGcW4B3x.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-B8vXmmLx.js → c4Diagram-VJAJSXHY-o8n2wzDv.js} +1 -1
- package/out/renderer/assets/channel-urLqozUh.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-rO5ve7XY.js → chunk-4BMEZGHF-D-n0UX21.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-CTPb1xgY.js → chunk-A2AXSNBT-DfLH07_4.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-5VpTdLBm.js → chunk-AEK57VVT-CL7wRm_N.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-C3CEf0kC.js → chunk-D6G4REZN-Cn9ko_6u.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-B7nn7Euk.js → chunk-RZ5BOZE2-D2Yy6cfD.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-CnPH3vBT.js → chunk-XZIHB7SX-DADi_UCc.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CdFb9UuH.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CdFb9UuH.js +1 -0
- package/out/renderer/assets/clone-R16IxTog.js +1 -0
- package/out/renderer/assets/{compact-pagination-Bu8vshU-.js → compact-pagination-BJGKcOBo.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-DUX1RPbJ.js → dagre-OKDRZEBW-CTUL3pXh.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-Cpv42kzr.js → diagram-SSKATNLV-DN8A3Gxk.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-CGkUnKEL.js → diagram-VNBRO52H-DMnKnMe_.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-iBbJbyJZ.js → erDiagram-Q7BY3M3F-D_QMDQ8A.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-B569Tv1u.js → flowDiagram-4HSFHLVR-C3lOP5SJ.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-nGbcgsgD.js → ganttDiagram-APWFNJXF-C_OA497D.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-vv_MhteL.js → gitGraphDiagram-7IBYFJ6S-CME4fotT.js} +1 -1
- package/out/renderer/assets/{graph-krOMwYSa.js → graph-StjGh6sY.js} +1 -1
- package/out/renderer/assets/icon-BgNKKXpS.css +1 -0
- package/out/renderer/assets/{icon-mKHwnRNs.js → icon-fm25vzsV.js} +1 -1
- package/out/renderer/assets/{index-BqWgqUKZ.js → index-CrQVJ-Co.js} +365 -371
- package/out/renderer/assets/{index-BgGl2-0g.js → index-Dr1tzeVu.js} +1 -1
- package/out/renderer/assets/{index-CT6w0yXj.js → index-ir1HIEvx.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-DHDA5253.js → infoDiagram-PH2N3AL5-BDK-X71A.js} +1 -1
- package/out/renderer/assets/{installer-DcKqSMWJ.js → installer-BlFThduE.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-CIrQ218m.js → journeyDiagram-U35MCT3I-BjNamBAK.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DG_YsZbL.js → kanban-definition-NDS4AKOZ-ggxQngdS.js} +1 -1
- package/out/renderer/assets/{layout-CB5zJSjt.js → layout-BwRA6JIM.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-B8pvrWy_.js → legacy-agents-page-6NVm9UCi.js} +4 -4
- package/out/renderer/assets/{mermaid.core-CYc-R2hQ.js → mermaid.core-CfsR-egY.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-C22fFThl.js → mindmap-definition-ALO5MXBD-DyShE6kV.js} +1 -1
- package/out/renderer/assets/{page-header-DmGvvMe3.js → page-header-DHRyHEm6.js} +1 -1
- package/out/renderer/assets/{parseISO-vr2fT-wq.js → parseISO-QkHFGs0Y.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-DKRytw7K.js → pieDiagram-IB7DONF6-3wxJVvt5.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CWdnqGKr.js → quadrantDiagram-7GDLP6J5-iNEHdUXN.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-B0XgopGa.js → radar-MK3ICKWK-tbLyRbLt.js} +1 -1
- package/out/renderer/assets/radient-auth-buttons-CJi5xMEA.js +6 -0
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CXL8GuIo.js → requirementDiagram-KVF5MWMF-CfXxmaaW.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BQKCHfmN.js → sankeyDiagram-QLVOVGJD-DaJoWNT6.js} +1 -1
- package/out/renderer/assets/{schedules-page-DtNhlycC.js → schedules-page-j6YSAreu.js} +8 -8
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-awR0fjHu.js → sequenceDiagram-X6HHIX6F-WF0xhSxd.js} +1 -1
- package/out/renderer/assets/settings-page-xzYs88jD.js +97 -0
- package/out/renderer/assets/{square-pen-717nTWtU.js → square-pen-D_sBefzl.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-BjhA_mJe.js → stateDiagram-DGXRK772-YdZ2xuTI.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CegpBrNz.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-BFEDYBl8.js → timeline-definition-BDJGKUSR-DYaIE99w.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-Bxx_WHvo.js → use-agent-like-mutation-s4bRf1u_.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CwLeRa1a.js → xychartDiagram-VJFVF3MP-DYQwMal7.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +52 -20
- package/out/renderer/assets/channel-DAvIgUhZ.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CP5aSNYY.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CP5aSNYY.js +0 -1
- package/out/renderer/assets/clone-CeahOD_w.js +0 -1
- package/out/renderer/assets/icon-DC3rStnR.css +0 -1
- package/out/renderer/assets/radient-auth-buttons-RrBw8fkd.js +0 -6
- package/out/renderer/assets/settings-page-DCLaspsn.js +0 -91
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-nAxLNCrm.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -13,6 +13,8 @@ const os = require("node:os");
|
|
|
13
13
|
const node_util = require("node:util");
|
|
14
14
|
const dotenv = require("dotenv");
|
|
15
15
|
const electronLog = require("electron-log");
|
|
16
|
+
const promises = require("node:fs/promises");
|
|
17
|
+
const node_net = require("node:net");
|
|
16
18
|
const node_http = require("node:http");
|
|
17
19
|
const https = require("node:https");
|
|
18
20
|
const electronUpdater = require("electron-updater");
|
|
@@ -84,7 +86,16 @@ const teamFields = zod.z.object({
|
|
|
84
86
|
}).strict();
|
|
85
87
|
const chains = zod.z.record(zod.z.array(zod.z.string().max(1024)).max(100));
|
|
86
88
|
const scheduleUnit = zod.z.enum(["minutes", "hours", "days"]);
|
|
87
|
-
|
|
89
|
+
function isAddressableVariableKey(key) {
|
|
90
|
+
return !/^\.+$/.test(key);
|
|
91
|
+
}
|
|
92
|
+
const variableKey = zod.z.string().min(1).max(128).refine(
|
|
93
|
+
(key) => [...key].every(
|
|
94
|
+
(character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127 && character !== "/" && character !== "\\"
|
|
95
|
+
)
|
|
96
|
+
).refine(isAddressableVariableKey);
|
|
97
|
+
const variableType = zod.z.enum(["str", "int", "float", "bool", "list", "dict"]);
|
|
98
|
+
const variableValue = zod.z.string().max(16384);
|
|
88
99
|
const scheduleWrite = zod.z.object({
|
|
89
100
|
is_active: zod.z.boolean().nullish(),
|
|
90
101
|
one_time: zod.z.boolean().nullish(),
|
|
@@ -278,6 +289,89 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
278
289
|
// `desktopRequestByteBudget`. Firing it costs the control budget and a
|
|
279
290
|
// 2-byte body, which is what lets the renderer issue it from a keystroke.
|
|
280
291
|
zod.z.object({ op: zod.z.literal("sessions.warm"), sessionId }).strict(),
|
|
292
|
+
/*
|
|
293
|
+
* A session's code memory: the names the session's own cells have left in its
|
|
294
|
+
* eval namespace.
|
|
295
|
+
*
|
|
296
|
+
* Session-addressed, because the runtime is what holds a namespace and the
|
|
297
|
+
* kernel it lives in is keyed by session id. The legacy
|
|
298
|
+
* `/v1/agents/{id}/execution-variables` route answered through the agent
|
|
299
|
+
* registry instead, which resolves agent-directory UUIDs - so a canonical
|
|
300
|
+
* session id could only ever 404 there, and the panel that taught "code
|
|
301
|
+
* memory" never loaded once.
|
|
302
|
+
*/
|
|
303
|
+
zod.z.object({ op: zod.z.literal("sessions.variables.list"), sessionId }).strict(),
|
|
304
|
+
zod.z.object({
|
|
305
|
+
op: zod.z.literal("sessions.variables.create"),
|
|
306
|
+
sessionId,
|
|
307
|
+
key: variableKey,
|
|
308
|
+
value: variableValue,
|
|
309
|
+
type: variableType
|
|
310
|
+
}).strict(),
|
|
311
|
+
zod.z.object({
|
|
312
|
+
op: zod.z.literal("sessions.variables.update"),
|
|
313
|
+
sessionId,
|
|
314
|
+
key: variableKey,
|
|
315
|
+
value: variableValue,
|
|
316
|
+
type: variableType
|
|
317
|
+
}).strict(),
|
|
318
|
+
zod.z.object({
|
|
319
|
+
op: zod.z.literal("sessions.variables.delete"),
|
|
320
|
+
sessionId,
|
|
321
|
+
key: variableKey
|
|
322
|
+
}).strict(),
|
|
323
|
+
// Machine-wide desktop presence. NOT a watch lease and deliberately not
|
|
324
|
+
// shaped like one: it names no session, because the fact it carries is
|
|
325
|
+
// "somebody is at this machine's screen and this app can raise a banner",
|
|
326
|
+
// which is a property of the app rather than of any conversation. The
|
|
327
|
+
// backend reads it to decide whether a BACKGROUND completion is worth a
|
|
328
|
+
// banner here at all (a session runtime with no surface to present on
|
|
329
|
+
// otherwise toasts on its own host, where nobody may be).
|
|
330
|
+
//
|
|
331
|
+
// `subscription_id` is the LIVE feed subscription the presence belongs to,
|
|
332
|
+
// not a per-session watch subscription: the server holds the lease against
|
|
333
|
+
// that socket, so a dropped feed revokes the claim without waiting for a
|
|
334
|
+
// heartbeat to go stale. That is the whole reason presence is a route on the
|
|
335
|
+
// feed rather than a local file - a desktop paired to a backend on another
|
|
336
|
+
// host cannot write to that host's disk.
|
|
337
|
+
zod.z.object({
|
|
338
|
+
op: zod.z.literal("sessions.presence"),
|
|
339
|
+
subscriptionId: zod.z.string().regex(/^[a-f0-9]{1,64}$/),
|
|
340
|
+
canNotify: zod.z.boolean(),
|
|
341
|
+
/*
|
|
342
|
+
* WHY THE CLAIM CARRIES MORE THAN "a desktop is connected".
|
|
343
|
+
*
|
|
344
|
+
* The backend's delivery lease answers two separate questions from
|
|
345
|
+
* this one beat, and a claim that names neither is a claim that
|
|
346
|
+
* delivers NOTHING:
|
|
347
|
+
*
|
|
348
|
+
* - `can_notify_kinds` is what `delivers(kind)` reads. Empty (the
|
|
349
|
+
* default for a client that forgot) means rung 2 is never eligible
|
|
350
|
+
* for any completion, so rung 4 raises the runtime's own banner —
|
|
351
|
+
* earlier, and its claim advances the read watermark, so the
|
|
352
|
+
* clickable banner this app exists to raise never composes.
|
|
353
|
+
* The backend's own test for this is
|
|
354
|
+
* `test_a_claim_with_no_kinds_claims_nothing`: an app that does not
|
|
355
|
+
* advertise must not win the rung.
|
|
356
|
+
* - `window` (with `session_id`) is what `attended` reads. Without it
|
|
357
|
+
* this app counts as watching NOTHING, so a completion in the
|
|
358
|
+
* conversation on screen raises a banner — the inverse of rung 1,
|
|
359
|
+
* and a regression against the per-session flag it replaces.
|
|
360
|
+
*
|
|
361
|
+
* `window` is sent even when there is no window: a windowless app
|
|
362
|
+
* (macOS, alive in the dock) can still raise a banner but cannot be
|
|
363
|
+
* displaying anything, which is why `session_id` must be "" there
|
|
364
|
+
* rather than the last conversation the closed window held.
|
|
365
|
+
*/
|
|
366
|
+
canNotifyKinds: zod.z.array(zod.z.enum(["complete", "error"])).max(4),
|
|
367
|
+
sessionId: zod.z.string().regex(/^([a-f0-9]{12})?$/),
|
|
368
|
+
window: zod.z.object({
|
|
369
|
+
exists: zod.z.boolean(),
|
|
370
|
+
focused: zod.z.boolean(),
|
|
371
|
+
visible: zod.z.boolean(),
|
|
372
|
+
minimized: zod.z.boolean()
|
|
373
|
+
}).strict()
|
|
374
|
+
}).strict(),
|
|
281
375
|
// The legacy surface, reached through the same authenticated vocabulary as
|
|
282
376
|
// everything else. These routes are gated in managed mode (agent inventory,
|
|
283
377
|
// cwd paths, job history and conversation content are the same tenant's data
|
|
@@ -384,28 +478,6 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
384
478
|
systemPrompt: zod.z.string().max(DESKTOP_SYSTEM_PROMPT_MAX_CHARS)
|
|
385
479
|
}).strict(),
|
|
386
480
|
zod.z.object({ op: zod.z.literal("legacy.agent.download"), agentId: id$1 }).strict(),
|
|
387
|
-
zod.z.object({ op: zod.z.literal("legacy.agent.variables.list"), agentId: id$1 }).strict(),
|
|
388
|
-
zod.z.object({
|
|
389
|
-
op: zod.z.literal("legacy.agent.variables.create"),
|
|
390
|
-
agentId: id$1,
|
|
391
|
-
variable: zod.z.record(zod.z.unknown())
|
|
392
|
-
}).strict(),
|
|
393
|
-
zod.z.object({
|
|
394
|
-
op: zod.z.literal("legacy.agent.variables.get"),
|
|
395
|
-
agentId: id$1,
|
|
396
|
-
key: variableKey
|
|
397
|
-
}).strict(),
|
|
398
|
-
zod.z.object({
|
|
399
|
-
op: zod.z.literal("legacy.agent.variables.update"),
|
|
400
|
-
agentId: id$1,
|
|
401
|
-
key: variableKey,
|
|
402
|
-
variable: zod.z.record(zod.z.unknown())
|
|
403
|
-
}).strict(),
|
|
404
|
-
zod.z.object({
|
|
405
|
-
op: zod.z.literal("legacy.agent.variables.delete"),
|
|
406
|
-
agentId: id$1,
|
|
407
|
-
key: variableKey
|
|
408
|
-
}).strict(),
|
|
409
481
|
zod.z.object({ op: zod.z.literal("legacy.job.cancel"), jobId: id$1 }).strict(),
|
|
410
482
|
zod.z.object({ op: zod.z.literal("commands.list") }).strict(),
|
|
411
483
|
zod.z.object({
|
|
@@ -492,6 +564,25 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
492
564
|
asideId: requestId
|
|
493
565
|
}).strict(),
|
|
494
566
|
zod.z.object({ op: zod.z.literal("mcp.list"), sessionId }).strict(),
|
|
567
|
+
zod.z.object({
|
|
568
|
+
op: zod.z.literal("mcp.credentials.store"),
|
|
569
|
+
sessionId,
|
|
570
|
+
name: zod.z.string().min(1).max(256),
|
|
571
|
+
values: zod.z.record(zod.z.string().min(1).max(128), zod.z.string().min(1).max(32768)).refine(
|
|
572
|
+
(secrets) => Object.keys(secrets).length <= 32 && Object.values(secrets).reduce(
|
|
573
|
+
(total, value) => total + value.length,
|
|
574
|
+
0
|
|
575
|
+
) <= 65536,
|
|
576
|
+
// Mirrors the owner's own bound (`local_operator/mcp/credentials.py`),
|
|
577
|
+
// so an oversized paste is refused as a sentence rather than
|
|
578
|
+
// serialized into a request the control budget rejects as an
|
|
579
|
+
// opaque 413.
|
|
580
|
+
{
|
|
581
|
+
message: "Too many secret values, or too much secret text, for one MCP credential write."
|
|
582
|
+
}
|
|
583
|
+
),
|
|
584
|
+
confirmedReplace: zod.z.array(zod.z.string().min(1).max(128)).max(32)
|
|
585
|
+
}).strict(),
|
|
495
586
|
zod.z.object({
|
|
496
587
|
op: zod.z.literal("mcp.control"),
|
|
497
588
|
sessionId,
|
|
@@ -795,6 +886,27 @@ function desktopEndpoint(request) {
|
|
|
795
886
|
can_notify: request.canNotify
|
|
796
887
|
}
|
|
797
888
|
};
|
|
889
|
+
case "sessions.presence":
|
|
890
|
+
return {
|
|
891
|
+
path: "/v1/desktop/presence",
|
|
892
|
+
method: "POST",
|
|
893
|
+
body: {
|
|
894
|
+
subscription_id: request.subscriptionId,
|
|
895
|
+
// The three fields the delivery lease reads beside it. Sent
|
|
896
|
+
// snake_case like the route's own model, and always sent: a
|
|
897
|
+
// defaulted claim is what made this app ineligible.
|
|
898
|
+
can_notify_kinds: request.canNotifyKinds,
|
|
899
|
+
session_id: request.sessionId,
|
|
900
|
+
window: request.window,
|
|
901
|
+
// `can_notify` means "can ATTEMPT delivery", never "the user will
|
|
902
|
+
// be reached": `Notification.isSupported()` knows nothing about
|
|
903
|
+
// macOS Focus/DND, Windows Focus Assist or a denied permission.
|
|
904
|
+
// The backend's suppression reads it as eligibility to try, and a
|
|
905
|
+
// claim never advances the read watermark, so a suppressed banner
|
|
906
|
+
// still leaves the durable unseen mark intact.
|
|
907
|
+
can_notify: request.canNotify
|
|
908
|
+
}
|
|
909
|
+
};
|
|
798
910
|
case "sessions.warm":
|
|
799
911
|
return {
|
|
800
912
|
path: `/v1/desktop/sessions/${request.sessionId}/warm`,
|
|
@@ -805,6 +917,31 @@ function desktopEndpoint(request) {
|
|
|
805
917
|
// makes a legal call answer 422.
|
|
806
918
|
body: {}
|
|
807
919
|
};
|
|
920
|
+
case "sessions.variables.list":
|
|
921
|
+
return {
|
|
922
|
+
path: `/v1/desktop/sessions/${request.sessionId}/variables`,
|
|
923
|
+
method: "GET"
|
|
924
|
+
};
|
|
925
|
+
case "sessions.variables.create":
|
|
926
|
+
return {
|
|
927
|
+
path: `/v1/desktop/sessions/${request.sessionId}/variables`,
|
|
928
|
+
method: "POST",
|
|
929
|
+
// The route's own body shape, not the op's: `{key, value, type}` on
|
|
930
|
+
// create and `{value, type}` on update, because the key is in the path
|
|
931
|
+
// once the variable exists and is immutable after that.
|
|
932
|
+
body: { key: request.key, value: request.value, type: request.type }
|
|
933
|
+
};
|
|
934
|
+
case "sessions.variables.update":
|
|
935
|
+
return {
|
|
936
|
+
path: `/v1/desktop/sessions/${request.sessionId}/variables/${encodeURIComponent(request.key)}`,
|
|
937
|
+
method: "PATCH",
|
|
938
|
+
body: { value: request.value, type: request.type }
|
|
939
|
+
};
|
|
940
|
+
case "sessions.variables.delete":
|
|
941
|
+
return {
|
|
942
|
+
path: `/v1/desktop/sessions/${request.sessionId}/variables/${encodeURIComponent(request.key)}`,
|
|
943
|
+
method: "DELETE"
|
|
944
|
+
};
|
|
808
945
|
case "legacy.models": {
|
|
809
946
|
const query = new URLSearchParams();
|
|
810
947
|
if (request.provider) query.set("provider", request.provider);
|
|
@@ -918,33 +1055,6 @@ function desktopEndpoint(request) {
|
|
|
918
1055
|
};
|
|
919
1056
|
case "legacy.agent.download":
|
|
920
1057
|
return { path: `/v1/agents/${request.agentId}/download`, method: "GET" };
|
|
921
|
-
case "legacy.agent.variables.list":
|
|
922
|
-
return {
|
|
923
|
-
path: `/v1/agents/${request.agentId}/execution-variables`,
|
|
924
|
-
method: "GET"
|
|
925
|
-
};
|
|
926
|
-
case "legacy.agent.variables.create":
|
|
927
|
-
return {
|
|
928
|
-
path: `/v1/agents/${request.agentId}/execution-variables`,
|
|
929
|
-
method: "POST",
|
|
930
|
-
body: request.variable
|
|
931
|
-
};
|
|
932
|
-
case "legacy.agent.variables.get":
|
|
933
|
-
return {
|
|
934
|
-
path: `/v1/agents/${request.agentId}/execution-variables/${request.key}`,
|
|
935
|
-
method: "GET"
|
|
936
|
-
};
|
|
937
|
-
case "legacy.agent.variables.update":
|
|
938
|
-
return {
|
|
939
|
-
path: `/v1/agents/${request.agentId}/execution-variables/${request.key}`,
|
|
940
|
-
method: "PATCH",
|
|
941
|
-
body: request.variable
|
|
942
|
-
};
|
|
943
|
-
case "legacy.agent.variables.delete":
|
|
944
|
-
return {
|
|
945
|
-
path: `/v1/agents/${request.agentId}/execution-variables/${request.key}`,
|
|
946
|
-
method: "DELETE"
|
|
947
|
-
};
|
|
948
1058
|
case "legacy.job.cancel":
|
|
949
1059
|
return { path: `/v1/jobs/${request.jobId}`, method: "DELETE" };
|
|
950
1060
|
case "commands.list":
|
|
@@ -1055,6 +1165,16 @@ function desktopEndpoint(request) {
|
|
|
1055
1165
|
path: `/v1/desktop/sessions/${request.sessionId}/mcp`,
|
|
1056
1166
|
method: "GET"
|
|
1057
1167
|
};
|
|
1168
|
+
case "mcp.credentials.store":
|
|
1169
|
+
return {
|
|
1170
|
+
path: `/v1/desktop/sessions/${request.sessionId}/mcp/credentials`,
|
|
1171
|
+
method: "POST",
|
|
1172
|
+
body: {
|
|
1173
|
+
name: request.name,
|
|
1174
|
+
values: request.values,
|
|
1175
|
+
confirmed_replace: request.confirmedReplace
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1058
1178
|
case "mcp.control":
|
|
1059
1179
|
return {
|
|
1060
1180
|
path: `/v1/desktop/sessions/${request.sessionId}/mcp`,
|
|
@@ -1137,6 +1257,341 @@ function desktopEndpoint(request) {
|
|
|
1137
1257
|
}
|
|
1138
1258
|
const MAX_PROBE_PATHS = 64;
|
|
1139
1259
|
const MAX_FILE_READ_BYTES = 64 * 1024 * 1024;
|
|
1260
|
+
const OPEN_SESSION_FLAG = "--open-session";
|
|
1261
|
+
const OPEN_CATALOGUE_FLAG = "--open-catalogue";
|
|
1262
|
+
const SESSION_ID$2 = /^[a-f0-9]{12}$/;
|
|
1263
|
+
function readOpenSessionArgv(argv) {
|
|
1264
|
+
let found = null;
|
|
1265
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1266
|
+
const argument = argv[index];
|
|
1267
|
+
if (argument === OPEN_SESSION_FLAG) {
|
|
1268
|
+
const next = argv[index + 1];
|
|
1269
|
+
if (next !== void 0) found = next;
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
if (argument.startsWith(`${OPEN_SESSION_FLAG}=`)) {
|
|
1273
|
+
found = argument.slice(OPEN_SESSION_FLAG.length + 1);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return found !== null && SESSION_ID$2.test(found) ? found : null;
|
|
1277
|
+
}
|
|
1278
|
+
function readLaunchTarget(argv) {
|
|
1279
|
+
const sessionId2 = readOpenSessionArgv(argv);
|
|
1280
|
+
if (sessionId2) return { kind: "session", sessionId: sessionId2 };
|
|
1281
|
+
if (argv.includes(OPEN_CATALOGUE_FLAG)) return { kind: "catalogue" };
|
|
1282
|
+
return { kind: "restore" };
|
|
1283
|
+
}
|
|
1284
|
+
const FEED_NOTIFIABLE_KINDS = ["complete", "error"];
|
|
1285
|
+
const DEFAULT_HEARTBEAT_SECONDS = 15;
|
|
1286
|
+
const MIN_HEARTBEAT_SECONDS = 1;
|
|
1287
|
+
const MAX_HEARTBEAT_SECONDS = 60;
|
|
1288
|
+
const MISSED_HEARTBEATS = 3;
|
|
1289
|
+
const RECONNECT_MIN_MS = 1e3;
|
|
1290
|
+
const RECONNECT_MAX_MS = 3e4;
|
|
1291
|
+
const PRESENCE_INTERVAL_MS = 15e3;
|
|
1292
|
+
const WATCHDOG_TICK_MS$1 = 1e3;
|
|
1293
|
+
const SSE_DATA_PREFIX = /^ /;
|
|
1294
|
+
const MAX_FRAME_BYTES$2 = 8 * 1024 * 1024;
|
|
1295
|
+
class DesktopFeedRelay {
|
|
1296
|
+
constructor(backendUrl, token, options) {
|
|
1297
|
+
this.backendUrl = backendUrl;
|
|
1298
|
+
this.token = token;
|
|
1299
|
+
this.options = options;
|
|
1300
|
+
}
|
|
1301
|
+
controller = null;
|
|
1302
|
+
/**
|
|
1303
|
+
* The live body reader, so the watchdog can END the read rather than only
|
|
1304
|
+
* signal the fetch.
|
|
1305
|
+
*
|
|
1306
|
+
* Aborting the controller is not enough on its own to be sure of this: it
|
|
1307
|
+
* aborts a fetch in flight, and whether that also rejects a body read already
|
|
1308
|
+
* in progress is the platform's business (undici does; a test double need
|
|
1309
|
+
* not). `cancel()` is the reader's own guarantee — the pending `read()`
|
|
1310
|
+
* resolves `done` and the pump reconnects — so the watchdog does not depend
|
|
1311
|
+
* on a behaviour it cannot assert here.
|
|
1312
|
+
*/
|
|
1313
|
+
reader = null;
|
|
1314
|
+
running = false;
|
|
1315
|
+
stopped = false;
|
|
1316
|
+
connected = false;
|
|
1317
|
+
subscriptionId = null;
|
|
1318
|
+
heartbeatMs = DEFAULT_HEARTBEAT_SECONDS * 1e3;
|
|
1319
|
+
lastFrameAt = 0;
|
|
1320
|
+
watchdog = null;
|
|
1321
|
+
presence = null;
|
|
1322
|
+
reconnectTimer = null;
|
|
1323
|
+
/**
|
|
1324
|
+
* Settles the promise `pause()` is parked on.
|
|
1325
|
+
*
|
|
1326
|
+
* The timer IS the only thing that would resolve it, so a `stop()` that only
|
|
1327
|
+
* cleared the timer left `pump()` awaiting a promise nothing could settle — a
|
|
1328
|
+
* leak nobody would find later (the relay refuses to restart, so it had no
|
|
1329
|
+
* visible symptom). Held so teardown ends the wait explicitly.
|
|
1330
|
+
*/
|
|
1331
|
+
reconnectResolve = null;
|
|
1332
|
+
attempt = 0;
|
|
1333
|
+
/**
|
|
1334
|
+
* Single observer per kind, not a subscriber set.
|
|
1335
|
+
*
|
|
1336
|
+
* The relay has exactly two consumers and both are main's: one fan-out that
|
|
1337
|
+
* puts a frame in front of the notifier and (when a window exists) in front
|
|
1338
|
+
* of the renderer, and one that mirrors the connection state to the sidebar.
|
|
1339
|
+
* A set would suggest that consumers can come and go independently, and the
|
|
1340
|
+
* lifecycle here is the opposite of that — the observers are re-attached
|
|
1341
|
+
* whenever the relay is rebuilt for a rotated backend URL, which is a thing
|
|
1342
|
+
* this class cannot do for itself.
|
|
1343
|
+
*/
|
|
1344
|
+
frameObserver = null;
|
|
1345
|
+
stateObserver = null;
|
|
1346
|
+
get isConnected() {
|
|
1347
|
+
return this.connected;
|
|
1348
|
+
}
|
|
1349
|
+
/** True when this relay can authenticate at all. */
|
|
1350
|
+
get available() {
|
|
1351
|
+
return Boolean(this.token);
|
|
1352
|
+
}
|
|
1353
|
+
observe(observer) {
|
|
1354
|
+
this.frameObserver = observer;
|
|
1355
|
+
}
|
|
1356
|
+
watchState(observer) {
|
|
1357
|
+
this.stateObserver = observer;
|
|
1358
|
+
observer?.({ connected: this.connected });
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Open the feed if the backend supports it. Idempotent, and safe to call
|
|
1362
|
+
* again on every backend-ready transition: a restart or an
|
|
1363
|
+
* external-backend rotation re-runs the capability gate against the backend
|
|
1364
|
+
* that is actually there now.
|
|
1365
|
+
*/
|
|
1366
|
+
async start() {
|
|
1367
|
+
if (this.running || this.stopped) return;
|
|
1368
|
+
if (!this.token) {
|
|
1369
|
+
this.setConnected(false);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
this.running = true;
|
|
1373
|
+
let features;
|
|
1374
|
+
try {
|
|
1375
|
+
const response = await this.options.request({ op: "capabilities" });
|
|
1376
|
+
const body = response.body;
|
|
1377
|
+
features = body?.result?.features;
|
|
1378
|
+
} catch {
|
|
1379
|
+
this.running = false;
|
|
1380
|
+
this.setConnected(false);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (this.stopped) return;
|
|
1384
|
+
if (!(Number(features?.desktop_feed ?? 0) >= 1)) {
|
|
1385
|
+
this.running = false;
|
|
1386
|
+
this.setConnected(false);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
void this.pump();
|
|
1390
|
+
}
|
|
1391
|
+
/** Tear everything down. Idempotent. */
|
|
1392
|
+
stop() {
|
|
1393
|
+
this.stopped = true;
|
|
1394
|
+
this.running = false;
|
|
1395
|
+
if (this.reconnectTimer) {
|
|
1396
|
+
clearTimeout(this.reconnectTimer);
|
|
1397
|
+
this.reconnectTimer = null;
|
|
1398
|
+
}
|
|
1399
|
+
this.reconnectResolve?.();
|
|
1400
|
+
this.reconnectResolve = null;
|
|
1401
|
+
this.endSocket();
|
|
1402
|
+
this.setConnected(false);
|
|
1403
|
+
}
|
|
1404
|
+
/** Abort the socket and its timers, leaving `stopped` alone. */
|
|
1405
|
+
endSocket() {
|
|
1406
|
+
if (this.watchdog) {
|
|
1407
|
+
clearInterval(this.watchdog);
|
|
1408
|
+
this.watchdog = null;
|
|
1409
|
+
}
|
|
1410
|
+
if (this.presence) {
|
|
1411
|
+
clearInterval(this.presence);
|
|
1412
|
+
this.presence = null;
|
|
1413
|
+
}
|
|
1414
|
+
this.subscriptionId = null;
|
|
1415
|
+
this.controller?.abort();
|
|
1416
|
+
this.controller = null;
|
|
1417
|
+
}
|
|
1418
|
+
/** Read until the socket ends, then pace a reconnect. */
|
|
1419
|
+
async pump() {
|
|
1420
|
+
while (!this.stopped) {
|
|
1421
|
+
await this.readOnce();
|
|
1422
|
+
if (this.stopped) return;
|
|
1423
|
+
this.setConnected(false);
|
|
1424
|
+
await this.pause();
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
pause() {
|
|
1428
|
+
const delay = Math.min(
|
|
1429
|
+
RECONNECT_MAX_MS,
|
|
1430
|
+
RECONNECT_MIN_MS * 2 ** Math.min(this.attempt, 5)
|
|
1431
|
+
);
|
|
1432
|
+
this.attempt += 1;
|
|
1433
|
+
return new Promise((resolve) => {
|
|
1434
|
+
this.reconnectResolve = resolve;
|
|
1435
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1436
|
+
this.reconnectTimer = null;
|
|
1437
|
+
this.reconnectResolve = null;
|
|
1438
|
+
resolve();
|
|
1439
|
+
}, delay);
|
|
1440
|
+
this.reconnectTimer.unref?.();
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
async readOnce() {
|
|
1444
|
+
const controller = new AbortController();
|
|
1445
|
+
this.controller = controller;
|
|
1446
|
+
const url = new URL("/v1/desktop/events", this.backendUrl);
|
|
1447
|
+
try {
|
|
1448
|
+
const response = await fetch(url, {
|
|
1449
|
+
headers: {
|
|
1450
|
+
Accept: "text/event-stream",
|
|
1451
|
+
Authorization: `Bearer ${this.token}`
|
|
1452
|
+
},
|
|
1453
|
+
redirect: "error",
|
|
1454
|
+
signal: controller.signal
|
|
1455
|
+
});
|
|
1456
|
+
if (!response.ok || !response.body) {
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
this.attempt = 0;
|
|
1460
|
+
this.setConnected(true);
|
|
1461
|
+
this.startWatchdog();
|
|
1462
|
+
const reader = response.body.getReader();
|
|
1463
|
+
this.reader = reader;
|
|
1464
|
+
const decoder = new TextDecoder();
|
|
1465
|
+
let buffer = "";
|
|
1466
|
+
for (; ; ) {
|
|
1467
|
+
const { done, value } = await reader.read();
|
|
1468
|
+
if (done) break;
|
|
1469
|
+
this.lastFrameAt = Date.now();
|
|
1470
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1471
|
+
for (; ; ) {
|
|
1472
|
+
const boundary = buffer.indexOf("\n\n");
|
|
1473
|
+
if (boundary < 0) {
|
|
1474
|
+
if (buffer.length > MAX_FRAME_BYTES$2) {
|
|
1475
|
+
reader.cancel().catch(() => void 0);
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
break;
|
|
1479
|
+
}
|
|
1480
|
+
const record = buffer.slice(0, boundary);
|
|
1481
|
+
buffer = buffer.slice(boundary + 2);
|
|
1482
|
+
for (const line of record.split("\n")) {
|
|
1483
|
+
if (!line.startsWith("data:")) continue;
|
|
1484
|
+
this.dispatch(line.slice(5).replace(SSE_DATA_PREFIX, ""));
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
} catch {
|
|
1489
|
+
} finally {
|
|
1490
|
+
if (this.controller === controller) this.controller = null;
|
|
1491
|
+
this.reader = null;
|
|
1492
|
+
if (this.watchdog) {
|
|
1493
|
+
clearInterval(this.watchdog);
|
|
1494
|
+
this.watchdog = null;
|
|
1495
|
+
}
|
|
1496
|
+
if (this.presence) {
|
|
1497
|
+
clearInterval(this.presence);
|
|
1498
|
+
this.presence = null;
|
|
1499
|
+
}
|
|
1500
|
+
this.subscriptionId = null;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
dispatch(data) {
|
|
1504
|
+
let frame;
|
|
1505
|
+
try {
|
|
1506
|
+
frame = JSON.parse(data);
|
|
1507
|
+
} catch {
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
if (frame.type === "open") {
|
|
1511
|
+
this.subscriptionId = frame.payload.subscription_id;
|
|
1512
|
+
if (typeof frame.payload.heartbeat_seconds === "number") {
|
|
1513
|
+
this.heartbeatMs = Math.min(
|
|
1514
|
+
MAX_HEARTBEAT_SECONDS,
|
|
1515
|
+
Math.max(
|
|
1516
|
+
this.options.heartbeatFloorSeconds ?? MIN_HEARTBEAT_SECONDS,
|
|
1517
|
+
frame.payload.heartbeat_seconds
|
|
1518
|
+
)
|
|
1519
|
+
) * 1e3;
|
|
1520
|
+
}
|
|
1521
|
+
this.startPresence();
|
|
1522
|
+
}
|
|
1523
|
+
try {
|
|
1524
|
+
this.frameObserver?.(frame);
|
|
1525
|
+
} catch {
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Tear the socket down when it has gone quiet for three heartbeats.
|
|
1530
|
+
*
|
|
1531
|
+
* A half-open socket reports no error, no end and no data: the reader simply
|
|
1532
|
+
* never resolves. Without this the app would look connected while no
|
|
1533
|
+
* completion could reach it, which is the silent-failure state this whole
|
|
1534
|
+
* change exists to remove.
|
|
1535
|
+
*/
|
|
1536
|
+
startWatchdog() {
|
|
1537
|
+
this.lastFrameAt = Date.now();
|
|
1538
|
+
if (this.watchdog) clearInterval(this.watchdog);
|
|
1539
|
+
this.watchdog = setInterval(() => {
|
|
1540
|
+
if (Date.now() - this.lastFrameAt < this.heartbeatMs * MISSED_HEARTBEATS)
|
|
1541
|
+
return;
|
|
1542
|
+
void this.reader?.cancel().catch(() => void 0);
|
|
1543
|
+
this.controller?.abort();
|
|
1544
|
+
}, WATCHDOG_TICK_MS$1);
|
|
1545
|
+
this.watchdog.unref?.();
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Renew the presence lease for as long as this subscription is live.
|
|
1549
|
+
*
|
|
1550
|
+
* The first beat is immediate: the backend holds the lease against the
|
|
1551
|
+
* subscription, so waiting a full interval would leave a window in which the
|
|
1552
|
+
* app can deliver a banner while the backend still believes nobody is there
|
|
1553
|
+
* and toasts on its own host instead.
|
|
1554
|
+
*/
|
|
1555
|
+
startPresence() {
|
|
1556
|
+
if (this.presence) clearInterval(this.presence);
|
|
1557
|
+
const beat = () => {
|
|
1558
|
+
const subscriptionId = this.subscriptionId;
|
|
1559
|
+
if (!subscriptionId) return;
|
|
1560
|
+
const context = this.options.presenceContext?.() ?? {
|
|
1561
|
+
sessionId: "",
|
|
1562
|
+
window: {
|
|
1563
|
+
exists: false,
|
|
1564
|
+
focused: false,
|
|
1565
|
+
visible: false,
|
|
1566
|
+
minimized: false
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
void this.options.beatPresence({
|
|
1570
|
+
subscriptionId,
|
|
1571
|
+
canNotify: true,
|
|
1572
|
+
// Always the FEED's kinds, never a window's: this app delivers a
|
|
1573
|
+
// completion window or no window, which is why the feed exists.
|
|
1574
|
+
canNotifyKinds: FEED_NOTIFIABLE_KINDS,
|
|
1575
|
+
sessionId: context.sessionId,
|
|
1576
|
+
window: context.window
|
|
1577
|
+
}).catch(() => void 0);
|
|
1578
|
+
};
|
|
1579
|
+
beat();
|
|
1580
|
+
this.presence = setInterval(
|
|
1581
|
+
beat,
|
|
1582
|
+
this.options.presenceIntervalMs ?? PRESENCE_INTERVAL_MS
|
|
1583
|
+
);
|
|
1584
|
+
this.presence.unref?.();
|
|
1585
|
+
}
|
|
1586
|
+
setConnected(connected) {
|
|
1587
|
+
if (this.connected === connected) return;
|
|
1588
|
+
this.connected = connected;
|
|
1589
|
+
try {
|
|
1590
|
+
this.stateObserver?.({ connected });
|
|
1591
|
+
} catch {
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1140
1595
|
const id = zod.z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
|
|
1141
1596
|
const MAX_UPLOAD_BYTES = 16 * 1024 * 1024;
|
|
1142
1597
|
const MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024;
|
|
@@ -1333,7 +1788,7 @@ const DESKTOP_STREAM_DETAIL = {
|
|
|
1333
1788
|
*/
|
|
1334
1789
|
refusedWithoutStatus: "The event stream was refused."
|
|
1335
1790
|
};
|
|
1336
|
-
const SESSION_ID = /^[a-f0-9]{12}$/;
|
|
1791
|
+
const SESSION_ID$1 = /^[a-f0-9]{12}$/;
|
|
1337
1792
|
const SAFE_ID = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
1338
1793
|
function transportFailureDetail(error) {
|
|
1339
1794
|
if (error instanceof Error && error.name === "AbortError") {
|
|
@@ -1343,11 +1798,14 @@ function transportFailureDetail(error) {
|
|
|
1343
1798
|
if (code === "ECONNREFUSED") return DESKTOP_STREAM_DETAIL.serverDown;
|
|
1344
1799
|
return DESKTOP_STREAM_DETAIL.connectionFailed;
|
|
1345
1800
|
}
|
|
1346
|
-
const
|
|
1801
|
+
const SILENCE_TIMEOUT_MS = 45e3;
|
|
1802
|
+
const WATCHDOG_TICK_MS = 1e3;
|
|
1803
|
+
const MAX_FRAME_BYTES$1 = 8 * 1024 * 1024;
|
|
1347
1804
|
class DesktopStreamRelay {
|
|
1348
|
-
constructor(backendUrl, token) {
|
|
1805
|
+
constructor(backendUrl, token, timings = {}) {
|
|
1349
1806
|
this.backendUrl = backendUrl;
|
|
1350
1807
|
this.token = token;
|
|
1808
|
+
this.timings = timings;
|
|
1351
1809
|
}
|
|
1352
1810
|
streams = /* @__PURE__ */ new Map();
|
|
1353
1811
|
/** Frame observer for main-owned side effects (notifications). It sees
|
|
@@ -1362,7 +1820,7 @@ class DesktopStreamRelay {
|
|
|
1362
1820
|
return Boolean(this.token);
|
|
1363
1821
|
}
|
|
1364
1822
|
subscribe(args, emit) {
|
|
1365
|
-
if (!SESSION_ID.test(args.sessionId)) {
|
|
1823
|
+
if (!SESSION_ID$1.test(args.sessionId)) {
|
|
1366
1824
|
throw new Error("Invalid session for streaming.");
|
|
1367
1825
|
}
|
|
1368
1826
|
if (args.epoch !== void 0 && !SAFE_ID.test(args.epoch)) {
|
|
@@ -1419,41 +1877,63 @@ class DesktopStreamRelay {
|
|
|
1419
1877
|
emit({
|
|
1420
1878
|
streamId,
|
|
1421
1879
|
kind: "error",
|
|
1422
|
-
detail: DESKTOP_STREAM_DETAIL.refused(response.status)
|
|
1880
|
+
detail: DESKTOP_STREAM_DETAIL.refused(response.status),
|
|
1881
|
+
// Carried so the renderer can tell "this conversation is gone"
|
|
1882
|
+
// from "this transport is down". Both are refusals; only one of
|
|
1883
|
+
// them is about the session, and the two need different words.
|
|
1884
|
+
status: response.status
|
|
1423
1885
|
});
|
|
1424
1886
|
return;
|
|
1425
1887
|
}
|
|
1426
1888
|
const reader = response.body.getReader();
|
|
1427
1889
|
const decoder = new TextDecoder();
|
|
1428
1890
|
let buffer = "";
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1891
|
+
let lastActivity = Date.now();
|
|
1892
|
+
const silenceTimeoutMs = this.timings.silenceTimeoutMs ?? SILENCE_TIMEOUT_MS;
|
|
1893
|
+
const watchdog = setInterval(() => {
|
|
1894
|
+
if (Date.now() - lastActivity < silenceTimeoutMs) return;
|
|
1895
|
+
clearInterval(watchdog);
|
|
1896
|
+
emit({
|
|
1897
|
+
streamId,
|
|
1898
|
+
kind: "error",
|
|
1899
|
+
detail: DESKTOP_STREAM_DETAIL.ended
|
|
1900
|
+
});
|
|
1901
|
+
void reader.cancel().catch(() => void 0);
|
|
1902
|
+
}, this.timings.watchdogTickMs ?? WATCHDOG_TICK_MS);
|
|
1903
|
+
watchdog.unref?.();
|
|
1904
|
+
try {
|
|
1433
1905
|
for (; ; ) {
|
|
1434
|
-
const
|
|
1435
|
-
if (
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1906
|
+
const { done, value } = await reader.read();
|
|
1907
|
+
if (done) break;
|
|
1908
|
+
lastActivity = Date.now();
|
|
1909
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1910
|
+
for (; ; ) {
|
|
1911
|
+
const boundary = buffer.indexOf("\n\n");
|
|
1912
|
+
if (boundary < 0) {
|
|
1913
|
+
if (buffer.length > MAX_FRAME_BYTES$1) {
|
|
1914
|
+
emit({
|
|
1915
|
+
streamId,
|
|
1916
|
+
kind: "error",
|
|
1917
|
+
detail: DESKTOP_STREAM_DETAIL.frameBudget
|
|
1918
|
+
});
|
|
1919
|
+
reader.cancel().catch(() => void 0);
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
break;
|
|
1444
1923
|
}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
emit({ streamId, kind: "data", data });
|
|
1924
|
+
const record = buffer.slice(0, boundary);
|
|
1925
|
+
buffer = buffer.slice(boundary + 2);
|
|
1926
|
+
for (const line of record.split("\n")) {
|
|
1927
|
+
if (line.startsWith("data:")) {
|
|
1928
|
+
const data = line.slice(5).replace(/^ /, "");
|
|
1929
|
+
this.observer?.(sessionId2, data);
|
|
1930
|
+
emit({ streamId, kind: "data", data });
|
|
1931
|
+
}
|
|
1454
1932
|
}
|
|
1455
1933
|
}
|
|
1456
1934
|
}
|
|
1935
|
+
} finally {
|
|
1936
|
+
clearInterval(watchdog);
|
|
1457
1937
|
}
|
|
1458
1938
|
emit({ streamId, kind: "end" });
|
|
1459
1939
|
} catch (error) {
|
|
@@ -1548,83 +2028,97 @@ function pythonBytecodeCacheDir(userDataDir) {
|
|
|
1548
2028
|
}
|
|
1549
2029
|
function withPythonBytecodeCache(env, userDataDir) {
|
|
1550
2030
|
const existing = env.PYTHONPYCACHEPREFIX?.trim();
|
|
1551
|
-
|
|
1552
|
-
return {
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
return BUNDLED_PYTHON_TREE_NAMES.map((name) => path$1.join(resourcesPath, name));
|
|
1557
|
-
}
|
|
1558
|
-
const FIND = "/usr/bin/find";
|
|
1559
|
-
const CHMOD = "/bin/chmod";
|
|
1560
|
-
const DIRECTORY_ACE = "everyone deny add_file,add_subdirectory";
|
|
1561
|
-
const BYTECODE_ACE = "everyone deny write,append";
|
|
1562
|
-
const MAX_FIND_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
1563
|
-
const PARTIAL_SEAL_NAMED = 3;
|
|
1564
|
-
function describePythonTreeSeal(seal) {
|
|
1565
|
-
if (!seal.supported) {
|
|
1566
|
-
return "Bundled interpreter bytecode seal is macOS-only (there is no code seal to protect elsewhere); the bytecode cache prefix still applies";
|
|
1567
|
-
}
|
|
1568
|
-
const trees = seal.sealed.join(", ") || "(none present)";
|
|
1569
|
-
if (seal.failures.length === 0) {
|
|
1570
|
-
return `Bundled interpreter trees sealed against bytecode writes: ${trees}; ${seal.directories} directory path(s) selected and now refusing new entries, ${seal.bytecodeFiles} bytecode file(s) selected and now refusing rewrites`;
|
|
1571
|
-
}
|
|
1572
|
-
const named = seal.failures.slice(0, PARTIAL_SEAL_NAMED).join("; ");
|
|
1573
|
-
const rest = seal.failures.length - PARTIAL_SEAL_NAMED;
|
|
1574
|
-
return `Bundled interpreter bytecode seal INCOMPLETE: ${trees}; ${seal.directories} directory path(s) and ${seal.bytecodeFiles} bytecode file(s) selected, ${seal.failures.length} refused and left with the access they had - bytecode writes into the bundle are not prevented on every path. First refusals: ${named}${rest > 0 ? ` (+${rest} more)` : ""}`;
|
|
1575
|
-
}
|
|
1576
|
-
function sealPythonInterpreterTrees(trees) {
|
|
1577
|
-
const result = {
|
|
1578
|
-
sealed: [],
|
|
1579
|
-
directories: 0,
|
|
1580
|
-
bytecodeFiles: 0,
|
|
1581
|
-
failures: [],
|
|
1582
|
-
supported: process.platform === "darwin"
|
|
2031
|
+
const prefix = existing && !insideAppBundle(existing) ? existing : pythonBytecodeCacheDir(userDataDir);
|
|
2032
|
+
return {
|
|
2033
|
+
...env,
|
|
2034
|
+
PYTHONPYCACHEPREFIX: prefix,
|
|
2035
|
+
PYTHONDONTWRITEBYTECODE: "1"
|
|
1583
2036
|
};
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
2037
|
+
}
|
|
2038
|
+
const VENV_BYTECODE_GUARD_FILE = "sitecustomize.py";
|
|
2039
|
+
const VENV_BYTECODE_GUARD_SENTINEL = "# Local Operator bytecode guard";
|
|
2040
|
+
function venvBytecodeGuardSource() {
|
|
2041
|
+
return `${VENV_BYTECODE_GUARD_SENTINEL}. Do not edit; the app rewrites this file.
|
|
2042
|
+
#
|
|
2043
|
+
# Why this file exists: this virtual environment is built on the interpreter
|
|
2044
|
+
# inside the Local Operator application bundle (see pyvenv.cfg), so every module
|
|
2045
|
+
# this python imports has its source - and CPython's bytecode cache for it -
|
|
2046
|
+
# inside a code-sealed .app. A __pycache__/*.pyc written there changes a sealed
|
|
2047
|
+
# resource, and macOS then answers "the app is damaged" on the next launch and
|
|
2048
|
+
# Squirrel refuses the next in-place update with -67028
|
|
2049
|
+
# errSecCSBadBundleFormat.
|
|
2050
|
+
#
|
|
2051
|
+
# PYTHONDONTWRITEBYTECODE and PYTHONPYCACHEPREFIX reach only the processes the
|
|
2052
|
+
# app started or that inherited its environment. This file reaches every process
|
|
2053
|
+
# that uses this environment, including one started by a shell, a script or
|
|
2054
|
+
# launchd, because site.py imports sitecustomize from site-packages on every
|
|
2055
|
+
# start.
|
|
2056
|
+
#
|
|
2057
|
+
# The cost, stated rather than hidden: a process under this flag compiles its
|
|
2058
|
+
# imports and caches nothing, so the backend recompiles its import graph on each
|
|
2059
|
+
# start. That is the deliberate trade against an application bundle that macOS
|
|
2060
|
+
# refuses to open, and it is confined to this venv.
|
|
2061
|
+
import sys
|
|
2062
|
+
|
|
2063
|
+
sys.dont_write_bytecode = True
|
|
2064
|
+
`;
|
|
2065
|
+
}
|
|
2066
|
+
function venvSitePackages(venvPath) {
|
|
2067
|
+
const candidates = [path$1.join(venvPath, "Lib", "site-packages")];
|
|
2068
|
+
try {
|
|
2069
|
+
for (const entry of fs.readdirSync(path$1.join(venvPath, "lib"))) {
|
|
2070
|
+
if (entry.startsWith("python")) {
|
|
2071
|
+
candidates.push(path$1.join(venvPath, "lib", entry, "site-packages"));
|
|
2072
|
+
}
|
|
1591
2073
|
}
|
|
1592
|
-
|
|
1593
|
-
result.sealed.push(tree);
|
|
1594
|
-
result.directories += applyAce(
|
|
1595
|
-
tree,
|
|
1596
|
-
["-type", "d"],
|
|
1597
|
-
DIRECTORY_ACE,
|
|
1598
|
-
result.failures
|
|
1599
|
-
);
|
|
1600
|
-
result.bytecodeFiles += applyAce(
|
|
1601
|
-
tree,
|
|
1602
|
-
["-type", "f", "-name", "*.pyc"],
|
|
1603
|
-
BYTECODE_ACE,
|
|
1604
|
-
result.failures
|
|
1605
|
-
);
|
|
2074
|
+
} catch {
|
|
1606
2075
|
}
|
|
1607
|
-
return
|
|
2076
|
+
return candidates.filter((candidate) => fs.existsSync(candidate));
|
|
1608
2077
|
}
|
|
1609
|
-
function
|
|
1610
|
-
const
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
return 0;
|
|
2078
|
+
function ensureVenvBytecodeGuard(venvPath) {
|
|
2079
|
+
const dirs = venvSitePackages(venvPath);
|
|
2080
|
+
if (dirs.length === 0) {
|
|
2081
|
+
return {
|
|
2082
|
+
path: null,
|
|
2083
|
+
written: false,
|
|
2084
|
+
reason: `no site-packages under ${venvPath} yet, so there is nothing to guard`
|
|
2085
|
+
};
|
|
1618
2086
|
}
|
|
1619
|
-
|
|
1620
|
-
|
|
2087
|
+
const path2 = path$1.join(dirs[0], VENV_BYTECODE_GUARD_FILE);
|
|
2088
|
+
const source = venvBytecodeGuardSource();
|
|
2089
|
+
let existing = null;
|
|
2090
|
+
try {
|
|
2091
|
+
existing = fs.readFileSync(path2, "utf8");
|
|
2092
|
+
} catch {
|
|
1621
2093
|
}
|
|
1622
|
-
if (
|
|
1623
|
-
|
|
2094
|
+
if (existing === source) {
|
|
2095
|
+
return {
|
|
2096
|
+
path: path2,
|
|
2097
|
+
written: false,
|
|
2098
|
+
reason: `${path2} already refuses bytecode writes`
|
|
2099
|
+
};
|
|
1624
2100
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
2101
|
+
if (existing !== null && !existing.startsWith(VENV_BYTECODE_GUARD_SENTINEL)) {
|
|
2102
|
+
return {
|
|
2103
|
+
path: path2,
|
|
2104
|
+
written: false,
|
|
2105
|
+
reason: `${path2} is not ours (no "${VENV_BYTECODE_GUARD_SENTINEL}" line), so it was left exactly as it is`
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
try {
|
|
2109
|
+
fs.writeFileSync(path2, source);
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
return {
|
|
2112
|
+
path: path2,
|
|
2113
|
+
written: false,
|
|
2114
|
+
reason: `could not write ${path2}: ${error instanceof Error ? error.message : String(error)}`
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
return {
|
|
2118
|
+
path: path2,
|
|
2119
|
+
written: true,
|
|
2120
|
+
reason: `${path2} now sets sys.dont_write_bytecode for every process using this venv`
|
|
2121
|
+
};
|
|
1628
2122
|
}
|
|
1629
2123
|
var LogFileType = /* @__PURE__ */ ((LogFileType2) => {
|
|
1630
2124
|
LogFileType2["INSTALLER"] = "backend-installer.log";
|
|
@@ -1839,15 +2333,652 @@ ${formattedErrors}`);
|
|
|
1839
2333
|
`Failed to load configuration: ${error instanceof Error ? error.message : String(error)}`,
|
|
1840
2334
|
LogFileType.BACKEND
|
|
1841
2335
|
);
|
|
1842
|
-
throw new Error(
|
|
1843
|
-
`Failed to load configuration: ${error instanceof Error ? error.message : String(error)}`
|
|
2336
|
+
throw new Error(
|
|
2337
|
+
`Failed to load configuration: ${error instanceof Error ? error.message : String(error)}`
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
const backendConfig = loadConfig();
|
|
2342
|
+
const apiConfig = {
|
|
2343
|
+
baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
|
|
2344
|
+
};
|
|
2345
|
+
const seedNamespace = "python-runtime-seed";
|
|
2346
|
+
const architectures = ["arm64", "x64"];
|
|
2347
|
+
const legacyResourceNames = ["python", "python_aarch64"];
|
|
2348
|
+
const checkoutSeedNames = { "arm64": "python_aarch64", "x64": "python" };
|
|
2349
|
+
const BUNDLED_PYTHON_LAYOUT = {
|
|
2350
|
+
seedNamespace,
|
|
2351
|
+
architectures,
|
|
2352
|
+
legacyResourceNames,
|
|
2353
|
+
checkoutSeedNames
|
|
2354
|
+
};
|
|
2355
|
+
const execute = node_util.promisify(node_child_process.execFile);
|
|
2356
|
+
const APP_BUNDLE_PATH = /\.app(?:\/|$)/i;
|
|
2357
|
+
const BYTECODE_EXTENSION = /\.py[co]$/;
|
|
2358
|
+
const OTOOL_LOAD_COMMAND = /Load command \d+/;
|
|
2359
|
+
const LC_LOAD_COMMAND_KIND = /cmd LC_(?:LOAD|LOAD_WEAK|REEXPORT|LAZY_LOAD|LOAD_UPWARD)_DYLIB\b/;
|
|
2360
|
+
const DYLIB_INSTALL_NAME = /\n\s*name (.+?) \(offset/;
|
|
2361
|
+
const RUNTIME_ID = /^[a-f0-9]{64}$/;
|
|
2362
|
+
const PYVENV_HOME$1 = /^home\s*=\s*(.+)$/m;
|
|
2363
|
+
const LOCK_OWNER = /^\s*\d+\s*$/;
|
|
2364
|
+
const LEGACY_BUNDLE_PYTHON_PATH = /\.app\/Contents\/Resources\/python(?:_aarch64)?\//;
|
|
2365
|
+
const PYTHON_SEED_NAMESPACE = BUNDLED_PYTHON_LAYOUT.seedNamespace;
|
|
2366
|
+
const FORMAT = 1;
|
|
2367
|
+
const READY = "environment-ready.json";
|
|
2368
|
+
const POINTER = "selected-environment.json";
|
|
2369
|
+
function inside(root, path2) {
|
|
2370
|
+
const rel = path$1.relative(root, path2);
|
|
2371
|
+
return rel === "" || !rel.startsWith(`..${path$1.sep}`) && rel !== ".." && !path$1.isAbsolute(rel);
|
|
2372
|
+
}
|
|
2373
|
+
function outsideApp(path2) {
|
|
2374
|
+
if (!path$1.isAbsolute(path2) || APP_BUNDLE_PATH.test(path2)) {
|
|
2375
|
+
throw new Error(
|
|
2376
|
+
`Managed Python must live outside every application bundle: ${path2}`
|
|
2377
|
+
);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
function realDirectory(path2) {
|
|
2381
|
+
const stat = fs.lstatSync(path2);
|
|
2382
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
2383
|
+
throw new Error(`Not a private directory: ${path2}`);
|
|
2384
|
+
}
|
|
2385
|
+
function isCache(path2) {
|
|
2386
|
+
return path2.split("/").includes("__pycache__") || BYTECODE_EXTENSION.test(path2);
|
|
2387
|
+
}
|
|
2388
|
+
function runtimeManifest(root, arch, allowCaches = false) {
|
|
2389
|
+
if (!["arm64", "x64"].includes(arch))
|
|
2390
|
+
throw new Error(`Unsupported Python architecture: ${arch}`);
|
|
2391
|
+
realDirectory(root);
|
|
2392
|
+
const realRoot = fs.realpathSync(root);
|
|
2393
|
+
const entries = [
|
|
2394
|
+
{ path: ".", mode: fs.lstatSync(root).mode & 511, kind: "directory" }
|
|
2395
|
+
];
|
|
2396
|
+
function walk(directory) {
|
|
2397
|
+
for (const name of fs.readdirSync(directory).sort()) {
|
|
2398
|
+
const path2 = path$1.join(directory, name);
|
|
2399
|
+
const rel = path$1.relative(root, path2).split(path$1.sep).join("/");
|
|
2400
|
+
if (allowCaches && isCache(rel)) continue;
|
|
2401
|
+
const stat = fs.lstatSync(path2);
|
|
2402
|
+
const mode = stat.mode & 511;
|
|
2403
|
+
if (stat.isSymbolicLink()) {
|
|
2404
|
+
const target2 = fs.readlinkSync(path2);
|
|
2405
|
+
if (path$1.isAbsolute(target2) || !inside(root, path$1.resolve(path$1.dirname(path2), target2)) || !inside(realRoot, fs.realpathSync(path2))) {
|
|
2406
|
+
throw new Error(`Python seed link escapes its runtime: ${rel}`);
|
|
2407
|
+
}
|
|
2408
|
+
entries.push({ path: rel, mode, kind: "symlink", value: target2 });
|
|
2409
|
+
} else if (stat.isDirectory()) {
|
|
2410
|
+
entries.push({ path: rel, mode, kind: "directory" });
|
|
2411
|
+
walk(path2);
|
|
2412
|
+
} else if (stat.isFile() && (allowCaches || stat.nlink === 1)) {
|
|
2413
|
+
entries.push({
|
|
2414
|
+
path: rel,
|
|
2415
|
+
mode,
|
|
2416
|
+
kind: "file",
|
|
2417
|
+
value: node_crypto.createHash("sha256").update(fs.readFileSync(path2)).digest("hex")
|
|
2418
|
+
});
|
|
2419
|
+
} else {
|
|
2420
|
+
throw new Error(
|
|
2421
|
+
`Python seed contains a hardlink or special file: ${rel}`
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
walk(root);
|
|
2427
|
+
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
2428
|
+
return { format: FORMAT, platform: "darwin", arch, entries };
|
|
2429
|
+
}
|
|
2430
|
+
function runtimeId(manifest) {
|
|
2431
|
+
return node_crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex");
|
|
2432
|
+
}
|
|
2433
|
+
function runtimeIdentity(root, arch) {
|
|
2434
|
+
return runtimeId(runtimeManifest(root, arch, true));
|
|
2435
|
+
}
|
|
2436
|
+
function firstDifference(expected, actual) {
|
|
2437
|
+
const expectedByPath = new Map(expected.entries.map((e) => [e.path, e]));
|
|
2438
|
+
const actualByPath = new Map(actual.entries.map((e) => [e.path, e]));
|
|
2439
|
+
for (const [path2, entry] of expectedByPath) {
|
|
2440
|
+
const other = actualByPath.get(path2);
|
|
2441
|
+
if (!other) return `${path2} is missing`;
|
|
2442
|
+
if (other.kind !== entry.kind)
|
|
2443
|
+
return `${path2} is a ${other.kind}, not a ${entry.kind}`;
|
|
2444
|
+
if ((other.value ?? "") !== (entry.value ?? ""))
|
|
2445
|
+
return `${path2} does not match the seed`;
|
|
2446
|
+
}
|
|
2447
|
+
for (const path2 of actualByPath.keys())
|
|
2448
|
+
if (!expectedByPath.has(path2)) return `${path2} was added`;
|
|
2449
|
+
return "the two trees differ in a way this message cannot name";
|
|
2450
|
+
}
|
|
2451
|
+
function managedPythonRoot(options) {
|
|
2452
|
+
outsideApp(options.support);
|
|
2453
|
+
return path$1.join(
|
|
2454
|
+
options.support,
|
|
2455
|
+
"managed-python",
|
|
2456
|
+
options.packaged ? "packaged" : "dev"
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
function seedPath(options) {
|
|
2460
|
+
return options.packaged ? path$1.join(options.resources, PYTHON_SEED_NAMESPACE, options.arch) : path$1.join(
|
|
2461
|
+
options.resources,
|
|
2462
|
+
BUNDLED_PYTHON_LAYOUT.checkoutSeedNames[options.arch]
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
function pythonPath(runtime) {
|
|
2466
|
+
return path$1.join(runtime, "bin", "python3");
|
|
2467
|
+
}
|
|
2468
|
+
async function verifyMachO(root, manifest) {
|
|
2469
|
+
let binaries = 0;
|
|
2470
|
+
for (const entry of manifest.entries) {
|
|
2471
|
+
if (entry.kind !== "file") continue;
|
|
2472
|
+
const path2 = path$1.join(root, entry.path);
|
|
2473
|
+
const fd = fs.openSync(path2, "r");
|
|
2474
|
+
const magic = Buffer.alloc(4);
|
|
2475
|
+
try {
|
|
2476
|
+
fs.readSync(fd, magic, 0, 4, 0);
|
|
2477
|
+
} finally {
|
|
2478
|
+
fs.closeSync(fd);
|
|
2479
|
+
}
|
|
2480
|
+
if (![
|
|
2481
|
+
"cffaedfe",
|
|
2482
|
+
"cefaedfe",
|
|
2483
|
+
"feedfacf",
|
|
2484
|
+
"feedface",
|
|
2485
|
+
"cafebabe",
|
|
2486
|
+
"bebafeca"
|
|
2487
|
+
].includes(magic.toString("hex")))
|
|
2488
|
+
continue;
|
|
2489
|
+
binaries++;
|
|
2490
|
+
await execute("/usr/bin/codesign", ["--verify", "--strict", path2], {
|
|
2491
|
+
timeout: 3e4
|
|
2492
|
+
});
|
|
2493
|
+
const { stdout } = await execute("/usr/bin/otool", ["-l", path2], {
|
|
2494
|
+
timeout: 3e4
|
|
2495
|
+
});
|
|
2496
|
+
for (const command of stdout.split(OTOOL_LOAD_COMMAND)) {
|
|
2497
|
+
if (!LC_LOAD_COMMAND_KIND.test(command)) continue;
|
|
2498
|
+
const dependency = DYLIB_INSTALL_NAME.exec(command)?.[1];
|
|
2499
|
+
if (dependency && path$1.isAbsolute(dependency) && !dependency.startsWith("/usr/lib/") && !dependency.startsWith("/System/Library/")) {
|
|
2500
|
+
throw new Error(
|
|
2501
|
+
`Python runtime has an external load dependency: ${dependency}`
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
if (binaries === 0)
|
|
2507
|
+
throw new Error("Python seed contains no signed Mach-O binaries");
|
|
2508
|
+
}
|
|
2509
|
+
function runtimesRoot(options) {
|
|
2510
|
+
return path$1.join(managedPythonRoot(options), "runtimes");
|
|
2511
|
+
}
|
|
2512
|
+
function environmentsRoot(options) {
|
|
2513
|
+
return path$1.join(managedPythonRoot(options), "environments");
|
|
2514
|
+
}
|
|
2515
|
+
function publishedRuntimeIsReusable(options, published) {
|
|
2516
|
+
try {
|
|
2517
|
+
return runtimeIdentity(published.runtime, options.arch) === runtimeIdentity(seedPath(options), options.arch);
|
|
2518
|
+
} catch {
|
|
2519
|
+
return false;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
const GENERATION_NAME = /^[a-f0-9]{64}-[0-9a-f-]{8,}$/;
|
|
2523
|
+
function usableRuntimeGeneration(options, id2) {
|
|
2524
|
+
let names;
|
|
2525
|
+
try {
|
|
2526
|
+
names = fs.readdirSync(runtimesRoot(options));
|
|
2527
|
+
} catch {
|
|
2528
|
+
return null;
|
|
2529
|
+
}
|
|
2530
|
+
for (const name of names.sort()) {
|
|
2531
|
+
if (!name.startsWith(`${id2}-`)) continue;
|
|
2532
|
+
const candidate = path$1.join(runtimesRoot(options), name);
|
|
2533
|
+
try {
|
|
2534
|
+
if (runtimeIdentity(candidate, options.arch) === id2) return candidate;
|
|
2535
|
+
} catch {
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
return null;
|
|
2539
|
+
}
|
|
2540
|
+
async function prepareRuntime(options) {
|
|
2541
|
+
const seed = seedPath(options);
|
|
2542
|
+
const manifest = runtimeManifest(seed, options.arch);
|
|
2543
|
+
const id2 = runtimeIdentity(seed, options.arch);
|
|
2544
|
+
const existing = usableRuntimeGeneration(options, id2);
|
|
2545
|
+
if (existing) return { runtime: existing, id: id2 };
|
|
2546
|
+
await verifyMachO(seed, manifest);
|
|
2547
|
+
const root = runtimesRoot(options);
|
|
2548
|
+
await promises.mkdir(root, { recursive: true, mode: 448 });
|
|
2549
|
+
realDirectory(root);
|
|
2550
|
+
if (!inside(fs.realpathSync(managedPythonRoot(options)), fs.realpathSync(root)))
|
|
2551
|
+
throw new Error("Runtime directory escaped its managed root");
|
|
2552
|
+
const runtime = path$1.join(root, `${id2}-${node_crypto.randomUUID()}`);
|
|
2553
|
+
const staging = await promises.mkdtemp(path$1.join(root, ".preparing-"));
|
|
2554
|
+
await execute("/usr/bin/ditto", ["--noacl", seed, staging], {
|
|
2555
|
+
timeout: 12e4
|
|
2556
|
+
});
|
|
2557
|
+
fs.chmodSync(staging, fs.lstatSync(seed).mode & 511);
|
|
2558
|
+
if (runtimeIdentity(staging, options.arch) !== id2)
|
|
2559
|
+
throw new Error("The Python runtime copy did not match its signed seed");
|
|
2560
|
+
for (const entry of manifest.entries) {
|
|
2561
|
+
if (entry.kind !== "file") continue;
|
|
2562
|
+
const source = fs.statSync(path$1.join(seed, entry.path));
|
|
2563
|
+
const copied = fs.statSync(path$1.join(staging, entry.path));
|
|
2564
|
+
if (source.dev === copied.dev && source.ino === copied.ino)
|
|
2565
|
+
throw new Error(`Runtime copy shares a seed inode: ${entry.path}`);
|
|
2566
|
+
}
|
|
2567
|
+
await verifyMachO(staging, manifest);
|
|
2568
|
+
await promises.rename(staging, runtime);
|
|
2569
|
+
return { runtime, id: id2 };
|
|
2570
|
+
}
|
|
2571
|
+
function describe$1(error) {
|
|
2572
|
+
return error instanceof Error ? error.message : String(error);
|
|
2573
|
+
}
|
|
2574
|
+
function selectionIsWellFormed(root, selection) {
|
|
2575
|
+
return selection.format === FORMAT && RUNTIME_ID.test(selection.runtimeId) && selection.runtime === path$1.join(root, "runtimes", selection.runtimeId);
|
|
2576
|
+
}
|
|
2577
|
+
function selectionMatchesGeneration(root, selection) {
|
|
2578
|
+
return selection.format === FORMAT && RUNTIME_ID.test(selection.runtimeId) && path$1.dirname(selection.runtime) === path$1.join(root, "runtimes") && GENERATION_NAME.test(path$1.basename(selection.runtime)) && selection.runtime.startsWith(
|
|
2579
|
+
path$1.join(root, "runtimes", `${selection.runtimeId}-`)
|
|
2580
|
+
) && path$1.dirname(selection.venv) === path$1.join(root, "environments") && selection.venv.startsWith(
|
|
2581
|
+
path$1.join(root, "environments", `${selection.runtimeId}-`)
|
|
2582
|
+
);
|
|
2583
|
+
}
|
|
2584
|
+
function inspectManagedSelection(options) {
|
|
2585
|
+
const root = managedPythonRoot(options);
|
|
2586
|
+
const pointer = path$1.join(root, POINTER);
|
|
2587
|
+
let raw;
|
|
2588
|
+
try {
|
|
2589
|
+
raw = fs.readFileSync(pointer, "utf8");
|
|
2590
|
+
} catch {
|
|
2591
|
+
return {
|
|
2592
|
+
kind: "unprepared",
|
|
2593
|
+
detail: `No environment is published for this instance (${pointer})`
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
let selection;
|
|
2597
|
+
try {
|
|
2598
|
+
selection = JSON.parse(raw);
|
|
2599
|
+
} catch {
|
|
2600
|
+
return {
|
|
2601
|
+
kind: "unprepared",
|
|
2602
|
+
detail: `The published environment pointer is not readable: ${pointer}`
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
if (!selectionIsWellFormed(root, selection) && !selectionMatchesGeneration(root, selection))
|
|
2606
|
+
return {
|
|
2607
|
+
kind: "unprepared",
|
|
2608
|
+
detail: `The published environment pointer does not describe a managed environment: ${pointer}`
|
|
2609
|
+
};
|
|
2610
|
+
const missing = (detail) => ({
|
|
2611
|
+
kind: "missing",
|
|
2612
|
+
detail,
|
|
2613
|
+
published: selection
|
|
2614
|
+
});
|
|
2615
|
+
for (const [what, path2] of [
|
|
2616
|
+
["Python runtime", selection.runtime],
|
|
2617
|
+
["environment", selection.venv]
|
|
2618
|
+
]) {
|
|
2619
|
+
try {
|
|
2620
|
+
realDirectory(path2);
|
|
2621
|
+
} catch (error) {
|
|
2622
|
+
return missing(
|
|
2623
|
+
`The selected ${what} is not there any more (${path2}): ${describe$1(error)}`
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
try {
|
|
2628
|
+
const ready = JSON.parse(
|
|
2629
|
+
fs.readFileSync(path$1.join(selection.venv, READY), "utf8")
|
|
2630
|
+
);
|
|
2631
|
+
if (JSON.stringify(ready) !== JSON.stringify(selection))
|
|
2632
|
+
return missing(
|
|
2633
|
+
`The selected environment does not match the record published with it: ${selection.venv}`
|
|
2634
|
+
);
|
|
2635
|
+
} catch (error) {
|
|
2636
|
+
return missing(
|
|
2637
|
+
`The selected environment is not complete (${selection.venv}): ${describe$1(error)}`
|
|
2638
|
+
);
|
|
2639
|
+
}
|
|
2640
|
+
if (!fs.existsSync(path$1.join(selection.venv, "bin", "local-operator")))
|
|
2641
|
+
return missing(
|
|
2642
|
+
`The selected environment has no backend installed: ${selection.venv}`
|
|
2643
|
+
);
|
|
2644
|
+
let home;
|
|
2645
|
+
try {
|
|
2646
|
+
home = PYVENV_HOME$1.exec(
|
|
2647
|
+
fs.readFileSync(path$1.join(selection.venv, "pyvenv.cfg"), "utf8")
|
|
2648
|
+
)?.[1]?.trim();
|
|
2649
|
+
} catch (error) {
|
|
2650
|
+
return missing(
|
|
2651
|
+
`The selected environment has no readable pyvenv.cfg (${selection.venv}): ${describe$1(error)}`
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
if (home !== path$1.join(selection.runtime, "bin"))
|
|
2655
|
+
return missing(
|
|
2656
|
+
`The selected environment does not belong to its external Python runtime (${selection.venv} names ${home ?? "no runtime"})`
|
|
2657
|
+
);
|
|
2658
|
+
let identity;
|
|
2659
|
+
try {
|
|
2660
|
+
identity = runtimeIdentity(selection.runtime, options.arch);
|
|
2661
|
+
} catch (error) {
|
|
2662
|
+
return missing(
|
|
2663
|
+
`The selected Python runtime could not be read (${selection.runtime}): ${describe$1(error)}`
|
|
2664
|
+
);
|
|
2665
|
+
}
|
|
2666
|
+
if (identity !== selection.runtimeId) {
|
|
2667
|
+
let reason;
|
|
2668
|
+
try {
|
|
2669
|
+
reason = firstDifference(
|
|
2670
|
+
runtimeManifest(seedPath(options), options.arch, true),
|
|
2671
|
+
runtimeManifest(selection.runtime, options.arch, true)
|
|
2672
|
+
);
|
|
2673
|
+
} catch {
|
|
2674
|
+
reason = "the two trees could not be compared";
|
|
2675
|
+
}
|
|
2676
|
+
return missing(
|
|
2677
|
+
`The selected Python runtime is no longer the runtime that was published (${reason}); a fresh runtime will be published beside it`
|
|
2678
|
+
);
|
|
2679
|
+
}
|
|
2680
|
+
return { kind: "ready", selection };
|
|
2681
|
+
}
|
|
2682
|
+
function readManagedSelection(options) {
|
|
2683
|
+
const state = inspectManagedSelection(options);
|
|
2684
|
+
return state.kind === "ready" ? state.selection : null;
|
|
2685
|
+
}
|
|
2686
|
+
async function managedSelectionReady(options) {
|
|
2687
|
+
return inspectManagedSelection(options).kind === "ready";
|
|
2688
|
+
}
|
|
2689
|
+
const PREPARATION_LOCK_MS = 12e4;
|
|
2690
|
+
function reapSupersededGenerations(options, keep, now = Date.now()) {
|
|
2691
|
+
const removed = [];
|
|
2692
|
+
for (const [root, kept] of [
|
|
2693
|
+
[runtimesRoot(options), keep.runtime],
|
|
2694
|
+
[environmentsRoot(options), keep.venv]
|
|
2695
|
+
]) {
|
|
2696
|
+
let names;
|
|
2697
|
+
try {
|
|
2698
|
+
names = fs.readdirSync(root);
|
|
2699
|
+
} catch {
|
|
2700
|
+
continue;
|
|
2701
|
+
}
|
|
2702
|
+
const survivors = new Set(kept ? [kept] : []);
|
|
2703
|
+
const generations = [];
|
|
2704
|
+
for (const name of names) {
|
|
2705
|
+
const path2 = path$1.join(root, name);
|
|
2706
|
+
if (name.startsWith(".preparing-")) {
|
|
2707
|
+
try {
|
|
2708
|
+
if (now - fs.statSync(path2).mtimeMs < PREPARATION_LOCK_MS) continue;
|
|
2709
|
+
fs.rmSync(path2, { recursive: true, force: true });
|
|
2710
|
+
removed.push(path2);
|
|
2711
|
+
} catch {
|
|
2712
|
+
}
|
|
2713
|
+
continue;
|
|
2714
|
+
}
|
|
2715
|
+
if (!GENERATION_NAME.test(name) || survivors.has(path2)) continue;
|
|
2716
|
+
try {
|
|
2717
|
+
generations.push({ path: path2, mtimeMs: fs.statSync(path2).mtimeMs });
|
|
2718
|
+
} catch {
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
generations.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2722
|
+
if (generations.length > 0) survivors.add(generations[0].path);
|
|
2723
|
+
for (const generation of generations.slice(1)) {
|
|
2724
|
+
try {
|
|
2725
|
+
fs.rmSync(generation.path, { recursive: true, force: true });
|
|
2726
|
+
removed.push(generation.path);
|
|
2727
|
+
} catch {
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
return removed;
|
|
2732
|
+
}
|
|
2733
|
+
let preparation = Promise.resolve();
|
|
2734
|
+
async function locked(root, work) {
|
|
2735
|
+
const previous = preparation;
|
|
2736
|
+
let release = () => {
|
|
2737
|
+
};
|
|
2738
|
+
preparation = new Promise((done) => {
|
|
2739
|
+
release = done;
|
|
2740
|
+
});
|
|
2741
|
+
await previous;
|
|
2742
|
+
const path2 = path$1.join(root, "preparation.lock");
|
|
2743
|
+
let acquired = false;
|
|
2744
|
+
try {
|
|
2745
|
+
await promises.mkdir(root, { recursive: true, mode: 448 });
|
|
2746
|
+
realDirectory(root);
|
|
2747
|
+
outsideApp(fs.realpathSync(root));
|
|
2748
|
+
const deadline2 = Date.now() + PREPARATION_LOCK_MS;
|
|
2749
|
+
while (!acquired) {
|
|
2750
|
+
if (fs.existsSync(path2) && (fs.lstatSync(path2).isSymbolicLink() || !LOCK_OWNER.test(fs.readFileSync(path2, "utf8"))))
|
|
2751
|
+
throw new Error("Unrecognized preparation lock was preserved");
|
|
2752
|
+
try {
|
|
2753
|
+
await execute(
|
|
2754
|
+
"/usr/bin/shlock",
|
|
2755
|
+
["-p", String(process.pid), "-f", path2],
|
|
2756
|
+
{ timeout: 5e3 }
|
|
2757
|
+
);
|
|
2758
|
+
acquired = true;
|
|
2759
|
+
} catch {
|
|
2760
|
+
if (Date.now() >= deadline2)
|
|
2761
|
+
throw new Error(
|
|
2762
|
+
"Another Local Operator instance is preparing its backend. Retry when it finishes."
|
|
2763
|
+
);
|
|
2764
|
+
await new Promise((done) => setTimeout(done, 250));
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
return await work();
|
|
2768
|
+
} finally {
|
|
2769
|
+
if (acquired && fs.readFileSync(path2, "utf8").trim() === String(process.pid))
|
|
2770
|
+
fs.unlinkSync(path2);
|
|
2771
|
+
release();
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
const IMPORT_PROBE = `import sys, sysconfig, os, json, encodings, webbrowser, _ssl, _sqlite3
|
|
2775
|
+
paths = [sys._base_executable, sys.base_prefix, sysconfig.get_path('stdlib'), encodings.__file__, webbrowser.__file__] + sys.path
|
|
2776
|
+
paths += [getattr(m, '__file__', '') for m in (_ssl, _sqlite3)]
|
|
2777
|
+
assert all('.app/' not in os.path.realpath(p).lower() for p in paths if p), paths
|
|
2778
|
+
print(json.dumps({'base_executable': sys._base_executable, 'base_prefix': sys.base_prefix, 'paths': paths}))`;
|
|
2779
|
+
async function smokeEnvironment(venv, env) {
|
|
2780
|
+
const python = path$1.join(venv, "bin", "python");
|
|
2781
|
+
await execute(python, ["-I", "-B", "-c", IMPORT_PROBE], {
|
|
2782
|
+
env,
|
|
2783
|
+
timeout: 3e4
|
|
2784
|
+
});
|
|
2785
|
+
const { stdout: version } = await execute(
|
|
2786
|
+
python,
|
|
2787
|
+
[
|
|
2788
|
+
"-I",
|
|
2789
|
+
"-B",
|
|
2790
|
+
"-c",
|
|
2791
|
+
"import importlib.metadata; print(importlib.metadata.version('local-operator'))"
|
|
2792
|
+
],
|
|
2793
|
+
{ env, timeout: 3e4 }
|
|
2794
|
+
);
|
|
2795
|
+
const home = await promises.mkdtemp(path$1.join(os.tmpdir(), "local-operator-backend-smoke-"));
|
|
2796
|
+
const isolated = withPythonBytecodeCache(
|
|
2797
|
+
{
|
|
2798
|
+
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
2799
|
+
HOME: home,
|
|
2800
|
+
TMPDIR: home,
|
|
2801
|
+
LANG: "en_US.UTF-8",
|
|
2802
|
+
TERM: "xterm-256color",
|
|
2803
|
+
LOCAL_OPERATOR_HOME: path$1.join(home, ".local-operator"),
|
|
2804
|
+
LOCAL_OPERATOR_CONFIG_DIR: path$1.join(home, "config"),
|
|
2805
|
+
XDG_CONFIG_HOME: path$1.join(home, "config"),
|
|
2806
|
+
XDG_CACHE_HOME: path$1.join(home, "cache"),
|
|
2807
|
+
XDG_DATA_HOME: path$1.join(home, "data")
|
|
2808
|
+
},
|
|
2809
|
+
home
|
|
2810
|
+
);
|
|
2811
|
+
const socket = node_net.createServer();
|
|
2812
|
+
await new Promise((done, reject) => {
|
|
2813
|
+
socket.once("error", reject);
|
|
2814
|
+
socket.listen(0, "127.0.0.1", done);
|
|
2815
|
+
});
|
|
2816
|
+
const address = socket.address();
|
|
2817
|
+
if (!address || typeof address === "string")
|
|
2818
|
+
throw new Error("Could not allocate backend smoke port");
|
|
2819
|
+
await new Promise((done) => socket.close(() => done()));
|
|
2820
|
+
const child = node_child_process.spawn(
|
|
2821
|
+
path$1.join(venv, "bin", "local-operator"),
|
|
2822
|
+
["serve", "--host", "127.0.0.1", "--port", String(address.port)],
|
|
2823
|
+
{ env: isolated, stdio: "ignore" }
|
|
2824
|
+
);
|
|
2825
|
+
let failed = null;
|
|
2826
|
+
child.on("error", (error) => {
|
|
2827
|
+
failed = error;
|
|
2828
|
+
});
|
|
2829
|
+
try {
|
|
2830
|
+
const deadline2 = Date.now() + 6e4;
|
|
2831
|
+
while (Date.now() < deadline2) {
|
|
2832
|
+
if (failed) throw failed;
|
|
2833
|
+
if (child.exitCode !== null)
|
|
2834
|
+
throw new Error(`Backend smoke exited with ${child.exitCode}`);
|
|
2835
|
+
try {
|
|
2836
|
+
const response = await fetch(
|
|
2837
|
+
`http://127.0.0.1:${address.port}/health`,
|
|
2838
|
+
{ signal: AbortSignal.timeout(1e3) }
|
|
2839
|
+
);
|
|
2840
|
+
if (response.ok) return version.trim();
|
|
2841
|
+
} catch {
|
|
2842
|
+
}
|
|
2843
|
+
await new Promise((done) => setTimeout(done, 200));
|
|
2844
|
+
}
|
|
2845
|
+
throw new Error(
|
|
2846
|
+
"The prepared backend did not become healthy; the previous selection was preserved"
|
|
2847
|
+
);
|
|
2848
|
+
} finally {
|
|
2849
|
+
if (child.exitCode === null && child.pid) {
|
|
2850
|
+
child.kill("SIGTERM");
|
|
2851
|
+
await Promise.race([
|
|
2852
|
+
new Promise((done) => child.once("exit", done)),
|
|
2853
|
+
new Promise((done) => setTimeout(done, 5e3))
|
|
2854
|
+
]);
|
|
2855
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
async function prepareManagedPython(options, install) {
|
|
2860
|
+
return locked(managedPythonRoot(options), async () => {
|
|
2861
|
+
const state = inspectManagedSelection(options);
|
|
2862
|
+
if (state.kind === "ready") return state.selection;
|
|
2863
|
+
const superseded = state.kind === "missing" ? state.detail : null;
|
|
2864
|
+
const published = state.kind === "missing" ? state.published : void 0;
|
|
2865
|
+
const root = managedPythonRoot(options);
|
|
2866
|
+
reapSupersededGenerations(options, {
|
|
2867
|
+
runtime: published && publishedRuntimeIsReusable(options, published) ? published.runtime : void 0
|
|
2868
|
+
});
|
|
2869
|
+
const { runtime, id: id2 } = await prepareRuntime(options);
|
|
2870
|
+
await promises.mkdir(environmentsRoot(options), { recursive: true, mode: 448 });
|
|
2871
|
+
realDirectory(environmentsRoot(options));
|
|
2872
|
+
const venv = path$1.join(environmentsRoot(options), `${id2}-${node_crypto.randomUUID()}`);
|
|
2873
|
+
if (!await install(venv, pythonPath(runtime)))
|
|
2874
|
+
throw new Error(
|
|
2875
|
+
"Backend preparation did not complete. Your previous environment and data were preserved."
|
|
2876
|
+
);
|
|
2877
|
+
const env = withPythonBytecodeCache({ ...process.env }, options.support);
|
|
2878
|
+
const backendVersion = await smokeEnvironment(venv, env);
|
|
2879
|
+
const selection = {
|
|
2880
|
+
format: FORMAT,
|
|
2881
|
+
runtimeId: id2,
|
|
2882
|
+
runtime,
|
|
2883
|
+
venv,
|
|
2884
|
+
backendVersion
|
|
2885
|
+
};
|
|
2886
|
+
const bytes = `${JSON.stringify(selection)}
|
|
2887
|
+
`;
|
|
2888
|
+
await promises.writeFile(path$1.join(venv, READY), bytes, { flag: "wx", mode: 384 });
|
|
2889
|
+
const temporary = path$1.join(root, `.selection-${node_crypto.randomUUID()}.json`);
|
|
2890
|
+
await promises.writeFile(temporary, bytes, { flag: "wx", mode: 384 });
|
|
2891
|
+
await promises.rename(temporary, path$1.join(root, POINTER));
|
|
2892
|
+
reapSupersededGenerations(options, { runtime, venv });
|
|
2893
|
+
if (superseded)
|
|
2894
|
+
console.warn(
|
|
2895
|
+
`Replaced an unusable managed Python selection: ${superseded}`
|
|
2896
|
+
);
|
|
2897
|
+
return selection;
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2900
|
+
function isLegacyManagedCommand(command, support) {
|
|
2901
|
+
const legacy = [
|
|
2902
|
+
path$1.join(support, "local-operator-venv"),
|
|
2903
|
+
path$1.join(support, "local-operator-venv-dev")
|
|
2904
|
+
];
|
|
2905
|
+
const candidates = [command];
|
|
2906
|
+
try {
|
|
2907
|
+
candidates.push(fs.realpathSync(command));
|
|
2908
|
+
} catch {
|
|
2909
|
+
}
|
|
2910
|
+
try {
|
|
2911
|
+
candidates.push(fs.readFileSync(command, "utf8").slice(0, 4096));
|
|
2912
|
+
} catch {
|
|
2913
|
+
}
|
|
2914
|
+
return candidates.some(
|
|
2915
|
+
(candidate) => legacy.some((path2) => candidate.includes(`${path2}/`)) || LEGACY_BUNDLE_PYTHON_PATH.test(candidate)
|
|
2916
|
+
);
|
|
2917
|
+
}
|
|
2918
|
+
const PACKAGED_VENV_DIR_NAME = "local-operator-venv";
|
|
2919
|
+
const DEV_VENV_DIR_NAME = "local-operator-venv-dev";
|
|
2920
|
+
const NO_SELECTION_DIR_NAME = "no-environment-selected";
|
|
2921
|
+
function isUnpreparedVenvPath(path2) {
|
|
2922
|
+
return path2.endsWith(`${path$1.sep}${NO_SELECTION_DIR_NAME}`);
|
|
2923
|
+
}
|
|
2924
|
+
function managedSupportRoot(home) {
|
|
2925
|
+
return path$1.join(home, "Library", "Application Support", "Local Operator");
|
|
2926
|
+
}
|
|
2927
|
+
function legacyVenvPaths(support) {
|
|
2928
|
+
return [
|
|
2929
|
+
path$1.join(support, PACKAGED_VENV_DIR_NAME),
|
|
2930
|
+
path$1.join(support, DEV_VENV_DIR_NAME)
|
|
2931
|
+
];
|
|
2932
|
+
}
|
|
2933
|
+
const VENV_PATH_ENV = "LOCAL_OPERATOR_VENV_PATH";
|
|
2934
|
+
const PYVENV_HOME = /^\s*home\s*=\s*(.+?)\s*$/;
|
|
2935
|
+
const BUNDLED_INTERPRETER_HOME = /^(.*?\.app)\/Contents\/Resources\/python(?:_aarch64)?\//;
|
|
2936
|
+
function managedVenvPath(input) {
|
|
2937
|
+
const name = input.packaged ? PACKAGED_VENV_DIR_NAME : DEV_VENV_DIR_NAME;
|
|
2938
|
+
if (input.platform === "win32") {
|
|
2939
|
+
return path$1.join(input.appDataPath, name);
|
|
2940
|
+
}
|
|
2941
|
+
if (input.platform === "darwin") {
|
|
2942
|
+
const support = managedSupportRoot(input.home);
|
|
2943
|
+
return readManagedSelection({
|
|
2944
|
+
support,
|
|
2945
|
+
resources: "",
|
|
2946
|
+
packaged: input.packaged,
|
|
2947
|
+
arch: process.arch
|
|
2948
|
+
})?.venv ?? path$1.join(
|
|
2949
|
+
support,
|
|
2950
|
+
"managed-python",
|
|
2951
|
+
input.packaged ? "packaged" : "dev",
|
|
2952
|
+
NO_SELECTION_DIR_NAME
|
|
2953
|
+
);
|
|
2954
|
+
}
|
|
2955
|
+
return path$1.join(input.home, ".config", "local-operator", name);
|
|
2956
|
+
}
|
|
2957
|
+
function venvInterpreter(venvPath) {
|
|
2958
|
+
let config;
|
|
2959
|
+
try {
|
|
2960
|
+
config = fs.readFileSync(path$1.join(venvPath, "pyvenv.cfg"), "utf8");
|
|
2961
|
+
} catch {
|
|
2962
|
+
return { kind: "none" };
|
|
2963
|
+
}
|
|
2964
|
+
const home = config.split("\n").map((line) => line.match(PYVENV_HOME)).find((match) => match != null)?.[1];
|
|
2965
|
+
if (!home) return { kind: "none" };
|
|
2966
|
+
const resourceMarked = home.match(BUNDLED_INTERPRETER_HOME);
|
|
2967
|
+
if (!resourceMarked) return { kind: "none" };
|
|
2968
|
+
const bundle = resourceMarked[1];
|
|
2969
|
+
return fs.existsSync(bundle) ? { kind: "bundled", bundle } : { kind: "missing", bundle };
|
|
2970
|
+
}
|
|
2971
|
+
function legacyEnvironmentReport(support) {
|
|
2972
|
+
const lines = [];
|
|
2973
|
+
for (const venv of legacyVenvPaths(support)) {
|
|
2974
|
+
const resolution = venvInterpreter(venv);
|
|
2975
|
+
if (resolution.kind === "none") continue;
|
|
2976
|
+
lines.push(
|
|
2977
|
+
resolution.kind === "missing" ? `The pre-split environment at ${venv} names an interpreter bundle that is not on disk (${resolution.bundle}); it is left exactly as it is.` : `The pre-split environment at ${venv} is built on the interpreter inside ${resolution.bundle}; it is left exactly as it is, and this instance does not use it.`
|
|
1844
2978
|
);
|
|
1845
2979
|
}
|
|
2980
|
+
return lines;
|
|
1846
2981
|
}
|
|
1847
|
-
const backendConfig = loadConfig();
|
|
1848
|
-
const apiConfig = {
|
|
1849
|
-
baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
|
|
1850
|
-
};
|
|
1851
2982
|
const SHEBANG = /^#!([^\r\n]+)\r?\n/;
|
|
1852
2983
|
const PYTHON_NAME = /[/\\]python(?:\d+(?:\.\d+)*)?$/;
|
|
1853
2984
|
const SERVE_ENTRYPOINT = "from local_operator.cli import main; main()";
|
|
@@ -2182,6 +3313,27 @@ class BackendServiceManager {
|
|
|
2182
3313
|
/** Survives relay recreation so notifications never silently detach
|
|
2183
3314
|
* when the backend URL rotates. */
|
|
2184
3315
|
streamObserver = null;
|
|
3316
|
+
/**
|
|
3317
|
+
* The machine-wide desktop feed relay, rebuilt when the backend URL rotates
|
|
3318
|
+
* for the same reason the stream relay is: a subscription must never pin a
|
|
3319
|
+
* stale origin. Held HERE rather than in `index.ts` because the desktop
|
|
3320
|
+
* bearer lives in this class and is never exposed — the feed needs it to
|
|
3321
|
+
* carry an SSE connection and to beat the presence lease.
|
|
3322
|
+
*/
|
|
3323
|
+
feedRelay = null;
|
|
3324
|
+
feedRelayUrl = "";
|
|
3325
|
+
/** Both survive relay recreation, so a URL rotation cannot silently detach
|
|
3326
|
+
* the banner path or leave the sidebar reading a stale connection state. */
|
|
3327
|
+
feedFrameObserver = null;
|
|
3328
|
+
feedStateObserver = null;
|
|
3329
|
+
/**
|
|
3330
|
+
* The window/focus/displayed-session answers the presence claim carries.
|
|
3331
|
+
*
|
|
3332
|
+
* Set by main once a window can exist — before that a windowless app is the
|
|
3333
|
+
* honest answer, which is also the reference's own default for a client that
|
|
3334
|
+
* has not said anything yet.
|
|
3335
|
+
*/
|
|
3336
|
+
presenceContext = null;
|
|
2185
3337
|
/**
|
|
2186
3338
|
* Called every time the backend becomes reachable and authenticated.
|
|
2187
3339
|
*
|
|
@@ -2242,6 +3394,60 @@ class BackendServiceManager {
|
|
|
2242
3394
|
this.streamObserver = observer;
|
|
2243
3395
|
this.streamRelay?.observe(observer);
|
|
2244
3396
|
}
|
|
3397
|
+
/**
|
|
3398
|
+
* The machine-wide feed, rebuilt when the backend URL rotates.
|
|
3399
|
+
*
|
|
3400
|
+
* Lazily constructed for the same reason `getStreamRelay` is: the desktop
|
|
3401
|
+
* token is minted inside `start()`, so a relay built at `app.whenReady()`
|
|
3402
|
+
* would capture `null` and never authenticate. Callers reach for it from the
|
|
3403
|
+
* backend-ready hook, which is the first moment the token exists.
|
|
3404
|
+
*/
|
|
3405
|
+
getDesktopFeedRelay() {
|
|
3406
|
+
if (!this.feedRelay || this.feedRelayUrl !== this.backendUrl) {
|
|
3407
|
+
this.feedRelay?.stop();
|
|
3408
|
+
this.feedRelay = new DesktopFeedRelay(
|
|
3409
|
+
this.backendUrl,
|
|
3410
|
+
this.desktopToken,
|
|
3411
|
+
{
|
|
3412
|
+
request: (input) => this.requestDesktop(input),
|
|
3413
|
+
// The presence beat is a contract op, so it travels the same
|
|
3414
|
+
// authenticated transport as every other control and the token
|
|
3415
|
+
// never leaves this class.
|
|
3416
|
+
beatPresence: (presence) => this.requestDesktop({
|
|
3417
|
+
op: "sessions.presence",
|
|
3418
|
+
...presence
|
|
3419
|
+
}),
|
|
3420
|
+
// What this app can say about its own window. Supplied by main
|
|
3421
|
+
// (it owns the window and the notifier's displayed session) and
|
|
3422
|
+
// read at each beat rather than captured, so a window that opens
|
|
3423
|
+
// or closes mid-lease is reported without a new relay.
|
|
3424
|
+
presenceContext: () => this.presenceContext?.() ?? null
|
|
3425
|
+
}
|
|
3426
|
+
);
|
|
3427
|
+
this.feedRelay.observe(this.feedFrameObserver);
|
|
3428
|
+
this.feedRelay.watchState(this.feedStateObserver);
|
|
3429
|
+
this.feedRelayUrl = this.backendUrl;
|
|
3430
|
+
}
|
|
3431
|
+
return this.feedRelay;
|
|
3432
|
+
}
|
|
3433
|
+
/**
|
|
3434
|
+
* Supply the presence claim's window state.
|
|
3435
|
+
*
|
|
3436
|
+
* Separate from `observeDesktopFeed` because it is a different question
|
|
3437
|
+
* asked of a different owner: the observers are consumers of frames, and
|
|
3438
|
+
* this is main's answer about itself. Applied to a relay that already
|
|
3439
|
+
* exists, so the ordering between "start the feed" and "make a window" does
|
|
3440
|
+
* not decide whether the claim is complete.
|
|
3441
|
+
*/
|
|
3442
|
+
providePresenceContext(context) {
|
|
3443
|
+
this.presenceContext = context;
|
|
3444
|
+
}
|
|
3445
|
+
observeDesktopFeed(frameObserver, stateObserver) {
|
|
3446
|
+
this.feedFrameObserver = frameObserver;
|
|
3447
|
+
this.feedStateObserver = stateObserver;
|
|
3448
|
+
this.feedRelay?.observe(frameObserver);
|
|
3449
|
+
this.feedRelay?.watchState(stateObserver);
|
|
3450
|
+
}
|
|
2245
3451
|
requestDesktop(input) {
|
|
2246
3452
|
return requestDesktop(input, this.backendUrl, this.desktopToken);
|
|
2247
3453
|
}
|
|
@@ -2280,24 +3486,12 @@ class BackendServiceManager {
|
|
|
2280
3486
|
error
|
|
2281
3487
|
);
|
|
2282
3488
|
}
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
this.
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
"Application Support",
|
|
2290
|
-
"Local Operator",
|
|
2291
|
-
"local-operator-venv"
|
|
2292
|
-
);
|
|
2293
|
-
} else {
|
|
2294
|
-
this.venvPath = path$1.join(
|
|
2295
|
-
electron.app.getPath("home"),
|
|
2296
|
-
".config",
|
|
2297
|
-
"local-operator",
|
|
2298
|
-
"local-operator-venv"
|
|
2299
|
-
);
|
|
2300
|
-
}
|
|
3489
|
+
this.venvPath = managedVenvPath({
|
|
3490
|
+
platform: process.platform,
|
|
3491
|
+
home: electron.app.getPath("home"),
|
|
3492
|
+
appDataPath: this.appDataPath,
|
|
3493
|
+
packaged: electron.app.isPackaged
|
|
3494
|
+
});
|
|
2301
3495
|
this.loadShellEnvironment();
|
|
2302
3496
|
logger.info(
|
|
2303
3497
|
`Backend Service Manager initialized. Disabled: ${this.isDisabled}`,
|
|
@@ -2551,6 +3745,21 @@ class BackendServiceManager {
|
|
|
2551
3745
|
windowsHide: true
|
|
2552
3746
|
});
|
|
2553
3747
|
if (stdout.trim()) {
|
|
3748
|
+
if (process.platform === "darwin" && isLegacyManagedCommand(
|
|
3749
|
+
stdout.trim().split("\n")[0],
|
|
3750
|
+
path$1.join(
|
|
3751
|
+
electron.app.getPath("home"),
|
|
3752
|
+
"Library",
|
|
3753
|
+
"Application Support",
|
|
3754
|
+
"Local Operator"
|
|
3755
|
+
)
|
|
3756
|
+
)) {
|
|
3757
|
+
logger.info(
|
|
3758
|
+
"The PATH command belongs to a legacy managed environment; preparing a separate backend instead",
|
|
3759
|
+
LogFileType.BACKEND
|
|
3760
|
+
);
|
|
3761
|
+
return false;
|
|
3762
|
+
}
|
|
2554
3763
|
logger.info(
|
|
2555
3764
|
`local-operator command found at: ${stdout.trim()}`,
|
|
2556
3765
|
LogFileType.BACKEND
|
|
@@ -2720,6 +3929,12 @@ class BackendServiceManager {
|
|
|
2720
3929
|
this.desktopToken = node_crypto.randomBytes(32).toString("hex");
|
|
2721
3930
|
let captured = null;
|
|
2722
3931
|
try {
|
|
3932
|
+
this.venvPath = managedVenvPath({
|
|
3933
|
+
platform: process.platform,
|
|
3934
|
+
home: electron.app.getPath("home"),
|
|
3935
|
+
appDataPath: this.appDataPath,
|
|
3936
|
+
packaged: electron.app.isPackaged
|
|
3937
|
+
});
|
|
2723
3938
|
const globalInstall = await this.checkLocalOperatorExists();
|
|
2724
3939
|
const env = this.backendSpawnEnv();
|
|
2725
3940
|
let interpreters;
|
|
@@ -3106,300 +4321,8 @@ class BackendServiceManager {
|
|
|
3106
4321
|
return this.restartPromise;
|
|
3107
4322
|
}
|
|
3108
4323
|
}
|
|
3109
|
-
const linuxInstallScriptRaw = '#!/bin/bash\n# Local Operator Backend Installation Script for Linux\n# This script sets up a virtual environment for the Local Operator backend\n# without requiring sudo or admin privileges.\n\n# Exit on error, undefined variables, and failed pipe commands\nset -e # Exit immediately if a command exits with a non-zero status\nset -u # Error on unset variables\nset -o pipefail # Fail if any command in a pipe fails\n\n# Configuration\n# Variables APP_NAME and MIN_PYTHON_VERSION removed as they were unused\nVENV_NAME="local-operator-venv"\nAPP_DATA_DIR="${HOME}/.config/local-operator"\nVENV_PATH="${APP_DATA_DIR}/${VENV_NAME}"\nLOG_FILE="${APP_DATA_DIR}/backend-install.log"\nTIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")\n\n# Keep CPython\'s bytecode cache out of the application directory.\n#\n# Same reason as the macOS script: an interpreter writing __pycache__/*.pyc\n# beside its own stdlib sources writes into the installed tree. Linux does not\n# code-seal the app the way macOS does, so this is uniformity and hygiene here\n# rather than the load-bearing fix it is on macOS - but it is also the value a\n# standalone run of this script has to agree with, because the app and the\n# script must not disagree about where bytecode goes.\n: "${PYTHONPYCACHEPREFIX:=${APP_DATA_DIR}/python-bytecode-cache}"\nexport PYTHONPYCACHEPREFIX\n\n# Create app data directory if it doesn\'t exist\nif ! mkdir -p "${APP_DATA_DIR}"; then\n echo "ERROR: Unable to create app data directory at ${APP_DATA_DIR}"\n echo "Please check permissions and try again."\n exit 1\nfi\n\n# Handle cleanup on script exit\ncleanup() {\n # Remove any temporary files created during execution\n if [ -f "${APP_DATA_DIR}/get-pip.py" ]; then\n rm -f "${APP_DATA_DIR}/get-pip.py"\n fi\n if [ -n "${FFMPEG_TEMP_ARCHIVE:-}" ] && [ -f "${FFMPEG_TEMP_ARCHIVE}" ]; then\n log "Removing temporary FFmpeg archive: ${FFMPEG_TEMP_ARCHIVE}"\n rm -f "${FFMPEG_TEMP_ARCHIVE}"\n fi\n echo "$(date): Cleanup completed."\n}\n\n# Set trap only for manual interruption (SIGINT, SIGTERM), not normal exits\ntrap cleanup SIGINT SIGTERM\n\n\n# Start logging\nexec > >(tee -a "${LOG_FILE}") 2>&1\necho "[${TIMESTAMP}]: Starting Local Operator backend installation..."\necho "[${TIMESTAMP}]: System information: $(uname -a)"\n\n# Function to log messages with timestamp (defined later, but used by FFmpeg section)\n_log_internal() {\n local internal_timestamp\n internal_timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${internal_timestamp}] $1"\n}\n\n# --- FFmpeg Installation ---\nBIN_DIR="${APP_DATA_DIR}/bin"\nFFMPEG_BIN="${BIN_DIR}/ffmpeg" # Path to ffmpeg binary\n\n_log_internal "Ensuring bin directory exists: ${BIN_DIR}"\nif ! mkdir -p "${BIN_DIR}"; then\n _log_internal "ERROR: Unable to create bin directory at ${BIN_DIR}"\n exit 1\nfi\n\n# Check if FFmpeg is already installed and executable\nif [ -f "${FFMPEG_BIN}" ] && [ -x "${FFMPEG_BIN}" ]; then\n _log_internal "FFmpeg already installed at ${FFMPEG_BIN}. Skipping download."\nelse\n _log_internal "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."\n\n FFMPEG_DOWNLOAD_URL=""\n\n LINUX_ARCH=$(uname -m)\n if [[ "${LINUX_ARCH}" == "x86_64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-x64"\n elif [[ "${LINUX_ARCH}" == "aarch64" ]] || [[ "${LINUX_ARCH}" == "arm64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-arm64"\n else\n _log_internal "Error: Unsupported CPU architecture for FFmpeg download: ${LINUX_ARCH}"\n exit 1\n fi\n\n _log_internal "Downloading FFmpeg from: ${FFMPEG_DOWNLOAD_URL}"\n if command -v curl >/dev/null 2>&1; then\n if ! curl -L "${FFMPEG_DOWNLOAD_URL}" -o "${FFMPEG_BIN}"; then\n _log_internal "Error: Failed to download FFmpeg using curl from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n elif command -v wget >/dev/null 2>&1; then\n if ! wget -O "${FFMPEG_BIN}" "${FFMPEG_DOWNLOAD_URL}"; then\n _log_internal "Error: Failed to download FFmpeg using wget from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n else\n _log_internal "Error: Neither curl nor wget found. Cannot download FFmpeg."\n exit 1\n fi\n _log_internal "FFmpeg binary downloaded successfully to ${FFMPEG_BIN}"\n\n chmod +x "${FFMPEG_BIN}"\n _log_internal "Set executable permissions for ${FFMPEG_BIN}"\n\n # Verify FFmpeg is executable after download\n if [ ! -f "${FFMPEG_BIN}" ] || [ ! -x "${FFMPEG_BIN}" ]; then\n _log_internal "Error: FFmpeg binary not found or not executable after download."\n exit 1\n fi\nfi\n\n_log_internal "FFmpeg installation complete. FFmpeg binary is at: ${FFMPEG_BIN}"\n\n# Function to check if a command exists\ncommand_exists() {\n command -v "$1" >/dev/null 2>&1\n}\n\n# Function to log messages with timestamp\nlog() {\n local timestamp\n timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${timestamp}] $1"\n}\n\n# Function to display error messages and exit\nerror_exit() {\n log "ERROR: $1" >&2\n exit "${2:-1}"\n}\n\n# Function to check if Python binary is valid for current architecture\nis_valid_python_binary() {\n local python_path="$1"\n \n # First check if file exists and is executable\n if [ ! -f "${python_path}" ] || [ ! -x "${python_path}" ]; then\n return 1\n fi\n \n # Try to run a simple Python command to verify it works on current architecture\n "${python_path}" -c "print(\'Testing Python executable\')" >/dev/null 2>&1\n return $?\n}\n\n# Check for network connectivity to key servers\ncheck_connectivity() {\n log "Checking network connectivity..."\n local servers=("pypi.org" "bootstrap.pypa.io")\n local has_connectivity=false\n \n for server in "${servers[@]}"; do\n if command_exists curl; then\n if curl --connect-timeout 5 -s "https://${server}" -o /dev/null; then\n has_connectivity=true\n break\n fi\n elif command_exists wget; then\n if wget --timeout=5 -q --spider "https://${server}"; then\n has_connectivity=true\n break\n fi\n fi\n done\n \n if [ "$has_connectivity" = false ]; then\n log "WARNING: Network connectivity issues detected. This may affect installation."\n else\n log "Network connectivity confirmed."\n fi\n}\n\n# Call connectivity check\ncheck_connectivity\n\n# Check if PYTHON_BIN is already set by the installer\nif [ -n "${PYTHON_BIN:-}" ]; then\n log "Using Python executable provided by installer: ${PYTHON_BIN}"\n # Verify the provided Python binary works on this architecture\n if ! is_valid_python_binary "${PYTHON_BIN}"; then\n log "Warning: The provided Python binary is not compatible with this system architecture."\n log "Will attempt to find a system Python installation instead."\n PYTHON_BIN=""\n fi\nfi\n\n# If PYTHON_BIN is empty or invalid, try to find a suitable Python installation\nif [ -z "${PYTHON_BIN:-}" ] || ! is_valid_python_binary "${PYTHON_BIN:-}"; then\n # Try to find a suitable Python installation\n log "Looking for a suitable Python installation..."\n \n # Try multiple possible locations to find Python\n POSSIBLE_PYTHON_PATHS=(\n # System paths first (more likely to be compatible with current architecture)\n "/usr/local/bin/python3.12"\n "/usr/bin/python3.12"\n "/usr/local/bin/python3"\n "/usr/bin/python3"\n # From environment variable (set by the installer)\n "${ELECTRON_RESOURCE_PATH:-}/python/bin/python3"\n # Development paths\n "$(dirname "$0")/../../../resources/python/bin/python3"\n "$(pwd)/resources/python/bin/python3"\n )\n\n # Find the first Python that exists and is version 3.12+ and works on this architecture\n PYTHON_BIN=""\n for path in "${POSSIBLE_PYTHON_PATHS[@]}"; do\n # Skip empty paths that might come from unset environment variables\n if [ -z "${path}" ]; then\n continue\n fi\n \n if is_valid_python_binary "${path}"; then\n # Check Python version\n if PY_VERSION=$("${path}" -c "import sys; print(f\'{sys.version_info.major}.{sys.version_info.minor}\')" 2>/dev/null); then\n MAJOR=$(echo "${PY_VERSION}" | cut -d. -f1)\n MINOR=$(echo "${PY_VERSION}" | cut -d. -f2)\n if [ "${MAJOR}" -eq 3 ] && [ "${MINOR}" -ge 12 ]; then\n PYTHON_BIN="${path}"\n log "Found suitable Python ${PY_VERSION} at ${path}"\n break\n else\n log "Python at ${path} is version ${PY_VERSION}, which is below the required 3.12+"\n fi\n fi\n else\n if [ -f "${path}" ]; then\n log "Python at ${path} exists but is not compatible with this system architecture"\n fi\n fi\n done\n\n # If we couldn\'t find a suitable Python, exit with error\n if [ -z "${PYTHON_BIN:-}" ]; then\n error_exit "No suitable Python installation found (version 3.12 or higher required).\\nPlease install Python 3.12 or higher before running this script.\\nYou can install Python from https://www.python.org/downloads/"\n fi\nfi\n\necho "Using Python: $PYTHON_BIN"\n"$PYTHON_BIN" --version || {\n echo "Error: Failed to run Python. Please ensure it is executable and accessible."\n echo "System architecture: $(uname -m)"\n echo "Python binary architecture: $(file "$PYTHON_BIN" 2>/dev/null || echo \'Unable to determine\')"\n exit 1\n}\n\n# Detect the Linux distribution to provide specific instructions\nif command_exists lsb_release; then\n DISTRO=$(lsb_release -is)\n DISTRO_VERSION=$(lsb_release -rs)\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelif [ -f /etc/os-release ]; then\n DISTRO=$(grep -oP \'(?<=^ID=).+\' /etc/os-release | tr -d \'"\')\n DISTRO_VERSION=$(grep -oP \'(?<=^VERSION_ID=).+\' /etc/os-release | tr -d \'"\')\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelse\n DISTRO="unknown"\n echo "Unable to detect Linux distribution"\nfi\n\n# Check if Python has venv module and ensurepip available\nlog "Checking for venv module and ensurepip availability..."\nVENV_AVAILABLE=0\nENSUREPIP_AVAILABLE=0\n\n"${PYTHON_BIN}" -c "import venv" > /dev/null 2>&1 || VENV_AVAILABLE=1\n"${PYTHON_BIN}" -c "import ensurepip" > /dev/null 2>&1 || ENSUREPIP_AVAILABLE=1\n\n# If venv is still not available, try to use virtualenv as a fallback\nif [ $VENV_AVAILABLE -ne 0 ]; then\n echo "venv module is not available. Checking for virtualenv as an alternative..."\n \n # Check if virtualenv is already installed\n if "$PYTHON_BIN" -c "import virtualenv" > /dev/null 2>&1; then\n echo "virtualenv is available, will use it instead of venv"\n else\n echo "ERROR: Neither venv nor virtualenv is available."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n exit 1\n fi\nfi\n\n# Create virtual environment if it doesn\'t exist\nif [ ! -d "$VENV_PATH" ]; then\n echo "Creating virtual environment at $VENV_PATH..."\n # Remove any potentially corrupted virtual environment\n if [ -e "$VENV_PATH" ]; then\n echo "Removing existing but potentially corrupted venv directory..."\n rm -rf "$VENV_PATH"\n fi\n \n # Make sure parent directory exists and is writable\n mkdir -p "$(dirname "$VENV_PATH")"\n \n # Try different methods to create a virtual environment\n VENV_CREATE_STATUS=1\n \n # First try with venv if available\n if [ $VENV_AVAILABLE -eq 0 ] && [ $ENSUREPIP_AVAILABLE -eq 0 ]; then\n echo "Creating virtual environment using venv module..."\n "$PYTHON_BIN" -m venv "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n fi\n \n # If venv failed, try virtualenv\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n echo "venv creation failed with status $VENV_CREATE_STATUS. Falling back to virtualenv..."\n if command_exists virtualenv; then\n virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n elif "$PYTHON_BIN" -m pip list | grep -q virtualenv; then\n "$PYTHON_BIN" -m virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n else\n echo "ERROR: Both venv and virtualenv are unavailable or failed."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n echo "You can install Python 3.12 from https://www.python.org/downloads/"\n exit 1\n fi\n fi\n \n # Check if virtual environment creation was successful\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n log "ERROR: Failed to create virtual environment. Exit code: $VENV_CREATE_STATUS"\n log "Virtual environment path: $VENV_PATH"\n log "Python binary used: $PYTHON_BIN"\n ls -la "$(dirname "$VENV_PATH")" || true\n log "Python executable permissions:"\n ls -la "$PYTHON_BIN" || true\n \n # Check for common issues\n log "Checking for common virtual environment creation issues..."\n \n # Check disk space\n df -h "$(dirname "$VENV_PATH")" || true\n \n # Check if directory is writable\n if [ ! -w "$(dirname "$VENV_PATH")" ]; then\n log "ERROR: Directory $(dirname "$VENV_PATH") is not writable"\n fi\n \n # Try to create a minimal virtual environment manually as a last resort\n log "Attempting to create a minimal virtual environment manually..."\n mkdir -p "$VENV_PATH/bin" || error_exit "Could not create directory $VENV_PATH/bin"\n echo "#!/bin/bash" > "$VENV_PATH/bin/activate"\n echo "export VIRTUAL_ENV=\\"$VENV_PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "export PATH=\\"$VENV_PATH/bin:\\$PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "unset PYTHONHOME" >> "$VENV_PATH/bin/activate"\n chmod +x "$VENV_PATH/bin/activate" || error_exit "Could not set execute permissions on activate script"\n \n # Create symlinks to the system Python\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python" || log "WARNING: Failed to create symlink for python"\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python3" || log "WARNING: Failed to create symlink for python3"\n \n # Try to bootstrap pip\n log "Bootstrapping pip in the minimal virtual environment..."\n if command_exists curl; then\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py" || log "WARNING: Failed to download get-pip.py"\n elif command_exists wget; then\n wget -q -O "$APP_DATA_DIR/get-pip.py" https://bootstrap.pypa.io/get-pip.py || log "WARNING: Failed to download get-pip.py"\n else\n log "ERROR: Neither curl nor wget available to download get-pip.py"\n error_exit "Installation cannot continue without being able to download pip"\n fi\n \n # shellcheck disable=SC1090\n source "$VENV_PATH/bin/activate" && python "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n error_exit "Failed to create even a minimal virtual environment."\n else\n log "Created a minimal virtual environment as a fallback."\n fi\n else\n log "Successfully created virtual environment"\n fi\nfi\n\n# Verify the virtual environment structure\necho "Verifying virtual environment structure..."\nif [ ! -f "$VENV_PATH/bin/python" ]; then\n echo "Python executable missing in virtual environment, creating symlink..."\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python"\nfi\n\nif [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "pip missing in virtual environment, attempting to bootstrap it..."\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py"\n "$VENV_PATH/bin/python" "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "ERROR: Failed to bootstrap pip in the virtual environment"\n exit 1\n fi\nfi\n\necho "Virtual environment structure verified"\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\necho "Upgrading pip..."\npython -m pip install --upgrade pip || {\n echo "WARNING: Failed to upgrade pip. Will try to continue with existing pip version."\n pip --version\n}\n\n# Check network connectivity to PyPI\necho "Checking network connectivity to PyPI..."\nif command_exists curl; then\n curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nelif command_exists wget; then\n wget -q --spider https://pypi.org/pypi/local-operator/json || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nfi\n\necho "Installing local-operator package..."\npython -m pip install --verbose local-operator || {\n echo "ERROR: Failed to install local-operator package. Exit code: $?"\n echo "Python version:"\n python --version\n echo "pip version:"\n pip --version\n echo "Available pip packages:"\n pip list\n exit 1\n}\necho "local-operator installation successful"\n\n# Verify installation\nif [ -f "$VENV_PATH/bin/local-operator" ]; then\n log "Local Operator backend installed successfully!"\n \n # Show more information about the installed binary\n ls -la "$VENV_PATH/bin/local-operator" || true\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n \n # Try to run the version command with full error output\n log "Testing local-operator binary..."\n "$VENV_PATH/bin/local-operator" --version || {\n log "ERROR: local-operator binary exists but failed to execute. Exit code: $?"\n log "Binary details:"\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n log "Binary permissions:"\n ls -la "$VENV_PATH/bin/local-operator" || true\n log ""\n log "This could be due to missing dependencies or architecture incompatibility."\n log "Try running the binary manually to see specific errors."\n \n # Don\'t exit here - just warn the user\n log "WARNING: Installation completed but binary verification failed."\n }\n \n # Create a summary of the installation\n SUMMARY_FILE="${APP_DATA_DIR}/installation-summary-${TIMESTAMP}.txt"\n {\n echo "=== Local Operator Backend Installation Summary ==="\n echo "Timestamp: $(date)"\n echo "Python Version: $("${PYTHON_BIN}" --version 2>&1)"\n echo "Virtual Environment: ${VENV_PATH}"\n echo "Binary Location: ${VENV_PATH}/bin/local-operator"\n echo "To activate the virtual environment, run: source ${VENV_PATH}/bin/activate"\n echo "To test the installation, run: ${VENV_PATH}/bin/local-operator --version"\n echo "Log file: ${LOG_FILE}"\n echo "=================================================="\n } > "${SUMMARY_FILE}"\n \n log "Installation summary saved to: ${SUMMARY_FILE}"\nelse\n log "Error: Failed to install Local Operator backend. Binary not found."\n log "Contents of bin directory:"\n ls -la "$VENV_PATH/bin/" || true\n log "Check the log file for details: ${LOG_FILE}"\n error_exit "Installation failed. Binary not installed properly."\nfi\n\nlog "Installation completed successfully at $(date)"\n\n# Clean up temporary files now that installation is complete\ncleanup\n\n# Print final instructions\nlog ""\nlog "=== INSTALLATION COMPLETE ==="\nlog "You can now use the Local Operator backend by running: ${VENV_PATH}/bin/local-operator"\nlog "Installation directory: ${VENV_PATH}"\nlog "Log file: ${LOG_FILE}"\nlog "================================"\n';
|
|
3110
|
-
const macosInstallScriptRaw = `#!/bin/bash
|
|
3111
|
-
# Local Operator Backend Installation Script for macOS
|
|
3112
|
-
# This script uses the bundled standalone Python and sets up a virtual environment for the Local Operator backend.
|
|
3113
|
-
|
|
3114
|
-
set -e # Exit immediately if a command exits with a non-zero status
|
|
3115
|
-
|
|
3116
|
-
# Configuration
|
|
3117
|
-
APP_NAME="Local Operator"
|
|
3118
|
-
VENV_NAME="local-operator-venv"
|
|
3119
|
-
APP_DATA_DIR="$HOME/Library/Application Support/$APP_NAME"
|
|
3120
|
-
VENV_PATH="$APP_DATA_DIR/$VENV_NAME"
|
|
3121
|
-
LOG_FILE="$APP_DATA_DIR/backend-install.log"
|
|
3122
|
-
|
|
3123
|
-
# Keep CPython's bytecode cache out of the application bundle.
|
|
3124
|
-
#
|
|
3125
|
-
# The bundled interpreter writes __pycache__/*.pyc beside the stdlib sources it
|
|
3126
|
-
# imports, and those sources live inside the code-signed .app - which is the
|
|
3127
|
-
# build's own extraResource. Every such write is a change to a sealed resource:
|
|
3128
|
-
# measured on an installed 0.17.3, \`codesign --verify --deep\` reported 308
|
|
3129
|
-
# \`file added:\` violations, all of them .pyc, and ShipIt then refuses the
|
|
3130
|
-
# in-place update with -67028 errSecCSBadBundleFormat. A file codesign reports
|
|
3131
|
-
# as *added* can be deleted and the seal comes back; a *modified* one cannot,
|
|
3132
|
-
# which is why nothing may ship a .pyc at all. The app sets this variable when
|
|
3133
|
-
# it spawns us; defaulting it here keeps a standalone run of this script in
|
|
3134
|
-
# agreement with the app about where bytecode goes. It is not a complete answer:
|
|
3135
|
-
# the app does not spawn every python that runs this interpreter, and a python
|
|
3136
|
-
# started by something else - a shell, a CLI script, an agent - has no prefix at
|
|
3137
|
-
# all (measured: a venv over this tree wrote 25 .pyc into it that way). The half
|
|
3138
|
-
# that covers those is the app's seal on the tree itself
|
|
3139
|
-
# (src/main/python-bytecode-cache.ts), which a standalone run of this script does
|
|
3140
|
-
# not apply. $HOME is the right place for the cache for the same reason the venv
|
|
3141
|
-
# lives there: it is never inside the thing that gets signed and swapped.
|
|
3142
|
-
: "\${PYTHONPYCACHEPREFIX:=$APP_DATA_DIR/python-bytecode-cache}"
|
|
3143
|
-
export PYTHONPYCACHEPREFIX
|
|
3144
|
-
|
|
3145
|
-
# Determine CPU Architecture and Python Directory Name
|
|
3146
|
-
ARCH=$(uname -m)
|
|
3147
|
-
PYTHON_DIR_NAME="python" # Default for x86_64
|
|
3148
|
-
PYTHON_ARCH_NAME="x86_64" # For logging
|
|
3149
|
-
|
|
3150
|
-
if [[ "$ARCH" == "x86_64" ]]; then
|
|
3151
|
-
PYTHON_DIR_NAME="python"
|
|
3152
|
-
PYTHON_ARCH_NAME="x86_64"
|
|
3153
|
-
elif [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then # arm64 is what uname -m returns on Apple Silicon
|
|
3154
|
-
PYTHON_DIR_NAME="python_aarch64"
|
|
3155
|
-
PYTHON_ARCH_NAME="aarch64"
|
|
3156
|
-
else
|
|
3157
|
-
echo "Error: Unsupported CPU architecture: $ARCH"
|
|
3158
|
-
exit 1
|
|
3159
|
-
fi
|
|
3160
|
-
echo "Detected CPU architecture: $ARCH, using Python directory name: $PYTHON_DIR_NAME"
|
|
3161
|
-
|
|
3162
|
-
# Check if PYTHON_BIN is already set by the installer
|
|
3163
|
-
if [[ -n "$PYTHON_BIN" ]]; then
|
|
3164
|
-
echo "Using Python executable provided by installer: $PYTHON_BIN"
|
|
3165
|
-
else
|
|
3166
|
-
# Get the path to the bundled Python based on architecture
|
|
3167
|
-
# Try multiple possible locations to find Python
|
|
3168
|
-
POSSIBLE_PYTHON_PATHS=(
|
|
3169
|
-
# From environment variable (set by the installer)
|
|
3170
|
-
"$ELECTRON_RESOURCE_PATH/$PYTHON_DIR_NAME/bin/python3"
|
|
3171
|
-
# Absolute paths for packaged app
|
|
3172
|
-
"/Applications/Local Operator.app/Contents/Resources/$PYTHON_DIR_NAME/bin/python3"
|
|
3173
|
-
"$HOME/Applications/Local Operator.app/Contents/Resources/$PYTHON_DIR_NAME/bin/python3"
|
|
3174
|
-
# Development paths
|
|
3175
|
-
"$(dirname "$0")/../../../resources/$PYTHON_DIR_NAME/bin/python3"
|
|
3176
|
-
"$(pwd)/resources/$PYTHON_DIR_NAME/bin/python3"
|
|
3177
|
-
# System Python as last resort (less ideal as it might not be the version we tested with)
|
|
3178
|
-
"/usr/bin/python3"
|
|
3179
|
-
)
|
|
3180
|
-
|
|
3181
|
-
# Find the first Python that exists
|
|
3182
|
-
PYTHON_BIN=""
|
|
3183
|
-
echo "Searching for Python in the following locations for $PYTHON_ARCH_NAME architecture (using directory $PYTHON_DIR_NAME):"
|
|
3184
|
-
for path in "\${POSSIBLE_PYTHON_PATHS[@]}"; do
|
|
3185
|
-
echo " - Checking $path"
|
|
3186
|
-
if [[ -f "$path" && -x "$path" ]]; then
|
|
3187
|
-
PYTHON_BIN="$path"
|
|
3188
|
-
echo "Found Python at $path"
|
|
3189
|
-
break
|
|
3190
|
-
else
|
|
3191
|
-
echo " Not found or not executable."
|
|
3192
|
-
fi
|
|
3193
|
-
done
|
|
3194
|
-
|
|
3195
|
-
# If we couldn't find Python, try to use the system Python
|
|
3196
|
-
if [[ -z "$PYTHON_BIN" ]]; then
|
|
3197
|
-
echo "Bundled Python for $PYTHON_ARCH_NAME not found in directory $PYTHON_DIR_NAME. Attempting to use system Python..."
|
|
3198
|
-
if command -v python3 &>/dev/null; then
|
|
3199
|
-
PYTHON_BIN=$(command -v python3)
|
|
3200
|
-
echo "Warning: Using system Python at $PYTHON_BIN. This may lead to unexpected behavior."
|
|
3201
|
-
else
|
|
3202
|
-
echo "Error: Could not find a suitable Python executable. Tried the following paths for $PYTHON_ARCH_NAME (expected in $PYTHON_DIR_NAME):"
|
|
3203
|
-
for path in "\${POSSIBLE_PYTHON_PATHS[@]}"; do
|
|
3204
|
-
echo " - $path (failed)"
|
|
3205
|
-
done
|
|
3206
|
-
exit 1
|
|
3207
|
-
fi
|
|
3208
|
-
fi
|
|
3209
|
-
fi
|
|
3210
|
-
|
|
3211
|
-
# Create app data directory if it doesn't exist
|
|
3212
|
-
mkdir -p "$APP_DATA_DIR"
|
|
3213
|
-
|
|
3214
|
-
# Start logging - capture everything and ensure it's visible to parent process
|
|
3215
|
-
exec > >(tee -a "$LOG_FILE") 2> >(tee -a "$LOG_FILE" >&2)
|
|
3216
|
-
echo "=============================================="
|
|
3217
|
-
echo "$(date): Starting Local Operator backend installation..."
|
|
3218
|
-
echo "=============================================="
|
|
3219
|
-
echo "Python bin path: $PYTHON_BIN"
|
|
3220
|
-
echo "Virtual environment path: $VENV_PATH"
|
|
3221
|
-
echo "App data directory: $APP_DATA_DIR"
|
|
3222
|
-
echo "Log file: $LOG_FILE"
|
|
3223
|
-
echo "=============================================="
|
|
3224
|
-
|
|
3225
|
-
# --- FFmpeg Installation ---
|
|
3226
|
-
BIN_DIR="$APP_DATA_DIR/bin"
|
|
3227
|
-
FFMPEG_BIN="$BIN_DIR/ffmpeg"
|
|
3228
|
-
|
|
3229
|
-
echo "Ensuring bin directory exists: $BIN_DIR"
|
|
3230
|
-
mkdir -p "$BIN_DIR"
|
|
3231
|
-
|
|
3232
|
-
# Check if FFmpeg is already installed and executable
|
|
3233
|
-
if [ -f "$FFMPEG_BIN" ] && [ -x "$FFMPEG_BIN" ]; then
|
|
3234
|
-
echo "FFmpeg already installed at $FFMPEG_BIN. Skipping download."
|
|
3235
|
-
else
|
|
3236
|
-
echo "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."
|
|
3237
|
-
|
|
3238
|
-
FFMPEG_DOWNLOAD_URL=""
|
|
3239
|
-
|
|
3240
|
-
if [[ "$ARCH" == "x86_64" ]]; then
|
|
3241
|
-
FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-x64"
|
|
3242
|
-
elif [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then
|
|
3243
|
-
FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-arm64"
|
|
3244
|
-
else
|
|
3245
|
-
echo "Error: Unsupported CPU architecture for FFmpeg download: $ARCH"
|
|
3246
|
-
exit 1
|
|
3247
|
-
fi
|
|
3248
|
-
|
|
3249
|
-
echo "Downloading FFmpeg from: $FFMPEG_DOWNLOAD_URL"
|
|
3250
|
-
if curl -L "$FFMPEG_DOWNLOAD_URL" -o "$FFMPEG_BIN"; then
|
|
3251
|
-
echo "FFmpeg downloaded successfully to $FFMPEG_BIN"
|
|
3252
|
-
chmod +x "$FFMPEG_BIN"
|
|
3253
|
-
echo "Set executable permissions for $FFMPEG_BIN"
|
|
3254
|
-
else
|
|
3255
|
-
echo "Error: Failed to download FFmpeg from $FFMPEG_DOWNLOAD_URL"
|
|
3256
|
-
exit 1
|
|
3257
|
-
fi
|
|
3258
|
-
|
|
3259
|
-
# Verify FFmpeg is executable after download
|
|
3260
|
-
if [ ! -f "$FFMPEG_BIN" ] || [ ! -x "$FFMPEG_BIN" ]; then
|
|
3261
|
-
echo "Error: FFmpeg binary not found or not executable after download."
|
|
3262
|
-
exit 1
|
|
3263
|
-
fi
|
|
3264
|
-
fi
|
|
3265
|
-
|
|
3266
|
-
echo "FFmpeg installation complete. FFmpeg binary is at: $FFMPEG_BIN"
|
|
3267
|
-
# --- End FFmpeg Installation ---
|
|
3268
|
-
|
|
3269
|
-
# Verify bundled Python exists
|
|
3270
|
-
if [ ! -f "$PYTHON_BIN" ]; then
|
|
3271
|
-
echo "Error: Bundled Python not found at $PYTHON_BIN"
|
|
3272
|
-
echo "Please ensure standalone Python is properly installed in the application resources."
|
|
3273
|
-
exit 1
|
|
3274
|
-
fi
|
|
3275
|
-
|
|
3276
|
-
# Make sure Python binary is executable
|
|
3277
|
-
chmod +x "$PYTHON_BIN"
|
|
3278
|
-
echo "Using bundled Python: $PYTHON_BIN"
|
|
3279
|
-
"$PYTHON_BIN" --version
|
|
3280
|
-
|
|
3281
|
-
# Check if Python has venv module available
|
|
3282
|
-
echo "Checking for venv module availability..."
|
|
3283
|
-
"$PYTHON_BIN" -m venv --help > /dev/null 2>&1 || {
|
|
3284
|
-
echo "ERROR: Python venv module not available in the Python installation"
|
|
3285
|
-
echo "Python details:"
|
|
3286
|
-
"$PYTHON_BIN" --version
|
|
3287
|
-
"$PYTHON_BIN" -c "import sys; print('Prefix:', sys.prefix); print('Exec Prefix:', sys.exec_prefix)"
|
|
3288
|
-
"$PYTHON_BIN" -c "import sys; print('Modules path:'); print('\\n'.join(sys.path))"
|
|
3289
|
-
exit 1
|
|
3290
|
-
}
|
|
3291
|
-
echo "venv module is available"
|
|
3292
|
-
|
|
3293
|
-
# Create virtual environment if it doesn't exist
|
|
3294
|
-
if [ ! -d "$VENV_PATH" ]; then
|
|
3295
|
-
echo "Creating virtual environment at $VENV_PATH..."
|
|
3296
|
-
# Remove any potentially corrupted virtual environment
|
|
3297
|
-
if [ -e "$VENV_PATH" ]; then
|
|
3298
|
-
echo "Removing existing but potentially corrupted venv directory..."
|
|
3299
|
-
rm -rf "$VENV_PATH"
|
|
3300
|
-
fi
|
|
3301
|
-
|
|
3302
|
-
# Make sure parent directory exists and is writable
|
|
3303
|
-
mkdir -p "$(dirname "$VENV_PATH")"
|
|
3304
|
-
|
|
3305
|
-
# Create the virtual environment with verbosity
|
|
3306
|
-
"$PYTHON_BIN" -m venv "$VENV_PATH" || {
|
|
3307
|
-
echo "ERROR: Failed to create virtual environment. Exit code: $?"
|
|
3308
|
-
echo "Virtual environment path: $VENV_PATH"
|
|
3309
|
-
echo "Python binary used: $PYTHON_BIN"
|
|
3310
|
-
ls -la "$(dirname "$VENV_PATH")"
|
|
3311
|
-
echo "Python executable permissions:"
|
|
3312
|
-
ls -la "$PYTHON_BIN"
|
|
3313
|
-
exit 1
|
|
3314
|
-
}
|
|
3315
|
-
echo "Successfully created virtual environment"
|
|
3316
|
-
fi
|
|
3317
|
-
|
|
3318
|
-
# Verify the virtual environment structure
|
|
3319
|
-
echo "Verifying virtual environment structure..."
|
|
3320
|
-
if [ ! -f "$VENV_PATH/bin/python" ] || [ ! -f "$VENV_PATH/bin/pip" ]; then
|
|
3321
|
-
echo "ERROR: Virtual environment is missing critical components"
|
|
3322
|
-
echo "Contents of virtual environment directory:"
|
|
3323
|
-
ls -la "$VENV_PATH"
|
|
3324
|
-
if [ -d "$VENV_PATH/bin" ]; then
|
|
3325
|
-
echo "Contents of bin directory:"
|
|
3326
|
-
ls -la "$VENV_PATH/bin"
|
|
3327
|
-
fi
|
|
3328
|
-
exit 1
|
|
3329
|
-
fi
|
|
3330
|
-
echo "Virtual environment structure verified"
|
|
3331
|
-
|
|
3332
|
-
# Activate virtual environment and install local-operator
|
|
3333
|
-
echo "Installing local-operator in virtual environment..."
|
|
3334
|
-
source "$VENV_PATH/bin/activate"
|
|
3335
|
-
|
|
3336
|
-
echo "Upgrading pip..."
|
|
3337
|
-
python -m pip install --upgrade pip || {
|
|
3338
|
-
echo "ERROR: Failed to upgrade pip. Exit code: $?"
|
|
3339
|
-
echo "pip version before failing:"
|
|
3340
|
-
pip --version
|
|
3341
|
-
exit 1
|
|
3342
|
-
}
|
|
3343
|
-
echo "pip upgrade successful:"
|
|
3344
|
-
pip --version
|
|
3345
|
-
|
|
3346
|
-
# Check network connectivity to PyPI
|
|
3347
|
-
echo "Checking network connectivity to PyPI..."
|
|
3348
|
-
curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {
|
|
3349
|
-
echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."
|
|
3350
|
-
echo "Attempting to ping common domains to diagnose network issues:"
|
|
3351
|
-
ping -c 1 google.com || echo "Cannot ping google.com"
|
|
3352
|
-
ping -c 1 pypi.org || echo "Cannot ping pypi.org"
|
|
3353
|
-
}
|
|
3354
|
-
|
|
3355
|
-
echo "Installing local-operator package..."
|
|
3356
|
-
python -m pip install --upgrade --verbose local-operator || {
|
|
3357
|
-
echo "ERROR: Failed to install local-operator package. Exit code: $?"
|
|
3358
|
-
echo "Python version:"
|
|
3359
|
-
python --version
|
|
3360
|
-
echo "pip version:"
|
|
3361
|
-
pip --version
|
|
3362
|
-
echo "Available pip packages:"
|
|
3363
|
-
pip list
|
|
3364
|
-
echo "Pip config:"
|
|
3365
|
-
pip config list
|
|
3366
|
-
echo "Network diagnosis:"
|
|
3367
|
-
curl -I https://pypi.org || echo "Cannot reach PyPI server"
|
|
3368
|
-
exit 1
|
|
3369
|
-
}
|
|
3370
|
-
echo "local-operator installation successful"
|
|
3371
|
-
|
|
3372
|
-
# Verify installation
|
|
3373
|
-
if [ -f "$VENV_PATH/bin/local-operator" ]; then
|
|
3374
|
-
echo "Local Operator backend installed successfully!"
|
|
3375
|
-
|
|
3376
|
-
# Show more information about the installed binary
|
|
3377
|
-
ls -la "$VENV_PATH/bin/local-operator"
|
|
3378
|
-
file "$VENV_PATH/bin/local-operator"
|
|
3379
|
-
|
|
3380
|
-
# Try to run the version command with full error output
|
|
3381
|
-
echo "Testing local-operator binary..."
|
|
3382
|
-
"$VENV_PATH/bin/local-operator" --version || {
|
|
3383
|
-
echo "ERROR: local-operator binary exists but failed to execute. Exit code: $?"
|
|
3384
|
-
echo "Binary details:"
|
|
3385
|
-
file "$VENV_PATH/bin/local-operator"
|
|
3386
|
-
echo "Binary permissions:"
|
|
3387
|
-
ls -la "$VENV_PATH/bin/local-operator"
|
|
3388
|
-
echo "Dependencies:"
|
|
3389
|
-
if command -v otool >/dev/null; then
|
|
3390
|
-
otool -L "$VENV_PATH/bin/local-operator" || echo "Could not get dependencies with otool"
|
|
3391
|
-
fi
|
|
3392
|
-
exit 1
|
|
3393
|
-
}
|
|
3394
|
-
else
|
|
3395
|
-
echo "Error: Failed to install Local Operator backend. Binary not found."
|
|
3396
|
-
echo "Contents of bin directory:"
|
|
3397
|
-
ls -la "$VENV_PATH/bin/"
|
|
3398
|
-
exit 1
|
|
3399
|
-
fi
|
|
3400
|
-
|
|
3401
|
-
echo "$(date): Installation completed successfully."
|
|
3402
|
-
`;
|
|
4324
|
+
const linuxInstallScriptRaw = '#!/bin/bash\n# Local Operator Backend Installation Script for Linux\n# This script sets up a virtual environment for the Local Operator backend\n# without requiring sudo or admin privileges.\n\n# Exit on error, undefined variables, and failed pipe commands\nset -e # Exit immediately if a command exits with a non-zero status\nset -u # Error on unset variables\nset -o pipefail # Fail if any command in a pipe fails\n\n# Configuration\n# Variables APP_NAME and MIN_PYTHON_VERSION removed as they were unused\nVENV_NAME="local-operator-venv"\nAPP_DATA_DIR="${HOME}/.config/local-operator"\nVENV_PATH="${APP_DATA_DIR}/${VENV_NAME}"\nLOG_FILE="${APP_DATA_DIR}/backend-install.log"\nTIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")\n\n# Which environment this installs into - the app\'s decision, handed in. See the\n# macOS script for why the script must not re-derive it (a packaged and an\n# unpackaged instance use different environments, and only the app knows which\n# it is); `LOCAL_OPERATOR_VENV_PATH` is set from `managedVenvPath`.\n: "${LOCAL_OPERATOR_VENV_PATH:=$VENV_PATH}"\nVENV_PATH="$LOCAL_OPERATOR_VENV_PATH"\n\n# Keep CPython\'s bytecode cache out of the application directory.\n#\n# Same reason as the macOS script: an interpreter writing __pycache__/*.pyc\n# beside its own stdlib sources writes into the installed tree. Linux does not\n# code-seal the app the way macOS does, so this is uniformity and hygiene here\n# rather than the load-bearing fix it is on macOS - but it is also the value a\n# standalone run of this script has to agree with, because the app and the\n# script must not disagree about where bytecode goes.\n: "${PYTHONPYCACHEPREFIX:=${APP_DATA_DIR}/python-bytecode-cache}"\nexport PYTHONPYCACHEPREFIX\n\n# The refusal half of the pair, set rather than merged with whatever the caller\n# had: CPython reads the flag before its first import, so nothing this script\n# runs - `-m venv`, pip, the venv they create - can write a `__pycache__` at\n# all. Kept in step with the app\'s `withPythonBytecodeCache`, which sets both\n# variables on every python it spawns.\nexport PYTHONDONTWRITEBYTECODE=1\n\n# Create app data directory if it doesn\'t exist\nif ! mkdir -p "${APP_DATA_DIR}"; then\n echo "ERROR: Unable to create app data directory at ${APP_DATA_DIR}"\n echo "Please check permissions and try again."\n exit 1\nfi\n\n# Handle cleanup on script exit\ncleanup() {\n # Remove any temporary files created during execution\n if [ -f "${APP_DATA_DIR}/get-pip.py" ]; then\n rm -f "${APP_DATA_DIR}/get-pip.py"\n fi\n if [ -n "${FFMPEG_TEMP_ARCHIVE:-}" ] && [ -f "${FFMPEG_TEMP_ARCHIVE}" ]; then\n log "Removing temporary FFmpeg archive: ${FFMPEG_TEMP_ARCHIVE}"\n rm -f "${FFMPEG_TEMP_ARCHIVE}"\n fi\n echo "$(date): Cleanup completed."\n}\n\n# Set trap only for manual interruption (SIGINT, SIGTERM), not normal exits\ntrap cleanup SIGINT SIGTERM\n\n\n# Start logging\nexec > >(tee -a "${LOG_FILE}") 2>&1\necho "[${TIMESTAMP}]: Starting Local Operator backend installation..."\necho "[${TIMESTAMP}]: System information: $(uname -a)"\n\n# Function to log messages with timestamp (defined later, but used by FFmpeg section)\n_log_internal() {\n local internal_timestamp\n internal_timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${internal_timestamp}] $1"\n}\n\n# --- FFmpeg Installation ---\nBIN_DIR="${APP_DATA_DIR}/bin"\nFFMPEG_BIN="${BIN_DIR}/ffmpeg" # Path to ffmpeg binary\n\n_log_internal "Ensuring bin directory exists: ${BIN_DIR}"\nif ! mkdir -p "${BIN_DIR}"; then\n _log_internal "ERROR: Unable to create bin directory at ${BIN_DIR}"\n exit 1\nfi\n\n# Check if FFmpeg is already installed and executable\nif [ -f "${FFMPEG_BIN}" ] && [ -x "${FFMPEG_BIN}" ]; then\n _log_internal "FFmpeg already installed at ${FFMPEG_BIN}. Skipping download."\nelse\n _log_internal "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."\n\n FFMPEG_DOWNLOAD_URL=""\n\n LINUX_ARCH=$(uname -m)\n if [[ "${LINUX_ARCH}" == "x86_64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-x64"\n elif [[ "${LINUX_ARCH}" == "aarch64" ]] || [[ "${LINUX_ARCH}" == "arm64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-arm64"\n else\n _log_internal "Error: Unsupported CPU architecture for FFmpeg download: ${LINUX_ARCH}"\n exit 1\n fi\n\n _log_internal "Downloading FFmpeg from: ${FFMPEG_DOWNLOAD_URL}"\n if command -v curl >/dev/null 2>&1; then\n if ! curl -L "${FFMPEG_DOWNLOAD_URL}" -o "${FFMPEG_BIN}"; then\n _log_internal "Error: Failed to download FFmpeg using curl from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n elif command -v wget >/dev/null 2>&1; then\n if ! wget -O "${FFMPEG_BIN}" "${FFMPEG_DOWNLOAD_URL}"; then\n _log_internal "Error: Failed to download FFmpeg using wget from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n else\n _log_internal "Error: Neither curl nor wget found. Cannot download FFmpeg."\n exit 1\n fi\n _log_internal "FFmpeg binary downloaded successfully to ${FFMPEG_BIN}"\n\n chmod +x "${FFMPEG_BIN}"\n _log_internal "Set executable permissions for ${FFMPEG_BIN}"\n\n # Verify FFmpeg is executable after download\n if [ ! -f "${FFMPEG_BIN}" ] || [ ! -x "${FFMPEG_BIN}" ]; then\n _log_internal "Error: FFmpeg binary not found or not executable after download."\n exit 1\n fi\nfi\n\n_log_internal "FFmpeg installation complete. FFmpeg binary is at: ${FFMPEG_BIN}"\n\n# Function to check if a command exists\ncommand_exists() {\n command -v "$1" >/dev/null 2>&1\n}\n\n# Function to log messages with timestamp\nlog() {\n local timestamp\n timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${timestamp}] $1"\n}\n\n# Function to display error messages and exit\nerror_exit() {\n log "ERROR: $1" >&2\n exit "${2:-1}"\n}\n\n# Function to check if Python binary is valid for current architecture\nis_valid_python_binary() {\n local python_path="$1"\n \n # First check if file exists and is executable\n if [ ! -f "${python_path}" ] || [ ! -x "${python_path}" ]; then\n return 1\n fi\n \n # Try to run a simple Python command to verify it works on current architecture\n "${python_path}" -c "print(\'Testing Python executable\')" >/dev/null 2>&1\n return $?\n}\n\n# Check for network connectivity to key servers\ncheck_connectivity() {\n log "Checking network connectivity..."\n local servers=("pypi.org" "bootstrap.pypa.io")\n local has_connectivity=false\n \n for server in "${servers[@]}"; do\n if command_exists curl; then\n if curl --connect-timeout 5 -s "https://${server}" -o /dev/null; then\n has_connectivity=true\n break\n fi\n elif command_exists wget; then\n if wget --timeout=5 -q --spider "https://${server}"; then\n has_connectivity=true\n break\n fi\n fi\n done\n \n if [ "$has_connectivity" = false ]; then\n log "WARNING: Network connectivity issues detected. This may affect installation."\n else\n log "Network connectivity confirmed."\n fi\n}\n\n# Call connectivity check\ncheck_connectivity\n\n# Check if PYTHON_BIN is already set by the installer\nif [ -n "${PYTHON_BIN:-}" ]; then\n log "Using Python executable provided by installer: ${PYTHON_BIN}"\n # Verify the provided Python binary works on this architecture\n if ! is_valid_python_binary "${PYTHON_BIN}"; then\n log "Warning: The provided Python binary is not compatible with this system architecture."\n log "Will attempt to find a system Python installation instead."\n PYTHON_BIN=""\n fi\nfi\n\n# If PYTHON_BIN is empty or invalid, try to find a suitable Python installation\nif [ -z "${PYTHON_BIN:-}" ] || ! is_valid_python_binary "${PYTHON_BIN:-}"; then\n # Try to find a suitable Python installation\n log "Looking for a suitable Python installation..."\n \n # Try multiple possible locations to find Python\n POSSIBLE_PYTHON_PATHS=(\n # System paths first (more likely to be compatible with current architecture)\n "/usr/local/bin/python3.12"\n "/usr/bin/python3.12"\n "/usr/local/bin/python3"\n "/usr/bin/python3"\n # From environment variable (set by the installer)\n "${ELECTRON_RESOURCE_PATH:-}/python/bin/python3"\n # Development paths\n "$(dirname "$0")/../../../resources/python/bin/python3"\n "$(pwd)/resources/python/bin/python3"\n )\n\n # Find the first Python that exists and is version 3.12+ and works on this architecture\n PYTHON_BIN=""\n for path in "${POSSIBLE_PYTHON_PATHS[@]}"; do\n # Skip empty paths that might come from unset environment variables\n if [ -z "${path}" ]; then\n continue\n fi\n \n if is_valid_python_binary "${path}"; then\n # Check Python version\n if PY_VERSION=$("${path}" -c "import sys; print(f\'{sys.version_info.major}.{sys.version_info.minor}\')" 2>/dev/null); then\n MAJOR=$(echo "${PY_VERSION}" | cut -d. -f1)\n MINOR=$(echo "${PY_VERSION}" | cut -d. -f2)\n if [ "${MAJOR}" -eq 3 ] && [ "${MINOR}" -ge 12 ]; then\n PYTHON_BIN="${path}"\n log "Found suitable Python ${PY_VERSION} at ${path}"\n break\n else\n log "Python at ${path} is version ${PY_VERSION}, which is below the required 3.12+"\n fi\n fi\n else\n if [ -f "${path}" ]; then\n log "Python at ${path} exists but is not compatible with this system architecture"\n fi\n fi\n done\n\n # If we couldn\'t find a suitable Python, exit with error\n if [ -z "${PYTHON_BIN:-}" ]; then\n error_exit "No suitable Python installation found (version 3.12 or higher required).\\nPlease install Python 3.12 or higher before running this script.\\nYou can install Python from https://www.python.org/downloads/"\n fi\nfi\n\necho "Using Python: $PYTHON_BIN"\n"$PYTHON_BIN" --version || {\n echo "Error: Failed to run Python. Please ensure it is executable and accessible."\n echo "System architecture: $(uname -m)"\n echo "Python binary architecture: $(file "$PYTHON_BIN" 2>/dev/null || echo \'Unable to determine\')"\n exit 1\n}\n\n# Detect the Linux distribution to provide specific instructions\nif command_exists lsb_release; then\n DISTRO=$(lsb_release -is)\n DISTRO_VERSION=$(lsb_release -rs)\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelif [ -f /etc/os-release ]; then\n DISTRO=$(grep -oP \'(?<=^ID=).+\' /etc/os-release | tr -d \'"\')\n DISTRO_VERSION=$(grep -oP \'(?<=^VERSION_ID=).+\' /etc/os-release | tr -d \'"\')\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelse\n DISTRO="unknown"\n echo "Unable to detect Linux distribution"\nfi\n\n# Check if Python has venv module and ensurepip available\nlog "Checking for venv module and ensurepip availability..."\nVENV_AVAILABLE=0\nENSUREPIP_AVAILABLE=0\n\n"${PYTHON_BIN}" -c "import venv" > /dev/null 2>&1 || VENV_AVAILABLE=1\n"${PYTHON_BIN}" -c "import ensurepip" > /dev/null 2>&1 || ENSUREPIP_AVAILABLE=1\n\n# If venv is still not available, try to use virtualenv as a fallback\nif [ $VENV_AVAILABLE -ne 0 ]; then\n echo "venv module is not available. Checking for virtualenv as an alternative..."\n \n # Check if virtualenv is already installed\n if "$PYTHON_BIN" -c "import virtualenv" > /dev/null 2>&1; then\n echo "virtualenv is available, will use it instead of venv"\n else\n echo "ERROR: Neither venv nor virtualenv is available."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n exit 1\n fi\nfi\n\n# Create virtual environment if it doesn\'t exist\nif [ ! -d "$VENV_PATH" ]; then\n echo "Creating virtual environment at $VENV_PATH..."\n # Remove any potentially corrupted virtual environment\n if [ -e "$VENV_PATH" ]; then\n echo "Removing existing but potentially corrupted venv directory..."\n rm -rf "$VENV_PATH"\n fi\n \n # Make sure parent directory exists and is writable\n mkdir -p "$(dirname "$VENV_PATH")"\n \n # Try different methods to create a virtual environment\n VENV_CREATE_STATUS=1\n \n # First try with venv if available\n if [ $VENV_AVAILABLE -eq 0 ] && [ $ENSUREPIP_AVAILABLE -eq 0 ]; then\n echo "Creating virtual environment using venv module..."\n "$PYTHON_BIN" -m venv "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n fi\n \n # If venv failed, try virtualenv\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n echo "venv creation failed with status $VENV_CREATE_STATUS. Falling back to virtualenv..."\n if command_exists virtualenv; then\n virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n elif "$PYTHON_BIN" -m pip list | grep -q virtualenv; then\n "$PYTHON_BIN" -m virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n else\n echo "ERROR: Both venv and virtualenv are unavailable or failed."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n echo "You can install Python 3.12 from https://www.python.org/downloads/"\n exit 1\n fi\n fi\n \n # Check if virtual environment creation was successful\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n log "ERROR: Failed to create virtual environment. Exit code: $VENV_CREATE_STATUS"\n log "Virtual environment path: $VENV_PATH"\n log "Python binary used: $PYTHON_BIN"\n ls -la "$(dirname "$VENV_PATH")" || true\n log "Python executable permissions:"\n ls -la "$PYTHON_BIN" || true\n \n # Check for common issues\n log "Checking for common virtual environment creation issues..."\n \n # Check disk space\n df -h "$(dirname "$VENV_PATH")" || true\n \n # Check if directory is writable\n if [ ! -w "$(dirname "$VENV_PATH")" ]; then\n log "ERROR: Directory $(dirname "$VENV_PATH") is not writable"\n fi\n \n # Try to create a minimal virtual environment manually as a last resort\n log "Attempting to create a minimal virtual environment manually..."\n mkdir -p "$VENV_PATH/bin" || error_exit "Could not create directory $VENV_PATH/bin"\n echo "#!/bin/bash" > "$VENV_PATH/bin/activate"\n echo "export VIRTUAL_ENV=\\"$VENV_PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "export PATH=\\"$VENV_PATH/bin:\\$PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "unset PYTHONHOME" >> "$VENV_PATH/bin/activate"\n chmod +x "$VENV_PATH/bin/activate" || error_exit "Could not set execute permissions on activate script"\n \n # Create symlinks to the system Python\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python" || log "WARNING: Failed to create symlink for python"\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python3" || log "WARNING: Failed to create symlink for python3"\n \n # Try to bootstrap pip\n log "Bootstrapping pip in the minimal virtual environment..."\n if command_exists curl; then\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py" || log "WARNING: Failed to download get-pip.py"\n elif command_exists wget; then\n wget -q -O "$APP_DATA_DIR/get-pip.py" https://bootstrap.pypa.io/get-pip.py || log "WARNING: Failed to download get-pip.py"\n else\n log "ERROR: Neither curl nor wget available to download get-pip.py"\n error_exit "Installation cannot continue without being able to download pip"\n fi\n \n # shellcheck disable=SC1090\n source "$VENV_PATH/bin/activate" && python "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n error_exit "Failed to create even a minimal virtual environment."\n else\n log "Created a minimal virtual environment as a fallback."\n fi\n else\n log "Successfully created virtual environment"\n fi\nfi\n\n# Verify the virtual environment structure\necho "Verifying virtual environment structure..."\nif [ ! -f "$VENV_PATH/bin/python" ]; then\n echo "Python executable missing in virtual environment, creating symlink..."\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python"\nfi\n\nif [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "pip missing in virtual environment, attempting to bootstrap it..."\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py"\n "$VENV_PATH/bin/python" "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "ERROR: Failed to bootstrap pip in the virtual environment"\n exit 1\n fi\nfi\n\necho "Virtual environment structure verified"\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\necho "Upgrading pip..."\npython -m pip install --upgrade pip || {\n echo "WARNING: Failed to upgrade pip. Will try to continue with existing pip version."\n pip --version\n}\n\n# Check network connectivity to PyPI\necho "Checking network connectivity to PyPI..."\nif command_exists curl; then\n curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nelif command_exists wget; then\n wget -q --spider https://pypi.org/pypi/local-operator/json || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nfi\n\necho "Installing local-operator package..."\npython -m pip install --verbose local-operator || {\n echo "ERROR: Failed to install local-operator package. Exit code: $?"\n echo "Python version:"\n python --version\n echo "pip version:"\n pip --version\n echo "Available pip packages:"\n pip list\n exit 1\n}\necho "local-operator installation successful"\n\n# Verify installation\nif [ -f "$VENV_PATH/bin/local-operator" ]; then\n log "Local Operator backend installed successfully!"\n \n # Show more information about the installed binary\n ls -la "$VENV_PATH/bin/local-operator" || true\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n \n # Try to run the version command with full error output\n log "Testing local-operator binary..."\n "$VENV_PATH/bin/local-operator" --version || {\n log "ERROR: local-operator binary exists but failed to execute. Exit code: $?"\n log "Binary details:"\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n log "Binary permissions:"\n ls -la "$VENV_PATH/bin/local-operator" || true\n log ""\n log "This could be due to missing dependencies or architecture incompatibility."\n log "Try running the binary manually to see specific errors."\n \n # Don\'t exit here - just warn the user\n log "WARNING: Installation completed but binary verification failed."\n }\n \n # Create a summary of the installation\n SUMMARY_FILE="${APP_DATA_DIR}/installation-summary-${TIMESTAMP}.txt"\n {\n echo "=== Local Operator Backend Installation Summary ==="\n echo "Timestamp: $(date)"\n echo "Python Version: $("${PYTHON_BIN}" --version 2>&1)"\n echo "Virtual Environment: ${VENV_PATH}"\n echo "Binary Location: ${VENV_PATH}/bin/local-operator"\n echo "To activate the virtual environment, run: source ${VENV_PATH}/bin/activate"\n echo "To test the installation, run: ${VENV_PATH}/bin/local-operator --version"\n echo "Log file: ${LOG_FILE}"\n echo "=================================================="\n } > "${SUMMARY_FILE}"\n \n log "Installation summary saved to: ${SUMMARY_FILE}"\nelse\n log "Error: Failed to install Local Operator backend. Binary not found."\n log "Contents of bin directory:"\n ls -la "$VENV_PATH/bin/" || true\n log "Check the log file for details: ${LOG_FILE}"\n error_exit "Installation failed. Binary not installed properly."\nfi\n\nlog "Installation completed successfully at $(date)"\n\n# Clean up temporary files now that installation is complete\ncleanup\n\n# Print final instructions\nlog ""\nlog "=== INSTALLATION COMPLETE ==="\nlog "You can now use the Local Operator backend by running: ${VENV_PATH}/bin/local-operator"\nlog "Installation directory: ${VENV_PATH}"\nlog "Log file: ${LOG_FILE}"\nlog "================================"\n';
|
|
4325
|
+
const macosInstallScriptRaw = '#!/bin/bash\n# Local Operator Backend Installation Script for macOS\n# This script uses the bundled standalone Python and sets up a virtual environment for the Local Operator backend.\n\nset -e # Exit immediately if a command exits with a non-zero status\n\n# Configuration\nAPP_NAME="Local Operator"\nVENV_NAME="local-operator-venv"\nAPP_DATA_DIR="${LOCAL_OPERATOR_SUPPORT_PATH:-$HOME/Library/Application Support/$APP_NAME}"\nVENV_PATH="$APP_DATA_DIR/$VENV_NAME"\nLOG_FILE="$APP_DATA_DIR/backend-install.log"\n\n# Which environment this installs into. The path is the app\'s decision, not this\n# script\'s: a packaged install and an unpackaged one must not share an\n# environment (the venv is built on whatever interpreter the instance resolves,\n# and the installed bundle\'s stdlib lives inside the code-sealed .app), and only\n# the app knows which one it is. The app passes its answer in\n# (`LOCAL_OPERATOR_VENV_PATH`, set from `managedVenvPath` in\n# src/main/backend/venv-paths.ts).\n#\n# macOS REFUSES a standalone run rather than falling back, and the asymmetry with\n# the Linux and Windows scripts is deliberate. Their default is the packaged name,\n# which is harmless there; here it is the exact environment this split exists to\n# stop a second instance from writing into - the measured failure is a dev-venv\n# interpreter whose stdlib is `/Applications/Local Operator.app/Contents/\n# Resources/python_aarch64`, so a silent default would rebuild that venv and\n# `pip install` into it, which is what R1 of the review caught. A caller that\n# cannot name the environment is a caller that should not be installing into one.\n: "${LOCAL_OPERATOR_VENV_PATH:?Pass the resolved managed environment path (see venv-paths.ts); this script will not guess which instance it belongs to}"\nVENV_PATH="$LOCAL_OPERATOR_VENV_PATH"\n\n# Keep CPython\'s bytecode cache out of the application bundle.\n#\n# The bundled interpreter writes __pycache__/*.pyc beside the stdlib sources it\n# imports, and those sources live inside the code-signed .app - which is the\n# build\'s own extraResource. Every such write is a change to a sealed resource:\n# measured on an installed 0.17.3, `codesign --verify --deep` reported 308\n# `file added:` violations, all of them .pyc, and ShipIt then refuses the\n# in-place update with -67028 errSecCSBadBundleFormat. A file codesign reports\n# as *added* can be deleted and the seal comes back; a *modified* one cannot,\n# which is why nothing may ship a .pyc at all. The app sets this variable when\n# it spawns us; defaulting it here keeps a standalone run of this script in\n# agreement with the app about where bytecode goes. It is not a complete answer:\n# the app does not spawn every python that runs this interpreter, and a python\n# started by something else - a shell, a CLI script, an agent - has no prefix at\n# all (measured: a venv over this tree wrote 25 .pyc into it that way). The half\n# that covers those is the app\'s seal on the tree itself\n# (src/main/python-bytecode-cache.ts), which a standalone run of this script does\n# not apply. $HOME is the right place for the cache for the same reason the venv\n# lives there: it is never inside the thing that gets signed and swapped.\n: "${PYTHONPYCACHEPREFIX:=$APP_DATA_DIR/python-bytecode-cache}"\nexport PYTHONPYCACHEPREFIX\n\n# And the refusal half of the same pair, so a standalone run of this script\n# cannot write bytecode into the bundle even where the redirect above does not\n# apply - a relative or unwritable prefix, or a child that drops the variable.\n# CPython reads the flag before its first import, so this script\'s own\n# `python -m venv`, its pip runs and the venv they create all compile without\n# writing a `__pycache__` anywhere. Measured on CPython: `0` and the empty\n# string are the two falsy spellings, so the value is set rather than merged\n# with whatever the caller had.\nexport PYTHONDONTWRITEBYTECODE=1\n\n# The architecture block that used to live here computed and logged the name of\n# a directory for an in-bundle search this script no longer performs: every run\n# announced which architecture-named directory it was about to use, and then\n# installed from `PYTHON_BIN` anyway - the line stated the opposite of how the\n# script finds Python, and the names it computed were used nowhere else (review\n# N1). Which interpreter to build the environment with is the caller\'s decision,\n# checked immediately below.\n\n# The app prepares a complete external runtime before invoking this script.\n# Searching /Applications here would reintroduce legacy-bundle execution during\n# migration. Standalone callers must make the same explicit path decision.\n: "${PYTHON_BIN:?Pass an external prepared Python executable}"\ncase "$PYTHON_BIN" in\n *.app/*) echo "Refusing to execute Python inside an application bundle" >&2; exit 1 ;;\nesac\n\n# Create app data directory if it doesn\'t exist\nmkdir -p "$APP_DATA_DIR"\n\n# Start logging - capture everything and ensure it\'s visible to parent process\nexec > >(tee -a "$LOG_FILE") 2> >(tee -a "$LOG_FILE" >&2)\necho "=============================================="\necho "$(date): Starting Local Operator backend installation..."\necho "=============================================="\necho "Python bin path: $PYTHON_BIN"\necho "Virtual environment path: $VENV_PATH"\necho "App data directory: $APP_DATA_DIR"\necho "Log file: $LOG_FILE"\necho "=============================================="\n\n# --- FFmpeg Installation ---\nBIN_DIR="$APP_DATA_DIR/bin"\nFFMPEG_BIN="$BIN_DIR/ffmpeg"\n\necho "Ensuring bin directory exists: $BIN_DIR"\nmkdir -p "$BIN_DIR"\n\n# Check if FFmpeg is already installed and executable\nif [ -f "$FFMPEG_BIN" ] && [ -x "$FFMPEG_BIN" ]; then\n echo "FFmpeg already installed at $FFMPEG_BIN. Skipping download."\nelse\n echo "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."\n\n # The architecture, read here because this URL is the one place left that needs\n # it: the environment is built on `PYTHON_BIN`, handed in above, so nothing\n # else in this script derives anything from `uname -m`. The block that used to\n # compute it at the top of the file was removed with the in-bundle interpreter\n # search it existed for (review N1) - and that removal took this variable with\n # it while this block still read it, so `$ARCH` was empty for every caller,\n # including the app, and any install on a machine without a cached ffmpeg\n # exited 1 here, before the venv was ever created. Measured: `bash\n # src/main/backend/scripts/macos-install-script.sh` with `PYTHON_BIN` set stops\n # on "Unsupported CPU architecture for FFmpeg download:".\n ARCH=$(uname -m)\n\n FFMPEG_DOWNLOAD_URL=""\n\n if [[ "$ARCH" == "x86_64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-x64"\n elif [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-arm64"\n else\n echo "Error: Unsupported CPU architecture for FFmpeg download: $ARCH"\n exit 1\n fi\n\n echo "Downloading FFmpeg from: $FFMPEG_DOWNLOAD_URL"\n if curl -L "$FFMPEG_DOWNLOAD_URL" -o "$FFMPEG_BIN"; then\n echo "FFmpeg downloaded successfully to $FFMPEG_BIN"\n chmod +x "$FFMPEG_BIN"\n echo "Set executable permissions for $FFMPEG_BIN"\n else\n echo "Error: Failed to download FFmpeg from $FFMPEG_DOWNLOAD_URL"\n exit 1\n fi\n\n # Verify FFmpeg is executable after download\n if [ ! -f "$FFMPEG_BIN" ] || [ ! -x "$FFMPEG_BIN" ]; then\n echo "Error: FFmpeg binary not found or not executable after download."\n exit 1\n fi\nfi\n\necho "FFmpeg installation complete. FFmpeg binary is at: $FFMPEG_BIN"\n# --- End FFmpeg Installation ---\n\n# Verify bundled Python exists\nif [ ! -f "$PYTHON_BIN" ]; then\n echo "Error: Bundled Python not found at $PYTHON_BIN"\n echo "Please ensure standalone Python is properly installed in the application resources."\n exit 1\nfi\n\n# Make sure Python binary is executable\necho "Using bundled Python: $PYTHON_BIN"\n"$PYTHON_BIN" --version\n\n# Check if Python has venv module available\necho "Checking for venv module availability..."\n"$PYTHON_BIN" -m venv --help > /dev/null 2>&1 || {\n echo "ERROR: Python venv module not available in the Python installation"\n echo "Python details:"\n "$PYTHON_BIN" --version\n "$PYTHON_BIN" -c "import sys; print(\'Prefix:\', sys.prefix); print(\'Exec Prefix:\', sys.exec_prefix)"\n "$PYTHON_BIN" -c "import sys; print(\'Modules path:\'); print(\'\\n\'.join(sys.path))"\n exit 1\n}\necho "venv module is available"\n\n# Create virtual environment if it doesn\'t exist\nif [ ! -d "$VENV_PATH" ]; then\n echo "Creating virtual environment at $VENV_PATH..."\n # Never repair a path we did not create. Preparation allocates a fresh final\n # pathname; a collision is evidence to preserve, not a reason to delete it.\n if [ -e "$VENV_PATH" ] || [ -L "$VENV_PATH" ]; then\n echo "Refusing to replace an existing environment path: $VENV_PATH" >&2\n exit 1\n fi\n \n # Make sure parent directory exists and is writable\n mkdir -p "$(dirname "$VENV_PATH")"\n \n # Create the virtual environment with verbosity\n "$PYTHON_BIN" -m venv "$VENV_PATH" || {\n echo "ERROR: Failed to create virtual environment. Exit code: $?"\n echo "Virtual environment path: $VENV_PATH"\n echo "Python binary used: $PYTHON_BIN"\n ls -la "$(dirname "$VENV_PATH")"\n echo "Python executable permissions:"\n ls -la "$PYTHON_BIN"\n exit 1\n }\n echo "Successfully created virtual environment"\nfi\n\n# Verify the virtual environment structure\necho "Verifying virtual environment structure..."\nif [ ! -f "$VENV_PATH/bin/python" ] || [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "ERROR: Virtual environment is missing critical components"\n echo "Contents of virtual environment directory:"\n ls -la "$VENV_PATH"\n if [ -d "$VENV_PATH/bin" ]; then\n echo "Contents of bin directory:"\n ls -la "$VENV_PATH/bin"\n fi\n exit 1\nfi\necho "Virtual environment structure verified"\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\necho "Upgrading pip..."\npython -m pip install --upgrade pip || {\n echo "ERROR: Failed to upgrade pip. Exit code: $?"\n echo "pip version before failing:"\n pip --version\n exit 1\n}\necho "pip upgrade successful:"\npip --version\n\n# Check network connectivity to PyPI\necho "Checking network connectivity to PyPI..."\ncurl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n echo "Attempting to ping common domains to diagnose network issues:"\n ping -c 1 google.com || echo "Cannot ping google.com"\n ping -c 1 pypi.org || echo "Cannot ping pypi.org"\n}\n\necho "Installing local-operator package..."\npython -m pip install --upgrade --verbose local-operator || {\n echo "ERROR: Failed to install local-operator package. Exit code: $?"\n echo "Python version:"\n python --version\n echo "pip version:"\n pip --version\n echo "Available pip packages:"\n pip list\n echo "Pip config:"\n pip config list\n echo "Network diagnosis:"\n curl -I https://pypi.org || echo "Cannot reach PyPI server"\n exit 1\n}\necho "local-operator installation successful"\n\n# Verify installation\nif [ -f "$VENV_PATH/bin/local-operator" ]; then\n echo "Local Operator backend installed successfully!"\n \n # Show more information about the installed binary\n ls -la "$VENV_PATH/bin/local-operator"\n file "$VENV_PATH/bin/local-operator"\n \n # Try to run the version command with full error output\n echo "Testing local-operator binary..."\n "$VENV_PATH/bin/local-operator" --version || {\n echo "ERROR: local-operator binary exists but failed to execute. Exit code: $?"\n echo "Binary details:"\n file "$VENV_PATH/bin/local-operator"\n echo "Binary permissions:"\n ls -la "$VENV_PATH/bin/local-operator"\n echo "Dependencies:"\n if command -v otool >/dev/null; then\n otool -L "$VENV_PATH/bin/local-operator" || echo "Could not get dependencies with otool"\n fi\n exit 1\n }\nelse\n echo "Error: Failed to install Local Operator backend. Binary not found."\n echo "Contents of bin directory:"\n ls -la "$VENV_PATH/bin/"\n exit 1\nfi\n\necho "$(date): Installation completed successfully."\n';
|
|
3403
4326
|
const windowsInstallScriptRaw = `# Local Operator Backend Installation Script for Windows
|
|
3404
4327
|
# This script installs pyenv-win, Python 3.12, and sets up a virtual environment for the Local Operator backend.
|
|
3405
4328
|
|
|
@@ -3412,6 +4335,17 @@ $VenvPath = "$AppDataDir\\\\$VenvName"
|
|
|
3412
4335
|
$LogFile = "$AppDataDir\\\\backend-install-shell.log"
|
|
3413
4336
|
$PyenvDir = "$env:USERPROFILE\\\\.pyenv"
|
|
3414
4337
|
|
|
4338
|
+
# Which environment this installs into - the app's decision, handed in rather
|
|
4339
|
+
# than re-derived. A packaged install and an unpackaged one must not share an
|
|
4340
|
+
# environment (the venv is built on whatever interpreter the instance resolves),
|
|
4341
|
+
# and only the app knows which it is: LOCAL_OPERATOR_VENV_PATH carries
|
|
4342
|
+
# managedVenvPath's answer (src/main/backend/venv-paths.ts). The default above is
|
|
4343
|
+
# what a standalone run uses - the packaged name, because that is what every
|
|
4344
|
+
# install on a disk today has.
|
|
4345
|
+
if ($env:LOCAL_OPERATOR_VENV_PATH) {
|
|
4346
|
+
$VenvPath = $env:LOCAL_OPERATOR_VENV_PATH
|
|
4347
|
+
}
|
|
4348
|
+
|
|
3415
4349
|
# Keep CPython's bytecode cache out of the application directory.
|
|
3416
4350
|
#
|
|
3417
4351
|
# Same rule as the macOS script, and stated here for the same reason: an
|
|
@@ -3425,6 +4359,11 @@ if (-not $env:PYTHONPYCACHEPREFIX) {
|
|
|
3425
4359
|
$env:PYTHONPYCACHEPREFIX = "$AppDataDir\\\\python-bytecode-cache"
|
|
3426
4360
|
}
|
|
3427
4361
|
|
|
4362
|
+
# The refusal half of the same pair, kept in step with the app's
|
|
4363
|
+
# \`withPythonBytecodeCache\`: CPython reads the flag before its first import, so
|
|
4364
|
+
# nothing this script runs can write a __pycache__ at all.
|
|
4365
|
+
$env:PYTHONDONTWRITEBYTECODE = "1"
|
|
4366
|
+
|
|
3428
4367
|
# Create app data directory if it doesn't exist
|
|
3429
4368
|
if (-not (Test-Path $AppDataDir)) {
|
|
3430
4369
|
New-Item -ItemType Directory -Path $AppDataDir -Force | Out-Null
|
|
@@ -3717,45 +4656,60 @@ try {
|
|
|
3717
4656
|
const macosInstallScript = macosInstallScriptRaw;
|
|
3718
4657
|
const linuxInstallScript = linuxInstallScriptRaw;
|
|
3719
4658
|
const windowsInstallScript = windowsInstallScriptRaw;
|
|
4659
|
+
const SETUP_FAILURE_CAUSES = [
|
|
4660
|
+
[
|
|
4661
|
+
/ENOSPC|No space left|not enough (?:free )?space/i,
|
|
4662
|
+
"This Mac ran out of disk space while setting up the backend. Free some space and retry."
|
|
4663
|
+
],
|
|
4664
|
+
[
|
|
4665
|
+
/Another Local Operator instance is preparing/i,
|
|
4666
|
+
"Another copy of Local Operator is setting up its backend right now. Retry in a moment."
|
|
4667
|
+
],
|
|
4668
|
+
[
|
|
4669
|
+
/*
|
|
4670
|
+
* The smoke arm only. `did not complete` used to be in here, and it is the
|
|
4671
|
+
* phrase `prepareManagedPython` throws when the install callback returns
|
|
4672
|
+
* false - a pip or network failure, where nothing was installed at all - so
|
|
4673
|
+
* the user read "The backend was installed but did not start correctly" about
|
|
4674
|
+
* an install that never happened (review round 2, N6). It now falls through
|
|
4675
|
+
* to the generic cause, which is true of it, and the app's own sentence still
|
|
4676
|
+
* follows under `The app recorded:`.
|
|
4677
|
+
*/
|
|
4678
|
+
/did not become healthy|Backend smoke|exited with/i,
|
|
4679
|
+
"The backend was installed but did not start correctly."
|
|
4680
|
+
],
|
|
4681
|
+
[
|
|
4682
|
+
/ENOENT|no such file or directory|could not find|did not match its signed seed/i,
|
|
4683
|
+
"A file the setup needed was missing, which usually means the download or the copy did not finish."
|
|
4684
|
+
]
|
|
4685
|
+
];
|
|
4686
|
+
function backendSetupFailureDetail(error, support) {
|
|
4687
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
4688
|
+
const cause = SETUP_FAILURE_CAUSES.find(([pattern]) => pattern.test(raw))?.[1] ?? "The backend could not be set up on this Mac.";
|
|
4689
|
+
return [
|
|
4690
|
+
`What happened: ${cause}`,
|
|
4691
|
+
`The app recorded: ${raw}`,
|
|
4692
|
+
`Where its environment lives: ${support}/managed-python`
|
|
4693
|
+
].join("\n\n");
|
|
4694
|
+
}
|
|
3720
4695
|
class BackendInstaller {
|
|
3721
4696
|
appDataPath = electron.app.getPath("userData");
|
|
3722
4697
|
venvPath;
|
|
3723
4698
|
resourcesPath;
|
|
3724
4699
|
pythonPath = null;
|
|
4700
|
+
preparationWindow = null;
|
|
3725
4701
|
/**
|
|
3726
4702
|
* Constructor
|
|
3727
4703
|
*/
|
|
3728
4704
|
constructor() {
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
this.
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
"local-operator-venv"
|
|
3738
|
-
);
|
|
3739
|
-
} else {
|
|
3740
|
-
this.venvPath = path$1.join(
|
|
3741
|
-
electron.app.getPath("home"),
|
|
3742
|
-
".config",
|
|
3743
|
-
"local-operator",
|
|
3744
|
-
"local-operator-venv"
|
|
3745
|
-
);
|
|
3746
|
-
}
|
|
3747
|
-
this.resourcesPath = process.env.NODE_ENV === "development" ? path$1.join(process.cwd(), "resources") : path$1.join(process.resourcesPath);
|
|
3748
|
-
if (electron.app.isPackaged) {
|
|
3749
|
-
const seal = sealPythonInterpreterTrees(
|
|
3750
|
-
bundledPythonTreePaths(process.resourcesPath)
|
|
3751
|
-
);
|
|
3752
|
-
const message = describePythonTreeSeal(seal);
|
|
3753
|
-
if (seal.supported && seal.failures.length > 0) {
|
|
3754
|
-
logger.warn(message, LogFileType.INSTALLER);
|
|
3755
|
-
} else {
|
|
3756
|
-
logger.info(message, LogFileType.INSTALLER);
|
|
3757
|
-
}
|
|
3758
|
-
}
|
|
4705
|
+
this.venvPath = managedVenvPath({
|
|
4706
|
+
platform: process.platform,
|
|
4707
|
+
home: electron.app.getPath("home"),
|
|
4708
|
+
appDataPath: this.appDataPath,
|
|
4709
|
+
packaged: electron.app.isPackaged
|
|
4710
|
+
});
|
|
4711
|
+
this.resourcesPath = !electron.app.isPackaged ? path$1.join(process.cwd(), "resources") : path$1.join(process.resourcesPath);
|
|
4712
|
+
if (process.platform !== "darwin") this.guardVenvBytecode();
|
|
3759
4713
|
this.pythonPath = this.findPython();
|
|
3760
4714
|
logger.info(
|
|
3761
4715
|
`Backend Installer initialized. Virtual environment path: ${this.venvPath}`,
|
|
@@ -3772,6 +4726,7 @@ class BackendInstaller {
|
|
|
3772
4726
|
* @returns Path to Python executable or null if not found
|
|
3773
4727
|
*/
|
|
3774
4728
|
findPython() {
|
|
4729
|
+
if (process.platform === "darwin") return null;
|
|
3775
4730
|
const possibilities = [];
|
|
3776
4731
|
if (process.arch === "arm64") {
|
|
3777
4732
|
possibilities.push(
|
|
@@ -3814,12 +4769,31 @@ class BackendInstaller {
|
|
|
3814
4769
|
);
|
|
3815
4770
|
return null;
|
|
3816
4771
|
}
|
|
4772
|
+
/**
|
|
4773
|
+
* Make the app-managed venv refuse bytecode writes for every process that
|
|
4774
|
+
* uses it, whoever started it.
|
|
4775
|
+
*
|
|
4776
|
+
* Logged rather than silent in both directions: a guard that was already
|
|
4777
|
+
* there, one this call wrote, and one that could not be written (no venv yet,
|
|
4778
|
+
* or a `sitecustomize.py` the operator owns) are three different states of the
|
|
4779
|
+
* same guarantee, and the field diagnosis starts from which one this machine
|
|
4780
|
+
* is in. `ensureVenvBytecodeGuard` never replaces a file it did not write.
|
|
4781
|
+
*/
|
|
4782
|
+
guardVenvBytecode() {
|
|
4783
|
+
const guard = ensureVenvBytecodeGuard(this.venvPath);
|
|
4784
|
+
logger.info(
|
|
4785
|
+
`Bytecode guard in the app-managed venv: ${guard.reason}`,
|
|
4786
|
+
LogFileType.INSTALLER
|
|
4787
|
+
);
|
|
4788
|
+
}
|
|
3817
4789
|
/**
|
|
3818
4790
|
* Check if backend is installed
|
|
3819
4791
|
* @returns Promise resolving to true if the backend is installed, false otherwise
|
|
3820
4792
|
*/
|
|
3821
4793
|
async isInstalled() {
|
|
3822
4794
|
try {
|
|
4795
|
+
if (process.platform === "darwin")
|
|
4796
|
+
return await managedSelectionReady(this.managedOptions());
|
|
3823
4797
|
if (!fs.existsSync(this.venvPath)) {
|
|
3824
4798
|
logger.info(
|
|
3825
4799
|
`Virtual environment not found at ${this.venvPath}`,
|
|
@@ -3866,30 +4840,90 @@ class BackendInstaller {
|
|
|
3866
4840
|
* Install backend
|
|
3867
4841
|
* @returns Promise resolving to true if the backend was installed successfully, false otherwise
|
|
3868
4842
|
*/
|
|
4843
|
+
managedOptions() {
|
|
4844
|
+
return {
|
|
4845
|
+
support: path$1.join(
|
|
4846
|
+
electron.app.getPath("home"),
|
|
4847
|
+
"Library",
|
|
4848
|
+
"Application Support",
|
|
4849
|
+
"Local Operator"
|
|
4850
|
+
),
|
|
4851
|
+
resources: this.resourcesPath,
|
|
4852
|
+
packaged: electron.app.isPackaged,
|
|
4853
|
+
arch: process.arch
|
|
4854
|
+
};
|
|
4855
|
+
}
|
|
3869
4856
|
async install() {
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
4857
|
+
if (process.platform !== "darwin") return this.installEnvironment();
|
|
4858
|
+
for (; ; ) {
|
|
4859
|
+
try {
|
|
4860
|
+
const state = inspectManagedSelection(this.managedOptions());
|
|
4861
|
+
if (state.kind === "missing")
|
|
4862
|
+
logger.warn(
|
|
4863
|
+
`Published backend environment is not usable and will be rebuilt: ${state.detail}`,
|
|
4864
|
+
LogFileType.INSTALLER
|
|
4865
|
+
);
|
|
4866
|
+
await prepareManagedPython(
|
|
4867
|
+
this.managedOptions(),
|
|
4868
|
+
async (venv, python) => {
|
|
4869
|
+
this.venvPath = venv;
|
|
4870
|
+
this.pythonPath = python;
|
|
4871
|
+
return this.installEnvironment();
|
|
4872
|
+
}
|
|
3883
4873
|
);
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
4874
|
+
return true;
|
|
4875
|
+
} catch (error) {
|
|
4876
|
+
logger.error(
|
|
4877
|
+
"Backend preparation failed",
|
|
4878
|
+
LogFileType.INSTALLER,
|
|
4879
|
+
error
|
|
4880
|
+
);
|
|
4881
|
+
const { response } = await electron.dialog.showMessageBox({
|
|
4882
|
+
type: "error",
|
|
4883
|
+
title: "Backend setup could not finish",
|
|
4884
|
+
message: "Your existing environment and data were preserved. Retry setup to start this version of Local Operator.",
|
|
4885
|
+
detail: backendSetupFailureDetail(
|
|
4886
|
+
error,
|
|
4887
|
+
this.managedOptions().support
|
|
4888
|
+
),
|
|
4889
|
+
buttons: ["Retry setup", "Quit"],
|
|
4890
|
+
defaultId: 0,
|
|
4891
|
+
cancelId: 1
|
|
4892
|
+
});
|
|
4893
|
+
if (response !== 0) return false;
|
|
4894
|
+
} finally {
|
|
4895
|
+
if (this.preparationWindow && !this.preparationWindow.isDestroyed())
|
|
4896
|
+
this.preparationWindow.close();
|
|
4897
|
+
this.preparationWindow = null;
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
async installEnvironment() {
|
|
4902
|
+
try {
|
|
4903
|
+
if (process.platform !== "darwin") {
|
|
4904
|
+
const { response } = await electron.dialog.showMessageBox({
|
|
4905
|
+
type: "info",
|
|
4906
|
+
title: "First-Time Setup Required",
|
|
4907
|
+
message: "Local Operator needs to set up some components to work properly. This one-time process will take just a few moments.",
|
|
4908
|
+
buttons: ["Set Up Now", "Cancel"],
|
|
4909
|
+
defaultId: 0,
|
|
4910
|
+
cancelId: 1
|
|
4911
|
+
});
|
|
4912
|
+
if (response === 1) {
|
|
4913
|
+
logger.info(
|
|
4914
|
+
"User cancelled backend installation",
|
|
4915
|
+
LogFileType.INSTALLER
|
|
4916
|
+
);
|
|
4917
|
+
setTimeout(() => {
|
|
4918
|
+
electron.app.exit(0);
|
|
4919
|
+
}, 500);
|
|
4920
|
+
return false;
|
|
4921
|
+
}
|
|
3888
4922
|
}
|
|
3889
4923
|
let scriptPath;
|
|
3890
4924
|
let cmd;
|
|
3891
4925
|
let args;
|
|
3892
|
-
const tempDir = os.tmpdir();
|
|
4926
|
+
const tempDir = fs.mkdtempSync(path$1.join(os.tmpdir(), "local-operator-install-"));
|
|
3893
4927
|
if (process.platform === "win32") {
|
|
3894
4928
|
scriptPath = path$1.join(tempDir, "install-backend-windows.ps1");
|
|
3895
4929
|
fs.writeFileSync(scriptPath, windowsInstallScript);
|
|
@@ -3934,6 +4968,8 @@ class BackendInstaller {
|
|
|
3934
4968
|
sandbox: false
|
|
3935
4969
|
}
|
|
3936
4970
|
});
|
|
4971
|
+
if (process.platform === "darwin")
|
|
4972
|
+
this.preparationWindow = progressWindow;
|
|
3937
4973
|
if (utils.is.dev && process.env.ELECTRON_RENDERER_URL) {
|
|
3938
4974
|
progressWindow.loadURL(
|
|
3939
4975
|
`${process.env.ELECTRON_RENDERER_URL}/installer`
|
|
@@ -4008,6 +5044,13 @@ class BackendInstaller {
|
|
|
4008
5044
|
try {
|
|
4009
5045
|
const env = withPythonBytecodeCache({ ...process.env }, this.appDataPath);
|
|
4010
5046
|
env.ELECTRON_RESOURCE_PATH = this.resourcesPath;
|
|
5047
|
+
env[VENV_PATH_ENV] = this.venvPath;
|
|
5048
|
+
env.HOME = electron.app.getPath("home");
|
|
5049
|
+
env.LOCAL_OPERATOR_SUPPORT_PATH = process.platform === "darwin" ? this.managedOptions().support : this.appDataPath;
|
|
5050
|
+
logger.info(
|
|
5051
|
+
`Setting ${VENV_PATH_ENV} to ${this.venvPath}`,
|
|
5052
|
+
LogFileType.INSTALLER
|
|
5053
|
+
);
|
|
4011
5054
|
logger.info(
|
|
4012
5055
|
`Setting ELECTRON_RESOURCE_PATH to ${this.resourcesPath}`,
|
|
4013
5056
|
LogFileType.INSTALLER
|
|
@@ -4087,7 +5130,7 @@ class BackendInstaller {
|
|
|
4087
5130
|
});
|
|
4088
5131
|
ipcMain.removeListener("cancel-installation", cancelHandler);
|
|
4089
5132
|
electron.app.removeListener("before-quit", appQuitHandler);
|
|
4090
|
-
if (!progressWindow.isDestroyed()) {
|
|
5133
|
+
if (process.platform !== "darwin" && !progressWindow.isDestroyed()) {
|
|
4091
5134
|
progressWindow.close();
|
|
4092
5135
|
}
|
|
4093
5136
|
if (installationCancelled) {
|
|
@@ -4103,6 +5146,7 @@ class BackendInstaller {
|
|
|
4103
5146
|
"Backend installation completed successfully",
|
|
4104
5147
|
LogFileType.INSTALLER
|
|
4105
5148
|
);
|
|
5149
|
+
this.guardVenvBytecode();
|
|
4106
5150
|
const localOperatorPath = process.platform === "win32" ? path$1.join(this.venvPath, "Scripts", "local-operator.exe") : path$1.join(this.venvPath, "bin", "local-operator");
|
|
4107
5151
|
if (!fs.existsSync(localOperatorPath)) {
|
|
4108
5152
|
logger.error(
|
|
@@ -4117,23 +5161,25 @@ class BackendInstaller {
|
|
|
4117
5161
|
"Installation verified successfully",
|
|
4118
5162
|
LogFileType.INSTALLER
|
|
4119
5163
|
);
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
5164
|
+
if (process.platform !== "darwin") {
|
|
5165
|
+
const { response: successResponse } = await electron.dialog.showMessageBox({
|
|
5166
|
+
type: "info",
|
|
5167
|
+
title: "Setup Complete",
|
|
5168
|
+
message: "Local Operator is ready to use. Everything has been set up successfully.",
|
|
5169
|
+
buttons: ["Let's Go!", "Cancel"],
|
|
5170
|
+
defaultId: 0,
|
|
5171
|
+
cancelId: 1
|
|
5172
|
+
});
|
|
5173
|
+
if (successResponse === 1) {
|
|
5174
|
+
logger.info(
|
|
5175
|
+
"User ended session after successful installation",
|
|
5176
|
+
LogFileType.INSTALLER
|
|
5177
|
+
);
|
|
5178
|
+
setTimeout(() => {
|
|
5179
|
+
electron.app.exit(0);
|
|
5180
|
+
}, 500);
|
|
5181
|
+
return false;
|
|
5182
|
+
}
|
|
4137
5183
|
}
|
|
4138
5184
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
4139
5185
|
if (process.platform === "win32") {
|
|
@@ -7929,7 +8975,7 @@ function relayedBytes(value) {
|
|
|
7929
8975
|
if (!(buffer instanceof ArrayBuffer)) return null;
|
|
7930
8976
|
return new Uint8Array(buffer, byteOffset, byteLength);
|
|
7931
8977
|
}
|
|
7932
|
-
function registerDesktopIPC(window, expectedUrl, request, streams, media, notifier) {
|
|
8978
|
+
function registerDesktopIPC(window, expectedUrl, request, streams, media, notifier, noteLeftSession) {
|
|
7933
8979
|
const opened = /* @__PURE__ */ new Map();
|
|
7934
8980
|
function authorize(event) {
|
|
7935
8981
|
const owner = window();
|
|
@@ -7964,6 +9010,15 @@ function registerDesktopIPC(window, expectedUrl, request, streams, media, notifi
|
|
|
7964
9010
|
focused: args.focused
|
|
7965
9011
|
});
|
|
7966
9012
|
});
|
|
9013
|
+
electron.ipcMain.handle("desktop-watch-release", (event, input) => {
|
|
9014
|
+
authorize(event);
|
|
9015
|
+
const args = input;
|
|
9016
|
+
if (typeof args?.sessionId !== "string") {
|
|
9017
|
+
throw new Error("Invalid watch release.");
|
|
9018
|
+
}
|
|
9019
|
+
notifier.releaseWatch(event.sender.id, args.sessionId);
|
|
9020
|
+
noteLeftSession?.(args.sessionId);
|
|
9021
|
+
});
|
|
7967
9022
|
}
|
|
7968
9023
|
if (media) {
|
|
7969
9024
|
electron.ipcMain.handle("desktop-media", (event, input, bytes) => {
|
|
@@ -8064,11 +9119,26 @@ function gateBody(kind, gateTitle, gateDetail) {
|
|
|
8064
9119
|
}
|
|
8065
9120
|
return subject || GATE_BODIES[kind] || GATE_BODIES.approval;
|
|
8066
9121
|
}
|
|
9122
|
+
function isBurstDigest(n) {
|
|
9123
|
+
return (n.burst_count ?? 0) > 1 || (n.session_ids?.length ?? 0) > 1;
|
|
9124
|
+
}
|
|
9125
|
+
function digestMemberCount(n) {
|
|
9126
|
+
return (n.member_tokens ?? []).filter(
|
|
9127
|
+
(member) => !!member?.session_id && !!member?.completion_token
|
|
9128
|
+
).length;
|
|
9129
|
+
}
|
|
9130
|
+
const SILENT_HOST = {
|
|
9131
|
+
windowAlive: () => true,
|
|
9132
|
+
noteDisplayed: () => void 0,
|
|
9133
|
+
reopen: () => void 0
|
|
9134
|
+
};
|
|
9135
|
+
const RENDERER_REPORT_TTL_MS = 45e3;
|
|
8067
9136
|
class DesktopNotifier {
|
|
8068
|
-
constructor(window, request, windowRaise = "focus") {
|
|
9137
|
+
constructor(window, request, windowRaise = "focus", host = SILENT_HOST) {
|
|
8069
9138
|
this.window = window;
|
|
8070
9139
|
this.request = request;
|
|
8071
9140
|
this.windowRaise = windowRaise;
|
|
9141
|
+
this.host = host;
|
|
8072
9142
|
}
|
|
8073
9143
|
delivered = /* @__PURE__ */ new Map();
|
|
8074
9144
|
/** Last known frontend epoch per session, to key dedupe on owner epoch. */
|
|
@@ -8237,12 +9307,24 @@ class DesktopNotifier {
|
|
|
8237
9307
|
* renderer's active subscription; `can_notify` is asserted only when main
|
|
8238
9308
|
* can really deliver. A focused window is interactive; a visible-but-
|
|
8239
9309
|
* unfocused one still watches but is not; a hidden one is neither.
|
|
9310
|
+
*
|
|
9311
|
+
* ``windowId`` IS THE WEB CONTENTS ID (``event.sender.id``, from the IPC
|
|
9312
|
+
* handler), and the map is keyed on it because that is what a close cleanup can
|
|
9313
|
+
* still name after the window is gone. Every reader must use the SAME id — see
|
|
9314
|
+
* `presence()`, where reading the ``BrowserWindow`` id instead made the
|
|
9315
|
+
* machine-wide presence report an empty session id for a window it was
|
|
9316
|
+
* claiming was focused on a conversation (review round 2, R2-3).
|
|
8240
9317
|
*/
|
|
8241
9318
|
async heartbeat(windowId, args) {
|
|
8242
9319
|
this.windows.set(windowId, {
|
|
8243
9320
|
visible: args.visible,
|
|
8244
|
-
focused: args.focused
|
|
9321
|
+
focused: args.focused,
|
|
9322
|
+
session: args.sessionId,
|
|
9323
|
+
// THE REPORT'S AGE, part of the record rather than a separate map
|
|
9324
|
+
// (R2-4): a state that cannot expire is a state that outlives its pane.
|
|
9325
|
+
seenAt: Date.now()
|
|
8245
9326
|
});
|
|
9327
|
+
this.host.noteDisplayed(args.sessionId);
|
|
8246
9328
|
return this.request({
|
|
8247
9329
|
op: "sessions.watch",
|
|
8248
9330
|
sessionId: args.sessionId,
|
|
@@ -8251,10 +9333,52 @@ class DesktopNotifier {
|
|
|
8251
9333
|
canNotify: this.canNotify
|
|
8252
9334
|
});
|
|
8253
9335
|
}
|
|
9336
|
+
/**
|
|
9337
|
+
* Forget a window's watch state on close.
|
|
9338
|
+
*
|
|
9339
|
+
* This had NO CALLER while `mainWindow.on("closed")` nulled the window
|
|
9340
|
+
* without telling the notifier, and `webContents` ids are not reused — so
|
|
9341
|
+
* after one window close the map held `{visible: true, focused: true}` for
|
|
9342
|
+
* the life of the process and every `when_unfocused` completion was
|
|
9343
|
+
* suppressed for good. That is the strongest candidate for the operator's
|
|
9344
|
+
* "I don't reliably get notified", and the fix is this call plus the
|
|
9345
|
+
* liveness read in `anyDisplayingFocused`.
|
|
9346
|
+
*/
|
|
8254
9347
|
forgetWindow(windowId) {
|
|
8255
9348
|
this.windows.delete(windowId);
|
|
8256
9349
|
}
|
|
8257
|
-
/**
|
|
9350
|
+
/**
|
|
9351
|
+
* Withdraw the DISPLAYED conversation when the pane that reported it leaves.
|
|
9352
|
+
*
|
|
9353
|
+
* THE NAVIGATION HALF OF R2-4. `forgetWindow` runs when a window closes, and
|
|
9354
|
+
* the report now expires on its own after `RENDERER_REPORT_TTL_MS` — but
|
|
9355
|
+
* navigating from a conversation to the catalogue or Settings stops the watch
|
|
9356
|
+
* and unsubscribes its stream while leaving the window, and therefore the map
|
|
9357
|
+
* entry, exactly where it was. Main's own feed then kept renewing "showing A"
|
|
9358
|
+
* every beat and the backend, which treats fresh presence as authoritative,
|
|
9359
|
+
* suppressed A's banner while no pane displayed it.
|
|
9360
|
+
*
|
|
9361
|
+
* IDENTITY-SAFE BY CONSTRUCTION, which is the part a naive clear gets wrong: a
|
|
9362
|
+
* cleanup runs asynchronously after the pane has already changed, so an
|
|
9363
|
+
* unconditional clear would wipe the session a DEEPER pane has since
|
|
9364
|
+
* subscribed to. This withdraws only the report that names the conversation
|
|
9365
|
+
* being left.
|
|
9366
|
+
*/
|
|
9367
|
+
releaseWatch(windowId, sessionId2) {
|
|
9368
|
+
const state = this.windows.get(windowId);
|
|
9369
|
+
if (!state) return;
|
|
9370
|
+
if (!sessionId2 || state.session !== sessionId2) return;
|
|
9371
|
+
this.windows.set(windowId, { ...state, session: "" });
|
|
9372
|
+
}
|
|
9373
|
+
/**
|
|
9374
|
+
* Called by the stream relay for every parsed frame it forwards, and by the
|
|
9375
|
+
* machine-wide feed for the frames it carries.
|
|
9376
|
+
*
|
|
9377
|
+
* ONE method for both sources on purpose. A `notification` frame's payload is
|
|
9378
|
+
* byte-identical whichever way it arrived — same `dedupe_key`, minted by the
|
|
9379
|
+
* backend — so two entry points would be two places to keep that true, and
|
|
9380
|
+
* the pair would eventually be delivered twice instead of once.
|
|
9381
|
+
*/
|
|
8258
9382
|
observe(sessionId2, frame) {
|
|
8259
9383
|
if (!this.canNotify) return;
|
|
8260
9384
|
if (this.windowRaise === "never") return;
|
|
@@ -8315,16 +9439,47 @@ class DesktopNotifier {
|
|
|
8315
9439
|
*/
|
|
8316
9440
|
async composed(sessionId2, n) {
|
|
8317
9441
|
if (!this.claim(n.dedupe_key)) return;
|
|
8318
|
-
if (n.focus_policy === "when_unfocused" && this.
|
|
9442
|
+
if (n.focus_policy === "when_unfocused" && this.anyDisplayingFocused(sessionId2))
|
|
9443
|
+
return;
|
|
8319
9444
|
if (n.completion_token) {
|
|
8320
9445
|
const outcome = await this.claimDelivery(sessionId2, n.completion_token);
|
|
8321
9446
|
if (outcome !== "won") {
|
|
8322
9447
|
if (outcome === "failed") this.delivered.delete(n.dedupe_key);
|
|
8323
9448
|
return;
|
|
8324
9449
|
}
|
|
9450
|
+
} else if (digestMemberCount(n) > 0 || isBurstDigest(n)) {
|
|
9451
|
+
const won = await this.claimDigestMembers(n);
|
|
9452
|
+
if (won === 0 && digestMemberCount(n) > 0) {
|
|
9453
|
+
this.delivered.delete(n.dedupe_key);
|
|
9454
|
+
return;
|
|
9455
|
+
}
|
|
8325
9456
|
}
|
|
8326
9457
|
this.show(
|
|
8327
|
-
|
|
9458
|
+
/*
|
|
9459
|
+
* A BURST DIGEST is not about one conversation (R1-2). The backend
|
|
9460
|
+
* caps per-tick banners and publishes ONE frame for the remainder,
|
|
9461
|
+
* carrying `burst_count` and `session_ids` and setting `session_id`
|
|
9462
|
+
* to the LAST overflow member — it must name something for the frame
|
|
9463
|
+
* to be a valid session frame at all. Routing its click to that id
|
|
9464
|
+
* opened one arbitrary member of the burst, which is the "the click
|
|
9465
|
+
* did not land where I expected" defect this change exists to
|
|
9466
|
+
* remove, so a digest opens the catalogue instead.
|
|
9467
|
+
*
|
|
9468
|
+
* Detected on the fields themselves rather than on a title string:
|
|
9469
|
+
* the strings are the backend's to word, and a surface that matched
|
|
9470
|
+
* on them would route a re-worded digest to a random conversation.
|
|
9471
|
+
*
|
|
9472
|
+
* A ONE-MEMBER DIGEST LANDS ON ITS OWN CONVERSATION, and that is a
|
|
9473
|
+
* decision rather than a fall-through (review round 3, R3-1). The frame
|
|
9474
|
+
* above is the case the claim branch now recognises and this one does
|
|
9475
|
+
* not: `burst_count` is 1 and it names exactly one conversation, so the
|
|
9476
|
+
* banner says one thing and the click goes there — the operator's own
|
|
9477
|
+
* request ("a click landing on the exact conversation") read literally.
|
|
9478
|
+
* Folding the member marker into `isBurstDigest` would send it to the
|
|
9479
|
+
* catalogue and lose that navigation for no gain in correctness, since
|
|
9480
|
+
* it is the CLAIM that has to happen for every member, not the routing.
|
|
9481
|
+
*/
|
|
9482
|
+
isBurstDigest(n) ? null : sessionId2,
|
|
8328
9483
|
n.title,
|
|
8329
9484
|
n.status,
|
|
8330
9485
|
n.body,
|
|
@@ -8332,6 +9487,36 @@ class DesktopNotifier {
|
|
|
8332
9487
|
n.body_is_failure ?? false
|
|
8333
9488
|
);
|
|
8334
9489
|
}
|
|
9490
|
+
/**
|
|
9491
|
+
* Claim a burst digest's members, and report how many this surface won.
|
|
9492
|
+
*
|
|
9493
|
+
* One `sessions.notified` per member, through the SAME atomic claim a single
|
|
9494
|
+
* frame uses — that is the point of the contract: the arbitration lives in one
|
|
9495
|
+
* place (the backend's `claim_delivery`), so a digest and an individual banner
|
|
9496
|
+
* for one of its members cannot both win.
|
|
9497
|
+
*
|
|
9498
|
+
* Members are claimed SEQUENTIALLY rather than in parallel, deliberately: this
|
|
9499
|
+
* runs on the delivery path with a banner's worth of latency behind it, and a
|
|
9500
|
+
* burst is capped at a handful of members by the backend's own ceiling.
|
|
9501
|
+
*
|
|
9502
|
+
* A `failed` claim is treated as NOT won, which makes the whole digest fail
|
|
9503
|
+
* closed when the backend is unreachable — the same choice the single-frame
|
|
9504
|
+
* path makes, and for the same reason: showing the banner anyway would risk a
|
|
9505
|
+
* second banner for completions another surface may also have reached.
|
|
9506
|
+
*/
|
|
9507
|
+
async claimDigestMembers(n) {
|
|
9508
|
+
const members = n.member_tokens ?? [];
|
|
9509
|
+
let won = 0;
|
|
9510
|
+
for (const member of members) {
|
|
9511
|
+
if (!member?.session_id || !member?.completion_token) continue;
|
|
9512
|
+
const outcome = await this.claimDelivery(
|
|
9513
|
+
member.session_id,
|
|
9514
|
+
member.completion_token
|
|
9515
|
+
);
|
|
9516
|
+
if (outcome === "won") won += 1;
|
|
9517
|
+
}
|
|
9518
|
+
return won;
|
|
9519
|
+
}
|
|
8335
9520
|
/**
|
|
8336
9521
|
* Ask the backend whether this surface owns this completion's toast.
|
|
8337
9522
|
*
|
|
@@ -8359,12 +9544,91 @@ class DesktopNotifier {
|
|
|
8359
9544
|
return "failed";
|
|
8360
9545
|
}
|
|
8361
9546
|
}
|
|
8362
|
-
|
|
8363
|
-
|
|
8364
|
-
|
|
9547
|
+
/**
|
|
9548
|
+
* Whether a LIVE window is displaying `sessionId` in the foreground.
|
|
9549
|
+
*
|
|
9550
|
+
* THE completion gate, and the predicate is deliberately two facts rather
|
|
9551
|
+
* than one. `anyFocused()` alone answers "is the app in front", which is not
|
|
9552
|
+
* the question: with the app focused on session A, a completion in session B
|
|
9553
|
+
* was suppressed and announced to nobody — and rung 3 had already deferred
|
|
9554
|
+
* to rung 2, so nothing else could raise it either. That is the operator's
|
|
9555
|
+
* reported symptom, made permanent by the presence mechanism itself (B1).
|
|
9556
|
+
*
|
|
9557
|
+
* Only a window showing THIS conversation is a reason not to banner, because
|
|
9558
|
+
* only then is the completion already on screen.
|
|
9559
|
+
*
|
|
9560
|
+
* Dead windows are filtered rather than assumed gone. `forgetWindow` runs on
|
|
9561
|
+
* `closed`, but a renderer process that dies takes its `webContents` id with
|
|
9562
|
+
* it and no `closed` event reaches this map, and `webContents` ids are not
|
|
9563
|
+
* reused — so an unfiltered stale `{visible: true, focused: true}` entry
|
|
9564
|
+
* suppresses every completion for the life of the process.
|
|
9565
|
+
*/
|
|
9566
|
+
anyDisplayingFocused(sessionId2) {
|
|
9567
|
+
for (const [id2, state] of this.windows) {
|
|
9568
|
+
if (!state.visible || !state.focused) continue;
|
|
9569
|
+
if (state.session !== sessionId2) continue;
|
|
9570
|
+
if (!this.host.windowAlive(id2)) continue;
|
|
9571
|
+
if (Date.now() - state.seenAt > RENDERER_REPORT_TTL_MS) continue;
|
|
9572
|
+
return true;
|
|
8365
9573
|
}
|
|
8366
9574
|
return false;
|
|
8367
9575
|
}
|
|
9576
|
+
/**
|
|
9577
|
+
* What this app can say about itself for the machine-wide presence claim.
|
|
9578
|
+
*
|
|
9579
|
+
* The claim is not "a desktop is connected" - the backend already knows that
|
|
9580
|
+
* from the socket. It is the two answers only the app has, and both are
|
|
9581
|
+
* read by the delivery ladder: `session_id` + `window` decide `attended`
|
|
9582
|
+
* (rung 1's "a watching surface already has it"), and a MISSING window is not
|
|
9583
|
+
* a missing value - it means this app is watching nothing, which is the
|
|
9584
|
+
* correct answer for the operator's own windowless case and the wrong answer
|
|
9585
|
+
* for a window that is merely minimised.
|
|
9586
|
+
*
|
|
9587
|
+
* `sessionId` comes from this notifier's own heartbeat map rather than from
|
|
9588
|
+
* the renderer, so it cannot disagree with the completion gate above: the
|
|
9589
|
+
* same value that suppresses a banner is the value that tells the backend
|
|
9590
|
+
* not to raise someone else's. An app that names a conversation it is not
|
|
9591
|
+
* showing suppresses the banner for a conversation nobody can see.
|
|
9592
|
+
*
|
|
9593
|
+
* Dead windows answer "no window" rather than "last known window": a
|
|
9594
|
+
* destroyed window has no `is*()` to read, and reporting its stale state
|
|
9595
|
+
* would advertise a surface that cannot display anything.
|
|
9596
|
+
*
|
|
9597
|
+
* THE SESSION IS READ BY THE ID THE WRITER USED (review round 2, R2-3).
|
|
9598
|
+
* `heartbeat` keys this map on `event.sender.id` — the WEB CONTENTS id —
|
|
9599
|
+
* because that is what the IPC event carries and what the close cleanup
|
|
9600
|
+
* captures. This accessor read `window.id`, the BrowserWindow id, which merely
|
|
9601
|
+
* happens to be equal early in a session and drifts as soon as anything else
|
|
9602
|
+
* is created (the browser host's own webContents, a second window). It then
|
|
9603
|
+
* reported a focused, visible window with `session_id: ""`, so the backend
|
|
9604
|
+
* could not recognise the conversation actually on screen and bannered it as
|
|
9605
|
+
* background.
|
|
9606
|
+
*/
|
|
9607
|
+
presence() {
|
|
9608
|
+
const window = this.window();
|
|
9609
|
+
if (!window || window.isDestroyed()) {
|
|
9610
|
+
return {
|
|
9611
|
+
sessionId: "",
|
|
9612
|
+
window: {
|
|
9613
|
+
exists: false,
|
|
9614
|
+
focused: false,
|
|
9615
|
+
visible: false,
|
|
9616
|
+
minimized: false
|
|
9617
|
+
}
|
|
9618
|
+
};
|
|
9619
|
+
}
|
|
9620
|
+
const reported = this.windows.get(window.webContents.id);
|
|
9621
|
+
const current = reported && Date.now() - reported.seenAt <= RENDERER_REPORT_TTL_MS;
|
|
9622
|
+
return {
|
|
9623
|
+
sessionId: current ? reported.session : "",
|
|
9624
|
+
window: {
|
|
9625
|
+
exists: true,
|
|
9626
|
+
focused: window.isFocused(),
|
|
9627
|
+
visible: window.isVisible(),
|
|
9628
|
+
minimized: window.isMinimized()
|
|
9629
|
+
}
|
|
9630
|
+
};
|
|
9631
|
+
}
|
|
8368
9632
|
/**
|
|
8369
9633
|
* Raise the banner for a pending approval or question.
|
|
8370
9634
|
*
|
|
@@ -8416,7 +9680,7 @@ class DesktopNotifier {
|
|
|
8416
9680
|
turn(sessionId2, seq) {
|
|
8417
9681
|
const key = `turn:${sessionId2}:${this.epochs.get(sessionId2) ?? ""}:${seq}`;
|
|
8418
9682
|
if (!this.claim(key)) return;
|
|
8419
|
-
if (this.
|
|
9683
|
+
if (this.anyDisplayingFocused(sessionId2)) return;
|
|
8420
9684
|
this.show(sessionId2, "Turn complete", "", "The agent finished its turn.");
|
|
8421
9685
|
}
|
|
8422
9686
|
claim(key) {
|
|
@@ -8480,9 +9744,12 @@ class DesktopNotifier {
|
|
|
8480
9744
|
});
|
|
8481
9745
|
notification.on("click", () => {
|
|
8482
9746
|
const target2 = this.window();
|
|
8483
|
-
if (!target2)
|
|
8484
|
-
|
|
9747
|
+
if (!target2 || target2.isDestroyed()) {
|
|
9748
|
+
this.host.reopen(sessionId2);
|
|
9749
|
+
return;
|
|
9750
|
+
}
|
|
8485
9751
|
target2.webContents.send("desktop-open-conversation", { sessionId: sessionId2 });
|
|
9752
|
+
raiseWindow(target2, this.windowRaise);
|
|
8486
9753
|
});
|
|
8487
9754
|
notification.show();
|
|
8488
9755
|
}
|
|
@@ -8553,15 +9820,20 @@ ${probe.stderr}`.trim();
|
|
|
8553
9820
|
detail: marker ? `${marker}: ${output || "no output"}` : `codesign --verify exited ${probe.exitCode}: ${output || "no output"}`
|
|
8554
9821
|
};
|
|
8555
9822
|
}
|
|
8556
|
-
function installedBundleSealBlock(appBundlePath, detail, version) {
|
|
9823
|
+
function installedBundleSealBlock(appBundlePath, detail, version, context = "update") {
|
|
9824
|
+
const message = context === "startup" ? "This copy of Local Operator did not pass its integrity check, so it can't repair itself." : version ? `This install of Local Operator can't be updated in place, so the update to version ${version} 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.";
|
|
8557
9825
|
return {
|
|
8558
9826
|
code: "installed-bundle-not-sealed",
|
|
8559
|
-
message
|
|
9827
|
+
message,
|
|
8560
9828
|
remedy: {
|
|
8561
9829
|
text: "Quit Local Operator, then download a fresh copy and replace the app in Applications.",
|
|
8562
9830
|
url: DOWNLOAD_PAGE_URL
|
|
8563
9831
|
},
|
|
8564
|
-
detail: `${appBundlePath}: ${detail}
|
|
9832
|
+
detail: `${appBundlePath}: ${detail}`,
|
|
9833
|
+
...context === "startup" ? {
|
|
9834
|
+
heading: "This copy of Local Operator needs replacing",
|
|
9835
|
+
dismissLabel: "Not now"
|
|
9836
|
+
} : {}
|
|
8565
9837
|
};
|
|
8566
9838
|
}
|
|
8567
9839
|
const VIOLATION_LINE = /^file (added|modified|missing):\s*(.+)$/;
|
|
@@ -8593,7 +9865,12 @@ function canonicalPath(path2) {
|
|
|
8593
9865
|
}
|
|
8594
9866
|
}
|
|
8595
9867
|
}
|
|
8596
|
-
const BUNDLED_PYTHON_DIRS = [
|
|
9868
|
+
const BUNDLED_PYTHON_DIRS = [
|
|
9869
|
+
...BUNDLED_PYTHON_LAYOUT.legacyResourceNames,
|
|
9870
|
+
...BUNDLED_PYTHON_LAYOUT.architectures.map(
|
|
9871
|
+
(arch) => `${BUNDLED_PYTHON_LAYOUT.seedNamespace}/${arch}`
|
|
9872
|
+
)
|
|
9873
|
+
];
|
|
8597
9874
|
function isPythonBytecodePath(bundlePath, candidate) {
|
|
8598
9875
|
const bundle = canonicalPath(bundlePath);
|
|
8599
9876
|
const path2 = canonicalPath(candidate);
|
|
@@ -9640,7 +10917,7 @@ function resolveGlobalInstallPlan(input) {
|
|
|
9640
10917
|
sourceBuild: false
|
|
9641
10918
|
};
|
|
9642
10919
|
}
|
|
9643
|
-
function buildPipUpgradeCommand(
|
|
10920
|
+
function buildPipUpgradeCommand(pythonPath2) {
|
|
9644
10921
|
const args = [
|
|
9645
10922
|
"-m",
|
|
9646
10923
|
"pip",
|
|
@@ -9651,9 +10928,9 @@ function buildPipUpgradeCommand(pythonPath) {
|
|
|
9651
10928
|
"local-operator"
|
|
9652
10929
|
];
|
|
9653
10930
|
return {
|
|
9654
|
-
command:
|
|
10931
|
+
command: pythonPath2,
|
|
9655
10932
|
args,
|
|
9656
|
-
display: `"${
|
|
10933
|
+
display: `"${pythonPath2}" ${args.join(" ")}`
|
|
9657
10934
|
};
|
|
9658
10935
|
}
|
|
9659
10936
|
const PIP_SHOW_VERSION_REGEX = /^Version:\s*(.+)$/m;
|
|
@@ -9678,6 +10955,7 @@ const BUNDLE_ID_REGEX = /^[\w.-]+$/;
|
|
|
9678
10955
|
const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
|
|
9679
10956
|
const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
|
|
9680
10957
|
const SEAL_PROBE_TIMEOUT_MS = 45e3;
|
|
10958
|
+
const STARTUP_SEAL_PROBE_DELAY_MS = 15e3;
|
|
9681
10959
|
function versionSuffix(version) {
|
|
9682
10960
|
return version ? ` to version ${version}` : "";
|
|
9683
10961
|
}
|
|
@@ -9760,6 +11038,19 @@ class UpdateService {
|
|
|
9760
11038
|
/** A failed install detected from the marker on this start, if any. */
|
|
9761
11039
|
pendingInstallFailure = null;
|
|
9762
11040
|
installFailureDelivered = false;
|
|
11041
|
+
/**
|
|
11042
|
+
* A refusal this process found at start-up, if any.
|
|
11043
|
+
*
|
|
11044
|
+
* Kept apart from `pendingInstallFailure` because they are different facts with
|
|
11045
|
+
* different remedies - that one is an install that did not finish, this one is a
|
|
11046
|
+
* bundle that can no longer be replaced in place - and both can be true of the
|
|
11047
|
+
* same start. Delivered through the same scheduler shape for the same reason:
|
|
11048
|
+
* the window is loading when this is produced (the service is constructed as
|
|
11049
|
+
* the window is created), and a bare `webContents.send` with no subscriber
|
|
11050
|
+
* still reports success.
|
|
11051
|
+
*/
|
|
11052
|
+
pendingInstallBlock = null;
|
|
11053
|
+
installBlockDelivered = false;
|
|
9763
11054
|
/**
|
|
9764
11055
|
* An install this process found still running, if any.
|
|
9765
11056
|
*
|
|
@@ -9826,6 +11117,15 @@ class UpdateService {
|
|
|
9826
11117
|
electronUpdater.autoUpdater.autoInstallOnAppQuit = false;
|
|
9827
11118
|
this.setupUpdateEvents();
|
|
9828
11119
|
this.recoverPendingInstall();
|
|
11120
|
+
const sealProbe = setTimeout(() => {
|
|
11121
|
+
void this.repairReachableBundleSeals().catch((error) => {
|
|
11122
|
+
logger.warn(
|
|
11123
|
+
`Start-up seal check could not run: ${error instanceof Error ? error.message : String(error)}`,
|
|
11124
|
+
LogFileType.UPDATE_SERVICE
|
|
11125
|
+
);
|
|
11126
|
+
});
|
|
11127
|
+
}, STARTUP_SEAL_PROBE_DELAY_MS);
|
|
11128
|
+
sealProbe.unref();
|
|
9829
11129
|
this.startPeriodicUpdateChecks();
|
|
9830
11130
|
}
|
|
9831
11131
|
/**
|
|
@@ -9893,13 +11193,32 @@ class UpdateService {
|
|
|
9893
11193
|
`Update refused (${block.code}): ${block.detail}`,
|
|
9894
11194
|
LogFileType.UPDATE_SERVICE
|
|
9895
11195
|
);
|
|
9896
|
-
this.sendToRenderer(
|
|
11196
|
+
this.sendToRenderer(
|
|
11197
|
+
"update-install-blocked",
|
|
11198
|
+
this.installBlockPayload(block, version ?? null)
|
|
11199
|
+
);
|
|
11200
|
+
}
|
|
11201
|
+
/**
|
|
11202
|
+
* The refusal payload the renderer's panel reads.
|
|
11203
|
+
*
|
|
11204
|
+
* One builder, so a refusal found at start-up and one found by the pre-flight
|
|
11205
|
+
* cannot arrive in two shapes: the renderer keys the heading off `code` and the
|
|
11206
|
+
* remedy off `remedy`, and a field missing in one path is a panel that renders
|
|
11207
|
+
* nothing where the user was told what to do.
|
|
11208
|
+
*/
|
|
11209
|
+
installBlockPayload(block, version) {
|
|
11210
|
+
return {
|
|
9897
11211
|
code: block.code,
|
|
9898
|
-
version
|
|
11212
|
+
version,
|
|
9899
11213
|
message: block.message,
|
|
9900
11214
|
remedy: block.remedy,
|
|
9901
|
-
detail: block.detail
|
|
9902
|
-
|
|
11215
|
+
detail: block.detail,
|
|
11216
|
+
// Both optional, and both must travel: the payload is built field by
|
|
11217
|
+
// field, so a copy the main process owns and this builder forgets is a
|
|
11218
|
+
// panel that renders the default heading (design D2, D3).
|
|
11219
|
+
heading: block.heading ?? null,
|
|
11220
|
+
dismissLabel: block.dismissLabel ?? null
|
|
11221
|
+
};
|
|
9903
11222
|
}
|
|
9904
11223
|
/**
|
|
9905
11224
|
* Read the marker a previous run may have left, and act on it.
|
|
@@ -9919,6 +11238,14 @@ class UpdateService {
|
|
|
9919
11238
|
* alone entirely, and the re-check below reports what really happens to it.
|
|
9920
11239
|
*/
|
|
9921
11240
|
recoverPendingInstall() {
|
|
11241
|
+
if (!electron.app.isPackaged) {
|
|
11242
|
+
const marker2 = readPendingInstallMarker(this.markerDir());
|
|
11243
|
+
logger.info(
|
|
11244
|
+
marker2 ? `Unpackaged instance: leaving the packaged app's pending install marker for version ${marker2.targetVersion} alone, with its install job and staging tree; this process cannot act on an install of a bundle it is not.` : "Unpackaged instance: no packaged install state to leave alone.",
|
|
11245
|
+
LogFileType.UPDATE_SERVICE
|
|
11246
|
+
);
|
|
11247
|
+
return;
|
|
11248
|
+
}
|
|
9922
11249
|
const marker = readPendingInstallMarker(this.markerDir());
|
|
9923
11250
|
const outcome = evaluatePendingInstall({
|
|
9924
11251
|
marker,
|
|
@@ -10131,6 +11458,43 @@ class UpdateService {
|
|
|
10131
11458
|
LogFileType.UPDATE_SERVICE
|
|
10132
11459
|
);
|
|
10133
11460
|
}
|
|
11461
|
+
/**
|
|
11462
|
+
* Deliver a refusal found at start-up once the renderer can hear it.
|
|
11463
|
+
*
|
|
11464
|
+
* Same shape as the failure's delivery, for the same measured reason: the
|
|
11465
|
+
* refusal is produced while the window is loading (the service is constructed
|
|
11466
|
+
* as the window is created), the panel subscribes from a React effect that can
|
|
11467
|
+
* run after `did-finish-load`, and `sendToRenderer` answers true for a push
|
|
11468
|
+
* that no subscriber saw. Unlike the failure it clears nothing on the way out -
|
|
11469
|
+
* this refusal is not tied to any on-disk state, and the pre-flight's own
|
|
11470
|
+
* direct push (`sendInstallBlock`) stays the path used when the window is
|
|
11471
|
+
* already up.
|
|
11472
|
+
*/
|
|
11473
|
+
scheduleInstallBlockDelivery(block) {
|
|
11474
|
+
this.pendingInstallBlock = block;
|
|
11475
|
+
if (this.installBlockDelivered) return;
|
|
11476
|
+
const deliver = () => this.deliverPendingInstallBlock();
|
|
11477
|
+
const webContents = this.mainWindow?.webContents;
|
|
11478
|
+
if (webContents && !webContents.isDestroyed()) {
|
|
11479
|
+
webContents.once("did-finish-load", deliver);
|
|
11480
|
+
}
|
|
11481
|
+
setTimeout(deliver, 5e3);
|
|
11482
|
+
}
|
|
11483
|
+
deliverPendingInstallBlock() {
|
|
11484
|
+
const block = this.pendingInstallBlock;
|
|
11485
|
+
if (!block || this.installBlockDelivered) return;
|
|
11486
|
+
if (!this.sendToRenderer(
|
|
11487
|
+
"update-install-blocked",
|
|
11488
|
+
this.installBlockPayload(block, null)
|
|
11489
|
+
)) {
|
|
11490
|
+
return;
|
|
11491
|
+
}
|
|
11492
|
+
this.installBlockDelivered = true;
|
|
11493
|
+
logger.info(
|
|
11494
|
+
"Reported a start-up refusal to the renderer",
|
|
11495
|
+
LogFileType.UPDATE_SERVICE
|
|
11496
|
+
);
|
|
11497
|
+
}
|
|
10134
11498
|
/**
|
|
10135
11499
|
* A substituted answer to "is this app's install job loaded", or null.
|
|
10136
11500
|
*
|
|
@@ -10375,17 +11739,68 @@ class UpdateService {
|
|
|
10375
11739
|
* backend. Those writes are `file added:` violations and deleting exactly
|
|
10376
11740
|
* those files restores the seal (measured; see `healPythonBytecode`), where a
|
|
10377
11741
|
* bundle broken any other way still gets the reinstall refusal below.
|
|
11742
|
+
*
|
|
11743
|
+
* The probe, the retry and the heal are `readBundleSeal` and `repairBundleSeal`
|
|
11744
|
+
* below, shared with the start-up repair pass: this method is the update-time
|
|
11745
|
+
* policy on top of them (an unanswerable probe proceeds, an unhealable bundle
|
|
11746
|
+
* refuses the install), and it must not grow a second copy of either.
|
|
10378
11747
|
*/
|
|
10379
11748
|
async probeInstalledBundleSeal(version) {
|
|
11749
|
+
const bundlePath = this.runningBundlePath();
|
|
11750
|
+
if (!bundlePath) return null;
|
|
11751
|
+
const { seal, heal, block } = await this.repairBundleSeal(
|
|
11752
|
+
bundlePath,
|
|
11753
|
+
version
|
|
11754
|
+
);
|
|
11755
|
+
if (seal.kind === "unavailable") {
|
|
11756
|
+
logger.warn(
|
|
11757
|
+
`Seal check could not run twice, continuing with the install: ${seal.detail}`,
|
|
11758
|
+
LogFileType.UPDATE_SERVICE
|
|
11759
|
+
);
|
|
11760
|
+
return null;
|
|
11761
|
+
}
|
|
11762
|
+
if (seal.kind === "sealed") {
|
|
11763
|
+
logger.info(
|
|
11764
|
+
heal ? `Installed bundle sealed again after removing ${heal.removed.length} added bytecode file(s); continuing with the install.` : `Installed bundle passed its seal check: ${bundlePath}`,
|
|
11765
|
+
LogFileType.UPDATE_SERVICE
|
|
11766
|
+
);
|
|
11767
|
+
}
|
|
11768
|
+
return block;
|
|
11769
|
+
}
|
|
11770
|
+
/**
|
|
11771
|
+
* The `.app` this process runs from, or null when it is not a bundle layout.
|
|
11772
|
+
*
|
|
11773
|
+
* Shared by the pre-flight and the start-up repair pass so both ask about the
|
|
11774
|
+
* same bundle, and so the "unpacked or dev run" answer is stated once: not a
|
|
11775
|
+
* bundle we recognise means nothing to verify, and refusing every install
|
|
11776
|
+
* because the path did not parse would be worse than the risk it guards
|
|
11777
|
+
* against.
|
|
11778
|
+
*/
|
|
11779
|
+
runningBundlePath() {
|
|
10380
11780
|
if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
|
|
10381
11781
|
const bundlePath = appBundleFromExecutable(process.execPath);
|
|
10382
11782
|
if (!bundlePath) {
|
|
10383
11783
|
logger.warn(
|
|
10384
|
-
`Could not derive an app bundle from ${process.execPath}; skipping the seal
|
|
11784
|
+
`Could not derive an app bundle from ${process.execPath}; skipping the seal checks.`,
|
|
10385
11785
|
LogFileType.UPDATE_SERVICE
|
|
10386
11786
|
);
|
|
10387
11787
|
return null;
|
|
10388
11788
|
}
|
|
11789
|
+
return bundlePath;
|
|
11790
|
+
}
|
|
11791
|
+
/**
|
|
11792
|
+
* One `codesign --verify` of `bundlePath`, retried once when it could not run.
|
|
11793
|
+
*
|
|
11794
|
+
* The retry lives here rather than at either caller because "did not complete"
|
|
11795
|
+
* is a fact about the probe, not about the install being offered: measured on
|
|
11796
|
+
* macOS 26.5, a 1 GiB bundle answers in 6.1 s warm, and a timeout or a probe
|
|
11797
|
+
* that died silently used to arrive as exit code 1 - which the pre-flight read
|
|
11798
|
+
* as "codesign rejected this bundle" and turned into a permanent reinstall
|
|
11799
|
+
* message (review R5). `probe` is returned beside the verdict because the
|
|
11800
|
+
* heal reads the violations out of the raw stdout, and a second probe for them
|
|
11801
|
+
* would be a second answer about one bundle.
|
|
11802
|
+
*/
|
|
11803
|
+
async readBundleSeal(bundlePath) {
|
|
10389
11804
|
const probeBundle = () => runCommand(
|
|
10390
11805
|
"/usr/bin/codesign",
|
|
10391
11806
|
["--verify", "--deep", "--verbose=2", bundlePath],
|
|
@@ -10401,20 +11816,29 @@ class UpdateService {
|
|
|
10401
11816
|
probe = await probeBundle();
|
|
10402
11817
|
seal = evaluateBundleSeal(probe);
|
|
10403
11818
|
}
|
|
10404
|
-
|
|
10405
|
-
|
|
10406
|
-
|
|
10407
|
-
|
|
10408
|
-
|
|
10409
|
-
|
|
10410
|
-
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
|
|
10416
|
-
|
|
10417
|
-
|
|
11819
|
+
return { seal, probe };
|
|
11820
|
+
}
|
|
11821
|
+
/**
|
|
11822
|
+
* Probe the bundle, heal the one break we cause, and probe again.
|
|
11823
|
+
*
|
|
11824
|
+
* The whole mechanism, run identically by the update pre-flight and by the
|
|
11825
|
+
* start-up pass: a sealed bundle is returned untouched, an unsealed one is
|
|
11826
|
+
* healed only when the plan says the break is ours (`planPythonBytecodeHeal`),
|
|
11827
|
+
* and the verdict that follows the heal is a fresh probe rather than an
|
|
11828
|
+
* assumption about what a deletion did. `block` is the refusal to show when the
|
|
11829
|
+
* bundle cannot be put back together, and it carries the version the caller was
|
|
11830
|
+
* asked about (null at start-up, where no update is being offered yet).
|
|
11831
|
+
*
|
|
11832
|
+
* Why start-up also runs this: the break is introduced by the app running, not
|
|
11833
|
+
* by the update, so by the time an install is attempted the bundle may already
|
|
11834
|
+
* have been refused by macOS at launch - measured on 2026-09-14, a bundle
|
|
11835
|
+
* signed at 09:31:53 with one added
|
|
11836
|
+
* `lib/python3.12/__pycache__/webbrowser.cpython-312.pyc` at 10:11:29, and no
|
|
11837
|
+
* packaged instance ever came back to report it.
|
|
11838
|
+
*/
|
|
11839
|
+
async repairBundleSeal(bundlePath, version, context = "update") {
|
|
11840
|
+
const { seal, probe } = await this.readBundleSeal(bundlePath);
|
|
11841
|
+
if (seal.kind !== "unsealed") return { seal, heal: null, block: null };
|
|
10418
11842
|
logger.error(
|
|
10419
11843
|
`Installed bundle failed its seal check: ${seal.detail}`,
|
|
10420
11844
|
LogFileType.UPDATE_SERVICE
|
|
@@ -10426,28 +11850,117 @@ class UpdateService {
|
|
|
10426
11850
|
`Installed bundle is not healable in place: ${heal.reason}`,
|
|
10427
11851
|
LogFileType.UPDATE_SERVICE
|
|
10428
11852
|
);
|
|
10429
|
-
return
|
|
11853
|
+
return {
|
|
11854
|
+
seal,
|
|
11855
|
+
heal,
|
|
11856
|
+
block: installedBundleSealBlock(
|
|
11857
|
+
bundlePath,
|
|
11858
|
+
seal.detail,
|
|
11859
|
+
version,
|
|
11860
|
+
context
|
|
11861
|
+
)
|
|
11862
|
+
};
|
|
10430
11863
|
}
|
|
10431
11864
|
logger.info(
|
|
10432
11865
|
`Removed ${heal.removed.length} bytecode file(s) our own interpreter had written into the sealed bundle: ${heal.reason}. Paths:
|
|
10433
11866
|
${heal.removed.join("\n")}`,
|
|
10434
11867
|
LogFileType.UPDATE_SERVICE
|
|
10435
11868
|
);
|
|
10436
|
-
const
|
|
11869
|
+
const { probe: healedProbe } = await this.readBundleSeal(bundlePath);
|
|
11870
|
+
const healed = evaluateBundleSeal(healedProbe);
|
|
10437
11871
|
if (healed.kind === "sealed") {
|
|
10438
|
-
|
|
10439
|
-
`Installed bundle sealed again after removing ${heal.removed.length} added bytecode file(s); continuing with the install.`,
|
|
10440
|
-
LogFileType.UPDATE_SERVICE
|
|
10441
|
-
);
|
|
10442
|
-
return null;
|
|
11872
|
+
return { seal: healed, heal, block: null };
|
|
10443
11873
|
}
|
|
10444
11874
|
logger.error(
|
|
10445
11875
|
`Installed bundle still fails its seal check after healing: ${healed.detail}`,
|
|
10446
11876
|
LogFileType.UPDATE_SERVICE
|
|
10447
11877
|
);
|
|
10448
|
-
return
|
|
11878
|
+
return {
|
|
11879
|
+
seal: healed,
|
|
11880
|
+
heal,
|
|
11881
|
+
block: installedBundleSealBlock(
|
|
11882
|
+
bundlePath,
|
|
11883
|
+
healed.detail,
|
|
11884
|
+
version,
|
|
11885
|
+
context
|
|
11886
|
+
)
|
|
11887
|
+
};
|
|
11888
|
+
}
|
|
11889
|
+
/** Inspect only the app this process runs from. An unpackaged instance must
|
|
11890
|
+
* never traverse, heal, seal or otherwise mutate another installed bundle. */
|
|
11891
|
+
async repairReachableBundleSeals() {
|
|
11892
|
+
this.reportLegacyVenvInterpreters();
|
|
11893
|
+
const running = this.runningBundlePath();
|
|
11894
|
+
if (running) await this.repairRunningBundleSeal(running);
|
|
11895
|
+
}
|
|
11896
|
+
/**
|
|
11897
|
+
* Say what the pre-split environments resolve to, and change nothing.
|
|
11898
|
+
*
|
|
11899
|
+
* An install from before this change has a venv whose `pyvenv.cfg` `home` is
|
|
11900
|
+
* inside an `.app`, and one whose bundle is already gone is a state this
|
|
11901
|
+
* machine has actually been in. Both are reported because they explain the log
|
|
11902
|
+
* a support conversation reads - "this venv belongs to a bundle that is not
|
|
11903
|
+
* here any more" and "this venv is still built on the installed app's
|
|
11904
|
+
* interpreter" are different facts, and silence makes them look like the same
|
|
11905
|
+
* healthy one. Neither is repaired: those environments are left byte-identical,
|
|
11906
|
+
* deliberately, so a rollback to an older build still finds what it built.
|
|
11907
|
+
*
|
|
11908
|
+
* Darwin only, and by construction rather than by choice: what is reported is a
|
|
11909
|
+
* venv built on the interpreter inside a code-sealed `.app`, and no other
|
|
11910
|
+
* platform has one (`BUNDLED_INTERPRETER_HOME` requires the `.app` component).
|
|
11911
|
+
*
|
|
11912
|
+
* It reads `legacyVenvPaths`, NOT `managedVenvPath`. That was the bug: on darwin
|
|
11913
|
+
* `managedVenvPath` answers with the post-split selection venv - whose
|
|
11914
|
+
* `pyvenv.cfg` names the external runtime by construction - or with the
|
|
11915
|
+
* `no-environment-selected` sentinel, so both iterations took the `continue` and
|
|
11916
|
+
* nothing was ever logged for the state this exists to describe (review R7).
|
|
11917
|
+
*/
|
|
11918
|
+
reportLegacyVenvInterpreters() {
|
|
11919
|
+
if (process.platform !== "darwin") return;
|
|
11920
|
+
for (const line of legacyEnvironmentReport(
|
|
11921
|
+
managedSupportRoot(electron.app.getPath("home"))
|
|
11922
|
+
))
|
|
11923
|
+
logger.info(line, LogFileType.UPDATE_SERVICE);
|
|
11924
|
+
}
|
|
11925
|
+
/**
|
|
11926
|
+
* Repair this bundle's seal at start-up, and say so either way.
|
|
11927
|
+
*
|
|
11928
|
+
* What this catches: a bundle that already carries bytecode its own interpreter
|
|
11929
|
+
* wrote after it was signed. macOS refuses such a bundle at launch
|
|
11930
|
+
* ("damaged and can't be opened") and ShipIt refuses the next in-place update
|
|
11931
|
+
* with -67028, so a break found here is repaired before either can happen, and
|
|
11932
|
+
* one that cannot be repaired is reported with the same refusal/remedy panel
|
|
11933
|
+
* the pre-flight uses - the user has to replace the app by hand, and finding
|
|
11934
|
+
* that out at the next update is worse than being told now.
|
|
11935
|
+
*
|
|
11936
|
+
* Nothing is reported when the bundle is sealed, which is every ordinary
|
|
11937
|
+
* start: the log line is the evidence that the pass ran at all.
|
|
11938
|
+
*/
|
|
11939
|
+
async repairRunningBundleSeal(bundlePath) {
|
|
11940
|
+
const { seal, heal, block } = await this.repairBundleSeal(
|
|
11941
|
+
bundlePath,
|
|
11942
|
+
null,
|
|
11943
|
+
"startup"
|
|
11944
|
+
);
|
|
11945
|
+
if (seal.kind === "sealed") {
|
|
11946
|
+
logger.info(
|
|
11947
|
+
heal ? `Start-up seal repair: removed ${heal.removed.length} added bytecode file(s) from ${bundlePath}, and the bundle verifies again.` : `Start-up seal check: ${bundlePath} is a sealed code object.`,
|
|
11948
|
+
LogFileType.UPDATE_SERVICE
|
|
11949
|
+
);
|
|
11950
|
+
return;
|
|
11951
|
+
}
|
|
11952
|
+
if (seal.kind === "unavailable") {
|
|
11953
|
+
logger.warn(
|
|
11954
|
+
`Start-up seal check could not run: ${seal.detail}`,
|
|
11955
|
+
LogFileType.UPDATE_SERVICE
|
|
11956
|
+
);
|
|
11957
|
+
return;
|
|
11958
|
+
}
|
|
11959
|
+
if (block) this.scheduleInstallBlockDelivery(block);
|
|
10449
11960
|
}
|
|
10450
|
-
/**
|
|
11961
|
+
/**
|
|
11962
|
+
* File names the updater's metadata knows this update by.
|
|
11963
|
+
*/
|
|
10451
11964
|
stagedArtifactCandidates(info) {
|
|
10452
11965
|
const names = /* @__PURE__ */ new Set();
|
|
10453
11966
|
if (info?.path) names.add(path$1.basename(info.path));
|
|
@@ -10904,6 +12417,13 @@ ${heal.removed.join("\n")}`,
|
|
|
10904
12417
|
);
|
|
10905
12418
|
return false;
|
|
10906
12419
|
}
|
|
12420
|
+
if (!electron.app.isPackaged) {
|
|
12421
|
+
logger.info(
|
|
12422
|
+
"Refusing to start an update install: this instance is unpackaged, so it is not the bundle an install replaces.",
|
|
12423
|
+
LogFileType.UPDATE_SERVICE
|
|
12424
|
+
);
|
|
12425
|
+
return false;
|
|
12426
|
+
}
|
|
10907
12427
|
logger.info(
|
|
10908
12428
|
"Preparing to quit and install the update...",
|
|
10909
12429
|
LogFileType.UPDATE_SERVICE
|
|
@@ -11544,9 +13064,9 @@ ${heal.removed.join("\n")}`,
|
|
|
11544
13064
|
* @returns Promise resolving to true if update was successful, false otherwise
|
|
11545
13065
|
*/
|
|
11546
13066
|
/** The version the bundled environment has installed, via `pip show`. */
|
|
11547
|
-
async readBundledBackendVersion(
|
|
13067
|
+
async readBundledBackendVersion(pythonPath2) {
|
|
11548
13068
|
const probe = await runCommand(
|
|
11549
|
-
|
|
13069
|
+
pythonPath2,
|
|
11550
13070
|
["-m", "pip", "show", "local-operator"],
|
|
11551
13071
|
{ timeoutMs: 12e4, env: this.pythonSpawnEnv() }
|
|
11552
13072
|
);
|
|
@@ -11686,16 +13206,19 @@ ${heal.removed.join("\n")}`,
|
|
|
11686
13206
|
LogFileType.UPDATE_SERVICE
|
|
11687
13207
|
);
|
|
11688
13208
|
}
|
|
11689
|
-
let
|
|
13209
|
+
let pythonPath2 = "";
|
|
11690
13210
|
const venvPath = this.backendService?.getVenvPath();
|
|
11691
13211
|
if (venvPath && electron.app.isPackaged) {
|
|
11692
13212
|
const venvBinDir = process.platform === "win32" ? "Scripts" : "bin";
|
|
11693
13213
|
const pythonName = process.platform === "win32" ? "python.exe" : "python3";
|
|
11694
|
-
|
|
13214
|
+
pythonPath2 = path$1.join(venvPath, venvBinDir, pythonName);
|
|
11695
13215
|
}
|
|
11696
|
-
if (!
|
|
13216
|
+
if (!pythonPath2 || !fs.existsSync(pythonPath2)) {
|
|
11697
13217
|
logger.error(
|
|
11698
|
-
`
|
|
13218
|
+
// `managedVenvPath` answers with a sentinel when no environment has
|
|
13219
|
+
// been published, and "no Python at …/no-environment-selected" sent a
|
|
13220
|
+
// reader to a directory-shaped path that nothing creates (review N3).
|
|
13221
|
+
isUnpreparedVenvPath(venvPath ?? "") ? "Cannot update the bundled backend: no environment has been selected for this instance yet" : `Cannot update the bundled backend: no Python at ${pythonPath2 || "an unknown path"}`,
|
|
11699
13222
|
LogFileType.UPDATE_SERVICE
|
|
11700
13223
|
);
|
|
11701
13224
|
await this.restartBackendAfterFailedUpgrade();
|
|
@@ -11705,8 +13228,8 @@ ${heal.removed.join("\n")}`,
|
|
|
11705
13228
|
);
|
|
11706
13229
|
return false;
|
|
11707
13230
|
}
|
|
11708
|
-
const pip = buildPipUpgradeCommand(
|
|
11709
|
-
const versionBefore = await this.readBundledBackendVersion(
|
|
13231
|
+
const pip = buildPipUpgradeCommand(pythonPath2);
|
|
13232
|
+
const versionBefore = await this.readBundledBackendVersion(pythonPath2);
|
|
11710
13233
|
logger.info(
|
|
11711
13234
|
`Executing pip command: ${pip.display}`,
|
|
11712
13235
|
LogFileType.UPDATE_SERVICE
|
|
@@ -11727,7 +13250,7 @@ ${pipRun.stderr.trim()}`,
|
|
|
11727
13250
|
LogFileType.UPDATE_SERVICE
|
|
11728
13251
|
);
|
|
11729
13252
|
}
|
|
11730
|
-
const versionAfter = await this.readBundledBackendVersion(
|
|
13253
|
+
const versionAfter = await this.readBundledBackendVersion(pythonPath2);
|
|
11731
13254
|
const upgradeLanded = pipRun.exitCode === 0 && didUpgradeLand({
|
|
11732
13255
|
before: versionBefore,
|
|
11733
13256
|
after: versionAfter,
|
|
@@ -11979,6 +13502,311 @@ ${pipRun.stderr.trim()}`,
|
|
|
11979
13502
|
}
|
|
11980
13503
|
}
|
|
11981
13504
|
}
|
|
13505
|
+
const AUTH_TIMEOUT_MS = 5e3;
|
|
13506
|
+
const MAX_FRAME_BYTES = 64 * 1024;
|
|
13507
|
+
const SESSION_ID = /^[a-f0-9]{12}$/;
|
|
13508
|
+
class ViewerEndpoint {
|
|
13509
|
+
constructor(host, key) {
|
|
13510
|
+
this.host = host;
|
|
13511
|
+
this.key = key;
|
|
13512
|
+
}
|
|
13513
|
+
server = null;
|
|
13514
|
+
port = 0;
|
|
13515
|
+
get boundPort() {
|
|
13516
|
+
return this.port;
|
|
13517
|
+
}
|
|
13518
|
+
/**
|
|
13519
|
+
* Bind on loopback and answer the port.
|
|
13520
|
+
*
|
|
13521
|
+
* IDEMPOTENT, and that is load-bearing: a window can be recreated, and the
|
|
13522
|
+
* app's own `activate` path runs again after every close. A second bind
|
|
13523
|
+
* would leak a listener, and a leaked listener is worse than a leaked
|
|
13524
|
+
* source because the record then advertises a port nothing reads.
|
|
13525
|
+
*/
|
|
13526
|
+
start() {
|
|
13527
|
+
if (this.server) return Promise.resolve(this.port);
|
|
13528
|
+
return new Promise((resolve, reject) => {
|
|
13529
|
+
const server = node_net.createServer((socket) => this.onConnection(socket));
|
|
13530
|
+
server.on("error", reject);
|
|
13531
|
+
server.listen(0, "127.0.0.1", () => {
|
|
13532
|
+
const address = server.address();
|
|
13533
|
+
if (address === null || typeof address === "string") {
|
|
13534
|
+
server.close();
|
|
13535
|
+
reject(new Error("The viewer endpoint could not resolve its port."));
|
|
13536
|
+
return;
|
|
13537
|
+
}
|
|
13538
|
+
this.server = server;
|
|
13539
|
+
this.port = address.port;
|
|
13540
|
+
server.removeAllListeners("error");
|
|
13541
|
+
server.on("error", () => void 0);
|
|
13542
|
+
resolve(this.port);
|
|
13543
|
+
});
|
|
13544
|
+
});
|
|
13545
|
+
}
|
|
13546
|
+
/** Stop serving. Idempotent, and safe on a listener that never bound. */
|
|
13547
|
+
close() {
|
|
13548
|
+
const server = this.server;
|
|
13549
|
+
this.server = null;
|
|
13550
|
+
this.port = 0;
|
|
13551
|
+
if (!server) return;
|
|
13552
|
+
try {
|
|
13553
|
+
server.close();
|
|
13554
|
+
} catch {
|
|
13555
|
+
}
|
|
13556
|
+
}
|
|
13557
|
+
onConnection(socket) {
|
|
13558
|
+
let authenticated = false;
|
|
13559
|
+
let buffer = "";
|
|
13560
|
+
let closed = false;
|
|
13561
|
+
let chain = Promise.resolve();
|
|
13562
|
+
const finish = () => {
|
|
13563
|
+
closed = true;
|
|
13564
|
+
socket.destroy();
|
|
13565
|
+
};
|
|
13566
|
+
const authTimer = setTimeout(() => {
|
|
13567
|
+
if (!authenticated) finish();
|
|
13568
|
+
}, AUTH_TIMEOUT_MS);
|
|
13569
|
+
socket.setEncoding("utf8");
|
|
13570
|
+
socket.on("error", finish);
|
|
13571
|
+
socket.on("close", () => clearTimeout(authTimer));
|
|
13572
|
+
socket.on("data", (chunk) => {
|
|
13573
|
+
if (closed) return;
|
|
13574
|
+
buffer += chunk;
|
|
13575
|
+
if (buffer.length > MAX_FRAME_BYTES) {
|
|
13576
|
+
finish();
|
|
13577
|
+
return;
|
|
13578
|
+
}
|
|
13579
|
+
for (; ; ) {
|
|
13580
|
+
const newline = buffer.indexOf("\n");
|
|
13581
|
+
if (newline < 0) break;
|
|
13582
|
+
const line = buffer.slice(0, newline);
|
|
13583
|
+
buffer = buffer.slice(newline + 1);
|
|
13584
|
+
chain = chain.then(
|
|
13585
|
+
() => this.onLine(line, socket, {
|
|
13586
|
+
isAuthenticated: () => authenticated,
|
|
13587
|
+
markAuthenticated: () => {
|
|
13588
|
+
authenticated = true;
|
|
13589
|
+
clearTimeout(authTimer);
|
|
13590
|
+
},
|
|
13591
|
+
finish
|
|
13592
|
+
}).catch(() => void 0)
|
|
13593
|
+
);
|
|
13594
|
+
}
|
|
13595
|
+
});
|
|
13596
|
+
}
|
|
13597
|
+
async onLine(line, socket, state) {
|
|
13598
|
+
let frame;
|
|
13599
|
+
try {
|
|
13600
|
+
frame = JSON.parse(line);
|
|
13601
|
+
} catch {
|
|
13602
|
+
return;
|
|
13603
|
+
}
|
|
13604
|
+
if (typeof frame !== "object" || frame === null) return;
|
|
13605
|
+
const record = frame;
|
|
13606
|
+
if (!state.isAuthenticated()) {
|
|
13607
|
+
const key = typeof record.key === "string" ? record.key : "";
|
|
13608
|
+
if (!constantTimeEqual(key, this.key)) {
|
|
13609
|
+
state.finish();
|
|
13610
|
+
return;
|
|
13611
|
+
}
|
|
13612
|
+
state.markAuthenticated();
|
|
13613
|
+
return;
|
|
13614
|
+
}
|
|
13615
|
+
const op = typeof record.op === "string" ? record.op : "";
|
|
13616
|
+
const req = record.req;
|
|
13617
|
+
let reply;
|
|
13618
|
+
try {
|
|
13619
|
+
reply = { op: "ack", req, detail: await this.apply(op, record) };
|
|
13620
|
+
} catch (error) {
|
|
13621
|
+
reply = {
|
|
13622
|
+
op: "error",
|
|
13623
|
+
req,
|
|
13624
|
+
detail: error instanceof Error ? error.message : "refused"
|
|
13625
|
+
};
|
|
13626
|
+
}
|
|
13627
|
+
try {
|
|
13628
|
+
socket.write(`${JSON.stringify(reply)}
|
|
13629
|
+
`);
|
|
13630
|
+
} catch {
|
|
13631
|
+
state.finish();
|
|
13632
|
+
}
|
|
13633
|
+
}
|
|
13634
|
+
async apply(op, frame) {
|
|
13635
|
+
if (op === "resume_session") {
|
|
13636
|
+
const sessionId2 = String(frame.session_id ?? "");
|
|
13637
|
+
if (!SESSION_ID.test(sessionId2)) {
|
|
13638
|
+
throw new Error("resume_session needs a session id");
|
|
13639
|
+
}
|
|
13640
|
+
return await this.host.resumeSession(sessionId2);
|
|
13641
|
+
}
|
|
13642
|
+
if (op === "focus_window") {
|
|
13643
|
+
return await this.host.focusWindow();
|
|
13644
|
+
}
|
|
13645
|
+
throw new Error(`unknown viewer op: ${op || "(none)"}`);
|
|
13646
|
+
}
|
|
13647
|
+
}
|
|
13648
|
+
function constantTimeEqual(actual, expected) {
|
|
13649
|
+
const a = Buffer.from(actual, "utf8");
|
|
13650
|
+
const b = Buffer.from(expected, "utf8");
|
|
13651
|
+
if (a.length !== b.length) {
|
|
13652
|
+
node_crypto.timingSafeEqual(b, b);
|
|
13653
|
+
return false;
|
|
13654
|
+
}
|
|
13655
|
+
return node_crypto.timingSafeEqual(a, b);
|
|
13656
|
+
}
|
|
13657
|
+
const VIEWER_RUN_DIRNAME = "run/viewers";
|
|
13658
|
+
const VIEWER_PROTOCOL = 1;
|
|
13659
|
+
const FOCUS_WINDOW_CAPABILITY = "focus-window-v1";
|
|
13660
|
+
const DESKTOP_NOTIFY_CAPABILITY = "desktop-notify-v1";
|
|
13661
|
+
const VIEWER_HEARTBEAT_INTERVAL_MS = 15e3;
|
|
13662
|
+
function viewerRunDir(env = process.env) {
|
|
13663
|
+
const root = env.LOCAL_OPERATOR_CONFIG_DIR || path$1.join(os.homedir(), ".local-operator");
|
|
13664
|
+
return path$1.join(root, VIEWER_RUN_DIRNAME);
|
|
13665
|
+
}
|
|
13666
|
+
function publishViewerRecord(record, dir = viewerRunDir()) {
|
|
13667
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
13668
|
+
fs.chmodSync(dir, 448);
|
|
13669
|
+
const target2 = path$1.join(dir, `${record.pid}.json`);
|
|
13670
|
+
const temporary = path$1.join(
|
|
13671
|
+
dir,
|
|
13672
|
+
`.${record.pid}.${node_crypto.randomBytes(6).toString("hex")}.tmp`
|
|
13673
|
+
);
|
|
13674
|
+
fs.writeFileSync(temporary, JSON.stringify(record), { mode: 384 });
|
|
13675
|
+
fs.renameSync(temporary, target2);
|
|
13676
|
+
return target2;
|
|
13677
|
+
}
|
|
13678
|
+
function unpublishViewerRecord(pid, dir = viewerRunDir()) {
|
|
13679
|
+
try {
|
|
13680
|
+
fs.unlinkSync(path$1.join(dir, `${pid}.json`));
|
|
13681
|
+
} catch {
|
|
13682
|
+
}
|
|
13683
|
+
}
|
|
13684
|
+
class ViewerRecordPublisher {
|
|
13685
|
+
constructor(dir = viewerRunDir(), pid = process.pid) {
|
|
13686
|
+
this.dir = dir;
|
|
13687
|
+
this.record = {
|
|
13688
|
+
pid,
|
|
13689
|
+
surface: "desktop",
|
|
13690
|
+
// 0 until the listener binds. A record advertising port 0 is not
|
|
13691
|
+
// dialable, which is the honest answer during the gap and the reason
|
|
13692
|
+
// the endpoint stamps the real port before anything else.
|
|
13693
|
+
control_port: 0,
|
|
13694
|
+
// The whole authorization story: 32 random bytes, 0600 on disk.
|
|
13695
|
+
control_key: node_crypto.randomBytes(32).toString("hex"),
|
|
13696
|
+
current_session: "",
|
|
13697
|
+
// True even with no window: `resume_session` RECREATES the window and
|
|
13698
|
+
// then navigates (B3), so routing a click here is still the cheapest
|
|
13699
|
+
// landing site on a machine where the app is alive in the dock. What
|
|
13700
|
+
// the record must not claim while no window exists is a CONVERSATION
|
|
13701
|
+
// (`current_session` stays `""`), because that field is what lets a
|
|
13702
|
+
// click skip the switch.
|
|
13703
|
+
can_switch: true,
|
|
13704
|
+
focused_at: 0,
|
|
13705
|
+
protocol: VIEWER_PROTOCOL,
|
|
13706
|
+
started_at: Date.now() / 1e3,
|
|
13707
|
+
heartbeat_at: Date.now() / 1e3,
|
|
13708
|
+
capabilities: [FOCUS_WINDOW_CAPABILITY, DESKTOP_NOTIFY_CAPABILITY]
|
|
13709
|
+
};
|
|
13710
|
+
}
|
|
13711
|
+
record;
|
|
13712
|
+
timer = null;
|
|
13713
|
+
stopped = false;
|
|
13714
|
+
/**
|
|
13715
|
+
* Whether anything has been written yet.
|
|
13716
|
+
*
|
|
13717
|
+
* `setControlPort` lands BEFORE `start` in the app's own ordering (the port
|
|
13718
|
+
* only exists once the listener has bound, and the listener is what the
|
|
13719
|
+
* record advertises), so a publisher that wrote on every setter would put a
|
|
13720
|
+
* port-0 record in the directory for a moment — and a reader that dialled it
|
|
13721
|
+
* would spend its whole dial timeout on nothing. Mutations accumulate; the
|
|
13722
|
+
* first write is `start`'s.
|
|
13723
|
+
*/
|
|
13724
|
+
started = false;
|
|
13725
|
+
get controlKey() {
|
|
13726
|
+
return this.record.control_key;
|
|
13727
|
+
}
|
|
13728
|
+
/** Record the port the control listener actually bound, and publish. */
|
|
13729
|
+
setControlPort(port) {
|
|
13730
|
+
this.record.control_port = port;
|
|
13731
|
+
this.publish();
|
|
13732
|
+
}
|
|
13733
|
+
/**
|
|
13734
|
+
* Record which conversation is on screen now.
|
|
13735
|
+
*
|
|
13736
|
+
* An early return on an unchanged value keeps the 15 s beat from being a
|
|
13737
|
+
* disk write at navigation cadence: a re-render that re-asserts the same
|
|
13738
|
+
* session costs nothing.
|
|
13739
|
+
*/
|
|
13740
|
+
noteSession(sessionId2) {
|
|
13741
|
+
if (this.record.current_session === sessionId2) return;
|
|
13742
|
+
this.record.current_session = sessionId2;
|
|
13743
|
+
this.publish();
|
|
13744
|
+
}
|
|
13745
|
+
/**
|
|
13746
|
+
* Withdraw the report that THIS conversation is on screen, because the pane
|
|
13747
|
+
* that made it has left it (QA round 2, Q1).
|
|
13748
|
+
*
|
|
13749
|
+
* `current_session` is the record's answer to "which conversation should a
|
|
13750
|
+
* banner click land on", so leaving it naming a conversation the user has
|
|
13751
|
+
* navigated away from is not a stale label — it is a wrong destination the
|
|
13752
|
+
* backend then confirms: `needs_switch` is false for a match, so the click is
|
|
13753
|
+
* answered with a raise, that branch reports success, and no later rung of the
|
|
13754
|
+
* click ladder is tried. The user gets whatever the window happens to show
|
|
13755
|
+
* (the catalogue, Settings) instead of the conversation they were called back
|
|
13756
|
+
* to, and this branch's own presence withdrawal is what makes that click
|
|
13757
|
+
* `focus_policy: "always"` — the case the feature exists for.
|
|
13758
|
+
*
|
|
13759
|
+
* IDENTITY-SAFE, like the notifier's own `releaseWatch` and for the same
|
|
13760
|
+
* reason: the release runs from a renderer cleanup, after the pane has already
|
|
13761
|
+
* changed, so an unconditional clear would wipe a session a DEEPER pane has
|
|
13762
|
+
* since reported. An EMPTY id withdraws nothing — `""` is this record's own
|
|
13763
|
+
* "nothing is on screen" value, so a caller sending it is not naming a
|
|
13764
|
+
* conversation to leave.
|
|
13765
|
+
*
|
|
13766
|
+
* Losing the report is self-healing rather than lossy: a pane that still holds
|
|
13767
|
+
* the conversation re-asserts it on its next heartbeat, which is the same
|
|
13768
|
+
* 15 s beat that wrote it.
|
|
13769
|
+
*/
|
|
13770
|
+
releaseSession(sessionId2) {
|
|
13771
|
+
if (!sessionId2 || this.record.current_session !== sessionId2) return;
|
|
13772
|
+
this.record.current_session = "";
|
|
13773
|
+
this.publish();
|
|
13774
|
+
}
|
|
13775
|
+
/** Stamp the focus GAIN edge only. A blur carries no routing information. */
|
|
13776
|
+
noteFocused() {
|
|
13777
|
+
this.record.focused_at = Date.now() / 1e3;
|
|
13778
|
+
this.publish();
|
|
13779
|
+
}
|
|
13780
|
+
/** Begin the beat, and write the record for the first time. Idempotent,
|
|
13781
|
+
* because a window can be recreated. */
|
|
13782
|
+
start() {
|
|
13783
|
+
if (this.timer || this.stopped) return;
|
|
13784
|
+
this.started = true;
|
|
13785
|
+
this.publish();
|
|
13786
|
+
this.timer = setInterval(
|
|
13787
|
+
() => this.publish(),
|
|
13788
|
+
VIEWER_HEARTBEAT_INTERVAL_MS
|
|
13789
|
+
);
|
|
13790
|
+
this.timer.unref?.();
|
|
13791
|
+
}
|
|
13792
|
+
/** Stop beating and remove the record. Safe to call twice. */
|
|
13793
|
+
stop() {
|
|
13794
|
+
this.stopped = true;
|
|
13795
|
+
if (this.timer) {
|
|
13796
|
+
clearInterval(this.timer);
|
|
13797
|
+
this.timer = null;
|
|
13798
|
+
}
|
|
13799
|
+
unpublishViewerRecord(this.record.pid, this.dir);
|
|
13800
|
+
}
|
|
13801
|
+
publish() {
|
|
13802
|
+
if (this.stopped || !this.started) return;
|
|
13803
|
+
this.record.heartbeat_at = Date.now() / 1e3;
|
|
13804
|
+
try {
|
|
13805
|
+
publishViewerRecord(this.record, this.dir);
|
|
13806
|
+
} catch {
|
|
13807
|
+
}
|
|
13808
|
+
}
|
|
13809
|
+
}
|
|
11982
13810
|
const WINDOW_MODES = ["normal", "headless", "inactive"];
|
|
11983
13811
|
const WINDOW_MODE_ENV = "LOCAL_OPERATOR_UI_WINDOW_MODE";
|
|
11984
13812
|
const WINDOW_SIZE_ENV = "LOCAL_OPERATOR_UI_WINDOW_SIZE";
|
|
@@ -12103,16 +13931,16 @@ function createApplicationMenu() {
|
|
|
12103
13931
|
const isDev = Boolean(process.env.ELECTRON_RENDERER_URL);
|
|
12104
13932
|
const zoomInHandler = () => {
|
|
12105
13933
|
if (mainWindow) {
|
|
12106
|
-
const
|
|
12107
|
-
const currentZoom =
|
|
12108
|
-
|
|
13934
|
+
const webContents2 = mainWindow.webContents;
|
|
13935
|
+
const currentZoom = webContents2.getZoomFactor();
|
|
13936
|
+
webContents2.setZoomFactor(currentZoom + 0.1);
|
|
12109
13937
|
}
|
|
12110
13938
|
};
|
|
12111
13939
|
const zoomOutHandler = () => {
|
|
12112
13940
|
if (mainWindow) {
|
|
12113
|
-
const
|
|
12114
|
-
const currentZoom =
|
|
12115
|
-
|
|
13941
|
+
const webContents2 = mainWindow.webContents;
|
|
13942
|
+
const currentZoom = webContents2.getZoomFactor();
|
|
13943
|
+
webContents2.setZoomFactor(currentZoom - 0.1);
|
|
12116
13944
|
}
|
|
12117
13945
|
};
|
|
12118
13946
|
const actualSizeHandler = () => {
|
|
@@ -12237,7 +14065,7 @@ function createApplicationMenu() {
|
|
|
12237
14065
|
const menu = electron.Menu.buildFromTemplate(template);
|
|
12238
14066
|
electron.Menu.setApplicationMenu(menu);
|
|
12239
14067
|
}
|
|
12240
|
-
function createWindow() {
|
|
14068
|
+
function createWindow(initialSession = null, openCatalogue = false) {
|
|
12241
14069
|
const mainWindow2 = new electron.BrowserWindow({
|
|
12242
14070
|
width: windowLaunch.width,
|
|
12243
14071
|
height: windowLaunch.height,
|
|
@@ -12284,16 +14112,33 @@ function createWindow() {
|
|
|
12284
14112
|
// than from anything this app controls, so hold the page at full
|
|
12285
14113
|
// rate explicitly instead of trusting the platform. `normal`
|
|
12286
14114
|
// keeps Electron's own default.
|
|
12287
|
-
backgroundThrottling: windowLaunch.backgroundThrottling
|
|
14115
|
+
backgroundThrottling: windowLaunch.backgroundThrottling,
|
|
14116
|
+
/*
|
|
14117
|
+
* The conversation this window is being created FOR, in the renderer
|
|
14118
|
+
* process's own argv so preload can read it before the first paint (B3).
|
|
14119
|
+
*
|
|
14120
|
+
* Omitted entirely rather than set to an empty value on an ordinary
|
|
14121
|
+
* launch: the preload parses a flag out of argv, and an empty one would
|
|
14122
|
+
* be a value it then has to decide means "no session".
|
|
14123
|
+
*
|
|
14124
|
+
* `openCatalogue` is the THIRD intent (R2-1): a burst digest's click has
|
|
14125
|
+
* no single conversation to name, and "no flag at all" already means
|
|
14126
|
+
* "restore what you had open" — so it needs a flag of its own rather than
|
|
14127
|
+
* sharing that absence.
|
|
14128
|
+
*/
|
|
14129
|
+
...launchArgumentFlags(initialSession, openCatalogue)
|
|
12288
14130
|
}
|
|
12289
14131
|
});
|
|
14132
|
+
if (initialSession) holdPresentUntilConversation(mainWindow2, initialSession);
|
|
12290
14133
|
mainWindow2.webContents.on("console-message", (_event, level, message) => {
|
|
12291
14134
|
if (level < 2) return;
|
|
12292
14135
|
const write = level >= 3 ? logger.error : logger.warn;
|
|
12293
14136
|
write.call(logger, `[renderer] ${message}`, LogFileType.BACKEND);
|
|
12294
14137
|
});
|
|
12295
14138
|
mainWindow2.on("ready-to-show", () => {
|
|
12296
|
-
|
|
14139
|
+
if (!heldForConversation.has(mainWindow2.id)) {
|
|
14140
|
+
presentWindow(mainWindow2, windowLaunch.show);
|
|
14141
|
+
}
|
|
12297
14142
|
if (windowLaunch.mode === "normal") return;
|
|
12298
14143
|
setTimeout(() => {
|
|
12299
14144
|
if (!mainWindow2 || mainWindow2.isDestroyed()) return;
|
|
@@ -12369,6 +14214,7 @@ function createWindow() {
|
|
|
12369
14214
|
} else {
|
|
12370
14215
|
mainWindow2.loadFile(path$1.join(__dirname, "../renderer/index.html"));
|
|
12371
14216
|
}
|
|
14217
|
+
attachBrowserHostToWindow?.(mainWindow2);
|
|
12372
14218
|
return mainWindow2;
|
|
12373
14219
|
}
|
|
12374
14220
|
const backendService = new BackendServiceManager();
|
|
@@ -12396,18 +14242,57 @@ if (windowLaunch.mode !== "normal") {
|
|
|
12396
14242
|
}
|
|
12397
14243
|
let mainWindow = null;
|
|
12398
14244
|
let activeUpdateService = null;
|
|
14245
|
+
const launchTarget = readLaunchTarget(process.argv);
|
|
14246
|
+
const launchSession = launchTarget.kind === "session" ? launchTarget.sessionId : null;
|
|
14247
|
+
const launchCatalogue = launchTarget.kind === "catalogue";
|
|
14248
|
+
function launchArgumentFlags(initialSession, openCatalogue) {
|
|
14249
|
+
if (initialSession) {
|
|
14250
|
+
return { additionalArguments: [`${OPEN_SESSION_FLAG}=${initialSession}`] };
|
|
14251
|
+
}
|
|
14252
|
+
if (openCatalogue) return { additionalArguments: [OPEN_CATALOGUE_FLAG] };
|
|
14253
|
+
return {};
|
|
14254
|
+
}
|
|
14255
|
+
let queuedSession = null;
|
|
14256
|
+
let viewerRecord = null;
|
|
14257
|
+
let viewerEndpoint = null;
|
|
14258
|
+
let openConversationInWindow = null;
|
|
14259
|
+
let attachBrowserHostToWindow = null;
|
|
14260
|
+
const CONVERSATION_PAINT_FALLBACK_MS = 3e3;
|
|
14261
|
+
const heldForConversation = /* @__PURE__ */ new Map();
|
|
14262
|
+
function holdPresentUntilConversation(window, session) {
|
|
14263
|
+
const timer = setTimeout(() => {
|
|
14264
|
+
releaseHeldWindow(window.id);
|
|
14265
|
+
}, CONVERSATION_PAINT_FALLBACK_MS);
|
|
14266
|
+
timer.unref?.();
|
|
14267
|
+
heldForConversation.set(window.id, { session, timer });
|
|
14268
|
+
}
|
|
14269
|
+
function releaseHeldWindow(windowId) {
|
|
14270
|
+
const held = heldForConversation.get(windowId);
|
|
14271
|
+
if (!held) return false;
|
|
14272
|
+
clearTimeout(held.timer);
|
|
14273
|
+
heldForConversation.delete(windowId);
|
|
14274
|
+
const window = electron.BrowserWindow.fromId(windowId);
|
|
14275
|
+
if (window && !window.isDestroyed()) presentWindow(window, windowLaunch.show);
|
|
14276
|
+
return true;
|
|
14277
|
+
}
|
|
14278
|
+
function releaseHeldWindowFor(session) {
|
|
14279
|
+
for (const [windowId, held] of heldForConversation) {
|
|
14280
|
+
if (held.session !== session) continue;
|
|
14281
|
+
releaseHeldWindow(windowId);
|
|
14282
|
+
}
|
|
14283
|
+
}
|
|
12399
14284
|
const zoomInFromEvent = () => {
|
|
12400
14285
|
if (mainWindow) {
|
|
12401
|
-
const
|
|
12402
|
-
const currentZoom =
|
|
12403
|
-
|
|
14286
|
+
const webContents2 = mainWindow.webContents;
|
|
14287
|
+
const currentZoom = webContents2.getZoomFactor();
|
|
14288
|
+
webContents2.setZoomFactor(currentZoom + 0.1);
|
|
12404
14289
|
}
|
|
12405
14290
|
};
|
|
12406
14291
|
const zoomOutFromEvent = () => {
|
|
12407
14292
|
if (mainWindow) {
|
|
12408
|
-
const
|
|
12409
|
-
const currentZoom =
|
|
12410
|
-
|
|
14293
|
+
const webContents2 = mainWindow.webContents;
|
|
14294
|
+
const currentZoom = webContents2.getZoomFactor();
|
|
14295
|
+
webContents2.setZoomFactor(currentZoom - 0.1);
|
|
12411
14296
|
}
|
|
12412
14297
|
};
|
|
12413
14298
|
const actualSizeFromEvent = () => {
|
|
@@ -12421,6 +14306,12 @@ if (!gotTheLock) {
|
|
|
12421
14306
|
electron.app.quit();
|
|
12422
14307
|
} else {
|
|
12423
14308
|
electron.app.on("second-instance", (_event, commandLine) => {
|
|
14309
|
+
const requested = readOpenSessionArgv(commandLine);
|
|
14310
|
+
if (requested) {
|
|
14311
|
+
if (openConversationInWindow) openConversationInWindow(requested);
|
|
14312
|
+
else queuedSession = requested;
|
|
14313
|
+
return;
|
|
14314
|
+
}
|
|
12424
14315
|
if (mainWindow) {
|
|
12425
14316
|
raiseWindow(mainWindow, windowLaunch.show);
|
|
12426
14317
|
}
|
|
@@ -12445,10 +14336,45 @@ electron.app.whenReady().then(async () => {
|
|
|
12445
14336
|
const desktopNotifier = new DesktopNotifier(
|
|
12446
14337
|
() => mainWindow,
|
|
12447
14338
|
sendDesktop,
|
|
12448
|
-
windowLaunch.show
|
|
14339
|
+
windowLaunch.show,
|
|
14340
|
+
{
|
|
14341
|
+
/*
|
|
14342
|
+
* Whether a window we have a heartbeat from is still there.
|
|
14343
|
+
*
|
|
14344
|
+
* `forgetWindow` is called on `closed`, which covers every ordinary
|
|
14345
|
+
* close — but a renderer process that dies takes its `webContents` id
|
|
14346
|
+
* with it and no `closed` event reaches the notifier, and those ids
|
|
14347
|
+
* are not reused. Filtering on liveness is what makes a stale
|
|
14348
|
+
* `{visible: true, focused: true}` entry unable to suppress every
|
|
14349
|
+
* completion for the rest of the run.
|
|
14350
|
+
*/
|
|
14351
|
+
windowAlive: (windowId) => {
|
|
14352
|
+
const contents = electron.webContents.fromId(windowId);
|
|
14353
|
+
return Boolean(contents && !contents.isDestroyed());
|
|
14354
|
+
},
|
|
14355
|
+
// A banner click with no window recreates one and navigates it. The
|
|
14356
|
+
// queue lives in `openSessionInWindow` because window creation does.
|
|
14357
|
+
reopen: (sessionId2) => openSessionInWindow(sessionId2),
|
|
14358
|
+
/*
|
|
14359
|
+
* The renderer's report that this conversation is on screen. Two
|
|
14360
|
+
* consumers, and neither is delivery:
|
|
14361
|
+
*
|
|
14362
|
+
* - the viewer record's `current_session`, which is how a later click
|
|
14363
|
+
* routes here and how it skips a redundant switch;
|
|
14364
|
+
* - the held first present of a click-created window, where this report
|
|
14365
|
+
* IS the "conversation applied" milestone `ready-to-show` is not
|
|
14366
|
+
* (B3).
|
|
14367
|
+
*/
|
|
14368
|
+
noteDisplayed: (sessionId2) => {
|
|
14369
|
+
viewerRecord?.noteSession(sessionId2);
|
|
14370
|
+
releaseHeldWindowFor(sessionId2);
|
|
14371
|
+
}
|
|
14372
|
+
}
|
|
12449
14373
|
);
|
|
12450
14374
|
backendService.onBackendReady(() => {
|
|
12451
14375
|
void desktopNotifier.refreshNotificationContract();
|
|
14376
|
+
void backendService.getDesktopFeedRelay().start();
|
|
14377
|
+
void publishViewerPresence();
|
|
12452
14378
|
});
|
|
12453
14379
|
backendService.observeStream((sessionId2, data) => {
|
|
12454
14380
|
try {
|
|
@@ -12457,13 +14383,85 @@ electron.app.whenReady().then(async () => {
|
|
|
12457
14383
|
}
|
|
12458
14384
|
});
|
|
12459
14385
|
const rendererUrl = process.env.ELECTRON_RENDERER_URL || node_url.pathToFileURL(path$1.join(__dirname, "../renderer/index.html")).href;
|
|
14386
|
+
backendService.observeDesktopFeed(
|
|
14387
|
+
(frame) => {
|
|
14388
|
+
if (frame.type === "notification") {
|
|
14389
|
+
desktopNotifier.observe(frame.session_id, frame);
|
|
14390
|
+
return;
|
|
14391
|
+
}
|
|
14392
|
+
const window = mainWindow;
|
|
14393
|
+
if (!window || window.isDestroyed()) return;
|
|
14394
|
+
window.webContents.send("desktop-feed-frame", frame);
|
|
14395
|
+
},
|
|
14396
|
+
(state) => {
|
|
14397
|
+
const window = mainWindow;
|
|
14398
|
+
if (!window || window.isDestroyed()) return;
|
|
14399
|
+
window.webContents.send("desktop-feed-state", state);
|
|
14400
|
+
}
|
|
14401
|
+
);
|
|
14402
|
+
backendService.providePresenceContext(() => desktopNotifier.presence());
|
|
14403
|
+
electron.ipcMain.on("desktop-feed-watch", () => {
|
|
14404
|
+
const window = mainWindow;
|
|
14405
|
+
if (!window || window.isDestroyed()) return;
|
|
14406
|
+
window.webContents.send("desktop-feed-state", {
|
|
14407
|
+
connected: backendService.getDesktopFeedRelay().isConnected
|
|
14408
|
+
});
|
|
14409
|
+
});
|
|
14410
|
+
const record = new ViewerRecordPublisher();
|
|
14411
|
+
const endpoint2 = new ViewerEndpoint(
|
|
14412
|
+
{
|
|
14413
|
+
resumeSession: async (sessionId2) => {
|
|
14414
|
+
openSessionInWindow(sessionId2);
|
|
14415
|
+
return `showing ${sessionId2}`;
|
|
14416
|
+
},
|
|
14417
|
+
focusWindow: async () => {
|
|
14418
|
+
const window = mainWindow;
|
|
14419
|
+
if (window && !window.isDestroyed()) {
|
|
14420
|
+
raiseWindow(window, windowLaunch.show);
|
|
14421
|
+
return "raised the window";
|
|
14422
|
+
}
|
|
14423
|
+
setupMainWindowWithUpdateService();
|
|
14424
|
+
return "opened a window";
|
|
14425
|
+
}
|
|
14426
|
+
},
|
|
14427
|
+
record.controlKey
|
|
14428
|
+
);
|
|
14429
|
+
viewerRecord = record;
|
|
14430
|
+
viewerEndpoint = endpoint2;
|
|
14431
|
+
let viewerPublished = false;
|
|
14432
|
+
async function publishViewerPresence() {
|
|
14433
|
+
if (viewerPublished) return;
|
|
14434
|
+
let features;
|
|
14435
|
+
try {
|
|
14436
|
+
const response = await backendService.requestDesktop({
|
|
14437
|
+
op: "capabilities"
|
|
14438
|
+
});
|
|
14439
|
+
const body = response.body;
|
|
14440
|
+
features = body?.result?.features;
|
|
14441
|
+
} catch {
|
|
14442
|
+
return;
|
|
14443
|
+
}
|
|
14444
|
+
if (!(Number(features?.desktop_presence ?? 0) >= 1)) return;
|
|
14445
|
+
try {
|
|
14446
|
+
const port = await endpoint2.start();
|
|
14447
|
+
record.setControlPort(port);
|
|
14448
|
+
record.start();
|
|
14449
|
+
viewerPublished = true;
|
|
14450
|
+
} catch (error) {
|
|
14451
|
+
logger.warn(
|
|
14452
|
+
`Viewer endpoint did not bind, so no record is published: ${String(error)}`,
|
|
14453
|
+
LogFileType.BACKEND
|
|
14454
|
+
);
|
|
14455
|
+
}
|
|
14456
|
+
}
|
|
12460
14457
|
registerDesktopIPC(
|
|
12461
14458
|
() => mainWindow,
|
|
12462
14459
|
rendererUrl,
|
|
12463
14460
|
sendDesktop,
|
|
12464
14461
|
() => backendService.getStreamRelay(),
|
|
12465
14462
|
(input, bytes) => backendService.requestDesktopMedia(input, bytes),
|
|
12466
|
-
desktopNotifier
|
|
14463
|
+
desktopNotifier,
|
|
14464
|
+
(sessionId2) => viewerRecord?.releaseSession(sessionId2)
|
|
12467
14465
|
);
|
|
12468
14466
|
electron.ipcMain.handle("open-file", async (_, filePath) => {
|
|
12469
14467
|
try {
|
|
@@ -12773,8 +14771,9 @@ electron.app.whenReady().then(async () => {
|
|
|
12773
14771
|
}
|
|
12774
14772
|
createApplicationMenu();
|
|
12775
14773
|
let updateService = null;
|
|
12776
|
-
function setupMainWindowWithUpdateService() {
|
|
12777
|
-
mainWindow = createWindow();
|
|
14774
|
+
function setupMainWindowWithUpdateService(initialSession = null, openCatalogue = false) {
|
|
14775
|
+
mainWindow = createWindow(initialSession, openCatalogue);
|
|
14776
|
+
const windowId = mainWindow.webContents.id;
|
|
12778
14777
|
if (mainWindow) {
|
|
12779
14778
|
mainWindow.webContents.on("before-input-event", (event, input) => {
|
|
12780
14779
|
const isCmdOrCtrl = input.control || input.meta;
|
|
@@ -12803,8 +14802,17 @@ electron.app.whenReady().then(async () => {
|
|
|
12803
14802
|
updateService.dispose();
|
|
12804
14803
|
updateService = null;
|
|
12805
14804
|
}
|
|
14805
|
+
desktopNotifier.forgetWindow(windowId);
|
|
14806
|
+
if (electron.BrowserWindow.getAllWindows().length === 0)
|
|
14807
|
+
viewerRecord?.noteSession("");
|
|
14808
|
+
if (heldForConversation.has(windowId)) {
|
|
14809
|
+
const held = heldForConversation.get(windowId);
|
|
14810
|
+
if (held) clearTimeout(held.timer);
|
|
14811
|
+
heldForConversation.delete(windowId);
|
|
14812
|
+
}
|
|
12806
14813
|
mainWindow = null;
|
|
12807
14814
|
});
|
|
14815
|
+
mainWindow.on("focus", () => viewerRecord?.noteFocused());
|
|
12808
14816
|
updateService.setupIpcHandlers();
|
|
12809
14817
|
updateService.handlePlatformSpecifics();
|
|
12810
14818
|
setTimeout(() => {
|
|
@@ -12850,12 +14858,28 @@ electron.app.whenReady().then(async () => {
|
|
|
12850
14858
|
);
|
|
12851
14859
|
}
|
|
12852
14860
|
}
|
|
12853
|
-
|
|
12854
|
-
|
|
14861
|
+
function openSessionInWindow(sessionId2) {
|
|
14862
|
+
const window = mainWindow;
|
|
14863
|
+
if (window && !window.isDestroyed()) {
|
|
14864
|
+
window.webContents.send("desktop-open-conversation", { sessionId: sessionId2 });
|
|
14865
|
+
raiseWindow(window, windowLaunch.show);
|
|
14866
|
+
return;
|
|
14867
|
+
}
|
|
14868
|
+
setupMainWindowWithUpdateService(sessionId2, sessionId2 === null);
|
|
14869
|
+
}
|
|
14870
|
+
openConversationInWindow = openSessionInWindow;
|
|
14871
|
+
attachBrowserHostToWindow = (window) => {
|
|
14872
|
+
void startBrowserHostForWindow(window);
|
|
14873
|
+
};
|
|
14874
|
+
setupMainWindowWithUpdateService(launchSession, launchCatalogue);
|
|
14875
|
+
if (queuedSession) {
|
|
14876
|
+
const queued = queuedSession;
|
|
14877
|
+
queuedSession = null;
|
|
14878
|
+
openSessionInWindow(queued);
|
|
14879
|
+
}
|
|
12855
14880
|
electron.app.on("activate", () => {
|
|
12856
14881
|
if (electron.BrowserWindow.getAllWindows().length === 0) {
|
|
12857
14882
|
setupMainWindowWithUpdateService();
|
|
12858
|
-
if (mainWindow) void startBrowserHostForWindow(mainWindow);
|
|
12859
14883
|
}
|
|
12860
14884
|
});
|
|
12861
14885
|
}).catch((error) => {
|
|
@@ -12891,6 +14915,8 @@ const QUIT_FAILSAFE_MARGIN_MS = 5e3;
|
|
|
12891
14915
|
const QUIT_CLEANUP_FAILSAFE_MS = CONSOLE_RESOLUTION_WORST_MS + INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
|
|
12892
14916
|
let backendQuitPending = false;
|
|
12893
14917
|
electron.app.on("will-quit", (event) => {
|
|
14918
|
+
viewerEndpoint?.close();
|
|
14919
|
+
viewerRecord?.stop();
|
|
12894
14920
|
if (backendService.isOwnedCleanupComplete()) return;
|
|
12895
14921
|
event.preventDefault();
|
|
12896
14922
|
if (backendQuitPending) return;
|