local-operator-ui 0.30.35 → 0.31.1

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 (102) hide show
  1. package/out/main/index.js +826 -11
  2. package/out/preload/index.js +77 -0
  3. package/out/renderer/assets/{_basePickBy-DBMIejg7.js → _basePickBy-Crl5Z_Pk.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-BcqrDipQ.js → _baseUniq-lQY3ywEZ.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-CKouhYcx.js → agent-details-page-BzDE7WbM.js} +2 -2
  6. package/out/renderer/assets/agent-hub-page-DR48vILu.js +6 -0
  7. package/out/renderer/assets/agents-page-BfwkfrEO.js +2 -0
  8. package/out/renderer/assets/{arc-C7db1al4.js → arc-gshxvskr.js} +1 -1
  9. package/out/renderer/assets/architectureDiagram-IEHRJDOE-C3dQvrMH.js +36 -0
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-DFXKZ0Pd.js → blockDiagram-JOT3LUYC-DQTNXYLN.js} +1 -1
  11. package/out/renderer/assets/book-open-CykMtqSE.js +6 -0
  12. package/out/renderer/assets/brand-mark-Bv0Hu-Cz.png +0 -0
  13. package/out/renderer/assets/browser-page-7T5O7_eK.js +1 -0
  14. package/out/renderer/assets/browser-webauthn-prompt-DzDALs8L.js +1 -0
  15. package/out/renderer/assets/{c4Diagram-VJAJSXHY-DREcsmeA.js → c4Diagram-VJAJSXHY-DeNuMdtv.js} +1 -1
  16. package/out/renderer/assets/channel-B1YKOpml.js +1 -0
  17. package/out/renderer/assets/{chunk-4BMEZGHF-1AW6sHDl.js → chunk-4BMEZGHF-CfQJf7ZV.js} +1 -1
  18. package/out/renderer/assets/{chunk-A2AXSNBT-BHYmovGQ.js → chunk-A2AXSNBT-QLV_8ftp.js} +1 -1
  19. package/out/renderer/assets/{chunk-AEK57VVT-VSZdczYO.js → chunk-AEK57VVT-CYj_TvcV.js} +1 -1
  20. package/out/renderer/assets/{chunk-D6G4REZN-CHy8bDy2.js → chunk-D6G4REZN-BTwxHZFL.js} +1 -1
  21. package/out/renderer/assets/{chunk-RZ5BOZE2-D7gxwymY.js → chunk-RZ5BOZE2-JwBm2JL9.js} +1 -1
  22. package/out/renderer/assets/{chunk-XZIHB7SX-Bu9gT-3-.js → chunk-XZIHB7SX-DPAqSKiB.js} +1 -1
  23. package/out/renderer/assets/classDiagram-GIVACNV2-BwbP74kr.js +1 -0
  24. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BwbP74kr.js +1 -0
  25. package/out/renderer/assets/clone-CkMWxgkB.js +1 -0
  26. package/out/renderer/assets/compact-pagination-B0hoPapl.js +1 -0
  27. package/out/renderer/assets/{console-mirror-Bjpk3DAz.js → console-mirror-naCglSJA.js} +14 -14
  28. package/out/renderer/assets/{consoleCapture-B-P-8vPw.js → consoleCapture-BmsgtbeJ.js} +1 -1
  29. package/out/renderer/assets/dagre-OKDRZEBW-BNS66odd.js +4 -0
  30. package/out/renderer/assets/diagram-SSKATNLV-TQ8UWNaL.js +43 -0
  31. package/out/renderer/assets/diagram-VNBRO52H-zxD3pv8s.js +24 -0
  32. package/out/renderer/assets/en-US-BkS8Ixxl.js +1 -0
  33. package/out/renderer/assets/{erDiagram-Q7BY3M3F-Dzwd2fLB.js → erDiagram-Q7BY3M3F-VXo3Cqqj.js} +1 -1
  34. package/out/renderer/assets/error-boundary-Du4Xf2hu.js +126 -0
  35. package/out/renderer/assets/{flowDiagram-4HSFHLVR-DpqxYeov.js → flowDiagram-4HSFHLVR-CAdzCrMJ.js} +1 -1
  36. package/out/renderer/assets/{ganttDiagram-APWFNJXF-CJqzp0mX.js → ganttDiagram-APWFNJXF-CGVbBlO6.js} +3 -3
  37. package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-DbLEZe6o.js +65 -0
  38. package/out/renderer/assets/{graph-BKZN53d3.js → graph-Do1SGRQn.js} +1 -1
  39. package/out/renderer/assets/index-1qEdZEMQ.js +1 -0
  40. package/out/renderer/assets/index-CSPo12AE.css +1 -0
  41. package/out/renderer/assets/{index-7OErfuvQ.js → index-CZlj7Oq2.js} +1 -1
  42. package/out/renderer/assets/index-DjpNajWu.js +7 -0
  43. package/out/renderer/assets/index-OpIz2cV2.css +1 -0
  44. package/out/renderer/assets/{index-C6pxQu_W.js → index-YQ32HBTm.js} +408 -447
  45. package/out/renderer/assets/infoDiagram-PH2N3AL5-CuB0GSNM.js +2 -0
  46. package/out/renderer/assets/installer-Cu5fgVHl.js +1 -0
  47. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BG7areA5.js → journeyDiagram-U35MCT3I-BqN69StY.js} +1 -1
  48. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BgMtdkNP.js → kanban-definition-NDS4AKOZ-CO2S4ykb.js} +1 -1
  49. package/out/renderer/assets/{layout-Bw_CgLbc.js → layout-7oQ5xKpW.js} +1 -1
  50. package/out/renderer/assets/{legacy-agents-page-NU8mTf6U.js → legacy-agents-page-B-wHGQFq.js} +4 -4
  51. package/out/renderer/assets/{mermaid.core-Dz6HoxZc.js → mermaid.core-SWP-HDAp.js} +19 -19
  52. package/out/renderer/assets/mindmap-definition-ALO5MXBD-ClOBpdLn.js +95 -0
  53. package/out/renderer/assets/{page-header-CYtvpazS.js → page-header-dIIc14HB.js} +1 -1
  54. package/out/renderer/assets/parseISO-BWtr7JL9.js +1 -0
  55. package/out/renderer/assets/{pieDiagram-IB7DONF6-DR4ycBv-.js → pieDiagram-IB7DONF6-UfaIVAHl.js} +1 -1
  56. package/out/renderer/assets/publication-validation-7J4xzRVZ.js +1 -0
  57. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DeXlOkVg.js → quadrantDiagram-7GDLP6J5-CFqb2JGS.js} +1 -1
  58. package/out/renderer/assets/{radar-MK3ICKWK-Dr73Vn9F.js → radar-MK3ICKWK-B10S5swo.js} +1 -1
  59. package/out/renderer/assets/radient-auth-buttons-QWV7OGyX.js +6 -0
  60. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DHeR1FW9.js → requirementDiagram-KVF5MWMF-BK2G66d1.js} +1 -1
  61. package/out/renderer/assets/sankeyDiagram-QLVOVGJD-D6ZCPpc5.js +10 -0
  62. package/out/renderer/assets/{schedules-page-C_-aK6li.js → schedules-page-CWzMhoam.js} +6 -6
  63. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BBJ035AA.js → sequenceDiagram-X6HHIX6F-AtPuLuMe.js} +1 -1
  64. package/out/renderer/assets/settings-page-CAn17oJ5.js +307 -0
  65. package/out/renderer/assets/stateDiagram-DGXRK772-CeU2ihmd.js +1 -0
  66. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-a70Okwiy.js +1 -0
  67. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BbDHYeOO.js → timeline-definition-BDJGKUSR-I9TCj8Py.js} +1 -1
  68. package/out/renderer/assets/use-download-agent-mutation-9tp697PV.js +61 -0
  69. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-B4F8fbBE.js → xychartDiagram-VJFVF3MP-COG1JJYK.js} +1 -1
  70. package/out/renderer/console-capture.html +4 -4
  71. package/out/renderer/index.html +6 -6
  72. package/out/renderer/installer.html +4 -4
  73. package/package.json +31 -2
  74. package/out/renderer/assets/agent-hub-page-CVdr01hB.js +0 -6
  75. package/out/renderer/assets/agents-page-HLrle3JE.js +0 -2
  76. package/out/renderer/assets/architectureDiagram-IEHRJDOE-CIccpocP.js +0 -36
  77. package/out/renderer/assets/browser-page-DA_7uhAS.js +0 -1
  78. package/out/renderer/assets/browser-webauthn-prompt-DIhdYBWb.js +0 -1
  79. package/out/renderer/assets/channel-Bpdhm4cc.js +0 -1
  80. package/out/renderer/assets/classDiagram-GIVACNV2-Dy2svcuX.js +0 -1
  81. package/out/renderer/assets/classDiagram-v2-COTLJTTW-Dy2svcuX.js +0 -1
  82. package/out/renderer/assets/clone-DnsDN_54.js +0 -1
  83. package/out/renderer/assets/compact-pagination-BRR4OtYY.js +0 -1
  84. package/out/renderer/assets/dagre-OKDRZEBW-CeR0cM5J.js +0 -4
  85. package/out/renderer/assets/diagram-SSKATNLV-Dhu6_Dgz.js +0 -43
  86. package/out/renderer/assets/diagram-VNBRO52H-R0bWQ_Bk.js +0 -24
  87. package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-PSAaXIbu.js +0 -65
  88. package/out/renderer/assets/icon-Cf2gAQ3W.js +0 -126
  89. package/out/renderer/assets/index-BIOY3qsR.css +0 -1
  90. package/out/renderer/assets/index-BV47Bsxl.js +0 -7
  91. package/out/renderer/assets/index-ByGvbPtr.css +0 -1
  92. package/out/renderer/assets/index-CeIHQLkR.js +0 -1
  93. package/out/renderer/assets/infoDiagram-PH2N3AL5-0WYVxV0b.js +0 -2
  94. package/out/renderer/assets/installer-DIgdF65j.js +0 -1
  95. package/out/renderer/assets/mindmap-definition-ALO5MXBD-C9GjbpP_.js +0 -95
  96. package/out/renderer/assets/parseISO-BIBIiSY3.js +0 -6
  97. package/out/renderer/assets/radient-auth-buttons--2wV70ya.js +0 -6
  98. package/out/renderer/assets/sankeyDiagram-QLVOVGJD-BwC3hgaf.js +0 -10
  99. package/out/renderer/assets/settings-page-BxyC2IfK.js +0 -307
  100. package/out/renderer/assets/stateDiagram-DGXRK772-DAU2jfYz.js +0 -1
  101. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DJY1Hfi_.js +0 -1
  102. package/out/renderer/assets/use-agent-like-mutation-asZxgbHQ.js +0 -1
package/out/main/index.js CHANGED
@@ -17,7 +17,7 @@ const promises = require("node:fs/promises");
17
17
  const node_net = require("node:net");
18
18
  const headless = require("@xterm/headless");
19
19
  const node_module = require("node:module");
20
- const node_http = require("node:http");
20
+ const http = require("node:http");
21
21
  const https = require("node:https");
22
22
  const electronUpdater = require("electron-updater");
23
23
  function _interopNamespaceDefault(e) {
@@ -37,6 +37,7 @@ function _interopNamespaceDefault(e) {
37
37
  return Object.freeze(n);
38
38
  }
39
39
  const path__namespace = /* @__PURE__ */ _interopNamespaceDefault(path$1);
40
+ const http__namespace = /* @__PURE__ */ _interopNamespaceDefault(http);
40
41
  const https__namespace = /* @__PURE__ */ _interopNamespaceDefault(https);
41
42
  const icon = path.join(__dirname, "../../resources/icon.png");
42
43
  const BACKEND_STATUS_CHANNEL = "backend-status";
@@ -389,7 +390,18 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
389
390
  * actionable is strictly additive, and a `null` here would be a
390
391
  * different request for every caller that never asked.
391
392
  */
392
- model: modelSelection.optional()
393
+ model: modelSelection.optional(),
394
+ /*
395
+ * The id a `sessions.draft` mint handed the pane, when it has one: the
396
+ * create then adopts that id (and the runtime already engaged for it)
397
+ * instead of minting a fresh session id. OMITTED when the pane never
398
+ * minted — an older backend, a draft the user sent before the first
399
+ * keystroke's mint answered, or a set of fields that changed since the
400
+ * mint (see the store's drop rule) — so the body is byte-for-byte the
401
+ * request this op sent before the draft could be warmed, and a backend
402
+ * that cannot resolve the id mints fresh rather than failing the send.
403
+ */
404
+ draftId: sessionId.optional()
393
405
  }).strict(),
394
406
  /*
395
407
  * What a NEW conversation's readings would be, without creating anything.
@@ -423,6 +435,49 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
423
435
  first turn will actually get. */
424
436
  model: modelSelection.optional()
425
437
  }).strict(),
438
+ /*
439
+ * Mint the id a NEW chat's runtime is warmed and then born on, from the
440
+ * pane's first keystroke.
441
+ *
442
+ * WHY THE OP EXISTS. A draft pane has no session to address, so there is
443
+ * nothing to warm: `sessions.warm` needs an id, and the multi-second engage
444
+ * the first send pays is exactly what the draft cannot pre-empt without one.
445
+ * The mint allocates that id and registers it with the daemon (fast, no
446
+ * engage), and the id then unlocks the same three doors a session pane uses
447
+ * — `events`, `watch` (the lease) and `warm` — through a deliberately narrow
448
+ * allow-list on the backend. `sessions.create` adopts the id on send, so the
449
+ * conversation the user lands in IS the one that was warmed; a daemon that
450
+ * has never seen the id (restart, expiry, eviction) mints fresh and the send
451
+ * works exactly as it did before this op existed.
452
+ *
453
+ * THE MINT ENGAGES NOTHING, and that is the lifetime design rather than an
454
+ * omission: the pane's own subscription and watch lease hold the bridge that
455
+ * keeps a warm alive, exactly as they do for a session, so abandoning the
456
+ * pane cancels an in-flight warm through the same `_detach` and a runtime
457
+ * nobody holds is reaped by the residency drain. There is no second, warmer-
458
+ * owned lifetime to get wrong.
459
+ *
460
+ * `requestId` IS A RECEIPT KEY, unlike `sessions.preview`'s token: a mint is
461
+ * fired once per pane, and a retry — a fast second keystroke, a lost
462
+ * response — must replay the SAME id, because two ids for one pane would
463
+ * warm two runtimes and leave a registry entry nobody can ever consume.
464
+ *
465
+ * The body is `sessions.create`'s first half, deliberately: same `cwd` bounds,
466
+ * same optional `target`, same optional `model` and the same 422 for an
467
+ * unresolvable profile. The pane is asking the question it will ask for real
468
+ * on the first send, so both derive from one selection and cannot disagree.
469
+ *
470
+ * Deliberately NOT a `MESSAGE_OPS` member (see `desktopRequestByteBudget`):
471
+ * a path and two optional short ids are not prose, so the mint costs the
472
+ * control budget — which is what lets a KEYSTROKE issue it.
473
+ */
474
+ zod.z.object({
475
+ op: zod.z.literal("sessions.draft"),
476
+ requestId,
477
+ cwd: zod.z.string().min(1).max(4096),
478
+ target: target.optional(),
479
+ model: modelSelection.optional()
480
+ }).strict(),
426
481
  zod.z.object({ op: zod.z.literal("sessions.get"), sessionId }).strict(),
427
482
  zod.z.object({
428
483
  op: zod.z.literal("sessions.history"),
@@ -859,6 +914,29 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
859
914
  untilMs: zod.z.number().int().nonnegative().optional(),
860
915
  days: zod.z.number().int().min(1).max(366).optional()
861
916
  }).strict(),
917
+ /*
918
+ * The per-model rate table's own read, and a SEPARATE op on purpose.
919
+ *
920
+ * Its rows come from a grouped scan of the RAW LEDGER rather than from the
921
+ * rollup `analytics.get` reads, which is what makes it cover the operator's
922
+ * whole existing history rather than only the days since the rollup shipped
923
+ * — and is also what makes it cost seconds on a large ledger. Riding
924
+ * `analytics.get` would add that scan to every analytics panel load,
925
+ * including the ones that never scroll to the table, so it is fetched on its
926
+ * own and its wait is bounded and stated on its own section.
927
+ *
928
+ * Same arguments as `analytics.get`, same `.strict()` door, same window
929
+ * bounds: the two ops are windowed by the same `since_ms`/`until_ms`, which
930
+ * is the one property that lets a reader hold a row here against the
931
+ * headline Total above it.
932
+ */
933
+ zod.z.object({
934
+ op: zod.z.literal("analytics.models"),
935
+ sessionId: sessionId.optional(),
936
+ sinceMs: zod.z.number().int().nonnegative().optional(),
937
+ untilMs: zod.z.number().int().nonnegative().optional(),
938
+ days: zod.z.number().int().min(1).max(366).optional()
939
+ }).strict(),
862
940
  /*
863
941
  * The two diagnostics reads. They ride their own capability key
864
942
  * (`diagnostics`) rather than the catalogue one, because `/analytics` and
@@ -1291,6 +1369,15 @@ const DESKTOP_CONTROL_DEADLINE_MS = 2e4;
1291
1369
  const DESKTOP_LONG_READ_DEADLINE_MS = 9e4;
1292
1370
  const LEDGER_READ_OPS = /* @__PURE__ */ new Set([
1293
1371
  "analytics.get",
1372
+ /*
1373
+ * The worst case of the same shape, and the reason the set is spelled by
1374
+ * shape rather than by measured cost: `/analytics/models` is a grouped scan
1375
+ * of the raw ledger with no covering index, measured in SECONDS on a 1.95 M
1376
+ * row ledger where `analytics.get` measures 12-40 s cold. On the control
1377
+ * budget it would be abandoned mid-read on nearly every open, so it is here
1378
+ * beside the op it is a slower sibling of.
1379
+ */
1380
+ "analytics.models",
1294
1381
  "sessions.report"
1295
1382
  ]);
1296
1383
  const PROVIDER_READ_OPS = /* @__PURE__ */ new Set(["usage.get"]);
@@ -1307,6 +1394,7 @@ const READ_ONLY_OPS = /* @__PURE__ */ new Set([
1307
1394
  "capabilities",
1308
1395
  "accounts.list",
1309
1396
  "analytics.get",
1397
+ "analytics.models",
1310
1398
  "commands.entities",
1311
1399
  "commands.list",
1312
1400
  "config.get",
@@ -1346,6 +1434,7 @@ const READ_ONLY_OPS = /* @__PURE__ */ new Set([
1346
1434
  ]);
1347
1435
  const PANEL_READ_OPS = /* @__PURE__ */ new Set([
1348
1436
  "analytics.get",
1437
+ "analytics.models",
1349
1438
  "usage.get",
1350
1439
  "sessions.report",
1351
1440
  "info.get",
@@ -1493,7 +1582,10 @@ function desktopEndpoint(request) {
1493
1582
  request_id: request.requestId,
1494
1583
  cwd: request.cwd,
1495
1584
  ...request.target ? { target: request.target } : {},
1496
- ...request.model ? { model: request.model } : {}
1585
+ ...request.model ? { model: request.model } : {},
1586
+ // Omitted, not nulled, when the pane has no minted id: see the field's
1587
+ // own note for the byte-identity promise this keeps.
1588
+ ...request.draftId ? { draft_id: request.draftId } : {}
1497
1589
  }
1498
1590
  };
1499
1591
  case "sessions.preview":
@@ -1507,6 +1599,17 @@ function desktopEndpoint(request) {
1507
1599
  ...request.model ? { model: request.model } : {}
1508
1600
  }
1509
1601
  };
1602
+ case "sessions.draft":
1603
+ return {
1604
+ path: "/v1/desktop/sessions/draft",
1605
+ method: "POST",
1606
+ body: {
1607
+ request_id: request.requestId,
1608
+ cwd: request.cwd,
1609
+ ...request.target ? { target: request.target } : {},
1610
+ ...request.model ? { model: request.model } : {}
1611
+ }
1612
+ };
1510
1613
  case "sessions.get":
1511
1614
  return {
1512
1615
  path: `/v1/desktop/sessions/${request.sessionId}`,
@@ -1902,6 +2005,25 @@ function desktopEndpoint(request) {
1902
2005
  query.set("until_ms", String(request.untilMs));
1903
2006
  return { path: `/v1/desktop/analytics?${query}`, method: "GET" };
1904
2007
  }
2008
+ /*
2009
+ * `/analytics/models`, NOT `/analytics?models=1`.
2010
+ *
2011
+ * The path segment is what keeps the two reads separable at the
2012
+ * transport: `desktopRequestDeadlineMs` sizes a wait from the op, and an
2013
+ * op that sometimes means "the cheap rollup" and sometimes "a 1.95 M-row
2014
+ * ledger scan" cannot have one honest budget. The two spellings of the
2015
+ * query string below are deliberately identical to `analytics.get`'s, so
2016
+ * the server resolves one window from either.
2017
+ */
2018
+ case "analytics.models": {
2019
+ const query = new URLSearchParams({ days: String(request.days ?? 30) });
2020
+ if (request.sessionId) query.set("session_id", request.sessionId);
2021
+ if (request.sinceMs !== void 0)
2022
+ query.set("since_ms", String(request.sinceMs));
2023
+ if (request.untilMs !== void 0)
2024
+ query.set("until_ms", String(request.untilMs));
2025
+ return { path: `/v1/desktop/analytics/models?${query}`, method: "GET" };
2026
+ }
1905
2027
  case "info.get":
1906
2028
  return { path: "/v1/desktop/info", method: "GET" };
1907
2029
  case "sessions.report": {
@@ -2136,6 +2258,115 @@ function readLaunchTarget(argv) {
2136
2258
  if (argv.includes(OPEN_CATALOGUE_FLAG)) return { kind: "catalogue" };
2137
2259
  return { kind: "restore" };
2138
2260
  }
2261
+ const WINDOW_CHROME_MODES = ["integrated", "native"];
2262
+ const WINDOW_CHROME_FLAG = "--window-chrome";
2263
+ const WINDOW_CHROME_ENV = "LOCAL_OPERATOR_UI_WINDOW_CHROME";
2264
+ const WINDOW_CHROME_ARG = "--lo-window-chrome";
2265
+ const WINDOW_CHROME_HEIGHT = 40;
2266
+ const WINDOW_CHROME_TRANSPARENT = "#00000000";
2267
+ const DEFAULT_WINDOW_CHROME_COLORS = {
2268
+ ground: "#22201c",
2269
+ symbol: "#c2bcaf",
2270
+ symbolInactive: "#a6a091"
2271
+ };
2272
+ function chromePlatformFor(platform) {
2273
+ if (platform === "darwin") return "mac";
2274
+ if (platform === "win32") return "win";
2275
+ return "linux";
2276
+ }
2277
+ function parseWindowChromeMode(value) {
2278
+ if (value === void 0) return null;
2279
+ const normalized = value.trim().toLowerCase();
2280
+ return WINDOW_CHROME_MODES.includes(normalized) ? normalized : null;
2281
+ }
2282
+ function readWindowChromeFlag(argv, name = WINDOW_CHROME_FLAG) {
2283
+ let found = false;
2284
+ let valueless = false;
2285
+ let value;
2286
+ for (let index = 0; index < argv.length; index += 1) {
2287
+ const argument = argv[index];
2288
+ if (argument === name) {
2289
+ const next = argv[index + 1];
2290
+ found = true;
2291
+ if (next === void 0 || next.startsWith("--")) {
2292
+ valueless = true;
2293
+ continue;
2294
+ }
2295
+ value = next;
2296
+ continue;
2297
+ }
2298
+ if (argument.startsWith(`${name}=`)) {
2299
+ found = true;
2300
+ value = argument.slice(name.length + 1);
2301
+ }
2302
+ }
2303
+ return { found, value, valueless };
2304
+ }
2305
+ function resolveWindowChrome(input) {
2306
+ const problems = [];
2307
+ const argv = input.argv ?? [];
2308
+ const env = input.env ?? {};
2309
+ const persisted = input.persisted ?? null;
2310
+ const flag = readWindowChromeFlag(argv);
2311
+ if (flag.found) {
2312
+ const parsed = parseWindowChromeMode(flag.value);
2313
+ if (parsed !== null) return { mode: parsed, source: "argument", problems };
2314
+ if (flag.valueless) {
2315
+ problems.push(
2316
+ `${WINDOW_CHROME_FLAG} was given with no value and names nothing`
2317
+ );
2318
+ } else {
2319
+ problems.push(
2320
+ `${WINDOW_CHROME_FLAG}=${flag.value} is not one of ${WINDOW_CHROME_MODES.join(", ")}`
2321
+ );
2322
+ }
2323
+ }
2324
+ const fromEnv = env[WINDOW_CHROME_ENV];
2325
+ if (fromEnv !== void 0 && fromEnv.trim() !== "") {
2326
+ const parsed = parseWindowChromeMode(fromEnv);
2327
+ if (parsed !== null)
2328
+ return { mode: parsed, source: "environment", problems };
2329
+ problems.push(
2330
+ `${WINDOW_CHROME_ENV}=${fromEnv} is not one of ${WINDOW_CHROME_MODES.join(", ")}`
2331
+ );
2332
+ }
2333
+ if (persisted !== null)
2334
+ return { mode: persisted, source: "persisted", problems };
2335
+ return { mode: "integrated", source: "default", problems };
2336
+ }
2337
+ function windowChromeArgument(facts) {
2338
+ return `${WINDOW_CHROME_ARG}=${facts.platform}:${facts.mode}`;
2339
+ }
2340
+ const WINDOW_CHROME_REPORT_CHANNEL = "window-chrome:report";
2341
+ const WINDOW_CHROME_STATE_CHANNEL = "window-chrome:state";
2342
+ const WINDOW_CHROME_MENU_CHANNEL = "window-chrome:popup-app-menu";
2343
+ const WINDOW_CHROME_FILE = "window-chrome.json";
2344
+ function windowChromeFactsFor(platform, mode) {
2345
+ return { platform: chromePlatformFor(platform), mode };
2346
+ }
2347
+ function windowChromeArgumentFor(platform, mode) {
2348
+ return windowChromeArgument(windowChromeFactsFor(platform, mode));
2349
+ }
2350
+ function resolveWindowChromeForLaunch(input) {
2351
+ const resolved = resolveWindowChrome({
2352
+ argv: input.argv,
2353
+ env: input.env,
2354
+ persisted: input.persisted?.mode ?? null
2355
+ });
2356
+ const problems = [...resolved.problems];
2357
+ const macRequestedNative = input.platform === "darwin" && resolved.mode === "native";
2358
+ if (macRequestedNative) {
2359
+ problems.push(
2360
+ "macOS has no native-frame fallback (the frame options are #462's shipped design and the renderer's 32px lane depends on them), so --window-chrome=native is ignored here: the app runs integrated"
2361
+ );
2362
+ }
2363
+ return {
2364
+ mode: macRequestedNative ? "integrated" : resolved.mode,
2365
+ colors: input.persisted?.colors ?? DEFAULT_WINDOW_CHROME_COLORS,
2366
+ source: input.persisted?.colors ? `${resolved.source}+persisted-colors` : resolved.source,
2367
+ problems
2368
+ };
2369
+ }
2139
2370
  const SERVE_RUN_DIRNAME = "run/serve";
2140
2371
  const REAPED_DIRNAME = "reaped";
2141
2372
  const REAPED_MAX_FILES = 200;
@@ -5376,9 +5607,9 @@ const SILENCE_TIMEOUT_MS = 45e3;
5376
5607
  const WATCHDOG_TICK_MS = 1e3;
5377
5608
  const MAX_FRAME_BYTES$1 = 8 * 1024 * 1024;
5378
5609
  const LINE_ENDING = /\r\n|\n|\r/;
5379
- const BLANK_LINE = /(?:\r\n|\n|\r)(?:\r\n|\n|\r)/;
5610
+ const BLANK_LINE$1 = /(?:\r\n|\n|\r)(?:\r\n|\n|\r)/;
5380
5611
  function nextRecord(buffer) {
5381
- const blank = BLANK_LINE.exec(buffer);
5612
+ const blank = BLANK_LINE$1.exec(buffer);
5382
5613
  if (blank === null) return null;
5383
5614
  const data = [];
5384
5615
  for (const line of buffer.slice(0, blank.index).split(LINE_ENDING)) {
@@ -7350,8 +7581,9 @@ function runBounded(command, args, env, bounds) {
7350
7581
  );
7351
7582
  });
7352
7583
  }
7584
+ const NEWLINES = /\r?\n/;
7353
7585
  const errorTail = (text, lines = 3) => {
7354
- const kept = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
7586
+ const kept = text.split(NEWLINES).map((line) => line.trim()).filter(Boolean);
7355
7587
  if (kept.length === 0) return "";
7356
7588
  const tail = kept.slice(-lines).join(" | ");
7357
7589
  return kept.length > lines ? `… ${tail}` : tail;
@@ -10969,7 +11201,7 @@ function managedPythonOptions() {
10969
11201
  arch: process.arch
10970
11202
  };
10971
11203
  }
10972
- 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 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# Nothing is fetched here but the package itself.\n#\n# This script used to download a third-party FFmpeg binary from a GitHub release\n# into `${APP_DATA_DIR}/bin`, and under `set -e` a failed download killed the\n# install before the venv existed - so a machine that can reach PyPI but not\n# github.com could not install at all. Nothing in the app or in `local-operator`\n# ever executed that binary. Tooling a task actually needs is acquired later, on\n# demand, through the app\'s Console with the user\'s approval; this script\'s job is\n# the environment below and nothing else. (`_log_internal`, the logging helper\n# that read like a second `log` and existed only for that section, went with it.)\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.\n#\n# Bounded on both spellings: `--connect-timeout 5`/`--max-time 30` for curl and\n# `--timeout=30`/`--tries=1` for wget (a single attempt, whose timeout covers\n# connect and read since wget has no separate connect bound for BusyBox). 5 ends\n# a black-hole network; 30 is the stall bound and is deliberately well above a\n# slow-but-working link\'s answer time, because a probe that fails a working\n# connection is noise users learn to ignore.\n#\n# `--fail` turns an HTTP error status into a non-zero exit (a proxy\'s 403/407,\n# any 4xx/5xx). It does NOT notice a captive portal answering 200 with its own\n# HTML page - measured, a portal-shaped 200 exits 0 with and without the flag -\n# so this check answers "did a request to the host complete", and the PyPI probe\n# below is the one that also checks WHAT came back.\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 --fail --connect-timeout 5 --max-time 30 -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=30 --tries=1 -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 "|LO1:environment"\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 --fail --connect-timeout 10 --max-time 60 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 --timeout=30 --tries=1 -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 --fail --connect-timeout 10 --max-time 60 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# --- The package install: uv when there is one, pip otherwise ------------------\n#\n# Same shape as the macOS script, and for the same reasons: uv resolves and\n# fetches in parallel - measured there, cold cache, three runs each, same\n# interpreter: uv\'s package install is 12.8-16.1 s against pip\'s 33.0-40.7 s,\n# plus the 2.3-2.8 s pip self-upgrade this path skips, so 1.5-2.8x across two\n# operators rather than the "14.8 s against 128.9 s" quoted here before that\n# reading was withdrawn (`docs/BUILD.md` has the full set). The pip path below is\n# unchanged and runs whenever uv is absent or cannot do the job, and pip STAYS in the venv\n# because the app\'s backend-update path runs `pip install --upgrade\n# local-operator` inside this same environment - which is also why the venv is\n# still created with `python -m venv` rather than `uv venv`.\n#\n# Nothing here searches PATH for a uv: an installed uv is a version and a\n# configuration nobody in this repository chose. `LOCAL_OPERATOR_UV_BIN` is the\n# app\'s own answer (`src/main/backend/uv-tool.ts`).\nUV_BIN="${LOCAL_OPERATOR_UV_BIN:-}"\n\n# Drop every UV_* variable the launching environment carried. Measured on uv\n# 0.12.17: a user-level `uv.toml` naming an unreachable index is obeyed by\n# `uv pip install` and ignored with `UV_NO_CONFIG=1`; an ambient `UV_INDEX_URL`\n# changes where packages come from, while `PIP_INDEX_URL` does not affect uv at\n# all. A name list would drift the day uv adds a variable - the namespace cannot.\n#\n# IT RUNS BEFORE THE SETTINGS BELOW ARE SET: a sweep after them takes them away,\n# and an empty `UV_CACHE_DIR` makes uv exit 2 with `a value is required for\n# \'--cache-dir <CACHE_DIR>\'` rather than falling back to a default.\nfor uv_ambient in $(env | sed -n \'s/^\\(UV_[A-Za-z0-9_]*\\)=.*/\\1/p\'); do\n unset "$uv_ambient"\ndone\n\n# The cache lives under the app\'s own support directory rather than the user\'s\n# shared `~/.cache/uv`, and is handed to uv per invocation rather than exported.\n# It persists (~118 MB for a full install, measured on macOS) and nothing else\n# reads it today; it is what makes a retry converge in seconds rather than tens\n# of seconds.\nUV_CACHE_DIR="${APP_DATA_DIR}/uv-cache"\n\n# Is the handed-down uv something we can actually run?\nuv_is_usable() {\n [ -n "${UV_BIN}" ] && [ -x "${UV_BIN}" ] && "${UV_BIN}" --version >/dev/null 2>&1\n}\n\n# UV_NO_CONFIG: never read `pyproject.toml`/`uv.toml`, wherever they are.\n# UV_PYTHON_DOWNLOADS=never: this install uses the interpreter it was handed and\n# never fetches another.\nuv_run() {\n UV_NO_CONFIG=1 UV_PYTHON_DOWNLOADS=never UV_CACHE_DIR="${UV_CACHE_DIR}" \\\n "${UV_BIN}" "$@"\n}\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\n# Check network connectivity to PyPI. A DIAGNOSTIC, not a gate: the install below\n# decides whether it can proceed.\n#\n# What this answers, stated precisely because an earlier version of this comment\n# claimed more than the flags buy: "did a TLS fetch to PyPI\'s JSON API complete,\n# and did the answer come back as JSON?". `--fail` turns an HTTP ERROR status\n# into a non-zero exit, and does NOT notice a captive portal answering 200 with\n# its own HTML page (measured: a portal-shaped 200 exits 0 with and without the\n# flag). Both spellings therefore check the CONTENT TYPE as well, since a portal\n# that reports "reachable" while pip is about to fail is the false negative this\n# warning exists to catch: curl through `-w \'%{content_type}\'`, and wget through\n# the response headers `-S` prints (`-S`/`--server-response` is in BusyBox\'s wget\n# since 2017 as well as GNU\'s, which is the wget a system without curl has).\n#\n# What the content type does NOT prove, stated so this paragraph is not read as\n# more than it says: a proxy answering 200 with `application/json` and an error\n# body (`{"detail":"blocked by proxy policy"}`) is silent here, because the\n# answer did come back as JSON. Only parsing the body - a fetch of PyPI\'s own\n# payload shape - would tell those apart, and a diagnostic that costs a parse is\n# not what stands in front of an install.\n#\n# Two bounds, two jobs: the connect bound ends a black-hole network, the total\n# stops a connected-but-stalled peer. Both must be POSITIVE - `--max-time 0` and\n# `--timeout=0` disable the bound rather than making it immediate on both curl and\n# wget. 30 rather than 10 because the total must not fire on a slow-but-working\n# link: a working endpoint that answered in 15s tripped a 10s bound and printed\n# this warning on an install that then succeeded.\necho "Checking network connectivity to PyPI..."\nif command_exists curl; then\n PYPI_PROBE_CONTENT_TYPE=$(curl -s --fail --connect-timeout 5 --max-time 30 -o /dev/null -w \'%{content_type}\' https://pypi.org/pypi/local-operator/json) || PYPI_PROBE_CONTENT_TYPE=""\n if [[ "${PYPI_PROBE_CONTENT_TYPE}" != application/json* ]]; then\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n fi\nelif command_exists wget; then\n # The headers are captured into a variable and matched with a here-string\n # rather than piped into `grep -q`: `-q` exits on the first match, the closed\n # pipe gives wget SIGPIPE, and the script runs under `set -o pipefail`, so a\n # WORKING link printed this warning whenever the match landed before wget had\n # finished writing its headers. The piped shape was this remediation\'s own - the\n # first fix piped it and was measured warning 1 time in 20 against real PyPI\n # (and 1 in 20 against a healthy local endpoint) while this shape warns 0 in 40\n # with the same flags - so the trap is recorded here because it is one line away\n # from being reintroduced, not because the released script ever shipped it.\n PYPI_PROBE_HEADERS=$(wget -q -S --spider --timeout=30 --tries=1 https://pypi.org/pypi/local-operator/json 2>&1) || PYPI_PROBE_HEADERS=""\n if ! grep -qi \'^ *content-type: application/json\' <<< "$PYPI_PROBE_HEADERS"; then\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n fi\nfi\n\n# --- Progress markers -------------------------------------------------------\n# One whole line per phase, read by the app and shown in the setup window. The\n# app matches the ENTIRE line (`|LO1:<phase>`, see src/shared/install-progress.ts)\n# and never a substring, so a marker has to stand alone: do not wrap it in\n# other text, do not re-indent it into a longer sentence, and do not emit one\n# for work this script does not actually do. A missing marker leaves the window\n# on its previous step, which is the honest failure; a marker a log line also\n# happens to produce is a wrong step presented as a measurement.\n#\n# WHY THE SCRIPT AND NOT ONLY THE APP: the install below is minutes of work on a\n# cold machine, and the app cannot see inside the venv it is about to create -\n# this is the only process that knows when the environment exists and when the\n# download starts.\necho "|LO1:components"\nUV_INSTALLED=false\nif uv_is_usable; then\n echo "Installing local-operator with uv ($("${UV_BIN}" --version 2>/dev/null || echo \'version unavailable\'))..."\n # No `pip install --upgrade pip` on this path: uv does not use pip.\n if uv_run pip install --python "${VENV_PATH}/bin/python" --upgrade local-operator; then\n UV_INSTALLED=true\n echo "local-operator installation with uv successful"\n else\n # The exit code matters here for the same reason it does on macOS: the\n # fallback is deliberately forgiving, so this line is the only evidence that\n # a bundled uv is present and failing for every user (QA Q2).\n UV_STATUS=$?\n echo "WARNING: the bundled uv is present but its install failed (exit ${UV_STATUS}); retrying with pip, which is what this script used before uv was bundled."\n fi\nelse\n echo "Bundled uv not available (LOCAL_OPERATOR_UV_BIN=${UV_BIN:-unset}); installing with pip."\nfi\n\nif [ "${UV_INSTALLED}" != true ]; then\n echo "Upgrading pip..."\n python -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 echo "Installing local-operator package..."\n python -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 }\nfi\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';
11204
+ 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 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# Nothing is fetched here but the package itself.\n#\n# This script used to download a third-party FFmpeg binary from a GitHub release\n# into `${APP_DATA_DIR}/bin`, and under `set -e` a failed download killed the\n# install before the venv existed - so a machine that can reach PyPI but not\n# github.com could not install at all. Nothing in the app or in `local-operator`\n# ever executed that binary. Tooling a task actually needs is acquired later, on\n# demand, through the app\'s Console with the user\'s approval; this script\'s job is\n# the environment below and nothing else. (`_log_internal`, the logging helper\n# that read like a second `log` and existed only for that section, went with it.)\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.\n#\n# Bounded on both spellings: `--connect-timeout 5`/`--max-time 30` for curl and\n# `--timeout=30`/`--tries=1` for wget (a single attempt, whose timeout covers\n# connect and read since wget has no separate connect bound for BusyBox). 5 ends\n# a black-hole network; 30 is the stall bound and is deliberately well above a\n# slow-but-working link\'s answer time, because a probe that fails a working\n# connection is noise users learn to ignore.\n#\n# `--fail` turns an HTTP error status into a non-zero exit (a proxy\'s 403/407,\n# any 4xx/5xx). It does NOT notice a captive portal answering 200 with its own\n# HTML page - measured, a portal-shaped 200 exits 0 with and without the flag -\n# so this check answers "did a request to the host complete", and the PyPI probe\n# below is the one that also checks WHAT came back.\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 --fail --connect-timeout 5 --max-time 30 -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=30 --tries=1 -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 "|LO1:environment"\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 --fail --connect-timeout 10 --max-time 60 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 --timeout=30 --tries=1 -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 --fail --connect-timeout 10 --max-time 60 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# --- The package install: uv when there is one, pip otherwise ------------------\n#\n# Same shape as the macOS script, and for the same reasons: uv resolves and\n# fetches in parallel - measured there, cold cache, three runs each, same\n# interpreter: uv\'s package install is 12.8-16.1 s against pip\'s 33.0-40.7 s,\n# plus the 2.3-2.8 s pip self-upgrade this path skips, so 1.5-2.8x across two\n# operators rather than the "14.8 s against 128.9 s" quoted here before that\n# reading was withdrawn (`docs/BUILD.md` has the full set). The pip path below is\n# unchanged and runs whenever uv is absent or cannot do the job, and pip STAYS in the venv\n# because the app\'s backend-update path runs `pip install --upgrade\n# local-operator` inside this same environment - which is also why the venv is\n# still created with `python -m venv` rather than `uv venv`.\n#\n# Nothing here searches PATH for a uv: an installed uv is a version and a\n# configuration nobody in this repository chose. `LOCAL_OPERATOR_UV_BIN` is the\n# app\'s own answer (`src/main/backend/uv-tool.ts`).\nUV_BIN="${LOCAL_OPERATOR_UV_BIN:-}"\n\n# Drop every UV_* variable the launching environment carried. Measured on uv\n# 0.12.17: a user-level `uv.toml` naming an unreachable index is obeyed by\n# `uv pip install` and ignored with `UV_NO_CONFIG=1`; an ambient `UV_INDEX_URL`\n# changes where packages come from, while `PIP_INDEX_URL` does not affect uv at\n# all. A name list would drift the day uv adds a variable - the namespace cannot.\n#\n# IT RUNS BEFORE THE SETTINGS BELOW ARE SET: a sweep after them takes them away,\n# and an empty `UV_CACHE_DIR` makes uv exit 2 with `a value is required for\n# \'--cache-dir <CACHE_DIR>\'` rather than falling back to a default.\nfor uv_ambient in $(env | sed -n \'s/^\\(UV_[A-Za-z0-9_]*\\)=.*/\\1/p\'); do\n unset "$uv_ambient"\ndone\n\n# The cache lives under the app\'s own support directory rather than the user\'s\n# shared `~/.cache/uv`, and is handed to uv per invocation rather than exported.\n# It persists (~118 MB for a full install, measured on macOS) and nothing else\n# reads it today; it is what makes a retry converge in seconds rather than tens\n# of seconds.\nUV_CACHE_DIR="${APP_DATA_DIR}/uv-cache"\n\n# Is the handed-down uv something we can actually run?\nuv_is_usable() {\n [ -n "${UV_BIN}" ] && [ -x "${UV_BIN}" ] && "${UV_BIN}" --version >/dev/null 2>&1\n}\n\n# UV_NO_CONFIG: never read `pyproject.toml`/`uv.toml`, wherever they are.\n# UV_PYTHON_DOWNLOADS=never: this install uses the interpreter it was handed and\n# never fetches another.\nuv_run() {\n UV_NO_CONFIG=1 UV_PYTHON_DOWNLOADS=never UV_CACHE_DIR="${UV_CACHE_DIR}" \\\n "${UV_BIN}" "$@"\n}\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\n# Check network connectivity to PyPI. A DIAGNOSTIC, not a gate: the install below\n# decides whether it can proceed.\n#\n# What this answers, stated precisely because an earlier version of this comment\n# claimed more than the flags buy: "did a TLS fetch to PyPI\'s JSON API complete,\n# and did the answer come back as JSON?". `--fail` turns an HTTP ERROR status\n# into a non-zero exit, and does NOT notice a captive portal answering 200 with\n# its own HTML page (measured: a portal-shaped 200 exits 0 with and without the\n# flag). Both spellings therefore check the CONTENT TYPE as well, since a portal\n# that reports "reachable" while pip is about to fail is the false negative this\n# warning exists to catch: curl through `-w \'%{content_type}\'`, and wget through\n# the response headers `-S` prints (`-S`/`--server-response` is in BusyBox\'s wget\n# since 2017 as well as GNU\'s, which is the wget a system without curl has).\n#\n# What the content type does NOT prove, stated so this paragraph is not read as\n# more than it says: a proxy answering 200 with `application/json` and an error\n# body (`{"detail":"blocked by proxy policy"}`) is silent here, because the\n# answer did come back as JSON. Only parsing the body - a fetch of PyPI\'s own\n# payload shape - would tell those apart, and a diagnostic that costs a parse is\n# not what stands in front of an install.\n#\n# Two bounds, two jobs: the connect bound ends a black-hole network, the total\n# stops a connected-but-stalled peer. Both must be POSITIVE - `--max-time 0` and\n# `--timeout=0` disable the bound rather than making it immediate on both curl and\n# wget. 30 rather than 10 because the total must not fire on a slow-but-working\n# link: a working endpoint that answered in 15s tripped a 10s bound and printed\n# this warning on an install that then succeeded.\necho "Checking network connectivity to PyPI..."\nif command_exists curl; then\n PYPI_PROBE_CONTENT_TYPE=$(curl -s --fail --connect-timeout 5 --max-time 30 -o /dev/null -w \'%{content_type}\' https://pypi.org/pypi/local-operator/json) || PYPI_PROBE_CONTENT_TYPE=""\n if [[ "${PYPI_PROBE_CONTENT_TYPE}" != application/json* ]]; then\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n fi\nelif command_exists wget; then\n # The headers are captured into a variable and matched with a here-string\n # rather than piped into `grep -q`: `-q` exits on the first match, the closed\n # pipe gives wget SIGPIPE, and the script runs under `set -o pipefail`, so a\n # WORKING link printed this warning whenever the match landed before wget had\n # finished writing its headers. The piped shape was this remediation\'s own - the\n # first fix piped it and was measured warning 1 time in 20 against real PyPI\n # (and 1 in 20 against a healthy local endpoint) while this shape warns 0 in 40\n # with the same flags - so the trap is recorded here because it is one line away\n # from being reintroduced, not because the released script ever shipped it.\n PYPI_PROBE_HEADERS=$(wget -q -S --spider --timeout=30 --tries=1 https://pypi.org/pypi/local-operator/json 2>&1) || PYPI_PROBE_HEADERS=""\n if ! grep -qi \'^ *content-type: application/json\' <<< "$PYPI_PROBE_HEADERS"; then\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n fi\nfi\n\n# --- Progress markers -------------------------------------------------------\n# One whole line per phase, read by the app and shown in the setup window. The\n# app matches the ENTIRE line (`|LO1:<phase>`, see src/shared/install-progress.ts)\n# and never a substring, so a marker has to stand alone: do not wrap it in\n# other text, do not re-indent it into a longer sentence, and do not emit one\n# for work this script does not actually do. A missing marker leaves the window\n# on its previous step, which is the honest failure; a marker a log line also\n# happens to produce is a wrong step presented as a measurement.\n#\n# WHY THE SCRIPT AND NOT ONLY THE APP: the install below is minutes of work on a\n# cold machine, and the app cannot see inside the venv it is about to create -\n# this is the only process that knows when the environment exists and when the\n# download starts.\necho "|LO1:components"\nUV_INSTALLED=false\nif uv_is_usable; then\n echo "Installing local-operator with uv ($("${UV_BIN}" --version 2>/dev/null || echo \'version unavailable\'))..."\n # No `pip install --upgrade pip` on this path: uv does not use pip.\n if uv_run pip install --python "${VENV_PATH}/bin/python" --upgrade local-operator; then\n UV_INSTALLED=true\n echo "local-operator installation with uv successful"\n else\n # The exit code matters here for the same reason it does on macOS: the\n # fallback is deliberately forgiving, so this line is the only evidence that\n # a bundled uv is present and failing for every user (QA Q2).\n UV_STATUS=$?\n echo "WARNING: the bundled uv is present but its install failed (exit ${UV_STATUS}); retrying with pip, which is what this script used before uv was bundled."\n fi\nelse\n if [ -n "${UV_BIN}" ]; then\n # Present but not runnable - the wrong architecture for this machine, a\n # truncated copy, a mount that lost the execute bit. Named apart from the\n # absent case below because the two are different facts about the install\n # (the Windows script has the same split, and this line\'s absence is how a\n # staged-for-the-wrong-arch uv reads as "nothing was staged"): both fall\n # back to pip, and only this one is a defect in what was delivered.\n echo "Bundled uv at ${UV_BIN} could not be run on this machine; installing with pip."\n else\n echo "Bundled uv not available (LOCAL_OPERATOR_UV_BIN=${UV_BIN:-unset}); installing with pip."\n fi\nfi\n\nif [ "${UV_INSTALLED}" != true ]; then\n echo "Upgrading pip..."\n python -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 echo "Installing local-operator package..."\n python -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 }\nfi\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';
10973
11205
  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# Nothing is fetched here but the package itself.\n#\n# This script used to download a third-party FFmpeg binary from a GitHub\n# release into `$APP_DATA_DIR/bin`. The macOS asset names it asked for do not\n# exist (curl without `--fail` wrote the 404 body to the binary path, `chmod +x`\n# made it executable, its own `[ -f ] && [ -x ]` verification passed and the next\n# run skipped the download, so the broken file was permanent), nothing in the app\n# or in `local-operator` ever executed it, and under `set -e` a failed download\n# killed the install before the venv existed - so a machine that can reach PyPI\n# but not github.com could not install at all. Tooling a task actually needs is\n# acquired later, on demand, through the app\'s Console with the user\'s approval;\n# this script\'s job is the environment below and nothing else.\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 "|LO1:environment"\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# --- The package install: uv when there is one, pip otherwise ------------------\n#\n# WHY UV. Installing the backend is the phase a user waits through on a first\n# run, and pip spends it resolving and fetching serially. Measured on this\n# machine, cold cache, three runs each, the same interpreter and dependency set:\n# pip\'s package install is 33.0-40.7 s against uv\'s 12.8-16.1 s, plus the\n# 2.3-2.8 s `pip install --upgrade pip` the uv path skips. QA\'s independent pair\n# on a quieter box was 33.9 s against 22.8 s, so read the ratio as 1.5-2.8x\n# ACROSS those two operators, and the seconds as this box\'s. Warm, a retry or a\n# repair: 15.9-33.8 s against 0.65-1.57 s.\n#\n# THE FIGURES THIS COMMENT USED TO QUOTE ARE WITHDRAWN, and this note is here so\n# they are not restored: "pip 128.9 s against uv 14.8 s, 178.3 s against 4.5 s\n# warm". The pip reading was taken at load ~90 and did not reproduce at five\n# further attempts. A comment that argues from a withdrawn number is how the next\n# maintainer decides on a ratio five times the measured one - and the decision it\n# argues is whether ~16-20 MiB per artifact earns its place, so the number is\n# load-bearing. `docs/BUILD.md` carries the full set.\n#\n# WHY A FALLBACK RATHER THAN UV ALONE. uv is a NEW resource in the bundle, and\n# every artifact built before this change has none. The pip path below is the one\n# that shipped until now, unchanged, and it runs whenever uv is absent or cannot\n# do the job - a dev checkout whose `pnpm setup-python` was never run, an older\n# artifact, a uv the platform refuses to spawn, a uv install that failed. A\n# fallback that has never been exercised is a claim rather than a feature, which\n# is why the CI install-script jobs run this script with no uv at all.\n#\n# WHY pip STAYS IN THE VENV, and why this does NOT use `uv venv`: the app\'s\n# backend-update path runs `pip install --upgrade local-operator` inside this same\n# environment (`update-service.ts`, and `update-install.ts` documents why pip is\n# the right command there). `python -m venv` seeds pip from the interpreter\'s own\n# `ensurepip` wheel, where `uv venv` produces an environment with no pip at all -\n# so switching the creation would silently break every later update. The check\n# above (`"$VENV_PATH/bin/pip"`) is what holds that on both paths.\n#\n# The app hands the path of the pinned, bundled uv in `LOCAL_OPERATOR_UV_BIN`\n# (`src/main/backend/uv-tool.ts`). Nothing here searches PATH for a uv: an\n# installed uv is a version and a configuration nobody in this repository chose,\n# and the point of bundling one is that the install is the same for every user.\nUV_BIN="${LOCAL_OPERATOR_UV_BIN:-}"\n\n# Drop every UV_* variable the launching environment carried.\n#\n# WHY THE WHOLE NAMESPACE rather than a list of the dangerous ones: uv reads its\n# configuration from `UV_*` and from `uv.toml`, and both are the caller\'s, not\n# this app\'s. Measured on uv 0.12.17: with a user-level `uv.toml` naming an index\n# that is not reachable, `uv pip install --dry-run six` fails with `tcp connect\n# error`; `UV_NO_CONFIG=1` makes the same command resolve from PyPI. And an\n# ambient `UV_INDEX_URL` changes where packages come from - while `PIP_INDEX_URL`\n# does not affect uv at all (measured, both directions). Unsetting a name list\n# would drift the day uv adds a variable; unsetting the namespace cannot.\n#\n# IT RUNS BEFORE THE SETTINGS BELOW ARE SET, and that order is load-bearing: a\n# sweep after them takes them away, and an empty `UV_CACHE_DIR` is not "use the\n# default cache" - uv exits 2 with `a value is required for \'--cache-dir\n# <CACHE_DIR>\'`. Measured by running this script on the uv path, where the\n# failure first surfaced as a silent pip install, because the fallback below\n# caught it exactly as designed.\nfor uv_ambient in $(env | sed -n \'s/^\\(UV_[A-Za-z0-9_]*\\)=.*/\\1/p\'); do\n unset "$uv_ambient"\ndone\n\n# The cache lives under the app\'s own support directory rather than the user\'s\n# shared `~/.cache/uv`, so the install neither reads nor pollutes a cache that\n# another tool (or another version of uv) is maintaining. Written here, after the\n# sweep, and handed to uv per invocation rather than exported.\n#\n# IT PERSISTS, and that is worth knowing on a user\'s disk: a full install leaves\n# ~118 MB there (measured; pip\'s own cache for the same dependency set is ~40 MB\n# and it also persists). Nothing else reads it today - the app\'s backend-update\n# path installs with pip - so it is there for the next provisioning or repair,\n# and it is what makes a retry converge in ~1.5 s instead of ~20 s.\nUV_CACHE_DIR="$APP_DATA_DIR/uv-cache"\n\n# Is the handed-down uv something we can actually run?\nuv_is_usable() {\n [ -n "$UV_BIN" ] && [ -x "$UV_BIN" ] && "$UV_BIN" --version >/dev/null 2>&1\n}\n\n# UV_NO_CONFIG: never read `pyproject.toml`/`uv.toml`, wherever they are.\n# UV_PYTHON_DOWNLOADS=never: this install uses the interpreter it was handed and\n# may never fetch another, which is also what keeps it working offline.\n# UV_CACHE_DIR: this install\'s own cache, passed explicitly for the same reason.\nuv_run() {\n UV_NO_CONFIG=1 UV_PYTHON_DOWNLOADS=never UV_CACHE_DIR="$UV_CACHE_DIR" \\\n "$UV_BIN" "$@"\n}\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\n# Check network connectivity to PyPI. A DIAGNOSTIC, not a gate: the install below\n# decides whether it can proceed.\n#\n# What this answers, stated precisely because an earlier version of this comment\n# claimed more than the flags buy: "did a TLS fetch to PyPI\'s JSON API complete,\n# and did the answer come back as JSON?". `--fail` turns an HTTP ERROR status\n# into a non-zero exit - a proxy\'s 403/407, any 4xx/5xx - and does NOT notice a\n# captive portal answering 200 with its own HTML page (measured: a portal-shaped\n# 200 returns exit 0 with AND without the flag). `-o /dev/null` cannot tell a\n# portal\'s page from PyPI\'s JSON either, so the content type is what\n# discriminates, and on a captive network a probe that reports "reachable" while\n# pip is about to fail is the false negative this warning exists to catch.\n#\n# What the content type does NOT prove, stated so this paragraph is not read as\n# more than it says: a proxy answering 200 with `application/json` and an error\n# body (`{"detail":"blocked by proxy policy"}`) is silent here, because the\n# answer did come back as JSON. Only parsing the body - a fetch of PyPI\'s own\n# payload shape - would tell those apart, and a diagnostic that costs a parse is\n# not what stands in front of an install.\n#\n# Two bounds, two jobs: `--connect-timeout 5` ends a black-hole network (a\n# connect that never completes), `--max-time 30` stops a connected-but-stalled\n# peer. Both must be POSITIVE: `--max-time 0` and `--connect-timeout 0` disable\n# the bound rather than making it immediate, which is why the test beside this\n# script requires `[1-9]`. 30 rather than 10 because the total must not fire on a\n# slow-but-working link: a working endpoint that answered in 15s tripped a 10s\n# total bound and printed this warning on an install that then succeeded, and a\n# warning that cries wolf is one users learn to ignore.\necho "Checking network connectivity to PyPI..."\nPYPI_PROBE_CONTENT_TYPE=$(curl -s --fail --connect-timeout 5 --max-time 30 -o /dev/null -w \'%{content_type}\' https://pypi.org/pypi/local-operator/json) || PYPI_PROBE_CONTENT_TYPE=""\nif [[ "$PYPI_PROBE_CONTENT_TYPE" != application/json* ]]; then\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 -W 2000 google.com || echo "Cannot ping google.com"\n ping -c 1 -W 2000 pypi.org || echo "Cannot ping pypi.org"\nfi\n\n# --- Progress markers -------------------------------------------------------\n# One whole line per phase, read by the app and shown in the setup window. The\n# app matches the ENTIRE line (`|LO1:<phase>`, see src/shared/install-progress.ts)\n# and never a substring, so a marker has to stand alone: do not wrap it in\n# other text, do not re-indent it into a longer sentence, and do not emit one\n# for work this script does not actually do. A missing marker leaves the window\n# on its previous step, which is the honest failure; a marker a log line also\n# happens to produce is a wrong step presented as a measurement.\n#\n# WHY THE SCRIPT AND NOT ONLY THE APP: the install below is minutes of work on a\n# cold machine, and the app cannot see inside the venv it is about to create -\n# this is the only process that knows when the environment exists and when the\n# download starts.\necho "|LO1:components"\nUV_INSTALLED=false\nif uv_is_usable; then\n echo "Installing local-operator with uv ($("$UV_BIN" --version 2>/dev/null || echo \'version unavailable\'))..."\n # No `pip install --upgrade pip` on this path: uv does not use pip, so the\n # upgrade would be a whole extra network round trip that changes nothing about\n # the result.\n if uv_run pip install --python "$VENV_PATH/bin/python" --upgrade local-operator; then\n UV_INSTALLED=true\n echo "local-operator installation with uv successful"\n else\n # WHY THE EXIT CODE IS PRINTED (review round 1, QA Q2): this fallback has to\n # be forgiving - an install must not fail because uv did - but "a bundled uv\n # is present and fails" is a defect rather than a degraded path, and this line\n # is the only place it shows up: the exit code is 0 and the UI is unchanged.\n # `uv_is_usable` passing and a uv install SUCCEEDING are two different facts.\n UV_STATUS=$?\n echo "WARNING: the bundled uv is present but its install failed (exit ${UV_STATUS}); retrying with pip, which is what this script used before uv was bundled."\n fi\nelse\n echo "Bundled uv not available (LOCAL_OPERATOR_UV_BIN=${UV_BIN:-unset}); installing with pip."\nfi\n\nif [ "$UV_INSTALLED" != true ]; then\n echo "Upgrading pip..."\n python -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 }\n echo "pip upgrade successful:"\n pip --version\n\n echo "Installing local-operator package..."\n python -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 -sI --fail --connect-timeout 5 --max-time 30 https://pypi.org || echo "Cannot reach PyPI server"\n exit 1\n }\nfi\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';
10974
11206
  const windowsInstallScriptRaw = '# Local Operator Backend Installation Script for Windows\n# This script installs pyenv-win, Python 3.12, and sets up a virtual environment for the Local Operator backend.\n\n# Configuration\n$AppName = "Local Operator"\n$PythonVersion = "3.12.0"\n$VenvName = "local-operator-venv"\n$AppDataDir = "$env:APPDATA\\\\$AppName"\n$VenvPath = "$AppDataDir\\\\$VenvName"\n$LogFile = "$AppDataDir\\\\backend-install-shell.log"\n$PyenvDir = "$env:USERPROFILE\\\\.pyenv"\n\n# Which environment this installs into - the app\'s decision, handed in rather\n# than re-derived. 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 only the app knows which it is: LOCAL_OPERATOR_VENV_PATH carries\n# managedVenvPath\'s answer (src/main/backend/venv-paths.ts). The default above is\n# what a standalone run uses - the packaged name, because that is what every\n# install on a disk today has.\nif ($env:LOCAL_OPERATOR_VENV_PATH) {\n $VenvPath = $env:LOCAL_OPERATOR_VENV_PATH\n}\n\n# Keep CPython\'s bytecode cache out of the application directory.\n#\n# Same rule as the macOS script, and stated here for the same reason: an\n# interpreter writes __pycache__/*.pyc beside the stdlib sources it imports. On\n# Windows those sources are the bundled python directory rather than a\n# code-signed bundle, so this is hygiene rather than the load-bearing fix it is\n# on macOS - but the app and the script must not disagree about where bytecode\n# goes. $AppDataDir is used for the same reason the venv is: it is never inside\n# the installed application tree.\nif (-not $env:PYTHONPYCACHEPREFIX) {\n $env:PYTHONPYCACHEPREFIX = "$AppDataDir\\\\python-bytecode-cache"\n}\n\n# The refusal half of the same pair, kept in step with the app\'s\n# `withPythonBytecodeCache`: CPython reads the flag before its first import, so\n# nothing this script runs can write a __pycache__ at all.\n$env:PYTHONDONTWRITEBYTECODE = "1"\n\n# Create app data directory if it doesn\'t exist\nif (-not (Test-Path $AppDataDir)) {\n New-Item -ItemType Directory -Path $AppDataDir -Force | Out-Null\n}\n\n# Start logging\nStart-Transcript -Path $LogFile -Append\nWrite-Output "$(Get-Date): Starting Local Operator backend installation..."\n\n# Nothing is fetched here but the package itself and Python\'s own toolchain.\n#\n# This script used to download a third-party FFmpeg binary from a GitHub release\n# into `$AppDataDir\\bin`. Nothing in the app or in `local-operator` ever executed\n# it. Tooling a task actually needs is acquired later, on demand, through the\n# app\'s Console with the user\'s approval; this script\'s job is the environment\n# below and nothing else. (pyenv-win\'s source archive below is the one remaining\n# third-party fetch, and it is a source archive the Windows install cannot do\n# without - see `scripts/install-scripts-network.test.mjs`, which keeps that list\n# down to the fetches each platform genuinely needs.)\n\n# Function to check if a command exists\nfunction Test-CommandExists {\n param ($command)\n $oldPreference = $ErrorActionPreference\n $ErrorActionPreference = \'stop\'\n try {\n if (Get-Command $command) { return $true }\n } catch {\n return $false\n } finally {\n $ErrorActionPreference = $oldPreference\n }\n}\n\n# Install pyenv-win if not installed\nif (-not (Test-Path $PyenvDir)) {\n Write-Output "Installing pyenv-win..."\n \n # Create temporary directory\n $TempDir = "$env:TEMP\\\\pyenv-win"\n if (Test-Path $TempDir) {\n Remove-Item -Path $TempDir -Recurse -Force\n }\n New-Item -ItemType Directory -Path $TempDir -Force | Out-Null\n \n # Download and extract pyenv-win\n $PyenvZip = "$TempDir\\\\pyenv-win.zip"\n # Bounded, and the bound FAILS LOUDLY. An unbounded request here holds a\n # first-run install open behind the progress bar forever on a black-hole\n # network; and without -ErrorAction Stop a fired -TimeoutSec is a\n # NON-TERMINATING error, so the script would walk straight into\n # Expand-Archive with an absent or partial zip and report an archive error\n # instead of "the download timed out". The partial file is removed in the\n # failure branch so a later run cannot expand what this one failed to fetch\n # (the next run clears $TempDir before it downloads at all, which is the\n # `if (Test-Path $TempDir) { Remove-Item ... }` above - named rather than\n # cited by line, because a line number in a script that keeps changing is\n # what a stale citation is made of).\n # 120 seconds is a payload bound rather than the 30-second stall bound the PyPI\n # probes use: this downloads a source archive instead of answering an API\n # call, so it only has to stop an indefinite hang.\n #\n # WHICH BOUND `-TimeoutSec` ACTUALLY IS DEPENDS ON THE POWERSHELL, and that is\n # a trap worth naming because the two paths differ here. The app spawns this\n # script with `powershell.exe` (Windows PowerShell 5.1, see\n # backend-installer.ts), where -TimeoutSec is the REQUEST\'s timeout - 120\n # seconds to complete the transfer. On PowerShell 7.4+ it was renamed to\n # -OperationTimeoutSeconds and -TimeoutSec survives only as an ALIAS of\n # -ConnectionTimeoutSeconds, i.e. a connect bound, so on a 7.x host (the CI\n # runner is one) a mirror that accepts and then stalls is not ended by this.\n # Do not "fix" that by adding the 7.x spelling: 5.1 does not know\n # -OperationTimeoutSeconds, and an unknown parameter is a binding error which\n # the catch below turns into `exit 1` on every install. The version-agnostic\n # answer is a bound on the transfer itself (a BITS job or a size/rate check),\n # which is a larger change than this one and is recorded rather than made.\n try {\n Invoke-WebRequest -Uri "https://github.com/pyenv-win/pyenv-win/archive/master.zip" -OutFile $PyenvZip -TimeoutSec 120 -ErrorAction Stop\n } catch {\n Remove-Item -Path $PyenvZip -Force -ErrorAction SilentlyContinue\n Write-Error "Failed to download pyenv-win from https://github.com/pyenv-win/pyenv-win/archive/master.zip within 120 seconds: $($_.Exception.Message)"\n exit 1\n }\n # -ErrorAction Stop for the same reason as the download: a truncated archive\n # must fail here rather than half-copy into $PyenvDir.\n Expand-Archive -Path $PyenvZip -DestinationPath $TempDir -ErrorAction Stop\n \n # Create .pyenv directory\n New-Item -ItemType Directory -Path $PyenvDir -Force | Out-Null\n \n # Copy pyenv-win files\n Copy-Item -Path "$TempDir\\\\pyenv-win-master\\\\*" -Destination $PyenvDir -Recurse\n \n # Set environment variables\n [System.Environment]::SetEnvironmentVariable("PYENV", "$PyenvDir\\\\pyenv-win", "User")\n [System.Environment]::SetEnvironmentVariable("PYENV_HOME", "$PyenvDir\\\\pyenv-win", "User")\n \n # Update PATH - ensure both bin and shims are added separately for better compatibility\n $Path = [System.Environment]::GetEnvironmentVariable("PATH", "User")\n $PyenvBinPath = "$PyenvDir\\\\pyenv-win\\\\bin"\n $PyenvShimsPath = "$PyenvDir\\\\pyenv-win\\\\shims"\n \n # Add bin path if not already in PATH\n if ($Path -notlike "*$PyenvBinPath*") {\n [System.Environment]::SetEnvironmentVariable("PATH", "$PyenvBinPath;$Path", "User")\n $Path = [System.Environment]::GetEnvironmentVariable("PATH", "User")\n }\n \n # Add shims path if not already in PATH\n if ($Path -notlike "*$PyenvShimsPath*") {\n [System.Environment]::SetEnvironmentVariable("PATH", "$PyenvShimsPath;$Path", "User")\n }\n \n # Set PYENV environment variables\n [System.Environment]::SetEnvironmentVariable("PYENV", "$PyenvDir\\\\pyenv-win", "User")\n [System.Environment]::SetEnvironmentVariable("PYENV_HOME", "$PyenvDir\\\\pyenv-win", "User")\n \n # Update current session PATH\n $env:PYENV = "$PyenvDir\\\\pyenv-win"\n $env:PYENV_HOME = "$PyenvDir\\\\pyenv-win"\n $env:PATH = "$PyenvDir\\\\pyenv-win\\\\bin;$PyenvDir\\\\pyenv-win\\\\shims;$env:PATH"\n \n # Refresh environment variables for the current process\n $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "User") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "Machine")\n \n # Clean up\n Remove-Item -Path $TempDir -Recurse -Force\n}\n\n# Refresh environment variables for current session\n$env:PYENV = "$PyenvDir\\\\pyenv-win"\n$env:PYENV_HOME = "$PyenvDir\\\\pyenv-win"\n$env:PATH = "$PyenvDir\\\\pyenv-win\\\\bin;$PyenvDir\\\\pyenv-win\\\\shims;$env:PATH"\n\n# Install Python 3.12 if not installed\n$PythonInstalled = $false\ntry {\n $InstalledVersions = & pyenv versions\n if ($InstalledVersions -like "*$PythonVersion*") {\n $PythonInstalled = $true\n }\n} catch {\n $PythonInstalled = $false\n}\n\nif (-not $PythonInstalled) {\n Write-Output "Installing Python $PythonVersion..."\n & pyenv install $PythonVersion\n if ($LASTEXITCODE -ne 0) {\n Write-Error "Failed to install Python $PythonVersion"\n exit 1\n }\n}\n\n# Set Python 3.12 as the local version\n& pyenv local $PythonVersion\nif ($LASTEXITCODE -ne 0) {\n Write-Error "Failed to set Python $PythonVersion as local version"\n exit 1\n}\n\n# Create virtual environment if it doesn\'t exist\nif (-not (Test-Path $VenvPath)) {\n Write-Output "|LO1:environment"\n Write-Output "Creating virtual environment at $VenvPath..."\n \n # Ensure the directory exists\n if (-not (Test-Path $AppDataDir)) {\n New-Item -ItemType Directory -Path $AppDataDir -Force | Out-Null\n Write-Output "Created directory: $AppDataDir"\n }\n \n # Use the full path to python from pyenv\n $PythonExe = "$PyenvDir\\\\pyenv-win\\\\versions\\\\$PythonVersion\\\\python.exe"\n \n if (Test-Path $PythonExe) {\n Write-Output "Using Python at: $PythonExe"\n & $PythonExe -m venv $VenvPath\n } else {\n Write-Output "Using system Python"\n & python -m venv $VenvPath\n }\n \n if ($LASTEXITCODE -ne 0) {\n Write-Error "Failed to create virtual environment"\n exit 1\n }\n}\n\n# Verify the virtual environment was created\nif (-not (Test-Path "$VenvPath\\\\Scripts\\\\Activate.ps1")) {\n Write-Error "Virtual environment activation script not found at $VenvPath\\\\Scripts\\\\Activate.ps1"\n exit 1\n}\n\n# --- The package install: uv when there is one, pip otherwise ------------------\n#\n# Same shape as the macOS and Linux scripts, and for the same reasons: uv resolves\n# and fetches in parallel - measured on macOS, cold cache, three runs each, same\n# interpreter: uv\'s package install is 12.8-16.1 s against pip\'s 33.0-40.7 s,\n# plus the 2.3-2.8 s pip self-upgrade this path skips, so 1.5-2.8x across two\n# operators rather than the "14.8 s against 128.9 s" quoted here before that\n# reading was withdrawn (`docs/BUILD.md` has the full set). The pip path below is\n# unchanged and runs whenever uv is absent or cannot do the job, and pip STAYS in the venv\n# because the app\'s backend-update path runs `pip install --upgrade\n# local-operator` inside this same environment - which is also why the venv is\n# still created with `python -m venv` rather than `uv venv`.\n#\n# Nothing here searches PATH for a uv: an installed uv is a version and a\n# configuration nobody in this repository chose. `LOCAL_OPERATOR_UV_BIN` is the\n# app\'s own answer (`src/main/backend/uv-tool.ts`).\n$UvBin = $env:LOCAL_OPERATOR_UV_BIN\n$UvCacheDir = "$AppDataDir\\uv-cache"\n\nfunction Test-UvUsable {\n if (-not $UvBin) { return $false }\n if (-not (Test-Path $UvBin)) { return $false }\n try {\n & $UvBin --version | Out-Null\n return ($LASTEXITCODE -eq 0)\n } catch {\n return $false\n }\n}\n\n# Drop every UV_* variable the launching environment carried, then set the three\n# settings this install depends on. Measured on uv 0.12.17: a user-level\n# `uv.toml` naming an unreachable index is obeyed by `uv pip install` and ignored\n# with `UV_NO_CONFIG=1`; an ambient `UV_INDEX_URL` changes where packages come\n# from, while `PIP_INDEX_URL` does not affect uv at all. A name list would drift\n# the day uv adds a variable - the namespace cannot.\n#\n# The names are MATERIALISED first (`@(...)` over a property projection):\n# removing entries of a collection that is still being enumerated is the shape\n# that throws `Collection was modified`.\n$uvAmbientNames = @(\n Get-ChildItem env: |\n Where-Object { $_.Name -like \'UV_*\' } |\n Select-Object -ExpandProperty Name\n)\nforeach ($uvAmbientName in $uvAmbientNames) {\n Remove-Item "env:$uvAmbientName" -ErrorAction SilentlyContinue\n}\n\n# UV_NO_CONFIG: never read `pyproject.toml`/`uv.toml`, wherever they are.\n# UV_PYTHON_DOWNLOADS=never: this install uses the interpreter it was handed and\n# never fetches another.\n# UV_CACHE_DIR: under the app\'s own support directory rather than the user\'s\n# shared uv cache.\n$env:UV_NO_CONFIG = "1"\n$env:UV_PYTHON_DOWNLOADS = "never"\n$env:UV_CACHE_DIR = $UvCacheDir\n\n# Activate virtual environment and install local-operator\n# --- Progress markers -------------------------------------------------------\n# One whole line per phase, read by the app and shown in the setup window. The\n# app matches the ENTIRE line (`|LO1:<phase>`, see src/shared/install-progress.ts)\n# and never a substring, so a marker has to stand alone: do not wrap it in\n# other text, do not re-indent it into a longer sentence, and do not emit one\n# for work this script does not actually do. A missing marker leaves the window\n# on its previous step, which is the honest failure; a marker a log line also\n# happens to produce is a wrong step presented as a measurement.\n#\n# WHY THE SCRIPT AND NOT ONLY THE APP: the install below is minutes of work on a\n# cold machine, and the app cannot see inside the venv it is about to create -\n# this is the only process that knows when the environment exists and when the\n# download starts.\nWrite-Output "|LO1:components"\nWrite-Output "Installing local-operator in virtual environment..."\n# Use PowerShell to run the activation script\n& "$VenvPath\\\\Scripts\\\\Activate.ps1"\n\n$UvInstalled = $false\nif (Test-UvUsable) {\n Write-Output "Installing local-operator with uv..."\n # No `pip install --upgrade pip` on this path: uv does not use pip.\n & $UvBin pip install --python "$VenvPath\\Scripts\\python.exe" --upgrade local-operator\n if ($LASTEXITCODE -eq 0) {\n $UvInstalled = $true\n Write-Output "local-operator installation with uv successful"\n } else {\n # The exit code is printed for the same reason as on macOS and Linux: the\n # fallback is deliberately forgiving, so this line is the only evidence\n # that a bundled uv is present and failing for every user (QA Q2).\n Write-Output "WARNING: the bundled uv is present but its install failed (exit $LASTEXITCODE); retrying with pip, which is what this script used before uv was bundled."\n }\n} else {\n if ($UvBin) {\n Write-Output "Bundled uv at $UvBin could not be run on this machine; installing with pip."\n } else {\n Write-Output "Bundled uv not available (LOCAL_OPERATOR_UV_BIN unset); installing with pip."\n }\n}\n\nif (-not $UvInstalled) {\n & python -m pip install --upgrade pip\n & python -m pip install --upgrade local-operator\n\n if ($LASTEXITCODE -ne 0) {\n Write-Error "Failed to install packages in virtual environment"\n exit 1\n }\n}\n\n# Verify installation\n$LocalOperatorInstalled = $false\ntry {\n # First try to run local-operator directly (it should be in PATH from the activated venv)\n $Version = & local-operator --version\n $LocalOperatorInstalled = $true\n Write-Output "Local Operator backend installed successfully!"\n Write-Output $Version\n} catch {\n Write-Output "Could not run local-operator directly, trying with full path..."\n try {\n # Try with explicit path\n $LocalOperatorExe = "$VenvPath\\\\Scripts\\\\local-operator.exe"\n if (Test-Path $LocalOperatorExe) {\n $Version = & $LocalOperatorExe --version\n $LocalOperatorInstalled = $true\n Write-Output "Local Operator backend installed successfully at $LocalOperatorExe!"\n Write-Output $Version\n } else {\n # Try without .exe extension\n $LocalOperatorCmd = "$VenvPath\\\\Scripts\\\\local-operator"\n if (Test-Path $LocalOperatorCmd) {\n $Version = & $LocalOperatorCmd --version\n $LocalOperatorInstalled = $true\n Write-Output "Local Operator backend installed successfully at $LocalOperatorCmd!"\n Write-Output $Version\n } else {\n Write-Error "Error: local-operator executable not found in expected locations."\n \n # Create a symlink to make it more accessible\n Write-Output "Attempting to create a symlink for local-operator..."\n $PythonModule = "$VenvPath\\\\Lib\\\\site-packages\\\\local_operator"\n if (Test-Path $PythonModule) {\n Write-Output "Found local_operator module at $PythonModule"\n \n # Create a batch file that runs the module\n $BatchContent = "@echo off`npython -m local_operator %*"\n Set-Content -Path "$VenvPath\\\\Scripts\\\\local-operator.bat" -Value $BatchContent\n \n # Test the batch file\n if (Test-Path "$VenvPath\\\\Scripts\\\\local-operator.bat") {\n Write-Output "Created local-operator.bat in Scripts directory"\n $LocalOperatorInstalled = $true\n } else {\n Write-Error "Failed to create local-operator.bat"\n exit 1\n }\n } else {\n Write-Error "local_operator module not found in site-packages"\n exit 1\n }\n }\n }\n } catch {\n Write-Error "Error: Failed to install Local Operator backend."\n Write-Error $_.Exception.Message\n exit 1\n }\n}\n\n# If we got here, installation was successful\nif ($LocalOperatorInstalled) {\n Write-Output "Local Operator backend installation verified."\n} else {\n Write-Error "Error: Failed to verify Local Operator backend installation."\n exit 1\n}\n\nWrite-Output "$(Get-Date): Installation completed successfully."\n\n# Properly close the transcript and ensure it\'s released\ntry {\n Write-Output "Finalizing installation and releasing resources..."\n # Flush any pending output\n [System.Console]::Out.Flush()\n # Stop transcript properly\n Stop-Transcript\n \n # Add a small delay to ensure file handles are released\n Start-Sleep -Seconds 1\n \n # Explicitly release any COM objects\n [System.GC]::Collect()\n [System.GC]::WaitForPendingFinalizers()\n \n Write-Output "Installation completed and resources released."\n} catch {\n Write-Error "Error during cleanup: $_"\n}\n';
10975
11207
  const macosInstallScript = macosInstallScriptRaw;
@@ -11057,8 +11289,9 @@ function ensureUvToolExecutable(options) {
11057
11289
  mode: null,
11058
11290
  reason: "no bundled uv is staged for this architecture (a dev checkout whose `pnpm setup-python` has not run, or an artifact built before uv was bundled)"
11059
11291
  };
11292
+ const platform = options.platform ?? process.platform;
11060
11293
  const mode = fs.statSync(path2).mode & 511;
11061
- if ((mode & OWNER_EXECUTE$1) !== 0)
11294
+ if (platform === "win32" || (mode & OWNER_EXECUTE$1) !== 0)
11062
11295
  return { path: path2, healed: false, mode, reason: null };
11063
11296
  try {
11064
11297
  fs.chmodSync(path2, UV_TOOL_MODE);
@@ -21025,7 +21258,7 @@ function validateRequest(raw) {
21025
21258
  };
21026
21259
  }
21027
21260
  async function startRpcServer(options) {
21028
- const server = node_http.createServer((req, res) => {
21261
+ const server = http.createServer((req, res) => {
21029
21262
  void handle(req, res, options);
21030
21263
  });
21031
21264
  const bound = await new Promise(
@@ -23868,6 +24101,41 @@ function describeTelemetryLaunch(decision) {
23868
24101
  const detail = [decision.offReason, ...decision.problems].filter((part) => part !== null && part !== void 0).join("; ");
23869
24102
  return `[telemetry] off: ${detail}`;
23870
24103
  }
24104
+ function titlebarOptions(platform, chrome) {
24105
+ if (platform === "darwin") {
24106
+ return { titleBarStyle: "hidden" };
24107
+ }
24108
+ if (platform !== "win32" && platform !== "linux") {
24109
+ return {};
24110
+ }
24111
+ if (chrome.mode === "native") return {};
24112
+ const chromePlatform = chromePlatformFor(platform);
24113
+ return {
24114
+ titleBarStyle: "hidden",
24115
+ titleBarOverlay: {
24116
+ color: chromePlatform === "win" ? (
24117
+ /* Transparent: the app's own ground shows through. See the docblock. */
24118
+ WINDOW_CHROME_TRANSPARENT
24119
+ ) : (
24120
+ /* Opaque reported ground: Linux forces alpha to ff, so a transparent
24121
+ value would paint the glyphs on black. The renderer reports the
24122
+ resolved ground of the element owning the controls' corner and main
24123
+ re-applies it with `setTitleBarOverlay`; this is the value for the
24124
+ first frame, before any report has arrived, which is why it is the
24125
+ palette's `canvas` rather than a guessed pane ground. */
24126
+ chrome.colors.ground
24127
+ ),
24128
+ symbolColor: chrome.colors.symbol,
24129
+ /*
24130
+ * 40 is the app's own toolbar step, so the caption buttons are exactly as
24131
+ * tall as the row they end: the chat header, every pane toolbar and the
24132
+ * sidebar's brand row are all `h-10`. It sits inside Microsoft's 32-48
24133
+ * range. `height` is an integer; a fractional one is rejected.
24134
+ */
24135
+ height: WINDOW_CHROME_HEIGHT
24136
+ }
24137
+ };
24138
+ }
23871
24139
  const CHROMIUM_TRANSPORT_CODES = [
23872
24140
  "ERR_INTERNET_DISCONNECTED",
23873
24141
  "ERR_NETWORK_CHANGED",
@@ -24365,6 +24633,273 @@ function dropStream(stream) {
24365
24633
  const destroy = stream?.destroy;
24366
24634
  if (typeof destroy === "function") destroy.call(stream);
24367
24635
  }
24636
+ const SERVER_RELEASE_REPO = "damianvtran/local-operator";
24637
+ const GITHUB_API_ORIGIN = "https://api.github.com";
24638
+ const RELEASE_NOTES_CACHE_FILE = "server-release-notes.json";
24639
+ const SUMMARY_LIMIT = 400;
24640
+ const RELEASE_NOTES_TIMEOUT_MS = 4e3;
24641
+ const CACHE_LIMIT = 12;
24642
+ const MAX_RESPONSE_BYTES = 512 * 1024;
24643
+ const TAG_PREFIX = /^v/i;
24644
+ function releaseTag(version) {
24645
+ const trimmed = String(version ?? "").trim();
24646
+ if (!trimmed) return "";
24647
+ return TAG_PREFIX.test(trimmed) ? trimmed : `v${trimmed}`;
24648
+ }
24649
+ function releasePageUrl(version) {
24650
+ return `https://github.com/${SERVER_RELEASE_REPO}/releases/tag/${releaseTag(version)}`;
24651
+ }
24652
+ function releaseNotesCachePath(userDataDir) {
24653
+ return path$1.join(userDataDir, RELEASE_NOTES_CACHE_FILE);
24654
+ }
24655
+ function normaliseCache(parsed) {
24656
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
24657
+ const cache = {};
24658
+ for (const [version, entry] of Object.entries(
24659
+ parsed
24660
+ )) {
24661
+ if (!entry || typeof entry !== "object") continue;
24662
+ const { version: named, summary, url } = entry;
24663
+ if (typeof summary !== "string" || !summary.trim()) continue;
24664
+ if (typeof url !== "string" || !url.trim()) continue;
24665
+ cache[version] = {
24666
+ version: typeof named === "string" && named ? named : version,
24667
+ summary,
24668
+ url
24669
+ };
24670
+ }
24671
+ return cache;
24672
+ }
24673
+ function readReleaseNotesCache(userDataDir) {
24674
+ try {
24675
+ return normaliseCache(
24676
+ JSON.parse(fs.readFileSync(releaseNotesCachePath(userDataDir), "utf8"))
24677
+ );
24678
+ } catch {
24679
+ return {};
24680
+ }
24681
+ }
24682
+ function writeReleaseNotesCache(userDataDir, cache) {
24683
+ try {
24684
+ fs.mkdirSync(userDataDir, { recursive: true });
24685
+ fs.writeFileSync(
24686
+ releaseNotesCachePath(userDataDir),
24687
+ `${JSON.stringify(cache, null, 2)}
24688
+ `,
24689
+ "utf8"
24690
+ );
24691
+ } catch {
24692
+ }
24693
+ }
24694
+ function rememberReleaseNotes(cache, notes) {
24695
+ const next = { ...cache, [notes.version]: notes };
24696
+ const versions = Object.keys(next);
24697
+ if (versions.length <= CACHE_LIMIT) return next;
24698
+ const trimmed = {};
24699
+ for (const version of versions.slice(versions.length - CACHE_LIMIT)) {
24700
+ trimmed[version] = next[version];
24701
+ }
24702
+ return trimmed;
24703
+ }
24704
+ function cachedReleaseNotes(cache, version) {
24705
+ return cache[String(version ?? "").trim()] ?? null;
24706
+ }
24707
+ function summariseReleaseBody(body, limit = SUMMARY_LIMIT) {
24708
+ if (typeof body !== "string") return null;
24709
+ const blocks = body.replace(CRLF, "\n").split(BLANK_LINE);
24710
+ for (const block of blocks) {
24711
+ const text = flattenBlock(block);
24712
+ if (text && !isMachineRow(text)) return truncateSummary(text, limit);
24713
+ }
24714
+ return null;
24715
+ }
24716
+ function isMachineRow(text) {
24717
+ return PR_HEADLINE.test(text) || MERGE_CITATION.test(text);
24718
+ }
24719
+ const CRLF = /\r\n?/g;
24720
+ const BLANK_LINE = /\n{2,}/;
24721
+ const HEADING_LINE = /^#{1,6}\s/;
24722
+ const RULE_LINE = /^(?:[-*_]\s*){3,}$/;
24723
+ const COMMENT_OPEN = /^<!--/;
24724
+ const COMMENT_CLOSE = /-->$/;
24725
+ const BLOCKQUOTE_MARKER = /^>\s?/;
24726
+ const LIST_MARKER = /^(?:[-*+]|\d+[.)])\s+/;
24727
+ const IMAGE = /!\[([^\]]*)\]\([^)]*\)/g;
24728
+ const LINK = /\[([^\]]*)\]\([^)]*\)/g;
24729
+ const CODE_SPAN = /`([^`]*)`/g;
24730
+ const CODE_PLACEHOLDER = /\uE000(\d+)\uE001/g;
24731
+ const MASK_ALPHABET = /[\uE000\uE001]/g;
24732
+ const EMPHASIS = /(?<!\*)\*{3}([\s\S]+?)(?<!\*)\*{3}(?!\*)|(?<!\*)\*{2}([\s\S]+?)(?<!\*)\*{2}(?!\*)|(?<!\*)\*(?!\*)([\s\S]+?)(?<!\*)\*(?!\*)/g;
24733
+ const WHITESPACE_RUN = /\s+/g;
24734
+ const PR_HEADLINE = /^#\d+\s+[—–-]\s/;
24735
+ const MERGE_CITATION = /\(merge [0-9a-f]{7,40}\)$/i;
24736
+ function flattenBlock(block) {
24737
+ const lines = block.split("\n").flatMap((line) => {
24738
+ const trimmed = line.trim();
24739
+ if (!trimmed) return [];
24740
+ if (HEADING_LINE.test(trimmed)) return [];
24741
+ if (RULE_LINE.test(trimmed)) return [];
24742
+ if (COMMENT_OPEN.test(trimmed) || COMMENT_CLOSE.test(trimmed)) return [];
24743
+ return [trimmed.replace(BLOCKQUOTE_MARKER, "").replace(LIST_MARKER, "")];
24744
+ });
24745
+ if (lines.length === 0) return "";
24746
+ return collapseWhitespace(flattenInline(lines.join(" ")));
24747
+ }
24748
+ function flattenInline(text) {
24749
+ const code = [];
24750
+ const masked = text.replace(MASK_ALPHABET, " ").replace(IMAGE, "$1").replace(LINK, "$1").replace(CODE_SPAN, (_span, inner) => {
24751
+ code.push(inner);
24752
+ return `${code.length - 1}`;
24753
+ });
24754
+ const unwrapped = masked.replace(EMPHASIS, unwrapEmphasis).replace(EMPHASIS, unwrapEmphasis);
24755
+ return unwrapped.replace(CODE_PLACEHOLDER, (placeholder, index) => {
24756
+ return code[Number(index)] ?? placeholder;
24757
+ });
24758
+ }
24759
+ function unwrapEmphasis(_match, triple, strong, italic) {
24760
+ return triple ?? strong ?? italic ?? "";
24761
+ }
24762
+ function collapseWhitespace(text) {
24763
+ return text.replace(WHITESPACE_RUN, " ").trim();
24764
+ }
24765
+ function truncateSummary(text, limit) {
24766
+ if (text.length <= limit) return text;
24767
+ const cut = text.slice(0, Math.max(0, limit - 3));
24768
+ const lastSpace = cut.lastIndexOf(" ");
24769
+ const words = lastSpace > limit / 2 ? cut.slice(0, lastSpace) : cut;
24770
+ const trimmed = words.trimEnd();
24771
+ const dropped = DanglingWord.test(trimmed) ? trimmed.slice(0, trimmed.lastIndexOf(" ")).trimEnd() : trimmed;
24772
+ const balanced = stripUnpairedEmphasis(dropped);
24773
+ const sentence = balanced.replace(TRAILING_STOP, "");
24774
+ return `${sentence}...`;
24775
+ }
24776
+ const DanglingWord = /(?:^|\s)(?:a|an|as|at|by|in|of|on|or|to)$/i;
24777
+ const TRAILING_STOP = /[.!?]$/;
24778
+ function stripUnpairedEmphasis(text) {
24779
+ const leading = text.match(/^\*{1,3}/);
24780
+ if (leading && !text.slice(leading[0].length).includes("*")) {
24781
+ return text.slice(leading[0].length).trimStart();
24782
+ }
24783
+ const trailing = text.match(/\*{1,3}$/);
24784
+ if (trailing && !text.slice(0, -trailing[0].length).includes("*")) {
24785
+ return text.slice(0, -trailing[0].length).trimEnd();
24786
+ }
24787
+ return text;
24788
+ }
24789
+ function parseReleaseResponse(payload, version) {
24790
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
24791
+ return null;
24792
+ }
24793
+ const release = payload;
24794
+ const summary = summariseReleaseBody(release.body);
24795
+ if (!summary) return null;
24796
+ const url = typeof release.html_url === "string" && release.html_url.trim() ? release.html_url.trim() : releasePageUrl(version);
24797
+ return { version: String(version).trim(), summary, url };
24798
+ }
24799
+ async function fetchServerReleaseNotes(version, options) {
24800
+ const named = String(version ?? "").trim();
24801
+ const tag = releaseTag(named);
24802
+ if (!tag) return { status: "absent", reason: "the offer named no version" };
24803
+ const cache = readReleaseNotesCache(options.userDataDir);
24804
+ const hit = cachedReleaseNotes(cache, named);
24805
+ if (hit) return { status: "found", notes: hit, cached: true };
24806
+ const origin = options.apiOrigin ?? GITHUB_API_ORIGIN;
24807
+ const url = `${origin}/repos/${SERVER_RELEASE_REPO}/releases/tags/${tag}`;
24808
+ let answer;
24809
+ try {
24810
+ answer = await getJson(url, options.timeoutMs ?? RELEASE_NOTES_TIMEOUT_MS);
24811
+ } catch (error) {
24812
+ return {
24813
+ status: "absent",
24814
+ reason: `the lookup for ${tag} failed: ${error instanceof Error ? error.message : String(error)}`
24815
+ };
24816
+ }
24817
+ if (answer.status !== 200) {
24818
+ return {
24819
+ status: "absent",
24820
+ reason: `GitHub answered ${answer.status} for ${tag}`
24821
+ };
24822
+ }
24823
+ let payload;
24824
+ try {
24825
+ payload = JSON.parse(answer.body);
24826
+ } catch {
24827
+ return {
24828
+ status: "absent",
24829
+ reason: `GitHub's answer for ${tag} was not JSON`
24830
+ };
24831
+ }
24832
+ const notes = parseReleaseResponse(payload, named);
24833
+ if (!notes) {
24834
+ return { status: "absent", reason: `the ${tag} release carries no notes` };
24835
+ }
24836
+ writeReleaseNotesCache(
24837
+ options.userDataDir,
24838
+ rememberReleaseNotes(cache, notes)
24839
+ );
24840
+ return { status: "found", notes, cached: false };
24841
+ }
24842
+ function getJson(url, timeoutMs) {
24843
+ return new Promise((resolve, reject) => {
24844
+ let settled = false;
24845
+ const settle2 = (finish) => {
24846
+ if (settled) return;
24847
+ settled = true;
24848
+ finish();
24849
+ };
24850
+ const fail = (error) => settle2(
24851
+ () => reject(error instanceof Error ? error : new Error(String(error)))
24852
+ );
24853
+ let parsed;
24854
+ try {
24855
+ parsed = new URL(url);
24856
+ } catch (error) {
24857
+ fail(error);
24858
+ return;
24859
+ }
24860
+ const transport = parsed.protocol === "http:" ? http__namespace : https__namespace;
24861
+ const request = transport.get(
24862
+ parsed,
24863
+ {
24864
+ timeout: timeoutMs,
24865
+ headers: {
24866
+ // GitHub refuses a request with no user agent, and the versioned
24867
+ // media type is what pins the payload shape this module parses.
24868
+ "user-agent": "local-operator-ui",
24869
+ accept: "application/vnd.github+json"
24870
+ }
24871
+ },
24872
+ (response) => {
24873
+ const chunks = [];
24874
+ let length = 0;
24875
+ response.on("data", (chunk) => {
24876
+ length += chunk.length;
24877
+ if (length > MAX_RESPONSE_BYTES) {
24878
+ request.destroy();
24879
+ fail(new Error("the answer was larger than this lookup accepts"));
24880
+ return;
24881
+ }
24882
+ chunks.push(chunk);
24883
+ });
24884
+ response.on(
24885
+ "end",
24886
+ () => settle2(
24887
+ () => resolve({
24888
+ status: response.statusCode ?? 0,
24889
+ body: Buffer.concat(chunks).toString("utf8")
24890
+ })
24891
+ )
24892
+ );
24893
+ response.on("error", fail);
24894
+ }
24895
+ );
24896
+ request.setTimeout(timeoutMs, () => {
24897
+ request.destroy();
24898
+ fail(new Error(`no answer within ${timeoutMs}ms`));
24899
+ });
24900
+ request.on("error", fail);
24901
+ });
24902
+ }
24368
24903
  function serverUpdateFailureRemedy(input) {
24369
24904
  if (input.rebuildRoute && /REFUSING to release a stale ref/i.test(input.diagnosis)) {
24370
24905
  return "This checkout is behind its remote, which is what `lop-update` refused to build from: bring the checkout up to date, and the next press here will build it.";
@@ -27473,6 +28008,51 @@ ${heal.removed.join("\n")}`,
27473
28008
  });
27474
28009
  });
27475
28010
  }
28011
+ /**
28012
+ * What changed in the server version this offer names, or null.
28013
+ *
28014
+ * The read is off the CRITICAL PATH in the sense that matters: it cannot fail
28015
+ * the offer. Every way it can come back empty - no release for the tag, an
28016
+ * unreachable host, an answer that is not JSON, a body with no prose in it -
28017
+ * is a `null` here and a line in this log, and the panel renders without the
28018
+ * paragraph rather than not at all. That is the shape the app's own channel
28019
+ * has always had (`updateInfo.releaseNotes` is optional and the card is drawn
28020
+ * either way), and it is the one a panel asking a reader to move has to keep:
28021
+ * the notes are the reason to move, never the condition for being told.
28022
+ *
28023
+ * AWAITED RATHER THAN SENT LATER, deliberately. The alternative is a second
28024
+ * renderer event carrying the notes after the offer is already on screen,
28025
+ * which buys a panel that grows under the reader's eyes and a state machine to
28026
+ * review in exchange for saving the one bounded round trip this makes per
28027
+ * version (every later check reads the cache). The bound is
28028
+ * `RELEASE_NOTES_TIMEOUT_MS`, and it is smaller than the wait the version read
28029
+ * above it can already impose with no bound at all.
28030
+ */
28031
+ async readServerReleaseNotes(version) {
28032
+ try {
28033
+ const lookup = await fetchServerReleaseNotes(version, {
28034
+ userDataDir: electron.app.getPath("userData")
28035
+ });
28036
+ if (lookup.status === "found") {
28037
+ logger.info(
28038
+ `Release notes for ${version} read from ${lookup.cached ? "the cache" : "GitHub"} (${lookup.notes.summary.length} chars)`,
28039
+ LogFileType.UPDATE_SERVICE
28040
+ );
28041
+ return lookup.notes;
28042
+ }
28043
+ logger.info(
28044
+ `No release notes for ${version}: ${lookup.reason}`,
28045
+ LogFileType.UPDATE_SERVICE
28046
+ );
28047
+ return null;
28048
+ } catch (error) {
28049
+ logger.warn(
28050
+ `Could not read release notes for ${version}: ${error}`,
28051
+ LogFileType.UPDATE_SERVICE
28052
+ );
28053
+ return null;
28054
+ }
28055
+ }
27476
28056
  /**
27477
28057
  * Check for backend updates using health API and PyPI
27478
28058
  * @param silent - Whether to suppress notifications on no update
@@ -27924,9 +28504,11 @@ ${heal.removed.join("\n")}`,
27924
28504
  `New backend version available: ${latestVersion} (installed: ${installedVersion})`,
27925
28505
  LogFileType.UPDATE_SERVICE
27926
28506
  );
28507
+ const releaseNotes = await this.readServerReleaseNotes(latestVersion);
27927
28508
  const updateInfo = {
27928
28509
  currentVersion: installedVersion,
27929
28510
  latestVersion,
28511
+ releaseNotes,
27930
28512
  /*
27931
28513
  * THE PROCESS'S OWN READING, not `/health`'s (review round 2, T1): the
27932
28514
  * panel's sentence is "the server you are using is running X until it
@@ -30162,6 +30744,193 @@ class ViewerRecordPublisher {
30162
30744
  }
30163
30745
  }
30164
30746
  }
30747
+ function windowChromeFilePath(userDataDir) {
30748
+ return path$1.join(userDataDir, WINDOW_CHROME_FILE);
30749
+ }
30750
+ function readPersistedWindowChrome(userDataDir) {
30751
+ const raw = readJson(
30752
+ windowChromeFilePath(userDataDir)
30753
+ );
30754
+ if (raw === null || typeof raw !== "object") return null;
30755
+ const mode = raw.mode === "native" || raw.mode === "integrated" ? raw.mode : null;
30756
+ if (mode === null) return null;
30757
+ const colors = parseChromeColors(raw.colors);
30758
+ return {
30759
+ mode,
30760
+ ...colors ? { colors } : {},
30761
+ ...typeof raw.themeId === "string" ? { themeId: raw.themeId } : {}
30762
+ };
30763
+ }
30764
+ function writePersistedWindowChrome(userDataDir, value) {
30765
+ return writeJsonAtomic(windowChromeFilePath(userDataDir), value);
30766
+ }
30767
+ const HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
30768
+ const FUNCTIONAL_COLOR = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/;
30769
+ function isValidChromeColor(value) {
30770
+ if (typeof value !== "string") return false;
30771
+ const trimmed = value.trim();
30772
+ return HEX_COLOR.test(trimmed) || FUNCTIONAL_COLOR.test(trimmed);
30773
+ }
30774
+ function mergeChromeColors(current, reported) {
30775
+ if (reported === void 0 || reported === null) return current;
30776
+ if (typeof reported !== "object") return null;
30777
+ const record = reported;
30778
+ const next = { ...current };
30779
+ let sawOne = false;
30780
+ for (const key of [
30781
+ "ground",
30782
+ "symbol",
30783
+ "symbolInactive"
30784
+ ]) {
30785
+ if (!(key in record)) continue;
30786
+ const value = record[key];
30787
+ if (!isValidChromeColor(value)) return null;
30788
+ sawOne = true;
30789
+ next[key] = value.trim();
30790
+ }
30791
+ return sawOne ? next : null;
30792
+ }
30793
+ function parseChromeColors(value) {
30794
+ if (value === null || typeof value !== "object") return null;
30795
+ const record = value;
30796
+ const ground = record.ground;
30797
+ const symbol = record.symbol;
30798
+ const symbolInactive = record.symbolInactive;
30799
+ if (!isValidChromeColor(ground) || !isValidChromeColor(symbol) || !isValidChromeColor(symbolInactive)) {
30800
+ return null;
30801
+ }
30802
+ return { ground, symbol, symbolInactive };
30803
+ }
30804
+ function resolveLaunchWindowChrome(input) {
30805
+ let persisted = null;
30806
+ try {
30807
+ persisted = readPersistedWindowChrome(input.userDataDir);
30808
+ } catch {
30809
+ persisted = null;
30810
+ }
30811
+ return resolveWindowChromeForLaunch({
30812
+ argv: input.argv,
30813
+ env: input.env,
30814
+ persisted,
30815
+ platform: input.platform
30816
+ });
30817
+ }
30818
+ function attachWindowChrome(options) {
30819
+ const { window, mode, userDataDir, headless: headless2, log } = options;
30820
+ let colors = options.colors;
30821
+ let cornerGround;
30822
+ let themeId;
30823
+ const platform = chromePlatformFor(process.platform);
30824
+ const hasOverlay = platform !== "mac" && mode === "integrated";
30825
+ const applyOverlay = (focused) => {
30826
+ if (!hasOverlay) return;
30827
+ const focusedNow = focused && !window.isDestroyed();
30828
+ try {
30829
+ window.setTitleBarOverlay({
30830
+ ...platform === "linux" ? { color: cornerGround ?? colors.ground } : {},
30831
+ symbolColor: focusedNow ? colors.symbol : colors.symbolInactive
30832
+ });
30833
+ } catch (error) {
30834
+ log(
30835
+ `[window-chrome] setTitleBarOverlay refused (${String(error)}) — the window keeps its previous caption colours`
30836
+ );
30837
+ }
30838
+ };
30839
+ const currentState = () => ({
30840
+ fullScreen: window.isDestroyed() ? false : window.isFullScreen(),
30841
+ maximized: window.isDestroyed() ? false : window.isMaximized(),
30842
+ focused: window.isDestroyed() ? false : window.isFocused()
30843
+ });
30844
+ const pushState = () => {
30845
+ if (window.isDestroyed()) return;
30846
+ window.webContents.send(WINDOW_CHROME_STATE_CHANNEL, currentState());
30847
+ };
30848
+ const persist = () => {
30849
+ writePersistedWindowChrome(userDataDir, {
30850
+ mode,
30851
+ colors,
30852
+ ...themeId ? { themeId } : {}
30853
+ });
30854
+ };
30855
+ const onReport = (event, report) => {
30856
+ if (event.sender !== window.webContents) return false;
30857
+ const next = mergeChromeColors(colors, report?.colors);
30858
+ if (next === null) {
30859
+ log(
30860
+ "[window-chrome] refused a report whose colours did not parse — the caption colours are unchanged"
30861
+ );
30862
+ return false;
30863
+ }
30864
+ colors = next;
30865
+ const nextCorner = isValidChromeColor(report?.cornerGround) ? report.cornerGround.trim() : void 0;
30866
+ cornerGround = nextCorner;
30867
+ if (typeof report?.themeId === "string") themeId = report.themeId;
30868
+ window.setBackgroundColor(colors.ground);
30869
+ applyOverlay(!window.isDestroyed() && window.isFocused());
30870
+ persist();
30871
+ log(
30872
+ `[window-chrome] applied themeId=${themeId ?? "(none)"} ground=${colors.ground} symbol=${colors.symbol}${cornerGround ? ` cornerGround=${cornerGround}` : ""}`
30873
+ );
30874
+ return true;
30875
+ };
30876
+ const onPopupAppMenu = (event) => {
30877
+ if (event.sender !== window.webContents) return false;
30878
+ if (headless2) {
30879
+ log(
30880
+ "[window-chrome] app menu popup refused: this launch is headless, so there is no window to hang it from"
30881
+ );
30882
+ return false;
30883
+ }
30884
+ if (window.isDestroyed()) return false;
30885
+ const menu = electron.Menu.getApplicationMenu();
30886
+ if (menu === null) {
30887
+ log("[window-chrome] app menu popup refused: no application menu is set");
30888
+ return false;
30889
+ }
30890
+ const [x, y] = window.getPosition();
30891
+ menu.popup({ window, x, y: y + WINDOW_CHROME_HEIGHT });
30892
+ return true;
30893
+ };
30894
+ electron.ipcMain.handle(WINDOW_CHROME_REPORT_CHANNEL, onReport);
30895
+ electron.ipcMain.handle(WINDOW_CHROME_MENU_CHANNEL, onPopupAppMenu);
30896
+ const onFullScreen = () => {
30897
+ pushState();
30898
+ };
30899
+ const onFocus = () => {
30900
+ applyOverlay(true);
30901
+ pushState();
30902
+ };
30903
+ const onBlur = () => {
30904
+ applyOverlay(false);
30905
+ pushState();
30906
+ };
30907
+ const onResizeState = () => {
30908
+ pushState();
30909
+ };
30910
+ window.on("enter-full-screen", onFullScreen);
30911
+ window.on("leave-full-screen", onFullScreen);
30912
+ window.on("maximize", onResizeState);
30913
+ window.on("unmaximize", onResizeState);
30914
+ window.on("focus", onFocus);
30915
+ window.on("blur", onBlur);
30916
+ window.setBackgroundColor(colors.ground);
30917
+ const sync = () => {
30918
+ applyOverlay(!window.isDestroyed() && window.isFocused());
30919
+ window.setBackgroundColor(colors.ground);
30920
+ pushState();
30921
+ };
30922
+ const dispose = () => {
30923
+ electron.ipcMain.removeHandler(WINDOW_CHROME_REPORT_CHANNEL);
30924
+ electron.ipcMain.removeHandler(WINDOW_CHROME_MENU_CHANNEL);
30925
+ window.removeListener("enter-full-screen", onFullScreen);
30926
+ window.removeListener("leave-full-screen", onFullScreen);
30927
+ window.removeListener("maximize", onResizeState);
30928
+ window.removeListener("unmaximize", onResizeState);
30929
+ window.removeListener("focus", onFocus);
30930
+ window.removeListener("blur", onBlur);
30931
+ };
30932
+ return { sync, dispose };
30933
+ }
30165
30934
  const BASE64_FILE_EXTENSIONS = ["csv", "tsv", "xls", "xlsx", "ods"];
30166
30935
  const resolveUserPath = (filePath, cwd) => resolveUserPath$1(filePath, cwd, electron.app.getPath("home"));
30167
30936
  const windowLaunch = resolveWindowLaunchPlan({
@@ -30430,7 +31199,36 @@ const ownLaunchRequest = () => ({
30430
31199
  trigger: "initial-present"
30431
31200
  });
30432
31201
  function createWindow(initialSession = null, openCatalogue = false, request = ownLaunchRequest()) {
31202
+ const chrome = resolveLaunchWindowChrome({
31203
+ argv: process.argv,
31204
+ env: launchEnv,
31205
+ userDataDir: electron.app.getPath("userData"),
31206
+ platform: process.platform
31207
+ });
31208
+ for (const problem of chrome.problems) {
31209
+ logger.warn(`[window-chrome] ${problem}`, LogFileType.BACKEND);
31210
+ console.log(`[window-chrome] ${problem}`);
31211
+ }
30433
31212
  const mainWindow2 = new electron.BrowserWindow({
31213
+ /*
31214
+ * The frame, per platform, from a pure function so the matrix is exercised on
31215
+ * any host (`scripts/titlebar-options.test.mjs`). macOS hides its title bar
31216
+ * and keeps the native traffic lights, which is why the renderer reserves their
31217
+ * 32px lane at the top of the shell (`chat-layout.tsx`) - the lane exists
31218
+ * because this line does, and shipping one without the other is either 32px of
31219
+ * blank space under a native title bar or lights drawn over the app's own row.
31220
+ */
31221
+ ...titlebarOptions(process.platform, {
31222
+ mode: chrome.mode,
31223
+ colors: chrome.colors
31224
+ }),
31225
+ /*
31226
+ * The ground the OS paints before the renderer's first frame. Without it
31227
+ * Electron's default is `#FFF`, so every launch on a dark palette opened with a
31228
+ * white flash - the same defect `INSTALL_WINDOW_CANVAS` fixed for the installer
31229
+ * window, and a hard-coded palette value that a test keeps honest.
31230
+ */
31231
+ backgroundColor: chrome.colors.ground,
30434
31232
  width: windowLaunch.width,
30435
31233
  height: windowLaunch.height,
30436
31234
  // The layout is verified down to 800x600 and not below: the app rail,
@@ -30501,9 +31299,18 @@ function createWindow(initialSession = null, openCatalogue = false, request = ow
30501
31299
  * silent. `rendererArgumentFlags` below concatenates them, and is `{}` when
30502
31300
  * neither applies, so "adds no option at all" stays literally true.
30503
31301
  */
30504
- ...rendererArgumentFlags(initialSession, openCatalogue)
31302
+ ...rendererArgumentFlags(initialSession, openCatalogue, chrome.mode)
30505
31303
  }
30506
31304
  });
31305
+ const chromeController = attachWindowChrome({
31306
+ window: mainWindow2,
31307
+ mode: chrome.mode,
31308
+ colors: chrome.colors,
31309
+ userDataDir: electron.app.getPath("userData"),
31310
+ headless: windowLaunch.mode === "headless",
31311
+ log: (line) => logger.info(line, LogFileType.BACKEND)
31312
+ });
31313
+ mainWindow2.on("closed", () => chromeController.dispose());
30507
31314
  if (initialSession)
30508
31315
  holdPresentUntilConversation(mainWindow2, initialSession, request);
30509
31316
  mainWindow2.webContents.on("console-message", (_event, level, message) => {
@@ -30651,8 +31458,16 @@ function launchArgumentFlags(initialSession, openCatalogue) {
30651
31458
  if (openCatalogue) return { additionalArguments: [OPEN_CATALOGUE_FLAG] };
30652
31459
  return {};
30653
31460
  }
30654
- function rendererArgumentFlags(initialSession, openCatalogue) {
31461
+ function rendererArgumentFlags(initialSession, openCatalogue, chromeMode) {
30655
31462
  const flags = [
31463
+ /*
31464
+ * The chrome facts, and they are ALWAYS written - unlike the conversation or
31465
+ * the dev driver's arming entry, which appear only when they apply. The
31466
+ * renderer chooses between two incompatible layouts on this value, so a reader
31467
+ * that had to infer it from an absent entry would be guessing; see
31468
+ * `readWindowChromeArgument` for why the guess fails towards `native`.
31469
+ */
31470
+ windowChromeArgumentFor(process.platform, chromeMode),
30656
31471
  ...launchArgumentFlags(initialSession, openCatalogue).additionalArguments ?? [],
30657
31472
  ...devDriverWebPreferences.additionalArguments ?? [],
30658
31473
  ...telemetryWebPreferences.additionalArguments ?? []