local-operator-ui 0.31.22 → 0.31.24

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 (91) hide show
  1. package/out/main/index.js +377 -32
  2. package/out/renderer/assets/{_basePickBy-DF0sHvnl.js → _basePickBy-DvYnhuYU.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-BFKFl7Wd.js → _baseUniq-bfmokOo7.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-vTsZEkqQ.js → agent-details-page-CKuk0h6D.js} +2 -2
  5. package/out/renderer/assets/agent-hub-page-mjoCtkLM.js +11 -0
  6. package/out/renderer/assets/{agents-page-DC8A3FoC.js → agents-page-CKyDYjEM.js} +2 -2
  7. package/out/renderer/assets/aida-control-B8b5SXix.js +50 -0
  8. package/out/renderer/assets/{arc-D0w6ee3G.js → arc-CFcKtuSP.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-BHo23p2c.js → architectureDiagram-IEHRJDOE-iKar6Oic.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-C4PlyJ6T.js → blockDiagram-JOT3LUYC-B2zh8hXK.js} +1 -1
  11. package/out/renderer/assets/{book-open-BI_Ayn7w.js → book-open-ARKlovXx.js} +1 -1
  12. package/out/renderer/assets/browser-page-UoqCN-xN.js +1 -0
  13. package/out/renderer/assets/{browser-webauthn-prompt-B5MUKS69.js → browser-webauthn-prompt-CFO8FunV.js} +1 -1
  14. package/out/renderer/assets/{c4Diagram-VJAJSXHY-CjzI59bD.js → c4Diagram-VJAJSXHY-BMHJDEcf.js} +1 -1
  15. package/out/renderer/assets/channel-CIrWsSCo.js +1 -0
  16. package/out/renderer/assets/{chunk-4BMEZGHF-hXZ4B7Yp.js → chunk-4BMEZGHF-DRAVdspP.js} +1 -1
  17. package/out/renderer/assets/{chunk-A2AXSNBT-C6wS72cg.js → chunk-A2AXSNBT-Cud6VnlY.js} +1 -1
  18. package/out/renderer/assets/{chunk-AEK57VVT-CLqS3upu.js → chunk-AEK57VVT-BDCFuoNl.js} +1 -1
  19. package/out/renderer/assets/{chunk-D6G4REZN-C6hjevFK.js → chunk-D6G4REZN-DlMa4UAs.js} +1 -1
  20. package/out/renderer/assets/{chunk-RZ5BOZE2-Bz6LVJIn.js → chunk-RZ5BOZE2-EHEIhkiY.js} +1 -1
  21. package/out/renderer/assets/{chunk-XZIHB7SX-DmbrcRnR.js → chunk-XZIHB7SX-D6SPBzK4.js} +1 -1
  22. package/out/renderer/assets/classDiagram-GIVACNV2-BDlMBcMy.js +1 -0
  23. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BDlMBcMy.js +1 -0
  24. package/out/renderer/assets/clone-DkQdBey4.js +1 -0
  25. package/out/renderer/assets/{console-mirror-CHRPaR1r.js → console-mirror-iQjAVQrh.js} +1 -1
  26. package/out/renderer/assets/{consoleCapture-DCMDDQHm.js → consoleCapture-f4bHcMlg.js} +1 -1
  27. package/out/renderer/assets/{dagre-OKDRZEBW-D80irjtc.js → dagre-OKDRZEBW-C69-0hU9.js} +1 -1
  28. package/out/renderer/assets/{diagram-SSKATNLV-Cw1zxYSh.js → diagram-SSKATNLV-DsUPj3k9.js} +1 -1
  29. package/out/renderer/assets/{diagram-VNBRO52H-DMzy3XEu.js → diagram-VNBRO52H-3cLE7l86.js} +1 -1
  30. package/out/renderer/assets/{erDiagram-Q7BY3M3F-YenYV77V.js → erDiagram-Q7BY3M3F-Gqm9ZUtK.js} +1 -1
  31. package/out/renderer/assets/{error-boundary-DYh9GOqG.js → error-boundary-BheI-_hJ.js} +1 -1
  32. package/out/renderer/assets/{flowDiagram-4HSFHLVR-Bds7_Ont.js → flowDiagram-4HSFHLVR-bu5rejsx.js} +1 -1
  33. package/out/renderer/assets/{ganttDiagram-APWFNJXF-Nl53cyHj.js → ganttDiagram-APWFNJXF-DOpEB0OK.js} +4 -4
  34. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-BJBHzwkB.js → gitGraphDiagram-7IBYFJ6S-BitJPB27.js} +1 -1
  35. package/out/renderer/assets/{graph-BPbBedPT.js → graph-CyyuMUaw.js} +1 -1
  36. package/out/renderer/assets/index-CsbIIOFq.css +1 -0
  37. package/out/renderer/assets/{index-BNjEDIyj.js → index-D-d-ykb4.js} +1 -1
  38. package/out/renderer/assets/{index-Bnk19Me4.js → index-Db927tvJ.js} +1 -1
  39. package/out/renderer/assets/{index-ec0p7piv.js → index-FOmxzVhB.js} +456 -431
  40. package/out/renderer/assets/infoDiagram-PH2N3AL5-D3FD5ygR.js +2 -0
  41. package/out/renderer/assets/installer-Dq_j5D8p.js +1 -0
  42. package/out/renderer/assets/{journeyDiagram-U35MCT3I-D6V9F8qH.js → journeyDiagram-U35MCT3I-CMXDard0.js} +1 -1
  43. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-3AEAKd1U.js → kanban-definition-NDS4AKOZ-CGZPXBz-.js} +1 -1
  44. package/out/renderer/assets/{layout-CWH68IUl.js → layout-TTjt9cpH.js} +1 -1
  45. package/out/renderer/assets/{legacy-agents-page-CaXRBxnF.js → legacy-agents-page-DsEHBb_T.js} +3 -3
  46. package/out/renderer/assets/{mermaid.core-SkXjY8IF.js → mermaid.core-BGnJVp5q.js} +5 -5
  47. package/out/renderer/assets/{mesh-page-Cr_4iB8D.js → mesh-page-BuM1oQ_i.js} +1 -1
  48. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-CLOmofrv.js → mindmap-definition-ALO5MXBD-CaLpfI_E.js} +1 -1
  49. package/out/renderer/assets/{mini-BAGYXudo.js → mini-KOisKeMi.js} +1 -1
  50. package/out/renderer/assets/org-surface-gate-CZiYqd1P.js +1 -0
  51. package/out/renderer/assets/{page-header-Dz4E9Ji9.js → page-header-B2VyeyKK.js} +1 -1
  52. package/out/renderer/assets/{pieDiagram-IB7DONF6-BVWVZWCS.js → pieDiagram-IB7DONF6-DgCZ_Tpy.js} +1 -1
  53. package/out/renderer/assets/projects-page-D8CTmXCv.js +35 -0
  54. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DeqQdNl0.js → quadrantDiagram-7GDLP6J5-8FVHpZVN.js} +1 -1
  55. package/out/renderer/assets/{radar-MK3ICKWK-aqXYJ4Ku.js → radar-MK3ICKWK-4pgGQ-2B.js} +1 -1
  56. package/out/renderer/assets/{radient-auth-buttons-BqeoOp94.js → radient-auth-buttons-DUT18lNx.js} +1 -1
  57. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D81fmjDn.js → requirementDiagram-KVF5MWMF-BubgmAeX.js} +1 -1
  58. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-C0C6sBpn.js → sankeyDiagram-QLVOVGJD-C8luu1Ce.js} +1 -1
  59. package/out/renderer/assets/{schedules-page-Blr9zzCG.js → schedules-page-CajtG50s.js} +3 -3
  60. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-TB7F8Hqt.js → sequenceDiagram-X6HHIX6F-DyNeye_f.js} +1 -1
  61. package/out/renderer/assets/settings-page-DZNyJZ23.js +292 -0
  62. package/out/renderer/assets/{settings-section-lpy--SWL.js → settings-section-DQ45Riip.js} +1 -1
  63. package/out/renderer/assets/{stateDiagram-DGXRK772-B6dcayLG.js → stateDiagram-DGXRK772-DRqrOBmL.js} +1 -1
  64. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-o-zf-20f.js +1 -0
  65. package/out/renderer/assets/{textarea-dtE2fZRJ.js → textarea-CaRKMTTG.js} +5 -5
  66. package/out/renderer/assets/{timeline-definition-BDJGKUSR-DUOG5GaW.js → timeline-definition-BDJGKUSR-DC76uMim.js} +1 -1
  67. package/out/renderer/assets/ui-preferences-store-qSXgzAHu.js +1 -0
  68. package/out/renderer/assets/{use-download-agent-mutation-724n3ZAY.js → use-download-agent-mutation-B_qjSLGd.js} +2 -2
  69. package/out/renderer/assets/{use-memberships-query-CComBrZl.js → use-memberships-query-Q1_CwjZE.js} +1 -1
  70. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BLvnlG7d.js → xychartDiagram-VJFVF3MP-CyMpnVUJ.js} +1 -1
  71. package/out/renderer/console-capture.html +5 -5
  72. package/out/renderer/index.html +8 -8
  73. package/out/renderer/installer.html +5 -5
  74. package/out/renderer/mini.html +6 -6
  75. package/package.json +2 -2
  76. package/out/renderer/assets/agent-hub-page-jm_fZiZn.js +0 -6
  77. package/out/renderer/assets/aida-control-Bwkluijq.js +0 -42
  78. package/out/renderer/assets/browser-page-TT7eVydU.js +0 -1
  79. package/out/renderer/assets/channel-BnPqoquV.js +0 -1
  80. package/out/renderer/assets/classDiagram-GIVACNV2-D_CSusj-.js +0 -1
  81. package/out/renderer/assets/classDiagram-v2-COTLJTTW-D_CSusj-.js +0 -1
  82. package/out/renderer/assets/clone-BRW-kqpe.js +0 -1
  83. package/out/renderer/assets/index-DcDXH072.css +0 -1
  84. package/out/renderer/assets/infoDiagram-PH2N3AL5-DBjhwY6o.js +0 -2
  85. package/out/renderer/assets/installer-BgtX9Kgc.js +0 -1
  86. package/out/renderer/assets/org-surface-gate-Dj95j2Nj.js +0 -1
  87. package/out/renderer/assets/projects-page-Cu2gc5tY.js +0 -40
  88. package/out/renderer/assets/settings-page-DpqcsEmy.js +0 -297
  89. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-TfdpV2Sz.js +0 -1
  90. package/out/renderer/assets/ui-preferences-store-B6-k1D10.js +0 -1
  91. /package/out/renderer/assets/{index-BJJKraKa.js → index-C8q85hqs.js} +0 -0
package/out/main/index.js CHANGED
@@ -130,6 +130,7 @@ const variableValue = zod.z.string().max(16384);
130
130
  const WAKE_MESSAGE_MAX_CHARS = 2e3;
131
131
  const wakeMessage = zod.z.string().min(1).max(WAKE_MESSAGE_MAX_CHARS);
132
132
  const wakeId = zod.z.string().min(1).max(64);
133
+ const monitorId = zod.z.string().min(1).max(64);
133
134
  const scheduleWrite = zod.z.object({
134
135
  is_active: zod.z.boolean().nullish(),
135
136
  one_time: zod.z.boolean().nullish(),
@@ -1268,6 +1269,20 @@ const desktopRequestUnion = zod.z.discriminatedUnion("op", [
1268
1269
  limit: zod.z.number().int().min(1).optional()
1269
1270
  }).strict(),
1270
1271
  zod.z.object({ op: zod.z.literal("wakes.remove"), sessionId, wakeId }).strict(),
1272
+ /*
1273
+ * The monitor surface's one write that has a UI: cancelling a standing watch
1274
+ * (`DELETE /v1/desktop/monitors/{session_id}/{monitor_id}`). The sibling
1275
+ * routes - the machine-wide listing and the arm - are deliberately not
1276
+ * mirrored yet: the pane and the composer's chip read the SESSION's own
1277
+ * `frontend.monitors` field (the design's §12 row states the desktop contract
1278
+ * as "the `monitors` field + command routes"), so a listing op would have no
1279
+ * reader, and the arm op waits for the form that will use it.
1280
+ */
1281
+ zod.z.object({
1282
+ op: zod.z.literal("monitors.cancel"),
1283
+ sessionId,
1284
+ monitorId
1285
+ }).strict(),
1271
1286
  zod.z.object({ op: zod.z.literal("mcp.list"), sessionId }).strict(),
1272
1287
  zod.z.object({
1273
1288
  op: zod.z.literal("mcp.credentials.store"),
@@ -2437,6 +2452,18 @@ function desktopEndpoint(request) {
2437
2452
  path: `/v1/desktop/wakes/${request.sessionId}/${request.wakeId}`,
2438
2453
  method: "DELETE"
2439
2454
  };
2455
+ /*
2456
+ * The monitor cancel, mapped like its wake sibling. Both segments are
2457
+ * `encodeURIComponent`ed even though both their schemas already refuse
2458
+ * separators: the schema is this client's own check, and a redirect or a
2459
+ * hand-built request must not be able to turn a handle into a path
2460
+ * fragment (the mesh writes' own rule).
2461
+ */
2462
+ case "monitors.cancel":
2463
+ return {
2464
+ path: `/v1/desktop/monitors/${encodeURIComponent(request.sessionId)}/${encodeURIComponent(request.monitorId)}`,
2465
+ method: "DELETE"
2466
+ };
2440
2467
  case "legacy.jobs.list": {
2441
2468
  const query = new URLSearchParams();
2442
2469
  if (request.agentId) query.set("agent_id", request.agentId);
@@ -8425,6 +8452,50 @@ function consoleInterpreter(consolePath) {
8425
8452
  "Cannot safely own this backend launcher; use a directly paired external backend"
8426
8453
  );
8427
8454
  }
8455
+ const LAUNCHER_PROBE_TIMEOUT_MS = 15e3;
8456
+ const LAUNCHER_PROBE_ATTEMPTS = 2;
8457
+ const LAUNCHER_PROBE_WORST_MS = LAUNCHER_PROBE_ATTEMPTS * (LAUNCHER_PROBE_TIMEOUT_MS + DEFAULT_BUDGET.graceMs + DEFAULT_BUDGET.slackMs);
8458
+ async function probeGlobalLauncher(consolePath, env, platform = process.platform, timeoutMs = LAUNCHER_PROBE_TIMEOUT_MS) {
8459
+ let interpreter = null;
8460
+ if (platform !== "win32") {
8461
+ try {
8462
+ interpreter = consoleInterpreter(consolePath);
8463
+ } catch (error) {
8464
+ return {
8465
+ usable: false,
8466
+ interpreter: null,
8467
+ reason: `it is not a local-operator launcher this app can run (${describe$1(error)})`
8468
+ };
8469
+ }
8470
+ if (!fs.existsSync(interpreter)) {
8471
+ return {
8472
+ usable: false,
8473
+ interpreter,
8474
+ reason: `the interpreter its own preamble names, ${interpreter}, no longer exists`
8475
+ };
8476
+ }
8477
+ }
8478
+ for (let attempt = 1; ; attempt++) {
8479
+ try {
8480
+ const stdout = await runBounded(consolePath, ["--version"], env, {
8481
+ ...DEFAULT_BUDGET,
8482
+ timeoutMs
8483
+ });
8484
+ const first = stdout.trim().split(NEWLINES)[0]?.trim() ?? "";
8485
+ return { usable: true, interpreter, version: first || null };
8486
+ } catch (error) {
8487
+ if (error instanceof ProbeTimeout) {
8488
+ if (attempt < LAUNCHER_PROBE_ATTEMPTS) continue;
8489
+ return {
8490
+ usable: false,
8491
+ interpreter,
8492
+ reason: `${consolePath} did not answer a --version probe within ${timeoutMs} ms`
8493
+ };
8494
+ }
8495
+ return { usable: false, interpreter, reason: describe$1(error) };
8496
+ }
8497
+ }
8498
+ }
8428
8499
  var LocalOperatorStartupMode = /* @__PURE__ */ ((LocalOperatorStartupMode2) => {
8429
8500
  LocalOperatorStartupMode2["EXISTING_SERVER"] = "EXISTING_SERVER";
8430
8501
  LocalOperatorStartupMode2["GLOBAL_INSTALL"] = "GLOBAL_INSTALL";
@@ -8578,6 +8649,42 @@ class BackendServiceManager {
8578
8649
  */
8579
8650
  managerMaySpawn = backendConfig.VITE_DISABLE_BACKEND_MANAGER !== "true";
8580
8651
  startupMode = "NOT_STARTED";
8652
+ /**
8653
+ * The last POSITIVE answer `probeLauncherFor` produced, keyed by the launcher
8654
+ * it answered about.
8655
+ *
8656
+ * WHY IT IS CACHED. `checkLocalOperatorExists` runs twice in one startup tick -
8657
+ * once for the install decision in `index.ts`, once inside `startOwned` when it
8658
+ * chooses between GLOBAL_INSTALL and APP_BUNDLED_VENV - and the verdict now
8659
+ * costs a spawn of the launcher (measured 1.96 s for the operator's own install
8660
+ * on this fleet-loaded host, 0.33 s idle). Paying that twice for one answer is a
8661
+ * second of startup latency bought for nothing.
8662
+ *
8663
+ * WHAT THE KEY IS, AND THE LIMIT IT SETS: the resolved path together with the
8664
+ * launcher FILE's own size and mtime, so a launcher rewritten in place - which is
8665
+ * exactly what `lop-update`, `uv tool` and a pip reinstall all do to a console
8666
+ * script - is probed again rather than inheriting the verdict the previous bytes
8667
+ * earned (the shim's shebang names a generation directory, so replacing it is how
8668
+ * an install moves). A launcher whose INTERPRETER is deleted underneath it, with
8669
+ * the shim itself untouched, keeps its old verdict until the app restarts - the
8670
+ * honest limit of a probe taken at startup, and one the spawn's own identity
8671
+ * probe still REPORTS BETTER than this one can: as a start failure in its own
8672
+ * words, not as a recovery, because nothing falls back to the managed environment
8673
+ * once a start is refused.
8674
+ *
8675
+ * AND ONLY POSITIVES ARE HELD; a negative is released the moment it settles
8676
+ * (`probeLauncherFor`, below). The directions are not symmetric: a positive is a
8677
+ * fact about an install that does not change under this process, while a negative
8678
+ * can be a fact about the MOMENT - a 15 s timeout on a loaded machine, a package
8679
+ * reinstall caught mid-flight - and one kept reading decides both call sites for
8680
+ * the whole session, which is how a working global install sits unused while the
8681
+ * app builds its own environment (review round 1, R1-1).
8682
+ *
8683
+ * The in-flight PROMISE is still what is held while it runs, so two callers in
8684
+ * one tick share one spawn rather than racing two; it is the settled negative
8685
+ * that is let go.
8686
+ */
8687
+ launcherVerdict = null;
8581
8688
  port;
8582
8689
  backendUrl;
8583
8690
  /**
@@ -9516,8 +9623,25 @@ class BackendServiceManager {
9516
9623
  return resolveGlobalConsoleScript();
9517
9624
  }
9518
9625
  /**
9519
- * Check if the local-operator command exists globally
9520
- * @returns Promise resolving to true if the command exists, false otherwise
9626
+ * Check if the local-operator command exists globally AND can actually run.
9627
+ *
9628
+ * WHY THIS IS NOT AN EXISTENCE CHECK ANY MORE. It gates the decision that skips
9629
+ * provisioning entirely (`index.ts`: a global command means `install()` is never
9630
+ * called and the app attaches to that CLI instead), so a negative answer is the
9631
+ * difference between the app preparing the backend it expects and the app
9632
+ * running whatever happens to be on the machine. `resolveGlobalConsoleScript`
9633
+ * answers "a file is there"; two broken installs pass that test and fail
9634
+ * everything after it: a launcher whose shebang names an interpreter that is
9635
+ * gone (a tool environment removed under the shim, a pruned generation), and a
9636
+ * launcher whose interpreter starts but cannot import `local_operator` (the
9637
+ * package uninstalled under it). Both used to surface as a spawn failure well
9638
+ * after provisioning had been skipped - three failed start attempts and then
9639
+ * "Failed to start the Local Operator backend service. Please restart the
9640
+ * application." - so the user was told to restart an app that could not start,
9641
+ * for a reason this probe can see in a second and act on (`probeGlobalLauncher`).
9642
+ *
9643
+ * @returns Promise resolving to true when the resolved launcher ran and
9644
+ * answered, false when there is none or it could not be used.
9521
9645
  */
9522
9646
  async checkLocalOperatorExists() {
9523
9647
  const command = this.globalConsoleScript();
@@ -9543,12 +9667,59 @@ class BackendServiceManager {
9543
9667
  );
9544
9668
  return false;
9545
9669
  }
9670
+ const usability = await this.probeLauncherFor(command);
9671
+ if (!usability.usable) {
9672
+ logger.warn(
9673
+ `The global local-operator at ${command} can not be used as this app's backend: ${usability.reason}. It will not suppress provisioning - this app is preparing its own managed environment instead, so the backend is the build this app expects rather than whatever that install contains.`,
9674
+ LogFileType.BACKEND
9675
+ );
9676
+ return false;
9677
+ }
9546
9678
  logger.info(
9547
- `local-operator command found at: ${command}`,
9679
+ `local-operator command found at: ${command}${usability.interpreter ? ` (interpreter ${usability.interpreter})` : ""}${usability.version ? `, reporting ${usability.version}` : ", which answered no version"}; this app will use it instead of preparing its own environment`,
9548
9680
  LogFileType.BACKEND
9549
9681
  );
9550
9682
  return true;
9551
9683
  }
9684
+ /**
9685
+ * One probe per launcher FINGERPRINT, shared by both call sites in a startup tick.
9686
+ *
9687
+ * See the field for why this is cached, what the key is, what its limit is and
9688
+ * which direction is released. The stat is the cheap half of the key and it never
9689
+ * decides anything on its own: a launcher that cannot be stat'ed (removed between
9690
+ * the resolution and this call) is keyed by path alone, and the probe then answers
9691
+ * for it honestly. `process.env` is handed to the probe rather than
9692
+ * `backendSpawnEnv()` - this answers a question about the LAUNCHER (does its own
9693
+ * `--version` run), not about the serve environment, and the launcher's shebang
9694
+ * names its interpreter absolutely - but it is handed WITH the bytecode guard:
9695
+ * this spawn runs an interpreter that imports `local_operator`, and every spawn of
9696
+ * an interpreter this app starts owes the bundle `withPythonBytecodeCache` (the
9697
+ * code-sealed-bundle mechanism; the same pair `backendSpawnEnv()` applies to the
9698
+ * serve child). A child that needs PATH customised beyond that is the serve
9699
+ * child's problem, and it is solved where that child is built.
9700
+ */
9701
+ probeLauncherFor(command) {
9702
+ let fingerprint = command;
9703
+ try {
9704
+ const stat = fs.statSync(command);
9705
+ fingerprint = `${command}\0${stat.size}\0${stat.mtimeMs}`;
9706
+ } catch {
9707
+ }
9708
+ if (this.launcherVerdict?.fingerprint === fingerprint) {
9709
+ return this.launcherVerdict.verdict;
9710
+ }
9711
+ const verdict = probeGlobalLauncher(
9712
+ command,
9713
+ withPythonBytecodeCache(process.env, this.appDataPath)
9714
+ );
9715
+ this.launcherVerdict = { fingerprint, verdict };
9716
+ verdict.then((answer) => {
9717
+ if (!answer.usable && this.launcherVerdict?.verdict === verdict) {
9718
+ this.launcherVerdict = null;
9719
+ }
9720
+ });
9721
+ return verdict;
9722
+ }
9552
9723
  /**
9553
9724
  * `sys.prefix` of the install the user's own `lop` runs, when it can be named.
9554
9725
  *
@@ -11970,9 +12141,9 @@ function managedPythonOptions() {
11970
12141
  arch: process.arch
11971
12142
  };
11972
12143
  }
11973
- 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';
11974
- 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';
11975
- 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';
12144
+ 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 # ...AND THEN WHATEVER `PATH` OFFERS, which is the half this probe used to\n # ignore entirely.\n #\n # WHY IT MATTERS, stated as the user story rather than as a rule: Python 3.12\n # on Linux usually arrives from a version manager (pyenv, mise, asdf, uv) or a\n # Homebrew/Linuxbrew prefix, and every one of those puts its interpreter\n # somewhere this list does not name while putting it on `PATH`. The list above\n # is what the app\'s own CI runner has (Ubuntu\'s `/usr/bin/python3`), so the gap\n # was invisible there and the report on the user\'s machine was the wrong one:\n # "No suitable Python installation found (version 3.12 or higher required).\n # Please install Python 3.12 or higher" - about a machine that had 3.12\n # installed and working, sending them to reinstall what they already had.\n #\n # APPENDED, NOT PREPENDED. A version manager on `PATH` can point at any minor\n # version, and the fixed list above is the more predictable of the two where it\n # resolves at all; the version and architecture checks in the loop below are\n # unchanged and still decide, so an entry added here is a candidate and never\n # an answer.\n for python_name in python3.12 python3 python; do\n resolved_python="$(command -v "${python_name}" 2>/dev/null || true)"\n if [ -z "${resolved_python}" ]; then\n continue\n fi\n # Deduplicated by hand: a repeated candidate costs a python spawn each and\n # repeats its own "below the required version" line, which reads as several\n # separate problems rather than one machine being looked at three times.\n already_listed=false\n for listed_path in "${POSSIBLE_PYTHON_PATHS[@]}"; do\n if [ "${listed_path}" = "${resolved_python}" ]; then\n already_listed=true\n break\n fi\n done\n if [ "${already_listed}" = false ]; then\n POSSIBLE_PYTHON_PATHS+=("${resolved_python}")\n fi\n done\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 # WHAT WAS SEARCHED IS PART OF THE DIAGNOSIS, and printing it is the second\n # half of the fix above: the sentence this used to end with told a user who\n # already had 3.12 to install it. Naming the paths makes the two real cases\n # tell themselves apart - nothing is installed, or something is installed and\n # this process cannot see it - and each of those has its own remedy.\n log "No suitable Python 3.12+ was found. Checked: ${POSSIBLE_PYTHON_PATHS[*]}"\n error_exit "No Python 3.12+ found: not in the standard locations and not on this process\'s PATH. Install Python 3.12 or higher (https://www.python.org/downloads/), or make an existing 3.12+ visible on this process\'s PATH."\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\n# the venv because the app\'s backend-update path runs `<venv>/bin/python -m pip\n# install --upgrade local-operator` inside this same environment - which is why\n# the environment must keep pip, and NOT a reason to avoid `uv venv`: the note\n# that stood here said `uv venv` leaves no pip at all, which is true of bare\n# `uv venv` and false of `uv venv --seed`, and this script\'s own creation path is\n# still `python -m venv` only because moving it has been measured on macOS and\n# not on this platform yet (see the macOS script for the numbers and the proof).\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.\n# UV_SYSTEM_CERTS: trust the PLATFORM trust store, not only the root bundle uv\n# ships. Off by default, which is the default this line changes; `--system-certs`\n# is the same setting spelled as a flag in the bundled uv 0.12.17.\n#\n# WHY: on a network that inspects TLS the root lives in the platform store (here,\n# `/etc/ssl/certs`), and uv does not read that store by default - it fails the\n# handshake against the roots compiled into it. uv names this remedy itself when\n# it fails: "Consider enabling use of system TLS certificates with the\n# `--system-certs` command-line flag". Nothing here passed it, so the failure\n# went to the fallback and the user was told to check a network that was working\n# for every other application on the machine.\n#\n# AND WHY THE PIP FALLBACK IS HANDED NO CA SETTING: pip 24.2 and newer read the\n# platform store by default, in addition to the Mozilla bundle they ship\n# (`truststore`, "always on since 24.2"), and every pip these installs produce\n# is newer than that - uv\'s own seed and the bundled interpreter\'s ensurepip\n# alike - so a root added to `/etc/ssl/certs` is trusted by BOTH clients and no\n# `PIP_CERT`/`SSL_CERT_FILE` is needed. The note this replaces claimed the\n# opposite (the fallback "resolves against certifi"; "neither client" would see\n# the store), which was true only of pip before 24.2; corrected rather than\n# deleted so it is not restored (review round 1, R1-2). The one shape it does\n# not cover is a dev checkout on a system python old enough to predate\n# truststore.\n#\n# IT CANNOT MAKE THINGS WORSE: every uv call below is already followed by the pip\n# fallback on a non-zero exit, so a platform store that cannot be read costs one\n# failed uv attempt and then the path that shipped before uv was bundled.\nuv_run() {\n UV_NO_CONFIG=1 UV_PYTHON_DOWNLOADS=never UV_SYSTEM_CERTS=1 \\\n 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';
12145
+ 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# --- The installer this script prefers: the app\'s own bundled uv -----------------\n#\n# Resolved HERE, above the environment rather than beside the package install,\n# because uv builds the environment too (see the note at the creation below).\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.\n# UV_SYSTEM_CERTS: trust the PLATFORM trust store, not only the root bundle uv\n# ships. It is OFF by default, which is the default this line changes, and the\n# flag\'s own name in the bundled uv is `--system-certs`.\n#\n# WHY THIS LINE EXISTS AT ALL. A network that inspects TLS - a corporate proxy,\n# an admin- or profile-installed root - carries its root in the platform store,\n# and uv does NOT read that store by default: it fails the handshake against\n# the roots compiled into it. This flag is the whole of the difference between\n# a machine that cannot install at all and one that installs normally.\n#\n# AND WHY THE PIP FALLBACK IS HANDED NO CA SETTING: pip 24.2 and newer read the\n# platform store by default, in addition to the Mozilla bundle they ship\n# (`truststore`, "always on since 24.2"), and every pip these installs produce\n# is newer than that - uv\'s own seed and the bundled interpreter\'s ensurepip\n# alike - so a root added to the store is trusted by BOTH clients and no\n# `PIP_CERT`/`SSL_CERT_FILE` is needed. The note this replaces claimed the\n# opposite ("neither client" would see the store; the fallback "resolves\n# against certifi"), which was true only of pip before 24.2; corrected rather\n# than deleted so it is not restored (review round 1, R1-2). The one shape it\n# does not cover is a dev checkout on a system python old enough to predate\n# truststore.\n#\n# IT CANNOT MAKE THINGS WORSE, which is why it is set unconditionally rather than\n# probed for: every uv call below is already followed by the pip fallback on a\n# non-zero exit, so the cost of a machine whose platform store cannot be read is\n# one failed uv attempt and the path that shipped before uv was bundled.\nuv_run() {\n UV_NO_CONFIG=1 UV_PYTHON_DOWNLOADS=never UV_SYSTEM_CERTS=1 \\\n UV_CACHE_DIR="$UV_CACHE_DIR" \\\n "$UV_BIN" "$@"\n}\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 # THE ENVIRONMENT: uv first, the interpreter\'s own venv module second.\n #\n # WHY uv. `python -m venv` seeds pip by running the interpreter\'s own\n # `ensurepip`, and that is the slow half of creating an environment. Measured on\n # this machine against the bundled interpreter, cold, one run each: `python -m\n # venv` 4.74 s against `uv venv --seed` 1.22 s. Those are this box\'s numbers\n # under fleet load, so read the saving as 3.5 s HERE and not as a constant; the\n # scoping report measured 5.1 s on a quieter machine. uv produces the same\n # environment: the same `pyvenv.cfg` home pointing at the external runtime, a\n # `bin/python` symlinked at the interpreter it was handed.\n #\n # WHY `--seed`, and this is the part the note that used to stand here got\n # wrong. The app\'s backend-update path runs `<venv>/bin/python -m pip install\n # --upgrade local-operator` inside this environment (`update-service.ts`,\n # `buildPipUpgradeCommand`), so the environment needs the pip MODULE. The old\n # note said `uv venv` "produces an environment with no pip at all" - true of\n # bare `uv venv`, false of the command below: `--seed` installs pip into the\n # environment (measured: `pip 26.2.1` in the seeded venv against `pip 25.0.1`\n # from the ensurepip wheel). The stale reason is deleted rather than left\n # standing, because a comment asserting a constraint that no longer holds is\n # how the next reader avoids a correct change.\n # Verified end to end before this change, not argued: the app\'s own command was\n # run inside one of these seeded environments and installed `local-operator\n # 0.63.6`, after which `<venv>/bin/local-operator --version` answered.\n #\n # WHY THE FALLBACK IS NOT OPTIONAL. `uv venv --seed` fetches the pip wheel, so\n # it needs the network - a phase earlier than the package install does. A uv\n # that cannot run, or that fails this one step, therefore falls through to the\n # interpreter\'s own `venv`, which is the path that shipped until now. The `rm`\n # on that path removes only the directory THIS attempt just made: the guard\n # above proved the path did not exist a moment ago.\n VENV_CREATED=false\n if uv_is_usable; then\n echo "Creating virtual environment with uv ($("$UV_BIN" --version 2>/dev/null || echo \'version unavailable\'))..."\n if uv_run venv --seed --python "$PYTHON_BIN" "$VENV_PATH"; then\n VENV_CREATED=true\n else\n UV_VENV_STATUS=$?\n echo "WARNING: the bundled uv could not create the environment (exit ${UV_VENV_STATUS}); retrying with the interpreter\'s own venv module."\n rm -rf "$VENV_PATH"\n fi\n fi\n\n if [ "$VENV_CREATED" != true ]; then\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 fi\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# THE ENVIRONMENT ABOVE ALREADY HAS PIP IN IT, and that is load-bearing: the app\'s\n# backend-update path runs `<venv>/bin/python -m pip install --upgrade\n# local-operator` inside this same environment (`update-service.ts`,\n# `buildPipUpgradeCommand`). Both creation paths provide it - `uv venv --seed`\n# installs pip, `python -m venv` seeds it from `ensurepip` - and the structural\n# check above (`"$VENV_PATH/bin/pip"`) is what holds that on both.\n#\n# `UV_BIN`, the ambient-`UV_*` sweep, `UV_CACHE_DIR` and `uv_run` are defined\n# beside that creation, above, because uv builds the environment as well as\n# installing into it.\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';
12146
+ 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\n# the venv because the app\'s backend-update path runs `pip install --upgrade\n# local-operator` inside this same environment - which is why the environment must\n# keep pip, and NOT a reason to avoid `uv venv`: the note that stood here said\n# `uv venv` leaves no pip at all, which is true of bare `uv venv` and false of\n# `uv venv --seed`, and this script\'s creation path is still `python -m venv` only\n# because moving it has been measured on macOS and not on this platform yet (see\n# the macOS script for the numbers and the proof).\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 four\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#\n# UV_SYSTEM_CERTS: trust the PLATFORM trust store (the Windows certificate\n# stores), not only the root bundle uv ships - off by default, which is the\n# default this line changes; `--system-certs` is the same setting spelled as a\n# flag in the bundled uv 0.12.17.\n#\n# WHY: on a network that inspects TLS the root is installed in the platform\n# store, and uv does not read that store by default - it fails the handshake\n# against the roots compiled into it. uv names the remedy itself when it fails:\n# "Consider enabling use of system TLS certificates with the `--system-certs`\n# command-line flag". Nothing here passed it.\n#\n# AND WHY THE PIP FALLBACK IS HANDED NO CA SETTING: pip 24.2 and newer read the\n# platform store by default, in addition to the Mozilla bundle they ship\n# (`truststore`, "always on since 24.2"), and every pip these installs produce\n# is newer than that - uv\'s own seed and the bundled interpreter\'s ensurepip\n# alike - so a root added to the Windows certificate stores is trusted by BOTH\n# clients and no `PIP_CERT`/`SSL_CERT_FILE` is needed. The note this replaces\n# claimed the opposite (the fallback "resolves against certifi"), which was\n# true only of pip before 24.2; corrected rather than deleted so it is not\n# restored (review round 1, R1-2). The one shape it does not cover is a dev\n# checkout on a system python old enough to predate truststore.\n#\n# IT CANNOT MAKE THINGS WORSE: every uv call below is already followed by the pip\n# fallback on a non-zero exit, so a platform store that cannot be read costs one\n# failed uv attempt and then the path that shipped before uv was bundled.\n$env:UV_NO_CONFIG = "1"\n$env:UV_PYTHON_DOWNLOADS = "never"\n$env:UV_CACHE_DIR = $UvCacheDir\n$env:UV_SYSTEM_CERTS = "1"\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';
11976
12147
  const macosInstallScript = macosInstallScriptRaw;
11977
12148
  const linuxInstallScript = linuxInstallScriptRaw;
11978
12149
  const windowsInstallScript = windowsInstallScriptRaw;
@@ -11981,6 +12152,61 @@ const SETUP_FAILURE_CAUSES = [
11981
12152
  /ENOSPC|No space left|not enough (?:free )?space/i,
11982
12153
  "This Mac ran out of disk space while setting up the backend. Free some space and retry."
11983
12154
  ],
12155
+ [
12156
+ /*
12157
+ * THE CLOCK, OR A CERTIFICATE THAT REALLY EXPIRED - the two this text cannot
12158
+ * tell apart, and it goes ABOVE the verification entry because the raw text of
12159
+ * a python expiry failure carries `CERTIFICATE_VERIFY_FAILED` as well as
12160
+ * "certificate has expired", so the entry below would otherwise answer first.
12161
+ *
12162
+ * WHY IT EXISTS (review round 1, R1-3): the verification entry used to carry a
12163
+ * bare `SSLError` alternative, and every SSL-shaped failure matched it -
12164
+ * including this one and the two that are not trust problems at all - so the
12165
+ * user was told to "ask IT for the root certificate" for an expired
12166
+ * certificate. A wrong clock is the one cause on this list the user can fix
12167
+ * alone, and nothing in the previous sentence mentioned it.
12168
+ */
12169
+ /certificate has expired|certificate is not yet valid|not valid at this time/i,
12170
+ "Local Operator read the package index's certificate as expired or not yet valid. A wrong clock is the usual cause - check this machine's date and time and retry; if the clock is right, the certificate itself has expired and the index's operator has to renew it."
12171
+ ],
12172
+ [
12173
+ /*
12174
+ * THE INSPECTED CONNECTION, and it goes above the unreachable-index entry
12175
+ * for the reason that entry's own note gives for its own position: a TLS
12176
+ * failure IS a network failure to the reader, and the general sentence that
12177
+ * follows ("check the connection and retry") is the wrong remedy for it -
12178
+ * the connection works for every other application on the machine, and
12179
+ * retrying it never helps.
12180
+ *
12181
+ * BOTH CLIENTS' WORDS ARE MATCHED, because both are on the install path and
12182
+ * they fail with different sentences for the same cause: uv says
12183
+ * `invalid peer certificate: UnknownIssuer`, pip says
12184
+ * `SSLError(SSLCertVerificationError(...))` / "There was a problem
12185
+ * confirming the ssl certificate" (both captured against a deliberately
12186
+ * untrusted local CA on macOS, not quoted from memory). Neither
12187
+ * sentence matched anything in this table before, so the user got the
12188
+ * app's generic words with no remedy in them at all.
12189
+ *
12190
+ * THE PATTERN IS THE VERIFICATION WORDS ONLY (review round 1, R1-3): a bare
12191
+ * `SSLError` also carried `SSLError(1, '[SSL: WRONG_VERSION_NUMBER]')` - a
12192
+ * proxy answering plain HTTP - and `SSLEOFError` - a handshake that was
12193
+ * aborted early - and answering either with a certificate remedy is
12194
+ * misdirection. Those now fall through to the generic cause instead.
12195
+ *
12196
+ * THE REMEDY NAMES THE STORE, and it is true of BOTH clients as they ship
12197
+ * (review round 1, R1-2): uv needs `--system-certs`/`UV_SYSTEM_CERTS`, which
12198
+ * the three install scripts now pass, and pip has read the platform store BY
12199
+ * DEFAULT since 24.2 (`truststore`; both pips these installs produce are
12200
+ * newer - uv's seed ships 26.x and the bundled interpreter's ensurepip 25.x)
12201
+ * - so a root added to the store is trusted by the uv path AND by the pip
12202
+ * fallback, and the sentence is not asking the user to do something that then
12203
+ * fails. The three scripts' notes that said the pip fallback "resolves
12204
+ * against certifi" were true only of pip before 24.2 and are corrected
12205
+ * beside this.
12206
+ */
12207
+ /invalid peer certificate|UnknownIssuer|SSLCertVerificationError|CERTIFICATE_VERIFY_FAILED|certificate verify failed|problem confirming the ssl certificate|certificate is not trusted/i,
12208
+ "Local Operator could not verify the package index's secure connection, which is usually a network that inspects it (a corporate proxy or firewall). Ask IT for the root certificate and add it to your system certificate store - this app already trusts that store - then retry."
12209
+ ],
11984
12210
  [
11985
12211
  /*
11986
12212
  * THE UNREACHABLE INDEX, and it goes above the file one on purpose.
@@ -18923,6 +19149,7 @@ class TabRegistry {
18923
19149
  homeSessionId: restored ? null : options.sessionId ?? null,
18924
19150
  nonce: restored || options.owner === "user" ? null : mintNonce(),
18925
19151
  handedTo: null,
19152
+ handedFrom: null,
18926
19153
  restored,
18927
19154
  restoreRow: options.restoreRow,
18928
19155
  createdAt: Date.now(),
@@ -19067,6 +19294,7 @@ class TabRegistry {
19067
19294
  );
19068
19295
  }
19069
19296
  if (record.owner !== "agent") this.assertAgentCapacity();
19297
+ if (record.handedFrom === null) record.handedFrom = record.owner;
19070
19298
  record.owner = "agent";
19071
19299
  record.sessionId = sessionId2;
19072
19300
  record.handedTo = sessionId2;
@@ -19094,6 +19322,7 @@ class TabRegistry {
19094
19322
  record.owner = "user";
19095
19323
  record.sessionId = record.homeSessionId;
19096
19324
  record.handedTo = null;
19325
+ record.handedFrom = null;
19097
19326
  record.allocationId = "";
19098
19327
  this.bumpEpoch(record.tabId);
19099
19328
  this.onChanged();
@@ -20420,12 +20649,48 @@ class BrowserHost {
20420
20649
  whenRestored() {
20421
20650
  return this.hydration;
20422
20651
  }
20652
+ /**
20653
+ * Start ONE restored tab's load, without waiting for it.
20654
+ *
20655
+ * The one spelling of "how a restored tab gets its page back", shared by
20656
+ * `hydrateOne` (which awaits the promise under the per-tab timeout) and the
20657
+ * budget drain at the end of the pass (which only STARTS it). `null` when the
20658
+ * tab or its view is already gone — the user can close a tab while the pass is
20659
+ * still running, and the drain iterates a queue collected across awaits.
20660
+ */
20661
+ startRestore(tabId, recorded) {
20662
+ const record = this.registry.get(tabId);
20663
+ if (!record) return null;
20664
+ const contents = record.view.webContents;
20665
+ if (contents.isDestroyed()) return null;
20666
+ const history = contents.navigationHistory;
20667
+ const entry = recorded.entries[recorded.activeIndex];
20668
+ return history?.restore ? history.restore({
20669
+ entries: recorded.entries,
20670
+ index: recorded.activeIndex
20671
+ }) : (
20672
+ // The honest degradation (see `NavigationHistoryLike`): a view with no
20673
+ // history API still gets put back on the page it was showing, without
20674
+ // its stack and its page state.
20675
+ entry ? contents.loadURL(entry.url) : Promise.resolve()
20676
+ );
20677
+ }
20423
20678
  /**
20424
20679
  * Apply each restored tab's history and debugger session, bounded.
20425
20680
  *
20426
20681
  * `Promise.allSettled` rather than `Promise.all`: the two steps below already turn
20427
20682
  * a failure into a log line, and a rejection here would be an unhandled rejection
20428
20683
  * on a promise only a test awaits.
20684
+ *
20685
+ * THE BUDGET STOPS THE PASS WAITING, NEVER THE WORK (2026-09-29, RC2b). When the
20686
+ * deadline expires the workers stop TAKING tabs, and whatever is still queued is
20687
+ * drained below: each remaining tab's load is STARTED, unawaited, so nothing is
20688
+ * left `about:blank` forever. Before the drain, the deadline check ran AFTER the
20689
+ * shift and silently dropped the tab it had just taken, and every other queued
20690
+ * tab got no load at all while the log claimed "they keep loading in the
20691
+ * background" (the field: up to 14 blank tabs from one launch). The constants
20692
+ * above are deliberately unchanged — one hung page must not withhold the host,
20693
+ * so what changed is only what happens AFTER the budget, not the budget.
20429
20694
  */
20430
20695
  async hydrateRestored(created) {
20431
20696
  const deadline2 = Date.now() + RESTORE_BUDGET_MS;
@@ -20433,17 +20698,17 @@ class BrowserHost {
20433
20698
  let reported = false;
20434
20699
  const worker = async () => {
20435
20700
  for (; ; ) {
20436
- const next = queue.shift();
20437
- if (!next) return;
20438
20701
  if (Date.now() >= deadline2) {
20439
- if (!reported) {
20702
+ if (queue.length > 0 && !reported) {
20440
20703
  reported = true;
20441
20704
  this.log(
20442
- `[browser] the ${RESTORE_BUDGET_MS}ms restore budget expired with ${queue.length + 1} tab(s) still loading; they keep loading in the background`
20705
+ `[browser] the ${RESTORE_BUDGET_MS}ms restore budget expired with ${queue.length} tab(s) still queued; they start loading in the background`
20443
20706
  );
20444
20707
  }
20445
20708
  return;
20446
20709
  }
20710
+ const next = queue.shift();
20711
+ if (!next) return;
20447
20712
  await this.hydrateOne(next.tabId, next.recorded);
20448
20713
  }
20449
20714
  };
@@ -20453,30 +20718,75 @@ class BrowserHost {
20453
20718
  worker
20454
20719
  )
20455
20720
  );
20721
+ for (const next of queue) {
20722
+ void this.restoreUnderTimeout(next.tabId, next.recorded).catch(
20723
+ (error) => {
20724
+ this.log(
20725
+ `[browser] could not start the restore of tab ${next.tabId}: ${String(error)}`
20726
+ );
20727
+ }
20728
+ );
20729
+ }
20456
20730
  }
20457
- /** One tab's half of the pass: the history stack, then the debugger session. */
20458
- async hydrateOne(tabId, recorded) {
20731
+ /**
20732
+ * The bounded half of ONE restore: start the load, wait
20733
+ * `RESTORE_TAB_TIMEOUT_MS`, and record a no-commit death as a load failure.
20734
+ *
20735
+ * Shared by the awaited first wave (`hydrateOne`) and the post-budget drain,
20736
+ * because the failure mode does not care whether anyone awaits it: a server
20737
+ * that accepts and never answers produces no `did-fail-load` and no commit,
20738
+ * so THIS timer is the only thing that can turn it into the strip's Failed
20739
+ * chip and the counted close. The drain ran a private half of this step until
20740
+ * round 1 (m-1 = Q-1 = U1 = D3), which is how stragglers spun outside the
20741
+ * close set.
20742
+ *
20743
+ * WHETHER THIS HYDRATION'S NAVIGATION COMMITTED A DOCUMENT, read from the
20744
+ * event rather than from a property, because neither of the two obvious
20745
+ * reads answers the question: `getURL()` already reports the TARGET url
20746
+ * while the restore is still pending, and `history.restore()`'s promise does
20747
+ * not settle until the load FINISHES, so it is still pending for a page that
20748
+ * committed seconds ago (both measured on Electron 44, 2026-09-29).
20749
+ * `did-navigate` is the commit, and it is what tells "the page came back but
20750
+ * is still fetching something" apart from "nothing ever arrived" — only the
20751
+ * second one is a failure to record (see the catch below).
20752
+ */
20753
+ async restoreUnderTimeout(tabId, recorded) {
20459
20754
  const record = this.registry.get(tabId);
20460
20755
  if (!record) return;
20461
20756
  const contents = record.view.webContents;
20462
- const history = contents.navigationHistory;
20463
- const entry = recorded.entries[recorded.activeIndex];
20757
+ let committed = false;
20758
+ const onCommitted = () => {
20759
+ committed = true;
20760
+ };
20761
+ contents.on("did-navigate", onCommitted);
20464
20762
  try {
20465
- await withTimeout(
20466
- history?.restore ? history.restore({
20467
- entries: recorded.entries,
20468
- index: recorded.activeIndex
20469
- }) : (
20470
- // The honest degradation (see `NavigationHistoryLike`): a view with no
20471
- // history API still gets put back on the page it was showing, without
20472
- // its stack and its page state.
20473
- entry ? contents.loadURL(entry.url) : Promise.resolve()
20474
- ),
20475
- RESTORE_TAB_TIMEOUT_MS
20476
- );
20763
+ const started = this.startRestore(tabId, recorded);
20764
+ if (started) await withTimeout(started, RESTORE_TAB_TIMEOUT_MS);
20477
20765
  } catch (error) {
20478
20766
  this.log(`[browser] could not restore tab ${tabId}: ${String(error)}`);
20767
+ if (!committed && !this.loadFailures.has(tabId) && this.registry.get(tabId) && !contents.isDestroyed()) {
20768
+ const entry = recorded.entries[recorded.activeIndex];
20769
+ this.recordLoadFailure(tabId, {
20770
+ code: -2,
20771
+ description: "ERR_FAILED (restore)",
20772
+ url: entry?.url ?? contents.getURL()
20773
+ });
20774
+ this.log(
20775
+ `[browser] tab ${tabId} is marked as failed: the restore did not commit a page`
20776
+ );
20777
+ }
20778
+ } finally {
20779
+ if (!contents.isDestroyed()) {
20780
+ contents.removeListener("did-navigate", onCommitted);
20781
+ }
20479
20782
  }
20783
+ }
20784
+ /** One tab's half of the pass: the history stack, then the debugger session. */
20785
+ async hydrateOne(tabId, recorded) {
20786
+ const record = this.registry.get(tabId);
20787
+ if (!record) return;
20788
+ const contents = record.view.webContents;
20789
+ await this.restoreUnderTimeout(tabId, recorded);
20480
20790
  if (!this.registry.get(tabId)) return;
20481
20791
  try {
20482
20792
  await withTimeout(this.cdp.attach(contents), RESTORE_TAB_TIMEOUT_MS);
@@ -21623,6 +21933,15 @@ function contentRectOrNull(value) {
21623
21933
  height: rect.height
21624
21934
  };
21625
21935
  }
21936
+ function paneNavigationDirection(input) {
21937
+ if (input.type !== "keyDown") return null;
21938
+ if (!(input.meta || input.control)) return null;
21939
+ if (input.alt || input.shift) return null;
21940
+ if (input.isAutoRepeat) return null;
21941
+ if (input.key === "[") return "back";
21942
+ if (input.key === "]") return "forward";
21943
+ return null;
21944
+ }
21626
21945
  const PROOF_SHAPE = /^[a-zA-Z0-9_-]{32,}$/;
21627
21946
  const MISSING_PROOF_MESSAGE = "missing private browser ownership proof";
21628
21947
  class OwnershipLedger {
@@ -23086,6 +23405,10 @@ function boundTabs(tabs2) {
23086
23405
  const kept = tabs2.filter((_tab, index) => keep.has(index));
23087
23406
  return { kept, dropped: tabs2.length - kept.length };
23088
23407
  }
23408
+ function restorableRow(row) {
23409
+ if (row.lastLoadFailed === true) return false;
23410
+ return !(row.owner === "agent" && row.active !== true);
23411
+ }
23089
23412
  function readSession(path2, log2 = () => {
23090
23413
  }) {
23091
23414
  try {
@@ -23106,13 +23429,20 @@ function readSession(path2, log2 = () => {
23106
23429
  }
23107
23430
  if (!Array.isArray(file.tabs)) return [];
23108
23431
  const sane = file.tabs.map(saneTab).filter((tab) => tab !== null);
23109
- const restorableRows = sane.filter((tab) => tab.lastLoadFailed !== true);
23110
- const dead = sane.length - restorableRows.length;
23432
+ const unflagged = sane.filter((tab) => tab.lastLoadFailed !== true);
23433
+ const dead = sane.length - unflagged.length;
23111
23434
  if (dead > 0) {
23112
23435
  log2(
23113
23436
  `[browser] ${dead} recorded tab(s) were showing a load failure when the session ended; not restored`
23114
23437
  );
23115
23438
  }
23439
+ const restorableRows = unflagged.filter(restorableRow);
23440
+ const agentSkipped = unflagged.length - restorableRows.length;
23441
+ if (agentSkipped > 0) {
23442
+ log2(
23443
+ `[browser] ${agentSkipped} recorded tab(s) were opened by an agent and were not active at the quit; not restored`
23444
+ );
23445
+ }
23116
23446
  const { kept, dropped } = boundTabs(restorableRows);
23117
23447
  if (dropped) {
23118
23448
  log2(
@@ -23148,9 +23478,10 @@ function captureTabs(records, activeTabId, failedTabIds) {
23148
23478
  ...entry.pageState ? { pageState: entry.pageState } : {}
23149
23479
  })) : [];
23150
23480
  const mark = failedTabIds.has(record.tabId) ? { lastLoadFailed: true } : {};
23481
+ const owner = record.handedFrom === "user" ? "user" : record.owner;
23151
23482
  if (entries.some(restorable)) {
23152
23483
  captured.push({
23153
- owner: record.owner,
23484
+ owner,
23154
23485
  active: record.tabId === activeTabId,
23155
23486
  ...mark,
23156
23487
  entries,
@@ -23161,7 +23492,7 @@ function captureTabs(records, activeTabId, failedTabIds) {
23161
23492
  const restored = record.restoreRow;
23162
23493
  if (restored) {
23163
23494
  captured.push({
23164
- owner: record.owner,
23495
+ owner,
23165
23496
  active: record.tabId === activeTabId,
23166
23497
  ...mark,
23167
23498
  entries: restored.entries,
@@ -23242,7 +23573,7 @@ class BrowserSessionStore {
23242
23573
  * explicitly instead of a representative sequence. */
23243
23574
  commitStopCapture(rows) {
23244
23575
  const decision = stopSnapshotDecision(
23245
- rows.filter((row) => row.lastLoadFailed !== true).length,
23576
+ rows.filter(restorableRow).length,
23246
23577
  readSession(this.path, this.log).length
23247
23578
  );
23248
23579
  if (decision.write) {
@@ -23746,6 +24077,12 @@ async function startBrowserHost(options) {
23746
24077
  };
23747
24078
  contents.on("will-navigate", onWillNavigate);
23748
24079
  contents.on("will-redirect", onWillNavigate);
24080
+ contents.on("before-input-event", (event, input) => {
24081
+ const direction = paneNavigationDirection(input);
24082
+ if (!direction) return;
24083
+ event.preventDefault();
24084
+ host.historyActive(direction);
24085
+ });
23749
24086
  const notifyChrome = () => {
23750
24087
  options.window.webContents.send("browser-state-changed");
23751
24088
  };
@@ -23818,6 +24155,14 @@ async function startBrowserHost(options) {
23818
24155
  event.preventDefault();
23819
24156
  log2("[browser] refused a webview attachment inside a popup");
23820
24157
  });
24158
+ childContents.on("before-input-event", (event, input) => {
24159
+ const direction = paneNavigationDirection(input);
24160
+ if (!direction) return;
24161
+ event.preventDefault();
24162
+ const navigation = childContents.navigationHistory;
24163
+ if (direction === "back") navigation.goBack();
24164
+ else navigation.goForward();
24165
+ });
23821
24166
  if (!options.backgroundThrottling) {
23822
24167
  childContents.setBackgroundThrottling(false);
23823
24168
  }
@@ -41619,7 +41964,7 @@ electron.app.on("window-all-closed", () => {
41619
41964
  electron.app.quit();
41620
41965
  });
41621
41966
  const QUIT_FAILSAFE_MARGIN_MS = 5e3;
41622
- const QUIT_CLEANUP_FAILSAFE_MS = INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
41967
+ const QUIT_CLEANUP_FAILSAFE_MS = LAUNCHER_PROBE_WORST_MS + INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
41623
41968
  let backendQuitPending = false;
41624
41969
  electron.app.on("will-quit", (event) => {
41625
41970
  viewerEndpoint?.close();