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.
Files changed (72) hide show
  1. package/bin/local-operator-ui.js +11 -1
  2. package/out/main/index.js +2636 -610
  3. package/out/preload/index.js +79 -0
  4. package/out/renderer/assets/{_basePickBy-mmFlh3ho.js → _basePickBy-BcgsHs9n.js} +1 -1
  5. package/out/renderer/assets/{_baseUniq-DAWnt4WG.js → _baseUniq-BpL7wRRP.js} +1 -1
  6. package/out/renderer/assets/{agent-details-page-CO72YkSX.js → agent-details-page-XeWCJu5r.js} +1 -1
  7. package/out/renderer/assets/{agent-hub-page-DhYc5_gP.js → agent-hub-page-CnCjDZpC.js} +1 -1
  8. package/out/renderer/assets/{agents-page-BopOzax_.js → agents-page-Bdno1dWD.js} +2 -2
  9. package/out/renderer/assets/{arc-BgHsUFIt.js → arc-BoPsrKtE.js} +1 -1
  10. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-BWxIiACo.js → architectureDiagram-IEHRJDOE-C2WpBBPk.js} +1 -1
  11. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CoJDUfWc.js → blockDiagram-JOT3LUYC-CGcW4B3x.js} +1 -1
  12. package/out/renderer/assets/{c4Diagram-VJAJSXHY-B8vXmmLx.js → c4Diagram-VJAJSXHY-o8n2wzDv.js} +1 -1
  13. package/out/renderer/assets/channel-urLqozUh.js +1 -0
  14. package/out/renderer/assets/{chunk-4BMEZGHF-rO5ve7XY.js → chunk-4BMEZGHF-D-n0UX21.js} +1 -1
  15. package/out/renderer/assets/{chunk-A2AXSNBT-CTPb1xgY.js → chunk-A2AXSNBT-DfLH07_4.js} +1 -1
  16. package/out/renderer/assets/{chunk-AEK57VVT-5VpTdLBm.js → chunk-AEK57VVT-CL7wRm_N.js} +1 -1
  17. package/out/renderer/assets/{chunk-D6G4REZN-C3CEf0kC.js → chunk-D6G4REZN-Cn9ko_6u.js} +1 -1
  18. package/out/renderer/assets/{chunk-RZ5BOZE2-B7nn7Euk.js → chunk-RZ5BOZE2-D2Yy6cfD.js} +1 -1
  19. package/out/renderer/assets/{chunk-XZIHB7SX-CnPH3vBT.js → chunk-XZIHB7SX-DADi_UCc.js} +1 -1
  20. package/out/renderer/assets/classDiagram-GIVACNV2-CdFb9UuH.js +1 -0
  21. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CdFb9UuH.js +1 -0
  22. package/out/renderer/assets/clone-R16IxTog.js +1 -0
  23. package/out/renderer/assets/{compact-pagination-Bu8vshU-.js → compact-pagination-BJGKcOBo.js} +1 -1
  24. package/out/renderer/assets/{dagre-OKDRZEBW-DUX1RPbJ.js → dagre-OKDRZEBW-CTUL3pXh.js} +1 -1
  25. package/out/renderer/assets/{diagram-SSKATNLV-Cpv42kzr.js → diagram-SSKATNLV-DN8A3Gxk.js} +1 -1
  26. package/out/renderer/assets/{diagram-VNBRO52H-CGkUnKEL.js → diagram-VNBRO52H-DMnKnMe_.js} +1 -1
  27. package/out/renderer/assets/{erDiagram-Q7BY3M3F-iBbJbyJZ.js → erDiagram-Q7BY3M3F-D_QMDQ8A.js} +1 -1
  28. package/out/renderer/assets/{flowDiagram-4HSFHLVR-B569Tv1u.js → flowDiagram-4HSFHLVR-C3lOP5SJ.js} +1 -1
  29. package/out/renderer/assets/{ganttDiagram-APWFNJXF-nGbcgsgD.js → ganttDiagram-APWFNJXF-C_OA497D.js} +1 -1
  30. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-vv_MhteL.js → gitGraphDiagram-7IBYFJ6S-CME4fotT.js} +1 -1
  31. package/out/renderer/assets/{graph-krOMwYSa.js → graph-StjGh6sY.js} +1 -1
  32. package/out/renderer/assets/icon-BgNKKXpS.css +1 -0
  33. package/out/renderer/assets/{icon-mKHwnRNs.js → icon-fm25vzsV.js} +1 -1
  34. package/out/renderer/assets/{index-BqWgqUKZ.js → index-CrQVJ-Co.js} +365 -371
  35. package/out/renderer/assets/{index-BgGl2-0g.js → index-Dr1tzeVu.js} +1 -1
  36. package/out/renderer/assets/{index-CT6w0yXj.js → index-ir1HIEvx.js} +1 -1
  37. package/out/renderer/assets/{infoDiagram-PH2N3AL5-DHDA5253.js → infoDiagram-PH2N3AL5-BDK-X71A.js} +1 -1
  38. package/out/renderer/assets/{installer-DcKqSMWJ.js → installer-BlFThduE.js} +1 -1
  39. package/out/renderer/assets/{journeyDiagram-U35MCT3I-CIrQ218m.js → journeyDiagram-U35MCT3I-BjNamBAK.js} +1 -1
  40. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DG_YsZbL.js → kanban-definition-NDS4AKOZ-ggxQngdS.js} +1 -1
  41. package/out/renderer/assets/{layout-CB5zJSjt.js → layout-BwRA6JIM.js} +1 -1
  42. package/out/renderer/assets/{legacy-agents-page-B8pvrWy_.js → legacy-agents-page-6NVm9UCi.js} +4 -4
  43. package/out/renderer/assets/{mermaid.core-CYc-R2hQ.js → mermaid.core-CfsR-egY.js} +5 -5
  44. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-C22fFThl.js → mindmap-definition-ALO5MXBD-DyShE6kV.js} +1 -1
  45. package/out/renderer/assets/{page-header-DmGvvMe3.js → page-header-DHRyHEm6.js} +1 -1
  46. package/out/renderer/assets/{parseISO-vr2fT-wq.js → parseISO-QkHFGs0Y.js} +1 -1
  47. package/out/renderer/assets/{pieDiagram-IB7DONF6-DKRytw7K.js → pieDiagram-IB7DONF6-3wxJVvt5.js} +1 -1
  48. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CWdnqGKr.js → quadrantDiagram-7GDLP6J5-iNEHdUXN.js} +1 -1
  49. package/out/renderer/assets/{radar-MK3ICKWK-B0XgopGa.js → radar-MK3ICKWK-tbLyRbLt.js} +1 -1
  50. package/out/renderer/assets/radient-auth-buttons-CJi5xMEA.js +6 -0
  51. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CXL8GuIo.js → requirementDiagram-KVF5MWMF-CfXxmaaW.js} +1 -1
  52. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BQKCHfmN.js → sankeyDiagram-QLVOVGJD-DaJoWNT6.js} +1 -1
  53. package/out/renderer/assets/{schedules-page-DtNhlycC.js → schedules-page-j6YSAreu.js} +8 -8
  54. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-awR0fjHu.js → sequenceDiagram-X6HHIX6F-WF0xhSxd.js} +1 -1
  55. package/out/renderer/assets/settings-page-xzYs88jD.js +97 -0
  56. package/out/renderer/assets/{square-pen-717nTWtU.js → square-pen-D_sBefzl.js} +1 -1
  57. package/out/renderer/assets/{stateDiagram-DGXRK772-BjhA_mJe.js → stateDiagram-DGXRK772-YdZ2xuTI.js} +1 -1
  58. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CegpBrNz.js +1 -0
  59. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BFEDYBl8.js → timeline-definition-BDJGKUSR-DYaIE99w.js} +1 -1
  60. package/out/renderer/assets/{use-agent-like-mutation-Bxx_WHvo.js → use-agent-like-mutation-s4bRf1u_.js} +1 -1
  61. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CwLeRa1a.js → xychartDiagram-VJFVF3MP-DYQwMal7.js} +1 -1
  62. package/out/renderer/index.html +3 -3
  63. package/out/renderer/installer.html +3 -3
  64. package/package.json +52 -20
  65. package/out/renderer/assets/channel-DAvIgUhZ.js +0 -1
  66. package/out/renderer/assets/classDiagram-GIVACNV2-CP5aSNYY.js +0 -1
  67. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CP5aSNYY.js +0 -1
  68. package/out/renderer/assets/clone-CeahOD_w.js +0 -1
  69. package/out/renderer/assets/icon-DC3rStnR.css +0 -1
  70. package/out/renderer/assets/radient-auth-buttons-RrBw8fkd.js +0 -6
  71. package/out/renderer/assets/settings-page-DCLaspsn.js +0 -91
  72. 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
- const variableKey = zod.z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
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 MAX_FRAME_BYTES = 8 * 1024 * 1024;
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
- for (; ; ) {
1430
- const { done, value } = await reader.read();
1431
- if (done) break;
1432
- buffer += decoder.decode(value, { stream: true });
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 boundary = buffer.indexOf("\n\n");
1435
- if (boundary < 0) {
1436
- if (buffer.length > MAX_FRAME_BYTES) {
1437
- emit({
1438
- streamId,
1439
- kind: "error",
1440
- detail: DESKTOP_STREAM_DETAIL.frameBudget
1441
- });
1442
- reader.cancel().catch(() => void 0);
1443
- return;
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
- break;
1446
- }
1447
- const record = buffer.slice(0, boundary);
1448
- buffer = buffer.slice(boundary + 2);
1449
- for (const line of record.split("\n")) {
1450
- if (line.startsWith("data:")) {
1451
- const data = line.slice(5).replace(/^ /, "");
1452
- this.observer?.(sessionId2, data);
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
- if (existing && !insideAppBundle(existing)) return { ...env };
1552
- return { ...env, PYTHONPYCACHEPREFIX: pythonBytecodeCacheDir(userDataDir) };
1553
- }
1554
- const BUNDLED_PYTHON_TREE_NAMES = ["python", "python_aarch64"];
1555
- function bundledPythonTreePaths(resourcesPath) {
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
- if (!result.supported) return result;
1585
- for (const tree of trees) {
1586
- let root;
1587
- try {
1588
- root = fs.lstatSync(tree);
1589
- } catch {
1590
- continue;
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
- if (!root.isDirectory()) continue;
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 result;
2076
+ return candidates.filter((candidate) => fs.existsSync(candidate));
1608
2077
  }
1609
- function applyAce(tree, selector, ace, failures) {
1610
- const run = node_child_process.spawnSync(
1611
- FIND,
1612
- [tree, ...selector, "-exec", CHMOD, "+a", ace, "{}", "+", "-print0"],
1613
- { encoding: "buffer", maxBuffer: MAX_FIND_OUTPUT_BYTES }
1614
- );
1615
- if (run.error) {
1616
- failures.push(`${tree}: ${String(run.error)}`);
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
- for (const line of String(run.stderr).split("\n")) {
1620
- if (line.trim().length > 0) failures.push(line.trim());
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 (run.status !== 0 && String(run.stderr).trim().length === 0) {
1623
- failures.push(`${tree}: ${FIND} exited ${run.status} with no explanation`);
2094
+ if (existing === source) {
2095
+ return {
2096
+ path: path2,
2097
+ written: false,
2098
+ reason: `${path2} already refuses bytecode writes`
2099
+ };
1624
2100
  }
1625
- let selected = 0;
1626
- for (const byte of run.stdout) if (byte === 0) selected += 1;
1627
- return selected;
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
- if (process.platform === "win32") {
2284
- this.venvPath = path$1.join(this.appDataPath, "local-operator-venv");
2285
- } else if (process.platform === "darwin") {
2286
- this.venvPath = path$1.join(
2287
- electron.app.getPath("home"),
2288
- "Library",
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
- if (process.platform === "win32") {
3730
- this.venvPath = path$1.join(this.appDataPath, "local-operator-venv");
3731
- } else if (process.platform === "darwin") {
3732
- this.venvPath = path$1.join(
3733
- electron.app.getPath("home"),
3734
- "Library",
3735
- "Application Support",
3736
- "Local Operator",
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
- try {
3871
- const { response } = await electron.dialog.showMessageBox({
3872
- type: "info",
3873
- title: "First-Time Setup Required",
3874
- message: "Local Operator needs to set up some components to work properly. This one-time process will take just a few moments.",
3875
- buttons: ["Set Up Now", "Cancel"],
3876
- defaultId: 0,
3877
- cancelId: 1
3878
- });
3879
- if (response === 1) {
3880
- logger.info(
3881
- "User cancelled backend installation",
3882
- LogFileType.INSTALLER
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
- setTimeout(() => {
3885
- electron.app.exit(0);
3886
- }, 500);
3887
- return false;
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
- const { response: successResponse } = await electron.dialog.showMessageBox({
4121
- type: "info",
4122
- title: "Setup Complete",
4123
- message: "Local Operator is ready to use. Everything has been set up successfully.",
4124
- buttons: ["Let's Go!", "Cancel"],
4125
- defaultId: 0,
4126
- cancelId: 1
4127
- });
4128
- if (successResponse === 1) {
4129
- logger.info(
4130
- "User ended session after successful installation",
4131
- LogFileType.INSTALLER
4132
- );
4133
- setTimeout(() => {
4134
- electron.app.exit(0);
4135
- }, 500);
4136
- return false;
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
- /** Called by the stream relay for every parsed frame it forwards. */
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.anyFocused()) return;
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
- sessionId2,
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
- anyFocused() {
8363
- for (const state of this.windows.values()) {
8364
- if (state.visible && state.focused) return true;
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.anyFocused()) return;
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) return;
8484
- raiseWindow(target2, this.windowRaise);
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: 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.",
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 = ["python", "python_aarch64"];
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(pythonPath) {
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: pythonPath,
10931
+ command: pythonPath2,
9655
10932
  args,
9656
- display: `"${pythonPath}" ${args.join(" ")}`
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("update-install-blocked", {
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: version ?? null,
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 pre-flight.`,
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
- if (seal.kind === "unavailable") {
10405
- logger.warn(
10406
- `Seal check could not run twice, continuing with the install: ${seal.detail}`,
10407
- LogFileType.UPDATE_SERVICE
10408
- );
10409
- return null;
10410
- }
10411
- if (seal.kind === "sealed") {
10412
- logger.info(
10413
- `Installed bundle passed its seal check: ${bundlePath}`,
10414
- LogFileType.UPDATE_SERVICE
10415
- );
10416
- return null;
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 installedBundleSealBlock(bundlePath, seal.detail, version);
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 healed = evaluateBundleSeal(await probeBundle());
11869
+ const { probe: healedProbe } = await this.readBundleSeal(bundlePath);
11870
+ const healed = evaluateBundleSeal(healedProbe);
10437
11871
  if (healed.kind === "sealed") {
10438
- logger.info(
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 installedBundleSealBlock(bundlePath, healed.detail, version);
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
- /** File names the updater's metadata knows this update by. */
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(pythonPath) {
13067
+ async readBundledBackendVersion(pythonPath2) {
11548
13068
  const probe = await runCommand(
11549
- pythonPath,
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 pythonPath = "";
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
- pythonPath = path$1.join(venvPath, venvBinDir, pythonName);
13214
+ pythonPath2 = path$1.join(venvPath, venvBinDir, pythonName);
11695
13215
  }
11696
- if (!pythonPath || !fs.existsSync(pythonPath)) {
13216
+ if (!pythonPath2 || !fs.existsSync(pythonPath2)) {
11697
13217
  logger.error(
11698
- `Cannot update the bundled backend: no Python at ${pythonPath || "an unknown path"}`,
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(pythonPath);
11709
- const versionBefore = await this.readBundledBackendVersion(pythonPath);
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(pythonPath);
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 webContents = mainWindow.webContents;
12107
- const currentZoom = webContents.getZoomFactor();
12108
- webContents.setZoomFactor(currentZoom + 0.1);
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 webContents = mainWindow.webContents;
12114
- const currentZoom = webContents.getZoomFactor();
12115
- webContents.setZoomFactor(currentZoom - 0.1);
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
- presentWindow(mainWindow2, windowLaunch.show);
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 webContents = mainWindow.webContents;
12402
- const currentZoom = webContents.getZoomFactor();
12403
- webContents.setZoomFactor(currentZoom + 0.1);
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 webContents = mainWindow.webContents;
12409
- const currentZoom = webContents.getZoomFactor();
12410
- webContents.setZoomFactor(currentZoom - 0.1);
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
- setupMainWindowWithUpdateService();
12854
- if (mainWindow) await startBrowserHostForWindow(mainWindow);
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;