local-operator-ui 0.23.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/out/main/index.js +1872 -1237
  2. package/out/renderer/assets/{_basePickBy-_D80de5G.js → _basePickBy-BVR8NHkD.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-2CVM3wP6.js → _baseUniq-onZkstHq.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-DDzWQkTU.js → agent-details-page-DC13Jibh.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-BJZLQyGG.js → agent-hub-page-iulwy94G.js} +1 -1
  6. package/out/renderer/assets/{agents-page-DemONOpM.js → agents-page-BSegINKc.js} +2 -2
  7. package/out/renderer/assets/{arc-5WLfklOA.js → arc-DAb06JtB.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-2esPWMfK.js → architectureDiagram-IEHRJDOE-sSe348HD.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CxEr3yHk.js → blockDiagram-JOT3LUYC-DB0hhoP8.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-CW3c7Z5k.js → c4Diagram-VJAJSXHY-CcMWb5_5.js} +1 -1
  11. package/out/renderer/assets/channel-Dl22yt_O.js +1 -0
  12. package/out/renderer/assets/{chunk-4BMEZGHF-D_Bp0wT-.js → chunk-4BMEZGHF-SRkv1Dzw.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-DmJd08BZ.js → chunk-A2AXSNBT-BBdcJhkk.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-ujtyZ3tC.js → chunk-AEK57VVT-BFLmAuIk.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-Dsw-P_RE.js → chunk-D6G4REZN--QHGDmI-.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-DehW_14d.js → chunk-RZ5BOZE2-Cy8U6l3Q.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-DMF0PowC.js → chunk-XZIHB7SX-DFfpTr7O.js} +1 -1
  18. package/out/renderer/assets/classDiagram-GIVACNV2-BguqLYlr.js +1 -0
  19. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BguqLYlr.js +1 -0
  20. package/out/renderer/assets/clone-C0R1R8sa.js +1 -0
  21. package/out/renderer/assets/{compact-pagination-uDiiJTfu.js → compact-pagination-DERWPPOM.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-Cis1R8z2.js → dagre-OKDRZEBW-DfjKcX6M.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-ccEDsRNu.js → diagram-SSKATNLV-Bm1j7Y3r.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-CvU-J7lk.js → diagram-VNBRO52H-BRx5TiDX.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-CzjCMPxk.js → erDiagram-Q7BY3M3F-BSxbIo11.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-B54QUlmV.js → flowDiagram-4HSFHLVR-R2ILhUKR.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-BGrZ0i8Q.js → ganttDiagram-APWFNJXF-Gd7TZJFy.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-rcGPhQBW.js → gitGraphDiagram-7IBYFJ6S-BAs2K2WV.js} +1 -1
  29. package/out/renderer/assets/{graph-J0pSfXIT.js → graph-zm35SWuR.js} +1 -1
  30. package/out/renderer/assets/{index-lFyj7RMx.js → index-CqIP2mt3.js} +1 -1
  31. package/out/renderer/assets/{index-DqOACDLL.js → index-DF2zdD6_.js} +350 -350
  32. package/out/renderer/assets/{index-DdfOG3_H.js → index-DXx2j7ov.js} +1 -1
  33. package/out/renderer/assets/{infoDiagram-PH2N3AL5-Bu4VJqUO.js → infoDiagram-PH2N3AL5-BcAWGJ1_.js} +1 -1
  34. package/out/renderer/assets/{journeyDiagram-U35MCT3I-CqkMOWse.js → journeyDiagram-U35MCT3I-VfDkAwaY.js} +1 -1
  35. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-6YsTdTHR.js → kanban-definition-NDS4AKOZ-BVgW6OQK.js} +1 -1
  36. package/out/renderer/assets/{layout-DxRkOtZA.js → layout-jfvNOQNg.js} +1 -1
  37. package/out/renderer/assets/{legacy-agents-page-B_TTtHbw.js → legacy-agents-page-yObswBd2.js} +1 -1
  38. package/out/renderer/assets/{mermaid.core-BGQGjyBM.js → mermaid.core-w7CiwIS9.js} +5 -5
  39. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-COAAKLot.js → mindmap-definition-ALO5MXBD-Ck5fBAvP.js} +1 -1
  40. package/out/renderer/assets/{parseISO-CJIN700N.js → parseISO-BfcDJeSX.js} +1 -1
  41. package/out/renderer/assets/{pieDiagram-IB7DONF6-bDFajT15.js → pieDiagram-IB7DONF6-xuxf3Is5.js} +1 -1
  42. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BGbdCwMH.js → quadrantDiagram-7GDLP6J5-B4UU5d5n.js} +1 -1
  43. package/out/renderer/assets/{radar-MK3ICKWK-D61WJ-zJ.js → radar-MK3ICKWK-BUD9nsvK.js} +1 -1
  44. package/out/renderer/assets/{radient-auth-buttons-XAgZ5KHN.js → radient-auth-buttons-BtcEZuSY.js} +1 -1
  45. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DGNaT_h7.js → requirementDiagram-KVF5MWMF-0fsS7ohi.js} +1 -1
  46. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BDNzMQ43.js → sankeyDiagram-QLVOVGJD-CUjpA8gC.js} +1 -1
  47. package/out/renderer/assets/{schedules-page-Cr0fc2A0.js → schedules-page-xXMGrdrg.js} +1 -1
  48. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-ByLl22wr.js → sequenceDiagram-X6HHIX6F-CX6mfjEg.js} +1 -1
  49. package/out/renderer/assets/{settings-page-DBLyEih6.js → settings-page-PLBi7GEM.js} +1 -1
  50. package/out/renderer/assets/{stateDiagram-DGXRK772-Cs28xqpn.js → stateDiagram-DGXRK772-DlOrLmRF.js} +1 -1
  51. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CsUzDnIT.js +1 -0
  52. package/out/renderer/assets/{timeline-definition-BDJGKUSR-Cc2py6Fo.js → timeline-definition-BDJGKUSR-B6_9nD67.js} +1 -1
  53. package/out/renderer/assets/{use-agent-like-mutation-CIJBNaU9.js → use-agent-like-mutation-Ckg2HQQI.js} +1 -1
  54. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-Buajaj4y.js → xychartDiagram-VJFVF3MP-B2JoquLl.js} +1 -1
  55. package/out/renderer/index.html +1 -1
  56. package/package.json +2 -2
  57. package/out/renderer/assets/channel-BihnEq5U.js +0 -1
  58. package/out/renderer/assets/classDiagram-GIVACNV2-B-F2uCmh.js +0 -1
  59. package/out/renderer/assets/classDiagram-v2-COTLJTTW-B-F2uCmh.js +0 -1
  60. package/out/renderer/assets/clone-Cxm1r7GC.js +0 -1
  61. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CyaNxwAy.js +0 -1
package/out/main/index.js CHANGED
@@ -13,6 +13,8 @@ const os = require("node:os");
13
13
  const node_util = require("node:util");
14
14
  const dotenv = require("dotenv");
15
15
  const electronLog = require("electron-log");
16
+ const promises = require("node:fs/promises");
17
+ const node_net = require("node:net");
16
18
  const node_http = require("node:http");
17
19
  const https = require("node:https");
18
20
  const electronUpdater = require("electron-updater");
@@ -55,6 +57,11 @@ const profileName = zod.z.string().min(1).max(128).refine(
55
57
  )
56
58
  );
57
59
  const target = zod.z.object({ kind: zod.z.enum(["agent", "team"]), name: profileName }).strict();
60
+ const modelSelection = zod.z.object({
61
+ provider: zod.z.string().min(1).max(128),
62
+ model_id: zod.z.string().min(1).max(512),
63
+ reasoning_effort: zod.z.string().min(1).max(64).nullable()
64
+ }).strict();
58
65
  const profileFields = zod.z.object({
59
66
  kind: zod.z.enum(["role", "specialist"]).optional(),
60
67
  description: zod.z.string().max(8e3).optional(),
@@ -151,7 +158,14 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
151
158
  op: zod.z.literal("sessions.create"),
152
159
  requestId,
153
160
  cwd: zod.z.string().min(1).max(4096),
154
- target: target.optional()
161
+ target: target.optional(),
162
+ /*
163
+ * OMITTED when the user never picked anything, so the body is the one
164
+ * this op sent before the draft's chips could open: making them
165
+ * actionable is strictly additive, and a `null` here would be a
166
+ * different request for every caller that never asked.
167
+ */
168
+ model: modelSelection.optional()
155
169
  }).strict(),
156
170
  /*
157
171
  * What a NEW conversation's readings would be, without creating anything.
@@ -168,7 +182,9 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
168
182
  * disagree with the session the first send creates.
169
183
  *
170
184
  * Body and response mirror `sessions.create` deliberately: same `cwd`, same
171
- * optional `target`, same 422 for an unresolvable profile. The response is a
185
+ * optional `target`, same `model` and same 422 for an unresolvable profile —
186
+ * the pane is asking the question it will ask for real on the first send, so
187
+ * the two bodies are derived from one selection. The response is a
172
188
  * `CanonicalFrontendSync` — the wire shape `sessions.watch` streams — whose
173
189
  * `snapshot.session_id` is EMPTY, because there is no session. The renderer
174
190
  * passes it to the strip and never into the canonical sessions store.
@@ -177,7 +193,11 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
177
193
  op: zod.z.literal("sessions.preview"),
178
194
  requestId,
179
195
  cwd: zod.z.string().min(1).max(4096),
180
- target: target.optional()
196
+ target: target.optional(),
197
+ /* Present only when the pane's chips were used: the preview then answers
198
+ the reading the CHOSEN model gives, which is the ladder and window the
199
+ first turn will actually get. */
200
+ model: modelSelection.optional()
181
201
  }).strict(),
182
202
  zod.z.object({ op: zod.z.literal("sessions.get"), sessionId }).strict(),
183
203
  zod.z.object({
@@ -681,7 +701,8 @@ function desktopEndpoint(request) {
681
701
  body: {
682
702
  request_id: request.requestId,
683
703
  cwd: request.cwd,
684
- ...request.target ? { target: request.target } : {}
704
+ ...request.target ? { target: request.target } : {},
705
+ ...request.model ? { model: request.model } : {}
685
706
  }
686
707
  };
687
708
  case "sessions.preview":
@@ -691,7 +712,8 @@ function desktopEndpoint(request) {
691
712
  body: {
692
713
  request_id: request.requestId,
693
714
  cwd: request.cwd,
694
- ...request.target ? { target: request.target } : {}
715
+ ...request.target ? { target: request.target } : {},
716
+ ...request.model ? { model: request.model } : {}
695
717
  }
696
718
  };
697
719
  case "sessions.get":
@@ -1528,83 +1550,97 @@ function pythonBytecodeCacheDir(userDataDir) {
1528
1550
  }
1529
1551
  function withPythonBytecodeCache(env, userDataDir) {
1530
1552
  const existing = env.PYTHONPYCACHEPREFIX?.trim();
1531
- if (existing && !insideAppBundle(existing)) return { ...env };
1532
- return { ...env, PYTHONPYCACHEPREFIX: pythonBytecodeCacheDir(userDataDir) };
1533
- }
1534
- const BUNDLED_PYTHON_TREE_NAMES = ["python", "python_aarch64"];
1535
- function bundledPythonTreePaths(resourcesPath) {
1536
- return BUNDLED_PYTHON_TREE_NAMES.map((name) => path$1.join(resourcesPath, name));
1537
- }
1538
- const FIND = "/usr/bin/find";
1539
- const CHMOD = "/bin/chmod";
1540
- const DIRECTORY_ACE = "everyone deny add_file,add_subdirectory";
1541
- const BYTECODE_ACE = "everyone deny write,append";
1542
- const MAX_FIND_OUTPUT_BYTES = 16 * 1024 * 1024;
1543
- const PARTIAL_SEAL_NAMED = 3;
1544
- function describePythonTreeSeal(seal) {
1545
- if (!seal.supported) {
1546
- return "Bundled interpreter bytecode seal is macOS-only (there is no code seal to protect elsewhere); the bytecode cache prefix still applies";
1547
- }
1548
- const trees = seal.sealed.join(", ") || "(none present)";
1549
- if (seal.failures.length === 0) {
1550
- return `Bundled interpreter trees sealed against bytecode writes: ${trees}; ${seal.directories} directory path(s) selected and now refusing new entries, ${seal.bytecodeFiles} bytecode file(s) selected and now refusing rewrites`;
1551
- }
1552
- const named = seal.failures.slice(0, PARTIAL_SEAL_NAMED).join("; ");
1553
- const rest = seal.failures.length - PARTIAL_SEAL_NAMED;
1554
- return `Bundled interpreter bytecode seal INCOMPLETE: ${trees}; ${seal.directories} directory path(s) and ${seal.bytecodeFiles} bytecode file(s) selected, ${seal.failures.length} refused and left with the access they had - bytecode writes into the bundle are not prevented on every path. First refusals: ${named}${rest > 0 ? ` (+${rest} more)` : ""}`;
1555
- }
1556
- function sealPythonInterpreterTrees(trees) {
1557
- const result = {
1558
- sealed: [],
1559
- directories: 0,
1560
- bytecodeFiles: 0,
1561
- failures: [],
1562
- supported: process.platform === "darwin"
1553
+ const prefix = existing && !insideAppBundle(existing) ? existing : pythonBytecodeCacheDir(userDataDir);
1554
+ return {
1555
+ ...env,
1556
+ PYTHONPYCACHEPREFIX: prefix,
1557
+ PYTHONDONTWRITEBYTECODE: "1"
1563
1558
  };
1564
- if (!result.supported) return result;
1565
- for (const tree of trees) {
1566
- let root;
1567
- try {
1568
- root = fs.lstatSync(tree);
1569
- } catch {
1570
- continue;
1559
+ }
1560
+ const VENV_BYTECODE_GUARD_FILE = "sitecustomize.py";
1561
+ const VENV_BYTECODE_GUARD_SENTINEL = "# Local Operator bytecode guard";
1562
+ function venvBytecodeGuardSource() {
1563
+ return `${VENV_BYTECODE_GUARD_SENTINEL}. Do not edit; the app rewrites this file.
1564
+ #
1565
+ # Why this file exists: this virtual environment is built on the interpreter
1566
+ # inside the Local Operator application bundle (see pyvenv.cfg), so every module
1567
+ # this python imports has its source - and CPython's bytecode cache for it -
1568
+ # inside a code-sealed .app. A __pycache__/*.pyc written there changes a sealed
1569
+ # resource, and macOS then answers "the app is damaged" on the next launch and
1570
+ # Squirrel refuses the next in-place update with -67028
1571
+ # errSecCSBadBundleFormat.
1572
+ #
1573
+ # PYTHONDONTWRITEBYTECODE and PYTHONPYCACHEPREFIX reach only the processes the
1574
+ # app started or that inherited its environment. This file reaches every process
1575
+ # that uses this environment, including one started by a shell, a script or
1576
+ # launchd, because site.py imports sitecustomize from site-packages on every
1577
+ # start.
1578
+ #
1579
+ # The cost, stated rather than hidden: a process under this flag compiles its
1580
+ # imports and caches nothing, so the backend recompiles its import graph on each
1581
+ # start. That is the deliberate trade against an application bundle that macOS
1582
+ # refuses to open, and it is confined to this venv.
1583
+ import sys
1584
+
1585
+ sys.dont_write_bytecode = True
1586
+ `;
1587
+ }
1588
+ function venvSitePackages(venvPath) {
1589
+ const candidates = [path$1.join(venvPath, "Lib", "site-packages")];
1590
+ try {
1591
+ for (const entry of fs.readdirSync(path$1.join(venvPath, "lib"))) {
1592
+ if (entry.startsWith("python")) {
1593
+ candidates.push(path$1.join(venvPath, "lib", entry, "site-packages"));
1594
+ }
1571
1595
  }
1572
- if (!root.isDirectory()) continue;
1573
- result.sealed.push(tree);
1574
- result.directories += applyAce(
1575
- tree,
1576
- ["-type", "d"],
1577
- DIRECTORY_ACE,
1578
- result.failures
1579
- );
1580
- result.bytecodeFiles += applyAce(
1581
- tree,
1582
- ["-type", "f", "-name", "*.pyc"],
1583
- BYTECODE_ACE,
1584
- result.failures
1585
- );
1596
+ } catch {
1586
1597
  }
1587
- return result;
1598
+ return candidates.filter((candidate) => fs.existsSync(candidate));
1588
1599
  }
1589
- function applyAce(tree, selector, ace, failures) {
1590
- const run = node_child_process.spawnSync(
1591
- FIND,
1592
- [tree, ...selector, "-exec", CHMOD, "+a", ace, "{}", "+", "-print0"],
1593
- { encoding: "buffer", maxBuffer: MAX_FIND_OUTPUT_BYTES }
1594
- );
1595
- if (run.error) {
1596
- failures.push(`${tree}: ${String(run.error)}`);
1597
- return 0;
1600
+ function ensureVenvBytecodeGuard(venvPath) {
1601
+ const dirs = venvSitePackages(venvPath);
1602
+ if (dirs.length === 0) {
1603
+ return {
1604
+ path: null,
1605
+ written: false,
1606
+ reason: `no site-packages under ${venvPath} yet, so there is nothing to guard`
1607
+ };
1598
1608
  }
1599
- for (const line of String(run.stderr).split("\n")) {
1600
- if (line.trim().length > 0) failures.push(line.trim());
1609
+ const path2 = path$1.join(dirs[0], VENV_BYTECODE_GUARD_FILE);
1610
+ const source = venvBytecodeGuardSource();
1611
+ let existing = null;
1612
+ try {
1613
+ existing = fs.readFileSync(path2, "utf8");
1614
+ } catch {
1601
1615
  }
1602
- if (run.status !== 0 && String(run.stderr).trim().length === 0) {
1603
- failures.push(`${tree}: ${FIND} exited ${run.status} with no explanation`);
1616
+ if (existing === source) {
1617
+ return {
1618
+ path: path2,
1619
+ written: false,
1620
+ reason: `${path2} already refuses bytecode writes`
1621
+ };
1604
1622
  }
1605
- let selected = 0;
1606
- for (const byte of run.stdout) if (byte === 0) selected += 1;
1607
- return selected;
1623
+ if (existing !== null && !existing.startsWith(VENV_BYTECODE_GUARD_SENTINEL)) {
1624
+ return {
1625
+ path: path2,
1626
+ written: false,
1627
+ reason: `${path2} is not ours (no "${VENV_BYTECODE_GUARD_SENTINEL}" line), so it was left exactly as it is`
1628
+ };
1629
+ }
1630
+ try {
1631
+ fs.writeFileSync(path2, source);
1632
+ } catch (error) {
1633
+ return {
1634
+ path: path2,
1635
+ written: false,
1636
+ reason: `could not write ${path2}: ${error instanceof Error ? error.message : String(error)}`
1637
+ };
1638
+ }
1639
+ return {
1640
+ path: path2,
1641
+ written: true,
1642
+ reason: `${path2} now sets sys.dont_write_bytecode for every process using this venv`
1643
+ };
1608
1644
  }
1609
1645
  var LogFileType = /* @__PURE__ */ ((LogFileType2) => {
1610
1646
  LogFileType2["INSTALLER"] = "backend-installer.log";
@@ -1828,6 +1864,905 @@ const backendConfig = loadConfig();
1828
1864
  const apiConfig = {
1829
1865
  baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
1830
1866
  };
1867
+ const seedNamespace = "python-runtime-seed";
1868
+ const architectures = ["arm64", "x64"];
1869
+ const legacyResourceNames = ["python", "python_aarch64"];
1870
+ const checkoutSeedNames = { "arm64": "python_aarch64", "x64": "python" };
1871
+ const BUNDLED_PYTHON_LAYOUT = {
1872
+ seedNamespace,
1873
+ architectures,
1874
+ legacyResourceNames,
1875
+ checkoutSeedNames
1876
+ };
1877
+ const execute = node_util.promisify(node_child_process.execFile);
1878
+ const APP_BUNDLE_PATH = /\.app(?:\/|$)/i;
1879
+ const BYTECODE_EXTENSION = /\.py[co]$/;
1880
+ const OTOOL_LOAD_COMMAND = /Load command \d+/;
1881
+ const LC_LOAD_COMMAND_KIND = /cmd LC_(?:LOAD|LOAD_WEAK|REEXPORT|LAZY_LOAD|LOAD_UPWARD)_DYLIB\b/;
1882
+ const DYLIB_INSTALL_NAME = /\n\s*name (.+?) \(offset/;
1883
+ const RUNTIME_ID = /^[a-f0-9]{64}$/;
1884
+ const PYVENV_HOME$1 = /^home\s*=\s*(.+)$/m;
1885
+ const LOCK_OWNER = /^\s*\d+\s*$/;
1886
+ const LEGACY_BUNDLE_PYTHON_PATH = /\.app\/Contents\/Resources\/python(?:_aarch64)?\//;
1887
+ const PYTHON_SEED_NAMESPACE = BUNDLED_PYTHON_LAYOUT.seedNamespace;
1888
+ const FORMAT = 1;
1889
+ const READY = "environment-ready.json";
1890
+ const POINTER = "selected-environment.json";
1891
+ function inside(root, path2) {
1892
+ const rel = path$1.relative(root, path2);
1893
+ return rel === "" || !rel.startsWith(`..${path$1.sep}`) && rel !== ".." && !path$1.isAbsolute(rel);
1894
+ }
1895
+ function outsideApp(path2) {
1896
+ if (!path$1.isAbsolute(path2) || APP_BUNDLE_PATH.test(path2)) {
1897
+ throw new Error(
1898
+ `Managed Python must live outside every application bundle: ${path2}`
1899
+ );
1900
+ }
1901
+ }
1902
+ function realDirectory(path2) {
1903
+ const stat = fs.lstatSync(path2);
1904
+ if (!stat.isDirectory() || stat.isSymbolicLink())
1905
+ throw new Error(`Not a private directory: ${path2}`);
1906
+ }
1907
+ function isCache(path2) {
1908
+ return path2.split("/").includes("__pycache__") || BYTECODE_EXTENSION.test(path2);
1909
+ }
1910
+ function runtimeManifest(root, arch, allowCaches = false) {
1911
+ if (!["arm64", "x64"].includes(arch))
1912
+ throw new Error(`Unsupported Python architecture: ${arch}`);
1913
+ realDirectory(root);
1914
+ const realRoot = fs.realpathSync(root);
1915
+ const entries = [
1916
+ { path: ".", mode: fs.lstatSync(root).mode & 511, kind: "directory" }
1917
+ ];
1918
+ function walk(directory) {
1919
+ for (const name of fs.readdirSync(directory).sort()) {
1920
+ const path2 = path$1.join(directory, name);
1921
+ const rel = path$1.relative(root, path2).split(path$1.sep).join("/");
1922
+ if (allowCaches && isCache(rel)) continue;
1923
+ const stat = fs.lstatSync(path2);
1924
+ const mode = stat.mode & 511;
1925
+ if (stat.isSymbolicLink()) {
1926
+ const target2 = fs.readlinkSync(path2);
1927
+ if (path$1.isAbsolute(target2) || !inside(root, path$1.resolve(path$1.dirname(path2), target2)) || !inside(realRoot, fs.realpathSync(path2))) {
1928
+ throw new Error(`Python seed link escapes its runtime: ${rel}`);
1929
+ }
1930
+ entries.push({ path: rel, mode, kind: "symlink", value: target2 });
1931
+ } else if (stat.isDirectory()) {
1932
+ entries.push({ path: rel, mode, kind: "directory" });
1933
+ walk(path2);
1934
+ } else if (stat.isFile() && (allowCaches || stat.nlink === 1)) {
1935
+ entries.push({
1936
+ path: rel,
1937
+ mode,
1938
+ kind: "file",
1939
+ value: node_crypto.createHash("sha256").update(fs.readFileSync(path2)).digest("hex")
1940
+ });
1941
+ } else {
1942
+ throw new Error(
1943
+ `Python seed contains a hardlink or special file: ${rel}`
1944
+ );
1945
+ }
1946
+ }
1947
+ }
1948
+ walk(root);
1949
+ entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1950
+ return { format: FORMAT, platform: "darwin", arch, entries };
1951
+ }
1952
+ function runtimeId(manifest) {
1953
+ return node_crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex");
1954
+ }
1955
+ function runtimeIdentity(root, arch) {
1956
+ return runtimeId(runtimeManifest(root, arch, true));
1957
+ }
1958
+ function firstDifference(expected, actual) {
1959
+ const expectedByPath = new Map(expected.entries.map((e) => [e.path, e]));
1960
+ const actualByPath = new Map(actual.entries.map((e) => [e.path, e]));
1961
+ for (const [path2, entry] of expectedByPath) {
1962
+ const other = actualByPath.get(path2);
1963
+ if (!other) return `${path2} is missing`;
1964
+ if (other.kind !== entry.kind)
1965
+ return `${path2} is a ${other.kind}, not a ${entry.kind}`;
1966
+ if ((other.value ?? "") !== (entry.value ?? ""))
1967
+ return `${path2} does not match the seed`;
1968
+ }
1969
+ for (const path2 of actualByPath.keys())
1970
+ if (!expectedByPath.has(path2)) return `${path2} was added`;
1971
+ return "the two trees differ in a way this message cannot name";
1972
+ }
1973
+ function managedPythonRoot(options) {
1974
+ outsideApp(options.support);
1975
+ return path$1.join(
1976
+ options.support,
1977
+ "managed-python",
1978
+ options.packaged ? "packaged" : "dev"
1979
+ );
1980
+ }
1981
+ function seedPath(options) {
1982
+ return options.packaged ? path$1.join(options.resources, PYTHON_SEED_NAMESPACE, options.arch) : path$1.join(
1983
+ options.resources,
1984
+ BUNDLED_PYTHON_LAYOUT.checkoutSeedNames[options.arch]
1985
+ );
1986
+ }
1987
+ function pythonPath(runtime) {
1988
+ return path$1.join(runtime, "bin", "python3");
1989
+ }
1990
+ async function verifyMachO(root, manifest) {
1991
+ let binaries = 0;
1992
+ for (const entry of manifest.entries) {
1993
+ if (entry.kind !== "file") continue;
1994
+ const path2 = path$1.join(root, entry.path);
1995
+ const fd = fs.openSync(path2, "r");
1996
+ const magic = Buffer.alloc(4);
1997
+ try {
1998
+ fs.readSync(fd, magic, 0, 4, 0);
1999
+ } finally {
2000
+ fs.closeSync(fd);
2001
+ }
2002
+ if (![
2003
+ "cffaedfe",
2004
+ "cefaedfe",
2005
+ "feedfacf",
2006
+ "feedface",
2007
+ "cafebabe",
2008
+ "bebafeca"
2009
+ ].includes(magic.toString("hex")))
2010
+ continue;
2011
+ binaries++;
2012
+ await execute("/usr/bin/codesign", ["--verify", "--strict", path2], {
2013
+ timeout: 3e4
2014
+ });
2015
+ const { stdout } = await execute("/usr/bin/otool", ["-l", path2], {
2016
+ timeout: 3e4
2017
+ });
2018
+ for (const command of stdout.split(OTOOL_LOAD_COMMAND)) {
2019
+ if (!LC_LOAD_COMMAND_KIND.test(command)) continue;
2020
+ const dependency = DYLIB_INSTALL_NAME.exec(command)?.[1];
2021
+ if (dependency && path$1.isAbsolute(dependency) && !dependency.startsWith("/usr/lib/") && !dependency.startsWith("/System/Library/")) {
2022
+ throw new Error(
2023
+ `Python runtime has an external load dependency: ${dependency}`
2024
+ );
2025
+ }
2026
+ }
2027
+ }
2028
+ if (binaries === 0)
2029
+ throw new Error("Python seed contains no signed Mach-O binaries");
2030
+ }
2031
+ function runtimesRoot(options) {
2032
+ return path$1.join(managedPythonRoot(options), "runtimes");
2033
+ }
2034
+ function environmentsRoot(options) {
2035
+ return path$1.join(managedPythonRoot(options), "environments");
2036
+ }
2037
+ function publishedRuntimeIsReusable(options, published) {
2038
+ try {
2039
+ return runtimeIdentity(published.runtime, options.arch) === runtimeIdentity(seedPath(options), options.arch);
2040
+ } catch {
2041
+ return false;
2042
+ }
2043
+ }
2044
+ const GENERATION_NAME = /^[a-f0-9]{64}-[0-9a-f-]{8,}$/;
2045
+ function usableRuntimeGeneration(options, id2) {
2046
+ let names;
2047
+ try {
2048
+ names = fs.readdirSync(runtimesRoot(options));
2049
+ } catch {
2050
+ return null;
2051
+ }
2052
+ for (const name of names.sort()) {
2053
+ if (!name.startsWith(`${id2}-`)) continue;
2054
+ const candidate = path$1.join(runtimesRoot(options), name);
2055
+ try {
2056
+ if (runtimeIdentity(candidate, options.arch) === id2) return candidate;
2057
+ } catch {
2058
+ }
2059
+ }
2060
+ return null;
2061
+ }
2062
+ async function prepareRuntime(options) {
2063
+ const seed = seedPath(options);
2064
+ const manifest = runtimeManifest(seed, options.arch);
2065
+ const id2 = runtimeIdentity(seed, options.arch);
2066
+ const existing = usableRuntimeGeneration(options, id2);
2067
+ if (existing) return { runtime: existing, id: id2 };
2068
+ await verifyMachO(seed, manifest);
2069
+ const root = runtimesRoot(options);
2070
+ await promises.mkdir(root, { recursive: true, mode: 448 });
2071
+ realDirectory(root);
2072
+ if (!inside(fs.realpathSync(managedPythonRoot(options)), fs.realpathSync(root)))
2073
+ throw new Error("Runtime directory escaped its managed root");
2074
+ const runtime = path$1.join(root, `${id2}-${node_crypto.randomUUID()}`);
2075
+ const staging = await promises.mkdtemp(path$1.join(root, ".preparing-"));
2076
+ await execute("/usr/bin/ditto", ["--noacl", seed, staging], {
2077
+ timeout: 12e4
2078
+ });
2079
+ fs.chmodSync(staging, fs.lstatSync(seed).mode & 511);
2080
+ if (runtimeIdentity(staging, options.arch) !== id2)
2081
+ throw new Error("The Python runtime copy did not match its signed seed");
2082
+ for (const entry of manifest.entries) {
2083
+ if (entry.kind !== "file") continue;
2084
+ const source = fs.statSync(path$1.join(seed, entry.path));
2085
+ const copied = fs.statSync(path$1.join(staging, entry.path));
2086
+ if (source.dev === copied.dev && source.ino === copied.ino)
2087
+ throw new Error(`Runtime copy shares a seed inode: ${entry.path}`);
2088
+ }
2089
+ await verifyMachO(staging, manifest);
2090
+ await promises.rename(staging, runtime);
2091
+ return { runtime, id: id2 };
2092
+ }
2093
+ function describe$1(error) {
2094
+ return error instanceof Error ? error.message : String(error);
2095
+ }
2096
+ function selectionIsWellFormed(root, selection) {
2097
+ return selection.format === FORMAT && RUNTIME_ID.test(selection.runtimeId) && selection.runtime === path$1.join(root, "runtimes", selection.runtimeId);
2098
+ }
2099
+ function selectionMatchesGeneration(root, selection) {
2100
+ return selection.format === FORMAT && RUNTIME_ID.test(selection.runtimeId) && path$1.dirname(selection.runtime) === path$1.join(root, "runtimes") && GENERATION_NAME.test(path$1.basename(selection.runtime)) && selection.runtime.startsWith(
2101
+ path$1.join(root, "runtimes", `${selection.runtimeId}-`)
2102
+ ) && path$1.dirname(selection.venv) === path$1.join(root, "environments") && selection.venv.startsWith(
2103
+ path$1.join(root, "environments", `${selection.runtimeId}-`)
2104
+ );
2105
+ }
2106
+ function inspectManagedSelection(options) {
2107
+ const root = managedPythonRoot(options);
2108
+ const pointer = path$1.join(root, POINTER);
2109
+ let raw;
2110
+ try {
2111
+ raw = fs.readFileSync(pointer, "utf8");
2112
+ } catch {
2113
+ return {
2114
+ kind: "unprepared",
2115
+ detail: `No environment is published for this instance (${pointer})`
2116
+ };
2117
+ }
2118
+ let selection;
2119
+ try {
2120
+ selection = JSON.parse(raw);
2121
+ } catch {
2122
+ return {
2123
+ kind: "unprepared",
2124
+ detail: `The published environment pointer is not readable: ${pointer}`
2125
+ };
2126
+ }
2127
+ if (!selectionIsWellFormed(root, selection) && !selectionMatchesGeneration(root, selection))
2128
+ return {
2129
+ kind: "unprepared",
2130
+ detail: `The published environment pointer does not describe a managed environment: ${pointer}`
2131
+ };
2132
+ const missing = (detail) => ({
2133
+ kind: "missing",
2134
+ detail,
2135
+ published: selection
2136
+ });
2137
+ for (const [what, path2] of [
2138
+ ["Python runtime", selection.runtime],
2139
+ ["environment", selection.venv]
2140
+ ]) {
2141
+ try {
2142
+ realDirectory(path2);
2143
+ } catch (error) {
2144
+ return missing(
2145
+ `The selected ${what} is not there any more (${path2}): ${describe$1(error)}`
2146
+ );
2147
+ }
2148
+ }
2149
+ try {
2150
+ const ready = JSON.parse(
2151
+ fs.readFileSync(path$1.join(selection.venv, READY), "utf8")
2152
+ );
2153
+ if (JSON.stringify(ready) !== JSON.stringify(selection))
2154
+ return missing(
2155
+ `The selected environment does not match the record published with it: ${selection.venv}`
2156
+ );
2157
+ } catch (error) {
2158
+ return missing(
2159
+ `The selected environment is not complete (${selection.venv}): ${describe$1(error)}`
2160
+ );
2161
+ }
2162
+ if (!fs.existsSync(path$1.join(selection.venv, "bin", "local-operator")))
2163
+ return missing(
2164
+ `The selected environment has no backend installed: ${selection.venv}`
2165
+ );
2166
+ let home;
2167
+ try {
2168
+ home = PYVENV_HOME$1.exec(
2169
+ fs.readFileSync(path$1.join(selection.venv, "pyvenv.cfg"), "utf8")
2170
+ )?.[1]?.trim();
2171
+ } catch (error) {
2172
+ return missing(
2173
+ `The selected environment has no readable pyvenv.cfg (${selection.venv}): ${describe$1(error)}`
2174
+ );
2175
+ }
2176
+ if (home !== path$1.join(selection.runtime, "bin"))
2177
+ return missing(
2178
+ `The selected environment does not belong to its external Python runtime (${selection.venv} names ${home ?? "no runtime"})`
2179
+ );
2180
+ let identity;
2181
+ try {
2182
+ identity = runtimeIdentity(selection.runtime, options.arch);
2183
+ } catch (error) {
2184
+ return missing(
2185
+ `The selected Python runtime could not be read (${selection.runtime}): ${describe$1(error)}`
2186
+ );
2187
+ }
2188
+ if (identity !== selection.runtimeId) {
2189
+ let reason;
2190
+ try {
2191
+ reason = firstDifference(
2192
+ runtimeManifest(seedPath(options), options.arch, true),
2193
+ runtimeManifest(selection.runtime, options.arch, true)
2194
+ );
2195
+ } catch {
2196
+ reason = "the two trees could not be compared";
2197
+ }
2198
+ return missing(
2199
+ `The selected Python runtime is no longer the runtime that was published (${reason}); a fresh runtime will be published beside it`
2200
+ );
2201
+ }
2202
+ return { kind: "ready", selection };
2203
+ }
2204
+ function readManagedSelection(options) {
2205
+ const state = inspectManagedSelection(options);
2206
+ return state.kind === "ready" ? state.selection : null;
2207
+ }
2208
+ async function managedSelectionReady(options) {
2209
+ return inspectManagedSelection(options).kind === "ready";
2210
+ }
2211
+ const PREPARATION_LOCK_MS = 12e4;
2212
+ function reapSupersededGenerations(options, keep, now = Date.now()) {
2213
+ const removed = [];
2214
+ for (const [root, kept] of [
2215
+ [runtimesRoot(options), keep.runtime],
2216
+ [environmentsRoot(options), keep.venv]
2217
+ ]) {
2218
+ let names;
2219
+ try {
2220
+ names = fs.readdirSync(root);
2221
+ } catch {
2222
+ continue;
2223
+ }
2224
+ const survivors = new Set(kept ? [kept] : []);
2225
+ const generations = [];
2226
+ for (const name of names) {
2227
+ const path2 = path$1.join(root, name);
2228
+ if (name.startsWith(".preparing-")) {
2229
+ try {
2230
+ if (now - fs.statSync(path2).mtimeMs < PREPARATION_LOCK_MS) continue;
2231
+ fs.rmSync(path2, { recursive: true, force: true });
2232
+ removed.push(path2);
2233
+ } catch {
2234
+ }
2235
+ continue;
2236
+ }
2237
+ if (!GENERATION_NAME.test(name) || survivors.has(path2)) continue;
2238
+ try {
2239
+ generations.push({ path: path2, mtimeMs: fs.statSync(path2).mtimeMs });
2240
+ } catch {
2241
+ }
2242
+ }
2243
+ generations.sort((a, b) => b.mtimeMs - a.mtimeMs);
2244
+ if (generations.length > 0) survivors.add(generations[0].path);
2245
+ for (const generation of generations.slice(1)) {
2246
+ try {
2247
+ fs.rmSync(generation.path, { recursive: true, force: true });
2248
+ removed.push(generation.path);
2249
+ } catch {
2250
+ }
2251
+ }
2252
+ }
2253
+ return removed;
2254
+ }
2255
+ let preparation = Promise.resolve();
2256
+ async function locked(root, work) {
2257
+ const previous = preparation;
2258
+ let release = () => {
2259
+ };
2260
+ preparation = new Promise((done) => {
2261
+ release = done;
2262
+ });
2263
+ await previous;
2264
+ const path2 = path$1.join(root, "preparation.lock");
2265
+ let acquired = false;
2266
+ try {
2267
+ await promises.mkdir(root, { recursive: true, mode: 448 });
2268
+ realDirectory(root);
2269
+ outsideApp(fs.realpathSync(root));
2270
+ const deadline2 = Date.now() + PREPARATION_LOCK_MS;
2271
+ while (!acquired) {
2272
+ if (fs.existsSync(path2) && (fs.lstatSync(path2).isSymbolicLink() || !LOCK_OWNER.test(fs.readFileSync(path2, "utf8"))))
2273
+ throw new Error("Unrecognized preparation lock was preserved");
2274
+ try {
2275
+ await execute(
2276
+ "/usr/bin/shlock",
2277
+ ["-p", String(process.pid), "-f", path2],
2278
+ { timeout: 5e3 }
2279
+ );
2280
+ acquired = true;
2281
+ } catch {
2282
+ if (Date.now() >= deadline2)
2283
+ throw new Error(
2284
+ "Another Local Operator instance is preparing its backend. Retry when it finishes."
2285
+ );
2286
+ await new Promise((done) => setTimeout(done, 250));
2287
+ }
2288
+ }
2289
+ return await work();
2290
+ } finally {
2291
+ if (acquired && fs.readFileSync(path2, "utf8").trim() === String(process.pid))
2292
+ fs.unlinkSync(path2);
2293
+ release();
2294
+ }
2295
+ }
2296
+ const IMPORT_PROBE = `import sys, sysconfig, os, json, encodings, webbrowser, _ssl, _sqlite3
2297
+ paths = [sys._base_executable, sys.base_prefix, sysconfig.get_path('stdlib'), encodings.__file__, webbrowser.__file__] + sys.path
2298
+ paths += [getattr(m, '__file__', '') for m in (_ssl, _sqlite3)]
2299
+ assert all('.app/' not in os.path.realpath(p).lower() for p in paths if p), paths
2300
+ print(json.dumps({'base_executable': sys._base_executable, 'base_prefix': sys.base_prefix, 'paths': paths}))`;
2301
+ async function smokeEnvironment(venv, env) {
2302
+ const python = path$1.join(venv, "bin", "python");
2303
+ await execute(python, ["-I", "-B", "-c", IMPORT_PROBE], {
2304
+ env,
2305
+ timeout: 3e4
2306
+ });
2307
+ const { stdout: version } = await execute(
2308
+ python,
2309
+ [
2310
+ "-I",
2311
+ "-B",
2312
+ "-c",
2313
+ "import importlib.metadata; print(importlib.metadata.version('local-operator'))"
2314
+ ],
2315
+ { env, timeout: 3e4 }
2316
+ );
2317
+ const home = await promises.mkdtemp(path$1.join(os.tmpdir(), "local-operator-backend-smoke-"));
2318
+ const isolated = withPythonBytecodeCache(
2319
+ {
2320
+ PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
2321
+ HOME: home,
2322
+ TMPDIR: home,
2323
+ LANG: "en_US.UTF-8",
2324
+ TERM: "xterm-256color",
2325
+ LOCAL_OPERATOR_HOME: path$1.join(home, ".local-operator"),
2326
+ LOCAL_OPERATOR_CONFIG_DIR: path$1.join(home, "config"),
2327
+ XDG_CONFIG_HOME: path$1.join(home, "config"),
2328
+ XDG_CACHE_HOME: path$1.join(home, "cache"),
2329
+ XDG_DATA_HOME: path$1.join(home, "data")
2330
+ },
2331
+ home
2332
+ );
2333
+ const socket = node_net.createServer();
2334
+ await new Promise((done, reject) => {
2335
+ socket.once("error", reject);
2336
+ socket.listen(0, "127.0.0.1", done);
2337
+ });
2338
+ const address = socket.address();
2339
+ if (!address || typeof address === "string")
2340
+ throw new Error("Could not allocate backend smoke port");
2341
+ await new Promise((done) => socket.close(() => done()));
2342
+ const child = node_child_process.spawn(
2343
+ path$1.join(venv, "bin", "local-operator"),
2344
+ ["serve", "--host", "127.0.0.1", "--port", String(address.port)],
2345
+ { env: isolated, stdio: "ignore" }
2346
+ );
2347
+ let failed = null;
2348
+ child.on("error", (error) => {
2349
+ failed = error;
2350
+ });
2351
+ try {
2352
+ const deadline2 = Date.now() + 6e4;
2353
+ while (Date.now() < deadline2) {
2354
+ if (failed) throw failed;
2355
+ if (child.exitCode !== null)
2356
+ throw new Error(`Backend smoke exited with ${child.exitCode}`);
2357
+ try {
2358
+ const response = await fetch(
2359
+ `http://127.0.0.1:${address.port}/health`,
2360
+ { signal: AbortSignal.timeout(1e3) }
2361
+ );
2362
+ if (response.ok) return version.trim();
2363
+ } catch {
2364
+ }
2365
+ await new Promise((done) => setTimeout(done, 200));
2366
+ }
2367
+ throw new Error(
2368
+ "The prepared backend did not become healthy; the previous selection was preserved"
2369
+ );
2370
+ } finally {
2371
+ if (child.exitCode === null && child.pid) {
2372
+ child.kill("SIGTERM");
2373
+ await Promise.race([
2374
+ new Promise((done) => child.once("exit", done)),
2375
+ new Promise((done) => setTimeout(done, 5e3))
2376
+ ]);
2377
+ if (child.exitCode === null) child.kill("SIGKILL");
2378
+ }
2379
+ }
2380
+ }
2381
+ async function prepareManagedPython(options, install) {
2382
+ return locked(managedPythonRoot(options), async () => {
2383
+ const state = inspectManagedSelection(options);
2384
+ if (state.kind === "ready") return state.selection;
2385
+ const superseded = state.kind === "missing" ? state.detail : null;
2386
+ const published = state.kind === "missing" ? state.published : void 0;
2387
+ const root = managedPythonRoot(options);
2388
+ reapSupersededGenerations(options, {
2389
+ runtime: published && publishedRuntimeIsReusable(options, published) ? published.runtime : void 0
2390
+ });
2391
+ const { runtime, id: id2 } = await prepareRuntime(options);
2392
+ await promises.mkdir(environmentsRoot(options), { recursive: true, mode: 448 });
2393
+ realDirectory(environmentsRoot(options));
2394
+ const venv = path$1.join(environmentsRoot(options), `${id2}-${node_crypto.randomUUID()}`);
2395
+ if (!await install(venv, pythonPath(runtime)))
2396
+ throw new Error(
2397
+ "Backend preparation did not complete. Your previous environment and data were preserved."
2398
+ );
2399
+ const env = withPythonBytecodeCache({ ...process.env }, options.support);
2400
+ const backendVersion = await smokeEnvironment(venv, env);
2401
+ const selection = {
2402
+ format: FORMAT,
2403
+ runtimeId: id2,
2404
+ runtime,
2405
+ venv,
2406
+ backendVersion
2407
+ };
2408
+ const bytes = `${JSON.stringify(selection)}
2409
+ `;
2410
+ await promises.writeFile(path$1.join(venv, READY), bytes, { flag: "wx", mode: 384 });
2411
+ const temporary = path$1.join(root, `.selection-${node_crypto.randomUUID()}.json`);
2412
+ await promises.writeFile(temporary, bytes, { flag: "wx", mode: 384 });
2413
+ await promises.rename(temporary, path$1.join(root, POINTER));
2414
+ reapSupersededGenerations(options, { runtime, venv });
2415
+ if (superseded)
2416
+ console.warn(
2417
+ `Replaced an unusable managed Python selection: ${superseded}`
2418
+ );
2419
+ return selection;
2420
+ });
2421
+ }
2422
+ function isLegacyManagedCommand(command, support) {
2423
+ const legacy = [
2424
+ path$1.join(support, "local-operator-venv"),
2425
+ path$1.join(support, "local-operator-venv-dev")
2426
+ ];
2427
+ const candidates = [command];
2428
+ try {
2429
+ candidates.push(fs.realpathSync(command));
2430
+ } catch {
2431
+ }
2432
+ try {
2433
+ candidates.push(fs.readFileSync(command, "utf8").slice(0, 4096));
2434
+ } catch {
2435
+ }
2436
+ return candidates.some(
2437
+ (candidate) => legacy.some((path2) => candidate.includes(`${path2}/`)) || LEGACY_BUNDLE_PYTHON_PATH.test(candidate)
2438
+ );
2439
+ }
2440
+ const PACKAGED_VENV_DIR_NAME = "local-operator-venv";
2441
+ const DEV_VENV_DIR_NAME = "local-operator-venv-dev";
2442
+ const NO_SELECTION_DIR_NAME = "no-environment-selected";
2443
+ function isUnpreparedVenvPath(path2) {
2444
+ return path2.endsWith(`${path$1.sep}${NO_SELECTION_DIR_NAME}`);
2445
+ }
2446
+ function managedSupportRoot(home) {
2447
+ return path$1.join(home, "Library", "Application Support", "Local Operator");
2448
+ }
2449
+ function legacyVenvPaths(support) {
2450
+ return [
2451
+ path$1.join(support, PACKAGED_VENV_DIR_NAME),
2452
+ path$1.join(support, DEV_VENV_DIR_NAME)
2453
+ ];
2454
+ }
2455
+ const VENV_PATH_ENV = "LOCAL_OPERATOR_VENV_PATH";
2456
+ const PYVENV_HOME = /^\s*home\s*=\s*(.+?)\s*$/;
2457
+ const BUNDLED_INTERPRETER_HOME = /^(.*?\.app)\/Contents\/Resources\/python(?:_aarch64)?\//;
2458
+ function managedVenvPath(input) {
2459
+ const name = input.packaged ? PACKAGED_VENV_DIR_NAME : DEV_VENV_DIR_NAME;
2460
+ if (input.platform === "win32") {
2461
+ return path$1.join(input.appDataPath, name);
2462
+ }
2463
+ if (input.platform === "darwin") {
2464
+ const support = managedSupportRoot(input.home);
2465
+ return readManagedSelection({
2466
+ support,
2467
+ resources: "",
2468
+ packaged: input.packaged,
2469
+ arch: process.arch
2470
+ })?.venv ?? path$1.join(
2471
+ support,
2472
+ "managed-python",
2473
+ input.packaged ? "packaged" : "dev",
2474
+ NO_SELECTION_DIR_NAME
2475
+ );
2476
+ }
2477
+ return path$1.join(input.home, ".config", "local-operator", name);
2478
+ }
2479
+ function venvInterpreter(venvPath) {
2480
+ let config;
2481
+ try {
2482
+ config = fs.readFileSync(path$1.join(venvPath, "pyvenv.cfg"), "utf8");
2483
+ } catch {
2484
+ return { kind: "none" };
2485
+ }
2486
+ const home = config.split("\n").map((line) => line.match(PYVENV_HOME)).find((match) => match != null)?.[1];
2487
+ if (!home) return { kind: "none" };
2488
+ const resourceMarked = home.match(BUNDLED_INTERPRETER_HOME);
2489
+ if (!resourceMarked) return { kind: "none" };
2490
+ const bundle = resourceMarked[1];
2491
+ return fs.existsSync(bundle) ? { kind: "bundled", bundle } : { kind: "missing", bundle };
2492
+ }
2493
+ function legacyEnvironmentReport(support) {
2494
+ const lines = [];
2495
+ for (const venv of legacyVenvPaths(support)) {
2496
+ const resolution = venvInterpreter(venv);
2497
+ if (resolution.kind === "none") continue;
2498
+ lines.push(
2499
+ resolution.kind === "missing" ? `The pre-split environment at ${venv} names an interpreter bundle that is not on disk (${resolution.bundle}); it is left exactly as it is.` : `The pre-split environment at ${venv} is built on the interpreter inside ${resolution.bundle}; it is left exactly as it is, and this instance does not use it.`
2500
+ );
2501
+ }
2502
+ return lines;
2503
+ }
2504
+ const SHEBANG = /^#!([^\r\n]+)\r?\n/;
2505
+ const PYTHON_NAME = /[/\\]python(?:\d+(?:\.\d+)*)?$/;
2506
+ const SERVE_ENTRYPOINT = "from local_operator.cli import main; main()";
2507
+ const PROBE_SCRIPT = "import json, sys; from local_operator.cli import main; print(json.dumps([sys.executable, getattr(sys, '_base_executable', None) or sys.executable, callable(main), [p for p in sys.path if p and p.startswith(sys.prefix)]]))";
2508
+ const DEFAULT_BUDGET = {
2509
+ totalMs: 45e3,
2510
+ attemptMs: 3e4,
2511
+ graceMs: 2e3,
2512
+ slackMs: 2e3
2513
+ };
2514
+ const DISCOVERY_TIMEOUT_MS = 5e3;
2515
+ const INTERPRETER_RESOLUTION_WORST_MS = DEFAULT_BUDGET.totalMs + 2 * (DISCOVERY_TIMEOUT_MS + DEFAULT_BUDGET.graceMs + DEFAULT_BUDGET.slackMs);
2516
+ class ProbeTimeout extends Error {
2517
+ constructor(interpreter, timeoutMs) {
2518
+ super(
2519
+ `${interpreter} did not answer an identity probe within ${timeoutMs} ms`
2520
+ );
2521
+ this.name = "ProbeTimeout";
2522
+ }
2523
+ }
2524
+ const describe = (error) => error instanceof Error ? error.message : String(error);
2525
+ function runBounded(command, args, env, bounds) {
2526
+ return new Promise((resolve, reject) => {
2527
+ const child = node_child_process.spawn(command, args, {
2528
+ env,
2529
+ // Piped, so the answer is read rather than inherited by the app.
2530
+ stdio: ["ignore", "pipe", "pipe"],
2531
+ windowsHide: true
2532
+ });
2533
+ let stdout = "";
2534
+ let stderr = "";
2535
+ child.stdout?.on("data", (chunk) => {
2536
+ stdout += chunk.toString();
2537
+ });
2538
+ child.stderr?.on("data", (chunk) => {
2539
+ stderr += chunk.toString();
2540
+ });
2541
+ let settled = false;
2542
+ let timedOut = false;
2543
+ const timers = [];
2544
+ const settle2 = (finish) => {
2545
+ if (settled) return;
2546
+ settled = true;
2547
+ for (const timer of timers) clearTimeout(timer);
2548
+ timers.length = 0;
2549
+ finish();
2550
+ };
2551
+ timers.push(
2552
+ setTimeout(() => {
2553
+ timedOut = true;
2554
+ child.kill("SIGTERM");
2555
+ timers.push(setTimeout(() => child.kill("SIGKILL"), bounds.graceMs));
2556
+ timers.push(
2557
+ setTimeout(
2558
+ () => settle2(() => reject(new ProbeTimeout(command, bounds.timeoutMs))),
2559
+ bounds.graceMs + bounds.slackMs
2560
+ )
2561
+ );
2562
+ }, bounds.timeoutMs)
2563
+ );
2564
+ child.on("error", (error) => settle2(() => reject(error)));
2565
+ child.on(
2566
+ "close",
2567
+ (code, signal) => settle2(() => {
2568
+ if (timedOut) {
2569
+ reject(new ProbeTimeout(command, bounds.timeoutMs));
2570
+ return;
2571
+ }
2572
+ if (code === 0) {
2573
+ resolve(stdout);
2574
+ return;
2575
+ }
2576
+ const detail = errorTail(stderr);
2577
+ reject(
2578
+ new Error(
2579
+ `exited with code ${code ?? "null"}${signal ? ` (${signal})` : ""}${detail ? `: ${detail}` : ""}`
2580
+ )
2581
+ );
2582
+ })
2583
+ );
2584
+ });
2585
+ }
2586
+ const errorTail = (text, lines = 3) => {
2587
+ const kept = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2588
+ if (kept.length === 0) return "";
2589
+ const tail = kept.slice(-lines).join(" | ");
2590
+ return kept.length > lines ? `… ${tail}` : tail;
2591
+ };
2592
+ function parseIdentity(stdout, interpreter) {
2593
+ const parsed = JSON.parse(stdout.trim());
2594
+ const [executable, baseExecutable, callable, prefixPaths] = parsed;
2595
+ if (typeof executable !== "string" || typeof callable !== "boolean")
2596
+ throw new Error(`${interpreter} reported no usable identity`);
2597
+ return {
2598
+ executable,
2599
+ baseExecutable: baseExecutable || executable,
2600
+ callable,
2601
+ prefixPaths: Array.isArray(prefixPaths) ? prefixPaths : []
2602
+ };
2603
+ }
2604
+ async function probeOnce(interpreter, env, timeoutMs, bounds) {
2605
+ return parseIdentity(
2606
+ await runBounded(interpreter, ["-c", PROBE_SCRIPT], env, {
2607
+ ...bounds,
2608
+ timeoutMs
2609
+ }),
2610
+ interpreter
2611
+ );
2612
+ }
2613
+ async function probeWithin(interpreter, env, deadline2, bounds) {
2614
+ let lastTimeout = null;
2615
+ for (let attempt = 1; ; attempt++) {
2616
+ const remaining = deadline2 - Date.now() - bounds.graceMs - bounds.slackMs;
2617
+ if (remaining <= 0) {
2618
+ if (lastTimeout) throw lastTimeout;
2619
+ throw new Error(
2620
+ `the ${bounds.totalMs} ms interpreter-probe budget ran out before ${interpreter} answered`
2621
+ );
2622
+ }
2623
+ try {
2624
+ return await probeOnce(
2625
+ interpreter,
2626
+ env,
2627
+ Math.min(bounds.attemptMs, remaining),
2628
+ bounds
2629
+ );
2630
+ } catch (error) {
2631
+ if (error instanceof ProbeTimeout) {
2632
+ lastTimeout = error;
2633
+ if (attempt < PROBE_ATTEMPTS && Date.now() < deadline2) continue;
2634
+ }
2635
+ throw error;
2636
+ }
2637
+ }
2638
+ }
2639
+ const PROBE_ATTEMPTS = 2;
2640
+ async function planFor(identity, interpreter, port, env, platform, deadline2, bounds) {
2641
+ if (!identity.callable || !path$1.isAbsolute(identity.executable))
2642
+ throw new Error(
2643
+ `the CLI entrypoint does not import in ${identity.executable || interpreter}`
2644
+ );
2645
+ const args = ["-c", SERVE_ENTRYPOINT, "serve", "--port", String(port)];
2646
+ if (platform !== "win32")
2647
+ return {
2648
+ command: "bash",
2649
+ args: ["-c", 'exec "$@"', "owned-serve", identity.executable, ...args],
2650
+ env
2651
+ };
2652
+ if (identity.executable.toLowerCase() === identity.baseExecutable.toLowerCase())
2653
+ return { command: identity.executable, args, env };
2654
+ if (!path$1.isAbsolute(identity.baseExecutable))
2655
+ throw new Error(
2656
+ `it is a venv redirector whose base interpreter is not an absolute path (${identity.baseExecutable || "nothing"})`
2657
+ );
2658
+ const baseEnv = {
2659
+ ...env,
2660
+ // Windows' own separator, deliberately independent of the host evaluating
2661
+ // the plan: the plan describes the Windows launch, not the machine that
2662
+ // built it, and the test host is not Windows.
2663
+ PYTHONPATH: [...identity.prefixPaths, env.PYTHONPATH].filter(Boolean).join(";")
2664
+ };
2665
+ const base = await probeWithin(
2666
+ identity.baseExecutable,
2667
+ baseEnv,
2668
+ deadline2,
2669
+ bounds
2670
+ );
2671
+ if (!base.callable || base.executable.toLowerCase() !== identity.baseExecutable.toLowerCase())
2672
+ throw new Error(
2673
+ `its base interpreter ${identity.baseExecutable} is not the serving process (${base.callable ? `it reported itself as ${base.executable || "nothing"}` : "it cannot import local_operator.cli even with the venv's own import paths"})`
2674
+ );
2675
+ return { command: identity.baseExecutable, args, env: baseEnv };
2676
+ }
2677
+ async function ownedServeLaunch(interpreters, port, env, platform = process.platform, budget = {}, fallbackClaims) {
2678
+ const bounds = {
2679
+ ...DEFAULT_BUDGET,
2680
+ ...budget
2681
+ };
2682
+ const deadline2 = Date.now() + bounds.totalMs;
2683
+ const rejected = [];
2684
+ const attempt = async (claims) => {
2685
+ for (const interpreter of [...new Set(claims)]) {
2686
+ if (!path$1.isAbsolute(interpreter)) {
2687
+ rejected.push(`${interpreter || "(empty)"}: not an absolute path`);
2688
+ continue;
2689
+ }
2690
+ try {
2691
+ const identity = await probeWithin(interpreter, env, deadline2, bounds);
2692
+ return await planFor(
2693
+ identity,
2694
+ interpreter,
2695
+ port,
2696
+ env,
2697
+ platform,
2698
+ deadline2,
2699
+ bounds
2700
+ );
2701
+ } catch (error) {
2702
+ rejected.push(`${interpreter}: ${describe(error)}`);
2703
+ }
2704
+ }
2705
+ return null;
2706
+ };
2707
+ if (interpreters.length === 0 && !fallbackClaims)
2708
+ throw new Error(
2709
+ "No backend interpreter was resolved. Install the Local Operator backend (`uv tool install local-operator`) or pair an external backend."
2710
+ );
2711
+ const planned = await attempt(interpreters) ?? (fallbackClaims ? await attempt(await fallbackClaims()) : null);
2712
+ if (planned) return planned;
2713
+ throw new Error(
2714
+ `No backend interpreter could be proven. Tried ${rejected.length}: ${rejected.join("; ")}. Install the backend with a supported installer (\`uv tool install local-operator\`) or pair an external backend.`
2715
+ );
2716
+ }
2717
+ async function firstLine(command, args, env) {
2718
+ try {
2719
+ const stdout = await runBounded(command, args, env, {
2720
+ ...DEFAULT_BUDGET,
2721
+ timeoutMs: DISCOVERY_TIMEOUT_MS
2722
+ });
2723
+ const line = stdout.trim().split(/\r?\n/)[0]?.trim();
2724
+ return line ? line : null;
2725
+ } catch {
2726
+ return null;
2727
+ }
2728
+ }
2729
+ async function windowsInterpreterCandidates(consolePath, env) {
2730
+ const directory = path$1.dirname(consolePath);
2731
+ const tool = path$1.basename(consolePath).replace(/\.(exe|cmd|bat)$/i, "");
2732
+ const candidates = [path$1.join(directory, "python.exe")];
2733
+ const uvTools = env.UV_TOOL_DIR ?? (env.APPDATA ? path$1.join(env.APPDATA, "uv", "tools") : null);
2734
+ if (uvTools) candidates.push(path$1.join(uvTools, tool, "Scripts", "python.exe"));
2735
+ const pipxHome = env.PIPX_HOME ?? (env.LOCALAPPDATA ? path$1.join(env.LOCALAPPDATA, "pipx") : null) ?? (env.USERPROFILE ? path$1.join(env.USERPROFILE, "pipx") : null);
2736
+ if (pipxHome)
2737
+ candidates.push(path$1.join(pipxHome, "venvs", tool, "Scripts", "python.exe"));
2738
+ return [...new Set(candidates)].filter(
2739
+ (candidate) => path$1.isAbsolute(candidate) && fs.existsSync(candidate)
2740
+ );
2741
+ }
2742
+ async function windowsPathInterpreterCandidates(env) {
2743
+ const found = [];
2744
+ const onPath = await firstLine("where", ["python.exe"], env);
2745
+ if (onPath) found.push(onPath);
2746
+ const viaLauncher = await firstLine(
2747
+ "py",
2748
+ ["-3", "-c", "import sys; print(sys.executable)"],
2749
+ env
2750
+ );
2751
+ if (viaLauncher) found.push(viaLauncher);
2752
+ return [...new Set(found)].filter(
2753
+ (candidate) => path$1.isAbsolute(candidate) && fs.existsSync(candidate)
2754
+ );
2755
+ }
2756
+ function consoleInterpreter(consolePath) {
2757
+ const script = fs.readFileSync(consolePath, "utf8");
2758
+ const shebang = SHEBANG.exec(script)?.[1];
2759
+ if (!shebang || !path$1.isAbsolute(shebang) || !PYTHON_NAME.test(shebang) || !script.includes("from local_operator.cli import main")) {
2760
+ throw new Error(
2761
+ "Cannot safely own this backend launcher; use a directly paired external backend"
2762
+ );
2763
+ }
2764
+ return shebang;
2765
+ }
1831
2766
  var LocalOperatorStartupMode = /* @__PURE__ */ ((LocalOperatorStartupMode2) => {
1832
2767
  LocalOperatorStartupMode2["EXISTING_SERVER"] = "EXISTING_SERVER";
1833
2768
  LocalOperatorStartupMode2["GLOBAL_INSTALL"] = "GLOBAL_INSTALL";
@@ -1835,8 +2770,21 @@ var LocalOperatorStartupMode = /* @__PURE__ */ ((LocalOperatorStartupMode2) => {
1835
2770
  LocalOperatorStartupMode2["NOT_STARTED"] = "NOT_STARTED";
1836
2771
  return LocalOperatorStartupMode2;
1837
2772
  })(LocalOperatorStartupMode || {});
2773
+ const CONSOLE_DISCOVERY_TIMEOUT_MS = 5e3;
2774
+ const CONSOLE_RESOLUTION_WORST_MS = 2 * CONSOLE_DISCOVERY_TIMEOUT_MS;
2775
+ const SHUTDOWN_TIMEOUT_DEFAULTS = {
2776
+ restart: 1e4,
2777
+ // 10 seconds for restart operations
2778
+ normal: 5e3,
2779
+ // 5 seconds for normal shutdowns
2780
+ force: 3e3
2781
+ // 3 seconds before force killing after SIGKILL
2782
+ };
2783
+ const OWNED_STOP_WORST_MS = SHUTDOWN_TIMEOUT_DEFAULTS.normal + SHUTDOWN_TIMEOUT_DEFAULTS.force;
2784
+ const READINESS_POLL_INTERVAL_MS = 1e3;
1838
2785
  const execPromise = node_util.promisify(node_child_process.exec);
1839
2786
  const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
2787
+ const LINE_BREAK = /\r?\n/;
1840
2788
  class BackendServiceManager {
1841
2789
  process = null;
1842
2790
  isRunning = false;
@@ -1848,8 +2796,18 @@ class BackendServiceManager {
1848
2796
  appDataPath = electron.app.getPath("userData");
1849
2797
  venvPath;
1850
2798
  healthCheckInterval = null;
1851
- exitPromise = null;
1852
- exitResolve = null;
2799
+ /** The only process this manager may terminate. Null means it owns nothing,
2800
+ * which is a reason to report "nothing to stop" - never to go looking. */
2801
+ ownedServe = null;
2802
+ /** In-flight start, so a stop can await the resolver/readiness work that has
2803
+ * not yet produced a child. Without it, cleanup can return before a spawn
2804
+ * that was already committed, and the caller installs over a live serve. */
2805
+ startPromise = null;
2806
+ restartPromise = null;
2807
+ /** Bumped by every stop. A start that began under an older epoch cannot
2808
+ * report success or adopt its child: the cancellation happened after it
2809
+ * checked, and this is what it re-checks across each await. */
2810
+ startEpoch = 0;
1853
2811
  shellEnv = {};
1854
2812
  // External/dev backends may be explicitly paired through main's environment.
1855
2813
  // Managed starts always rotate this; it is never exposed by preload or logs.
@@ -1952,14 +2910,7 @@ class BackendServiceManager {
1952
2910
  // Flag to track when the app is being closed
1953
2911
  isAutoUpdating = false;
1954
2912
  // Flag to track when an autoupdate is in progress
1955
- shutdownTimeoutMs = {
1956
- restart: 1e4,
1957
- // 10 seconds for restart operations
1958
- normal: 5e3,
1959
- // 5 seconds for normal shutdowns
1960
- force: 3e3
1961
- // 3 seconds before force killing after SIGKILL
1962
- };
2913
+ shutdownTimeoutMs = { ...SHUTDOWN_TIMEOUT_DEFAULTS };
1963
2914
  // Configurable timeouts for different shutdown scenarios
1964
2915
  /**
1965
2916
  * Constructor
@@ -1982,24 +2933,12 @@ class BackendServiceManager {
1982
2933
  error
1983
2934
  );
1984
2935
  }
1985
- if (process.platform === "win32") {
1986
- this.venvPath = path$1.join(this.appDataPath, "local-operator-venv");
1987
- } else if (process.platform === "darwin") {
1988
- this.venvPath = path$1.join(
1989
- electron.app.getPath("home"),
1990
- "Library",
1991
- "Application Support",
1992
- "Local Operator",
1993
- "local-operator-venv"
1994
- );
1995
- } else {
1996
- this.venvPath = path$1.join(
1997
- electron.app.getPath("home"),
1998
- ".config",
1999
- "local-operator",
2000
- "local-operator-venv"
2001
- );
2002
- }
2936
+ this.venvPath = managedVenvPath({
2937
+ platform: process.platform,
2938
+ home: electron.app.getPath("home"),
2939
+ appDataPath: this.appDataPath,
2940
+ packaged: electron.app.isPackaged
2941
+ });
2003
2942
  this.loadShellEnvironment();
2004
2943
  logger.info(
2005
2944
  `Backend Service Manager initialized. Disabled: ${this.isDisabled}`,
@@ -2246,8 +3185,28 @@ class BackendServiceManager {
2246
3185
  `Checking if local-operator command exists: ${command}`,
2247
3186
  LogFileType.BACKEND
2248
3187
  );
2249
- const { stdout } = await execPromise(command);
3188
+ const { stdout } = await execPromise(command, {
3189
+ // Bounded: a quit that arrives mid-start waits on this same path, and an
3190
+ // unbounded child here is a wait with nothing underneath it.
3191
+ timeout: CONSOLE_DISCOVERY_TIMEOUT_MS,
3192
+ windowsHide: true
3193
+ });
2250
3194
  if (stdout.trim()) {
3195
+ if (process.platform === "darwin" && isLegacyManagedCommand(
3196
+ stdout.trim().split("\n")[0],
3197
+ path$1.join(
3198
+ electron.app.getPath("home"),
3199
+ "Library",
3200
+ "Application Support",
3201
+ "Local Operator"
3202
+ )
3203
+ )) {
3204
+ logger.info(
3205
+ "The PATH command belongs to a legacy managed environment; preparing a separate backend instead",
3206
+ LogFileType.BACKEND
3207
+ );
3208
+ return false;
3209
+ }
2251
3210
  logger.info(
2252
3211
  `local-operator command found at: ${stdout.trim()}`,
2253
3212
  LogFileType.BACKEND
@@ -2374,12 +3333,30 @@ class BackendServiceManager {
2374
3333
  }
2375
3334
  return false;
2376
3335
  }
3336
+ /** Keep PATH discovery authoritative; `ownedServeLaunch` proves the identity
3337
+ * of what it found rather than trusting the name it was resolved under. */
3338
+ async resolveGlobalConsole(env) {
3339
+ const { stdout } = await execPromise(
3340
+ process.platform === "win32" ? "where local-operator" : "which local-operator",
3341
+ { env, timeout: CONSOLE_DISCOVERY_TIMEOUT_MS }
3342
+ );
3343
+ return stdout.trim().split(LINE_BREAK)[0];
3344
+ }
2377
3345
  /**
2378
3346
  * Start the backend service
2379
3347
  * @returns Promise resolving to true if the backend was started successfully, false otherwise
2380
3348
  */
2381
- async start() {
2382
- this.isAppClosing = false;
3349
+ start() {
3350
+ if (this.isAppClosing) return Promise.resolve(false);
3351
+ if (this.startPromise) return this.startPromise;
3352
+ this.startPromise = this.startOwned(this.startEpoch).finally(() => {
3353
+ this.startPromise = null;
3354
+ });
3355
+ return this.startPromise;
3356
+ }
3357
+ async startOwned(epoch) {
3358
+ if (this.ownedServe?.stop) return false;
3359
+ if (this.ownedServe) return this.isRunning;
2383
3360
  if (this.isDisabled) {
2384
3361
  logger.info(
2385
3362
  "Backend Service Manager is disabled. Skipping backend start.",
@@ -2388,155 +3365,120 @@ class BackendServiceManager {
2388
3365
  this.notifyBackendReady();
2389
3366
  return true;
2390
3367
  }
2391
- if (await this.checkExistingBackend()) {
3368
+ const existing = await this.checkExistingBackend();
3369
+ if (epoch !== this.startEpoch || this.isAppClosing) return false;
3370
+ if (existing) {
2392
3371
  this.isRunning = true;
2393
3372
  this.startupMode = "EXISTING_SERVER";
2394
3373
  this.startHealthCheck();
2395
3374
  return true;
2396
3375
  }
2397
3376
  this.desktopToken = node_crypto.randomBytes(32).toString("hex");
3377
+ let captured = null;
2398
3378
  try {
2399
- if (await this.checkLocalOperatorExists()) {
2400
- logger.info(
2401
- "Using globally installed local-operator command",
2402
- LogFileType.BACKEND
2403
- );
3379
+ this.venvPath = managedVenvPath({
3380
+ platform: process.platform,
3381
+ home: electron.app.getPath("home"),
3382
+ appDataPath: this.appDataPath,
3383
+ packaged: electron.app.isPackaged
3384
+ });
3385
+ const globalInstall = await this.checkLocalOperatorExists();
3386
+ const env = this.backendSpawnEnv();
3387
+ let interpreters;
3388
+ if (globalInstall) {
2404
3389
  this.startupMode = "GLOBAL_INSTALL";
2405
- const cmd = process.platform === "win32" ? "cmd.exe" : "bash";
2406
- const args = process.platform === "win32" ? ["/c", `local-operator serve --port ${this.port}`] : ["-c", `local-operator serve --port ${this.port}`];
2407
- logger.info(
2408
- `Starting backend service with global command: ${cmd} ${args.join(" ")}`,
2409
- LogFileType.BACKEND
2410
- );
2411
- this.process = node_child_process.spawn(cmd, args, {
2412
- detached: false,
2413
- // Ensure process is not detached from parent
2414
- stdio: "pipe",
2415
- env: this.backendSpawnEnv(),
2416
- // On Windows, we need to create a new process group to ensure proper termination
2417
- ...process.platform === "win32" ? { windowsHide: true } : {}
2418
- });
3390
+ const executable = await this.resolveGlobalConsole(env);
3391
+ interpreters = process.platform === "win32" ? await windowsInterpreterCandidates(executable, env) : [consoleInterpreter(executable)];
2419
3392
  } else {
2420
- logger.info(
2421
- "Global local-operator not found, using virtual environment",
2422
- LogFileType.BACKEND
2423
- );
2424
3393
  this.startupMode = "APP_BUNDLED_VENV";
2425
- let cmd;
2426
- let args;
2427
- if (process.platform === "win32") {
2428
- const localOperatorExe = path$1.join(
2429
- this.venvPath,
2430
- "Scripts",
2431
- "local-operator.exe"
2432
- );
2433
- if (fs.existsSync(localOperatorExe)) {
2434
- cmd = localOperatorExe;
2435
- args = ["serve", "--port", this.port.toString()];
2436
- } else {
2437
- const pythonExe = path$1.join(this.venvPath, "Scripts", "python.exe");
2438
- if (fs.existsSync(pythonExe)) {
2439
- cmd = pythonExe;
2440
- args = [
2441
- "-m",
2442
- "local_operator",
2443
- "serve",
2444
- "--port",
2445
- this.port.toString()
2446
- ];
2447
- } else {
2448
- const activateScript = path$1.join(
2449
- this.venvPath,
2450
- "Scripts",
2451
- "Activate.ps1"
2452
- );
2453
- cmd = "powershell.exe";
2454
- args = [
2455
- "-ExecutionPolicy",
2456
- "Bypass",
2457
- "-Command",
2458
- `"& '${activateScript}'; local-operator serve --port ${this.port}"`
2459
- ];
2460
- }
2461
- }
2462
- } else {
2463
- const activateScript = path$1.join(this.venvPath, "bin", "activate");
2464
- cmd = "bash";
2465
- args = [
2466
- "-c",
2467
- `. "${activateScript}" && local-operator serve --port ${this.port}`
2468
- ];
2469
- }
2470
- logger.info(
2471
- `Starting backend service with venv: ${cmd} ${args.join(" ")}`,
2472
- LogFileType.BACKEND
3394
+ const bin = path$1.join(
3395
+ this.venvPath,
3396
+ process.platform === "win32" ? "Scripts" : "bin"
2473
3397
  );
2474
- this.process = node_child_process.spawn(cmd, args, {
2475
- detached: false,
2476
- // Ensure process is not detached from parent
2477
- stdio: "pipe",
2478
- env: this.backendSpawnEnv(),
2479
- // On Windows, we need to create a new process group to ensure proper termination
2480
- ...process.platform === "win32" ? { windowsHide: true } : {}
2481
- });
2482
- }
2483
- if (this.process.stdout) {
2484
- this.process.stdout.on("data", (data) => {
3398
+ interpreters = [
3399
+ path$1.join(bin, process.platform === "win32" ? "python.exe" : "python")
3400
+ ];
3401
+ env.VIRTUAL_ENV = this.venvPath;
3402
+ env.PATH = `${bin}${process.platform === "win32" ? ";" : ":"}${env.PATH ?? ""}`;
3403
+ env.PYTHONHOME = void 0;
3404
+ }
3405
+ const launch = await ownedServeLaunch(
3406
+ interpreters,
3407
+ this.port,
3408
+ env,
3409
+ process.platform,
3410
+ {},
3411
+ // The PATH-side claims spawn discovery children, so they are offered
3412
+ // only after the claims above have all failed (round 3, F14).
3413
+ process.platform === "win32" ? () => windowsPathInterpreterCandidates(env) : void 0
3414
+ );
3415
+ if (epoch !== this.startEpoch || this.isAppClosing) return false;
3416
+ const child = node_child_process.spawn(launch.command, launch.args, {
3417
+ detached: false,
3418
+ stdio: "pipe",
3419
+ // The plan owns the environment it proved: a Windows venv's base
3420
+ // interpreter needs the venv's import paths, added there and not here.
3421
+ env: launch.env,
3422
+ windowsHide: true
3423
+ });
3424
+ const generation = this.captureServe(child);
3425
+ captured = generation;
3426
+ if (child.stdout) {
3427
+ child.stdout.on("data", (data) => {
2485
3428
  logger.info(`Backend stdout: ${data}`, LogFileType.BACKEND);
2486
3429
  });
2487
3430
  }
2488
- if (this.process.stderr) {
2489
- this.process.stderr.on("data", (data) => {
3431
+ if (child.stderr) {
3432
+ child.stderr.on("data", (data) => {
2490
3433
  logger.error(`Backend stderr: ${data}`, LogFileType.BACKEND);
2491
3434
  });
2492
3435
  }
2493
- this.process.on("exit", (code) => {
2494
- logger.info(
2495
- `Backend process exited with code ${code}`,
2496
- LogFileType.BACKEND
2497
- );
2498
- this.isRunning = false;
2499
- this.process = null;
2500
- if (this.exitResolve) {
2501
- this.exitResolve();
2502
- this.exitResolve = null;
2503
- }
2504
- const isExpectedWindowsExit = process.platform === "win32" && (this.isAppClosing || this.isAutoUpdating) && code === 1;
2505
- if (code !== 0 && code !== null && !isExpectedWindowsExit) {
2506
- electron.dialog.showErrorBox(
2507
- "Backend Error",
2508
- `The Local Operator backend service exited unexpectedly with code ${code}. Please restart the application.`
2509
- );
2510
- }
2511
- });
2512
3436
  let attempts = 0;
2513
3437
  const maxAttempts = 30;
2514
3438
  while (attempts < maxAttempts) {
2515
- if (await this.checkHealth()) {
3439
+ const healthy = await this.checkHealth();
3440
+ if (epoch !== this.startEpoch || generation.exited || generation.stop)
3441
+ break;
3442
+ if (healthy) {
2516
3443
  this.isRunning = true;
2517
3444
  this.startHealthCheck();
2518
3445
  this.notifyBackendReady();
2519
3446
  return true;
2520
3447
  }
2521
- await new Promise((resolve) => setTimeout(resolve, 1e3));
3448
+ await new Promise(
3449
+ (resolve) => setTimeout(resolve, READINESS_POLL_INTERVAL_MS)
3450
+ );
2522
3451
  attempts++;
2523
3452
  }
3453
+ await this.stopGeneration(generation, false);
3454
+ if (epoch !== this.startEpoch) return false;
2524
3455
  logger.error(
2525
3456
  "Failed to start backend service after multiple attempts",
2526
3457
  LogFileType.BACKEND
2527
3458
  );
2528
- electron.dialog.showErrorBox(
3459
+ this.reportStartFailure(
2529
3460
  "Backend Error",
2530
3461
  "Failed to start the Local Operator backend service. Please check the logs for more information."
2531
3462
  );
2532
3463
  return false;
2533
3464
  } catch (error) {
3465
+ if (captured) {
3466
+ try {
3467
+ await this.stopGeneration(captured, false);
3468
+ } catch (cleanupError) {
3469
+ logger.error(
3470
+ "Owned backend cleanup after a failed start did not confirm exit",
3471
+ LogFileType.BACKEND,
3472
+ cleanupError
3473
+ );
3474
+ }
3475
+ }
2534
3476
  logger.error(
2535
3477
  "Error starting backend service:",
2536
3478
  LogFileType.BACKEND,
2537
3479
  error
2538
3480
  );
2539
- electron.dialog.showErrorBox(
3481
+ this.reportStartFailure(
2540
3482
  "Backend Error",
2541
3483
  `Error starting the Local Operator backend service: ${error}`
2542
3484
  );
@@ -2544,232 +3486,156 @@ class BackendServiceManager {
2544
3486
  }
2545
3487
  }
2546
3488
  /**
2547
- * Stop the backend service
2548
- * @param isRestart - Whether this stop is part of a restart operation
2549
- * @returns Promise resolving when the backend has been stopped
3489
+ * Stop only the captured serve generation, including failed startups. A port
3490
+ * or process name is not ownership. Unconfirmed exit blocks replacement.
2550
3491
  */
2551
3492
  async stop(isRestart = false) {
2552
- if (!isRestart) {
2553
- this.isAppClosing = true;
2554
- }
3493
+ this.startEpoch++;
3494
+ if (!isRestart) this.isAppClosing = true;
2555
3495
  this.disposeStreamRelay();
2556
- if (this.healthCheckInterval) {
2557
- clearInterval(this.healthCheckInterval);
2558
- this.healthCheckInterval = null;
2559
- }
2560
- if (this.isDisabled || this.isExternalBackend) {
2561
- logger.info(
2562
- "Skipping backend stop (disabled or external backend)",
2563
- LogFileType.BACKEND
2564
- );
2565
- return;
2566
- }
2567
- if (this.process && this.isRunning) {
2568
- logger.info(
2569
- `Stopping backend service... (isRestart: ${isRestart})`,
2570
- LogFileType.BACKEND
2571
- );
2572
- this.exitPromise = new Promise((resolve) => {
2573
- this.exitResolve = resolve;
2574
- if (!this.process) {
2575
- if (this.exitResolve) {
2576
- this.exitResolve();
2577
- this.exitResolve = null;
2578
- }
2579
- return;
2580
- }
2581
- this.process.once("exit", () => {
2582
- logger.info("Backend process exited", LogFileType.BACKEND);
2583
- this.process = null;
2584
- this.isRunning = false;
2585
- if (this.exitResolve) {
2586
- this.exitResolve();
2587
- this.exitResolve = null;
2588
- }
2589
- });
2590
- });
2591
- try {
2592
- const pid = this.process.pid;
2593
- if (process.platform === "win32") {
2594
- if (pid) {
2595
- logger.info(
2596
- `Terminating Windows process with PID ${pid}`,
2597
- LogFileType.BACKEND
2598
- );
2599
- node_child_process.spawn("taskkill", ["/pid", pid.toString(), "/t"]);
2600
- }
2601
- } else {
2602
- logger.info(
2603
- "Sending SIGTERM to backend process",
2604
- LogFileType.BACKEND
2605
- );
2606
- this.process.kill("SIGTERM");
2607
- }
2608
- let forceKillTimeoutId = null;
2609
- const timeoutPromise = new Promise((resolve) => {
2610
- const timeoutMs = isRestart ? this.shutdownTimeoutMs.restart : this.shutdownTimeoutMs.normal;
2611
- logger.info(
2612
- `Setting shutdown timeout to ${timeoutMs}ms for ${isRestart ? "restart" : "normal"} operation`,
2613
- LogFileType.BACKEND
2614
- );
2615
- forceKillTimeoutId = setTimeout(() => {
2616
- if (this.process) {
2617
- logger.info(
2618
- `Backend process did not exit within ${timeoutMs}ms, force killing`,
2619
- LogFileType.BACKEND
2620
- );
2621
- try {
2622
- if (process.platform === "win32" && this.process.pid) {
2623
- node_child_process.spawn("taskkill", [
2624
- "/pid",
2625
- this.process.pid.toString(),
2626
- "/f",
2627
- "/t"
2628
- ]);
2629
- try {
2630
- execPromise(
2631
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
2632
- ).catch((error) => {
2633
- logger.warn(
2634
- "Error terminating python processes:",
2635
- LogFileType.BACKEND,
2636
- error
2637
- );
2638
- });
2639
- execPromise("taskkill /f /im local-operator.exe /t").catch(
2640
- (error) => {
2641
- logger.warn(
2642
- "Error terminating local-operator processes:",
2643
- LogFileType.BACKEND,
2644
- error
2645
- );
2646
- }
2647
- );
2648
- } catch (taskkillError) {
2649
- logger.warn(
2650
- "Error executing taskkill command:",
2651
- LogFileType.BACKEND,
2652
- taskkillError
2653
- );
2654
- }
2655
- } else if (this.process) {
2656
- this.process.kill("SIGKILL");
2657
- if (!isRestart) {
2658
- try {
2659
- execPromise('pkill -f "local-operator serve"').catch(
2660
- (error) => {
2661
- logger.warn(
2662
- "Error killing processes by pattern:",
2663
- LogFileType.BACKEND,
2664
- error
2665
- );
2666
- }
2667
- );
2668
- } catch (pkillError) {
2669
- logger.warn(
2670
- "Error executing pkill command:",
2671
- LogFileType.BACKEND,
2672
- pkillError
2673
- );
2674
- }
2675
- }
2676
- setTimeout(() => {
2677
- if (this.process) {
2678
- logger.warn(
2679
- `Process still exists after SIGKILL, attempting more aggressive termination after ${this.shutdownTimeoutMs.force}ms`,
2680
- LogFileType.BACKEND
2681
- );
2682
- try {
2683
- if (!isRestart) {
2684
- execPromise(
2685
- 'pkill -9 -f "local-operator serve"'
2686
- ).catch(() => {
2687
- });
2688
- }
2689
- this.process = null;
2690
- this.isRunning = false;
2691
- if (this.exitResolve) {
2692
- this.exitResolve();
2693
- this.exitResolve = null;
2694
- }
2695
- } catch (finalError) {
2696
- logger.error(
2697
- "Error during final force kill attempt:",
2698
- LogFileType.BACKEND,
2699
- finalError
2700
- );
2701
- }
2702
- }
2703
- }, this.shutdownTimeoutMs.force);
2704
- }
2705
- } catch (error) {
2706
- logger.error(
2707
- "Error force killing process:",
2708
- LogFileType.BACKEND,
2709
- error
2710
- );
2711
- }
2712
- this.process = null;
2713
- this.isRunning = false;
2714
- if (this.exitResolve) {
2715
- this.exitResolve();
2716
- this.exitResolve = null;
2717
- }
2718
- }
2719
- resolve();
2720
- }, timeoutMs);
2721
- });
2722
- await Promise.race([this.exitPromise, timeoutPromise]);
2723
- if (forceKillTimeoutId) {
2724
- clearTimeout(forceKillTimeoutId);
2725
- forceKillTimeoutId = null;
2726
- }
2727
- if (!isRestart) {
2728
- logger.info(
2729
- "Performing additional cleanup for final shutdown",
2730
- LogFileType.BACKEND
2731
- );
2732
- try {
2733
- if (process.platform === "win32") {
2734
- await execPromise(
2735
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
2736
- ).catch(() => {
2737
- });
2738
- await execPromise("taskkill /f /im local-operator.exe /t").catch(
2739
- () => {
2740
- }
2741
- );
2742
- } else {
2743
- await execPromise('pkill -f "local-operator serve"').catch(() => {
2744
- });
2745
- await new Promise((resolve) => setTimeout(resolve, 1e3));
2746
- await execPromise('pkill -9 -f "local-operator serve"').catch(
2747
- () => {
2748
- }
2749
- );
2750
- }
2751
- } catch (cleanupError) {
2752
- logger.warn(
2753
- "Error during additional cleanup (this may be normal if processes were already terminated):",
2754
- LogFileType.BACKEND,
2755
- cleanupError
2756
- );
2757
- }
2758
- }
2759
- } catch (error) {
2760
- logger.error(
2761
- "Error stopping backend process:",
2762
- LogFileType.BACKEND,
2763
- error
3496
+ this.stopHealthCheck();
3497
+ const generation = this.ownedServe;
3498
+ if (generation) await this.stopGeneration(generation, isRestart);
3499
+ await this.startPromise;
3500
+ }
3501
+ captureServe(child) {
3502
+ let resolveExit = () => {
3503
+ };
3504
+ const generation = {
3505
+ child,
3506
+ exited: false,
3507
+ exit: new Promise((resolve) => {
3508
+ resolveExit = resolve;
3509
+ }),
3510
+ resolveExit: () => resolveExit(),
3511
+ stop: null,
3512
+ timers: /* @__PURE__ */ new Set()
3513
+ };
3514
+ this.ownedServe = generation;
3515
+ this.process = child;
3516
+ const exited = (code) => {
3517
+ if (generation.exited) return;
3518
+ generation.exited = true;
3519
+ for (const timer of generation.timers) clearTimeout(timer);
3520
+ generation.timers.clear();
3521
+ generation.resolveExit();
3522
+ if (this.ownedServe !== generation) return;
3523
+ this.ownedServe = null;
3524
+ this.process = null;
3525
+ this.isRunning = false;
3526
+ if (code != null && code !== 0 && !generation.stop && !this.isAppClosing && !this.isAutoUpdating) {
3527
+ electron.dialog.showErrorBox(
3528
+ "Backend Error",
3529
+ `The Local Operator backend service exited unexpectedly with code ${code}. Please restart the application.`
2764
3530
  );
2765
- this.process = null;
2766
- this.isRunning = false;
2767
- if (this.exitResolve) {
2768
- this.exitResolve();
2769
- this.exitResolve = null;
3531
+ }
3532
+ };
3533
+ child.once("exit", exited);
3534
+ child.on("error", (error) => {
3535
+ logger.error("Owned backend process error", LogFileType.BACKEND, error);
3536
+ if (child.pid === void 0) exited();
3537
+ });
3538
+ return generation;
3539
+ }
3540
+ /** Signals may only be sent while the handle itself still reports a live
3541
+ * process. Node keeps `kill()` callable on a reaped child, and on a PID the
3542
+ * OS has since recycled that call reaches whatever now holds the number. */
3543
+ canSignal(generation) {
3544
+ return !generation.exited && generation.child.exitCode === null && generation.child.signalCode === null;
3545
+ }
3546
+ /**
3547
+ * The one termination sequence: SIGTERM, wait out the grace, SIGKILL, wait
3548
+ * again, and throw if the process never reported exit.
3549
+ *
3550
+ * Failing rather than returning is the point. Callers use a resolved stop as
3551
+ * permission to replace the backend - install over it, spawn a successor - so
3552
+ * "we signalled it and moved on" would authorise exactly the double-serve
3553
+ * this class exists to prevent. An unconfirmed exit keeps ownership, and the
3554
+ * caller reports failure instead of proceeding.
3555
+ */
3556
+ stopGeneration(generation, isRestart) {
3557
+ if (generation.stop) return generation.stop;
3558
+ generation.stop = (async () => {
3559
+ const waitForExit = (ms) => new Promise((resolve) => {
3560
+ if (generation.exited) {
3561
+ resolve(true);
3562
+ return;
3563
+ }
3564
+ const timer = setTimeout(() => {
3565
+ generation.timers.delete(timer);
3566
+ resolve(false);
3567
+ }, ms);
3568
+ generation.timers.add(timer);
3569
+ void generation.exit.then(() => {
3570
+ clearTimeout(timer);
3571
+ generation.timers.delete(timer);
3572
+ resolve(true);
3573
+ });
3574
+ });
3575
+ if (this.canSignal(generation)) generation.child.kill("SIGTERM");
3576
+ const grace = isRestart ? this.shutdownTimeoutMs.restart : this.shutdownTimeoutMs.normal;
3577
+ if (!await waitForExit(grace)) {
3578
+ if (this.canSignal(generation)) generation.child.kill("SIGKILL");
3579
+ if (!await waitForExit(this.shutdownTimeoutMs.force)) {
3580
+ throw new Error(
3581
+ "Owned backend exit is unconfirmed; refusing replacement"
3582
+ );
2770
3583
  }
2771
3584
  }
2772
- logger.info("Backend service stopped", LogFileType.BACKEND);
3585
+ if (!isRestart && !this.ownedServe)
3586
+ this.startupMode = "NOT_STARTED";
3587
+ logger.info("Owned backend generation stopped", LogFileType.BACKEND);
3588
+ })();
3589
+ return generation.stop;
3590
+ }
3591
+ stopHealthCheck() {
3592
+ if (this.healthCheckInterval) clearInterval(this.healthCheckInterval);
3593
+ this.healthCheckInterval = null;
3594
+ }
3595
+ /** Whether a quit may proceed. `will-quit` cannot await a listener, so it
3596
+ * prevents the first quit, runs cleanup, and asks this on the retry. */
3597
+ /** Whether a shutdown is in flight.
3598
+ *
3599
+ * `stop(false)` is terminal and is what the quit path calls, so this is the
3600
+ * state a caller checks before raising blocking UI it would never be able to
3601
+ * dismiss - `index.ts`'s start-failure paths are the callers. */
3602
+ isShuttingDown() {
3603
+ return this.isAppClosing;
3604
+ }
3605
+ /**
3606
+ * Report a start failure to the user - unless the app is on its way out.
3607
+ *
3608
+ * `showErrorBox` is a native modal: it parks the main thread until somebody
3609
+ * dismisses it, and Electron cannot run the quit it was asked for past a
3610
+ * parked thread. On the quit path that is not a message, it is a deadlock -
3611
+ * the quit has already prevented itself, the owned cleanup it waits on can
3612
+ * never finish, and the quit's own failsafe cannot even fire, because that is
3613
+ * a timer on the thread the dialog holds (review round 4, Q-20: a SIGTERM
3614
+ * during the first start left the app alive 50 s later with the failsafe line
3615
+ * never logged). A shutdown in flight logs the failure instead, where the
3616
+ * post-mortem finds it without the process still being up.
3617
+ */
3618
+ reportStartFailure(title, message) {
3619
+ if (this.isAppClosing) {
3620
+ logger.error(
3621
+ `${title} (not shown; the app is shutting down): ${message}`,
3622
+ LogFileType.BACKEND
3623
+ );
3624
+ return;
3625
+ }
3626
+ electron.dialog.showErrorBox(title, message);
3627
+ }
3628
+ isOwnedCleanupComplete() {
3629
+ return this.isAppClosing && !this.ownedServe && !this.startPromise;
3630
+ }
3631
+ /** Synchronous exit cannot await cleanup. Canonical runtimes deliberately
3632
+ * outlive their HTTP server, so neither PID rediscovery nor descent is safe. */
3633
+ emergencyStopOwned() {
3634
+ this.startEpoch++;
3635
+ this.isAppClosing = true;
3636
+ const generation = this.ownedServe;
3637
+ if (generation && !generation.exited && generation.child.exitCode === null && generation.child.signalCode === null) {
3638
+ generation.child.kill("SIGKILL");
2773
3639
  }
2774
3640
  }
2775
3641
  /**
@@ -2814,7 +3680,10 @@ class BackendServiceManager {
2814
3680
  * assertable without waiting out a 30s interval.
2815
3681
  */
2816
3682
  async checkUnhealthyBackend() {
3683
+ const epoch = this.startEpoch;
2817
3684
  const isHealthy = await this.checkHealth();
3685
+ if (epoch !== this.startEpoch || this.isAppClosing || this.isAutoUpdating || this.isDisabled)
3686
+ return;
2818
3687
  if (isHealthy) return;
2819
3688
  logger.info("Backend health check failed", LogFileType.BACKEND);
2820
3689
  if (this.isExternalBackend) {
@@ -2832,8 +3701,7 @@ class BackendServiceManager {
2832
3701
  await this.start();
2833
3702
  return;
2834
3703
  }
2835
- await this.stop(true);
2836
- await this.start();
3704
+ await this.restart();
2837
3705
  }
2838
3706
  /**
2839
3707
  * Get the port number used by the backend service
@@ -2883,348 +3751,25 @@ class BackendServiceManager {
2883
3751
  * timeouts from the stop operation don't affect the newly started process
2884
3752
  * @returns Promise resolving to true if the restart was successful, false otherwise
2885
3753
  */
2886
- async restart() {
2887
- logger.info("Restarting backend service...", LogFileType.BACKEND);
2888
- this.isAppClosing = false;
2889
- await this.stop(true);
2890
- await new Promise((resolve) => setTimeout(resolve, 2e3));
2891
- if (this.process) {
2892
- logger.warn(
2893
- "Process still exists after stop, attempting to force terminate",
2894
- LogFileType.BACKEND
2895
- );
3754
+ restart() {
3755
+ if (this.restartPromise) return this.restartPromise;
3756
+ this.restartPromise = (async () => {
2896
3757
  try {
2897
- if (process.platform === "win32" && this.process.pid) {
2898
- await node_util.promisify(node_child_process.exec)(`taskkill /pid ${this.process.pid} /f /t`);
2899
- } else if (this.process) {
2900
- this.process.kill("SIGKILL");
2901
- }
2902
- await new Promise((resolve) => {
2903
- if (!this.process) {
2904
- resolve(null);
2905
- return;
2906
- }
2907
- this.process.once("exit", () => {
2908
- resolve(null);
2909
- });
2910
- setTimeout(resolve, 1e3);
2911
- });
2912
- this.process = null;
2913
- this.isRunning = false;
3758
+ await this.stop(true);
3759
+ if (this.isAppClosing) return false;
3760
+ return await this.start();
2914
3761
  } catch (error) {
2915
- logger.error(
2916
- "Error force killing process during restart:",
2917
- LogFileType.BACKEND,
2918
- error
2919
- );
3762
+ logger.error("Backend restart refused", LogFileType.BACKEND, error);
3763
+ return false;
2920
3764
  }
2921
- }
2922
- const success = await this.start();
2923
- if (success) {
2924
- logger.info(
2925
- "Backend service restarted successfully",
2926
- LogFileType.BACKEND
2927
- );
2928
- } else {
2929
- logger.error("Failed to restart backend service", LogFileType.BACKEND);
2930
- }
2931
- return success;
2932
- }
2933
- }
2934
- const linuxInstallScriptRaw = '#!/bin/bash\n# Local Operator Backend Installation Script for Linux\n# This script sets up a virtual environment for the Local Operator backend\n# without requiring sudo or admin privileges.\n\n# Exit on error, undefined variables, and failed pipe commands\nset -e # Exit immediately if a command exits with a non-zero status\nset -u # Error on unset variables\nset -o pipefail # Fail if any command in a pipe fails\n\n# Configuration\n# Variables APP_NAME and MIN_PYTHON_VERSION removed as they were unused\nVENV_NAME="local-operator-venv"\nAPP_DATA_DIR="${HOME}/.config/local-operator"\nVENV_PATH="${APP_DATA_DIR}/${VENV_NAME}"\nLOG_FILE="${APP_DATA_DIR}/backend-install.log"\nTIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")\n\n# Keep CPython\'s bytecode cache out of the application directory.\n#\n# Same reason as the macOS script: an interpreter writing __pycache__/*.pyc\n# beside its own stdlib sources writes into the installed tree. Linux does not\n# code-seal the app the way macOS does, so this is uniformity and hygiene here\n# rather than the load-bearing fix it is on macOS - but it is also the value a\n# standalone run of this script has to agree with, because the app and the\n# script must not disagree about where bytecode goes.\n: "${PYTHONPYCACHEPREFIX:=${APP_DATA_DIR}/python-bytecode-cache}"\nexport PYTHONPYCACHEPREFIX\n\n# Create app data directory if it doesn\'t exist\nif ! mkdir -p "${APP_DATA_DIR}"; then\n echo "ERROR: Unable to create app data directory at ${APP_DATA_DIR}"\n echo "Please check permissions and try again."\n exit 1\nfi\n\n# Handle cleanup on script exit\ncleanup() {\n # Remove any temporary files created during execution\n if [ -f "${APP_DATA_DIR}/get-pip.py" ]; then\n rm -f "${APP_DATA_DIR}/get-pip.py"\n fi\n if [ -n "${FFMPEG_TEMP_ARCHIVE:-}" ] && [ -f "${FFMPEG_TEMP_ARCHIVE}" ]; then\n log "Removing temporary FFmpeg archive: ${FFMPEG_TEMP_ARCHIVE}"\n rm -f "${FFMPEG_TEMP_ARCHIVE}"\n fi\n echo "$(date): Cleanup completed."\n}\n\n# Set trap only for manual interruption (SIGINT, SIGTERM), not normal exits\ntrap cleanup SIGINT SIGTERM\n\n\n# Start logging\nexec > >(tee -a "${LOG_FILE}") 2>&1\necho "[${TIMESTAMP}]: Starting Local Operator backend installation..."\necho "[${TIMESTAMP}]: System information: $(uname -a)"\n\n# Function to log messages with timestamp (defined later, but used by FFmpeg section)\n_log_internal() {\n local internal_timestamp\n internal_timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${internal_timestamp}] $1"\n}\n\n# --- FFmpeg Installation ---\nBIN_DIR="${APP_DATA_DIR}/bin"\nFFMPEG_BIN="${BIN_DIR}/ffmpeg" # Path to ffmpeg binary\n\n_log_internal "Ensuring bin directory exists: ${BIN_DIR}"\nif ! mkdir -p "${BIN_DIR}"; then\n _log_internal "ERROR: Unable to create bin directory at ${BIN_DIR}"\n exit 1\nfi\n\n# Check if FFmpeg is already installed and executable\nif [ -f "${FFMPEG_BIN}" ] && [ -x "${FFMPEG_BIN}" ]; then\n _log_internal "FFmpeg already installed at ${FFMPEG_BIN}. Skipping download."\nelse\n _log_internal "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."\n\n FFMPEG_DOWNLOAD_URL=""\n\n LINUX_ARCH=$(uname -m)\n if [[ "${LINUX_ARCH}" == "x86_64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-x64"\n elif [[ "${LINUX_ARCH}" == "aarch64" ]] || [[ "${LINUX_ARCH}" == "arm64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-arm64"\n else\n _log_internal "Error: Unsupported CPU architecture for FFmpeg download: ${LINUX_ARCH}"\n exit 1\n fi\n\n _log_internal "Downloading FFmpeg from: ${FFMPEG_DOWNLOAD_URL}"\n if command -v curl >/dev/null 2>&1; then\n if ! curl -L "${FFMPEG_DOWNLOAD_URL}" -o "${FFMPEG_BIN}"; then\n _log_internal "Error: Failed to download FFmpeg using curl from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n elif command -v wget >/dev/null 2>&1; then\n if ! wget -O "${FFMPEG_BIN}" "${FFMPEG_DOWNLOAD_URL}"; then\n _log_internal "Error: Failed to download FFmpeg using wget from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n else\n _log_internal "Error: Neither curl nor wget found. Cannot download FFmpeg."\n exit 1\n fi\n _log_internal "FFmpeg binary downloaded successfully to ${FFMPEG_BIN}"\n\n chmod +x "${FFMPEG_BIN}"\n _log_internal "Set executable permissions for ${FFMPEG_BIN}"\n\n # Verify FFmpeg is executable after download\n if [ ! -f "${FFMPEG_BIN}" ] || [ ! -x "${FFMPEG_BIN}" ]; then\n _log_internal "Error: FFmpeg binary not found or not executable after download."\n exit 1\n fi\nfi\n\n_log_internal "FFmpeg installation complete. FFmpeg binary is at: ${FFMPEG_BIN}"\n\n# Function to check if a command exists\ncommand_exists() {\n command -v "$1" >/dev/null 2>&1\n}\n\n# Function to log messages with timestamp\nlog() {\n local timestamp\n timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${timestamp}] $1"\n}\n\n# Function to display error messages and exit\nerror_exit() {\n log "ERROR: $1" >&2\n exit "${2:-1}"\n}\n\n# Function to check if Python binary is valid for current architecture\nis_valid_python_binary() {\n local python_path="$1"\n \n # First check if file exists and is executable\n if [ ! -f "${python_path}" ] || [ ! -x "${python_path}" ]; then\n return 1\n fi\n \n # Try to run a simple Python command to verify it works on current architecture\n "${python_path}" -c "print(\'Testing Python executable\')" >/dev/null 2>&1\n return $?\n}\n\n# Check for network connectivity to key servers\ncheck_connectivity() {\n log "Checking network connectivity..."\n local servers=("pypi.org" "bootstrap.pypa.io")\n local has_connectivity=false\n \n for server in "${servers[@]}"; do\n if command_exists curl; then\n if curl --connect-timeout 5 -s "https://${server}" -o /dev/null; then\n has_connectivity=true\n break\n fi\n elif command_exists wget; then\n if wget --timeout=5 -q --spider "https://${server}"; then\n has_connectivity=true\n break\n fi\n fi\n done\n \n if [ "$has_connectivity" = false ]; then\n log "WARNING: Network connectivity issues detected. This may affect installation."\n else\n log "Network connectivity confirmed."\n fi\n}\n\n# Call connectivity check\ncheck_connectivity\n\n# Check if PYTHON_BIN is already set by the installer\nif [ -n "${PYTHON_BIN:-}" ]; then\n log "Using Python executable provided by installer: ${PYTHON_BIN}"\n # Verify the provided Python binary works on this architecture\n if ! is_valid_python_binary "${PYTHON_BIN}"; then\n log "Warning: The provided Python binary is not compatible with this system architecture."\n log "Will attempt to find a system Python installation instead."\n PYTHON_BIN=""\n fi\nfi\n\n# If PYTHON_BIN is empty or invalid, try to find a suitable Python installation\nif [ -z "${PYTHON_BIN:-}" ] || ! is_valid_python_binary "${PYTHON_BIN:-}"; then\n # Try to find a suitable Python installation\n log "Looking for a suitable Python installation..."\n \n # Try multiple possible locations to find Python\n POSSIBLE_PYTHON_PATHS=(\n # System paths first (more likely to be compatible with current architecture)\n "/usr/local/bin/python3.12"\n "/usr/bin/python3.12"\n "/usr/local/bin/python3"\n "/usr/bin/python3"\n # From environment variable (set by the installer)\n "${ELECTRON_RESOURCE_PATH:-}/python/bin/python3"\n # Development paths\n "$(dirname "$0")/../../../resources/python/bin/python3"\n "$(pwd)/resources/python/bin/python3"\n )\n\n # Find the first Python that exists and is version 3.12+ and works on this architecture\n PYTHON_BIN=""\n for path in "${POSSIBLE_PYTHON_PATHS[@]}"; do\n # Skip empty paths that might come from unset environment variables\n if [ -z "${path}" ]; then\n continue\n fi\n \n if is_valid_python_binary "${path}"; then\n # Check Python version\n if PY_VERSION=$("${path}" -c "import sys; print(f\'{sys.version_info.major}.{sys.version_info.minor}\')" 2>/dev/null); then\n MAJOR=$(echo "${PY_VERSION}" | cut -d. -f1)\n MINOR=$(echo "${PY_VERSION}" | cut -d. -f2)\n if [ "${MAJOR}" -eq 3 ] && [ "${MINOR}" -ge 12 ]; then\n PYTHON_BIN="${path}"\n log "Found suitable Python ${PY_VERSION} at ${path}"\n break\n else\n log "Python at ${path} is version ${PY_VERSION}, which is below the required 3.12+"\n fi\n fi\n else\n if [ -f "${path}" ]; then\n log "Python at ${path} exists but is not compatible with this system architecture"\n fi\n fi\n done\n\n # If we couldn\'t find a suitable Python, exit with error\n if [ -z "${PYTHON_BIN:-}" ]; then\n error_exit "No suitable Python installation found (version 3.12 or higher required).\\nPlease install Python 3.12 or higher before running this script.\\nYou can install Python from https://www.python.org/downloads/"\n fi\nfi\n\necho "Using Python: $PYTHON_BIN"\n"$PYTHON_BIN" --version || {\n echo "Error: Failed to run Python. Please ensure it is executable and accessible."\n echo "System architecture: $(uname -m)"\n echo "Python binary architecture: $(file "$PYTHON_BIN" 2>/dev/null || echo \'Unable to determine\')"\n exit 1\n}\n\n# Detect the Linux distribution to provide specific instructions\nif command_exists lsb_release; then\n DISTRO=$(lsb_release -is)\n DISTRO_VERSION=$(lsb_release -rs)\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelif [ -f /etc/os-release ]; then\n DISTRO=$(grep -oP \'(?<=^ID=).+\' /etc/os-release | tr -d \'"\')\n DISTRO_VERSION=$(grep -oP \'(?<=^VERSION_ID=).+\' /etc/os-release | tr -d \'"\')\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelse\n DISTRO="unknown"\n echo "Unable to detect Linux distribution"\nfi\n\n# Check if Python has venv module and ensurepip available\nlog "Checking for venv module and ensurepip availability..."\nVENV_AVAILABLE=0\nENSUREPIP_AVAILABLE=0\n\n"${PYTHON_BIN}" -c "import venv" > /dev/null 2>&1 || VENV_AVAILABLE=1\n"${PYTHON_BIN}" -c "import ensurepip" > /dev/null 2>&1 || ENSUREPIP_AVAILABLE=1\n\n# If venv is still not available, try to use virtualenv as a fallback\nif [ $VENV_AVAILABLE -ne 0 ]; then\n echo "venv module is not available. Checking for virtualenv as an alternative..."\n \n # Check if virtualenv is already installed\n if "$PYTHON_BIN" -c "import virtualenv" > /dev/null 2>&1; then\n echo "virtualenv is available, will use it instead of venv"\n else\n echo "ERROR: Neither venv nor virtualenv is available."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n exit 1\n fi\nfi\n\n# Create virtual environment if it doesn\'t exist\nif [ ! -d "$VENV_PATH" ]; then\n echo "Creating virtual environment at $VENV_PATH..."\n # Remove any potentially corrupted virtual environment\n if [ -e "$VENV_PATH" ]; then\n echo "Removing existing but potentially corrupted venv directory..."\n rm -rf "$VENV_PATH"\n fi\n \n # Make sure parent directory exists and is writable\n mkdir -p "$(dirname "$VENV_PATH")"\n \n # Try different methods to create a virtual environment\n VENV_CREATE_STATUS=1\n \n # First try with venv if available\n if [ $VENV_AVAILABLE -eq 0 ] && [ $ENSUREPIP_AVAILABLE -eq 0 ]; then\n echo "Creating virtual environment using venv module..."\n "$PYTHON_BIN" -m venv "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n fi\n \n # If venv failed, try virtualenv\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n echo "venv creation failed with status $VENV_CREATE_STATUS. Falling back to virtualenv..."\n if command_exists virtualenv; then\n virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n elif "$PYTHON_BIN" -m pip list | grep -q virtualenv; then\n "$PYTHON_BIN" -m virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n else\n echo "ERROR: Both venv and virtualenv are unavailable or failed."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n echo "You can install Python 3.12 from https://www.python.org/downloads/"\n exit 1\n fi\n fi\n \n # Check if virtual environment creation was successful\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n log "ERROR: Failed to create virtual environment. Exit code: $VENV_CREATE_STATUS"\n log "Virtual environment path: $VENV_PATH"\n log "Python binary used: $PYTHON_BIN"\n ls -la "$(dirname "$VENV_PATH")" || true\n log "Python executable permissions:"\n ls -la "$PYTHON_BIN" || true\n \n # Check for common issues\n log "Checking for common virtual environment creation issues..."\n \n # Check disk space\n df -h "$(dirname "$VENV_PATH")" || true\n \n # Check if directory is writable\n if [ ! -w "$(dirname "$VENV_PATH")" ]; then\n log "ERROR: Directory $(dirname "$VENV_PATH") is not writable"\n fi\n \n # Try to create a minimal virtual environment manually as a last resort\n log "Attempting to create a minimal virtual environment manually..."\n mkdir -p "$VENV_PATH/bin" || error_exit "Could not create directory $VENV_PATH/bin"\n echo "#!/bin/bash" > "$VENV_PATH/bin/activate"\n echo "export VIRTUAL_ENV=\\"$VENV_PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "export PATH=\\"$VENV_PATH/bin:\\$PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "unset PYTHONHOME" >> "$VENV_PATH/bin/activate"\n chmod +x "$VENV_PATH/bin/activate" || error_exit "Could not set execute permissions on activate script"\n \n # Create symlinks to the system Python\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python" || log "WARNING: Failed to create symlink for python"\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python3" || log "WARNING: Failed to create symlink for python3"\n \n # Try to bootstrap pip\n log "Bootstrapping pip in the minimal virtual environment..."\n if command_exists curl; then\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py" || log "WARNING: Failed to download get-pip.py"\n elif command_exists wget; then\n wget -q -O "$APP_DATA_DIR/get-pip.py" https://bootstrap.pypa.io/get-pip.py || log "WARNING: Failed to download get-pip.py"\n else\n log "ERROR: Neither curl nor wget available to download get-pip.py"\n error_exit "Installation cannot continue without being able to download pip"\n fi\n \n # shellcheck disable=SC1090\n source "$VENV_PATH/bin/activate" && python "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n error_exit "Failed to create even a minimal virtual environment."\n else\n log "Created a minimal virtual environment as a fallback."\n fi\n else\n log "Successfully created virtual environment"\n fi\nfi\n\n# Verify the virtual environment structure\necho "Verifying virtual environment structure..."\nif [ ! -f "$VENV_PATH/bin/python" ]; then\n echo "Python executable missing in virtual environment, creating symlink..."\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python"\nfi\n\nif [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "pip missing in virtual environment, attempting to bootstrap it..."\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py"\n "$VENV_PATH/bin/python" "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "ERROR: Failed to bootstrap pip in the virtual environment"\n exit 1\n fi\nfi\n\necho "Virtual environment structure verified"\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\necho "Upgrading pip..."\npython -m pip install --upgrade pip || {\n echo "WARNING: Failed to upgrade pip. Will try to continue with existing pip version."\n pip --version\n}\n\n# Check network connectivity to PyPI\necho "Checking network connectivity to PyPI..."\nif command_exists curl; then\n curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nelif command_exists wget; then\n wget -q --spider https://pypi.org/pypi/local-operator/json || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nfi\n\necho "Installing local-operator package..."\npython -m pip install --verbose local-operator || {\n echo "ERROR: Failed to install local-operator package. Exit code: $?"\n echo "Python version:"\n python --version\n echo "pip version:"\n pip --version\n echo "Available pip packages:"\n pip list\n exit 1\n}\necho "local-operator installation successful"\n\n# Verify installation\nif [ -f "$VENV_PATH/bin/local-operator" ]; then\n log "Local Operator backend installed successfully!"\n \n # Show more information about the installed binary\n ls -la "$VENV_PATH/bin/local-operator" || true\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n \n # Try to run the version command with full error output\n log "Testing local-operator binary..."\n "$VENV_PATH/bin/local-operator" --version || {\n log "ERROR: local-operator binary exists but failed to execute. Exit code: $?"\n log "Binary details:"\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n log "Binary permissions:"\n ls -la "$VENV_PATH/bin/local-operator" || true\n log ""\n log "This could be due to missing dependencies or architecture incompatibility."\n log "Try running the binary manually to see specific errors."\n \n # Don\'t exit here - just warn the user\n log "WARNING: Installation completed but binary verification failed."\n }\n \n # Create a summary of the installation\n SUMMARY_FILE="${APP_DATA_DIR}/installation-summary-${TIMESTAMP}.txt"\n {\n echo "=== Local Operator Backend Installation Summary ==="\n echo "Timestamp: $(date)"\n echo "Python Version: $("${PYTHON_BIN}" --version 2>&1)"\n echo "Virtual Environment: ${VENV_PATH}"\n echo "Binary Location: ${VENV_PATH}/bin/local-operator"\n echo "To activate the virtual environment, run: source ${VENV_PATH}/bin/activate"\n echo "To test the installation, run: ${VENV_PATH}/bin/local-operator --version"\n echo "Log file: ${LOG_FILE}"\n echo "=================================================="\n } > "${SUMMARY_FILE}"\n \n log "Installation summary saved to: ${SUMMARY_FILE}"\nelse\n log "Error: Failed to install Local Operator backend. Binary not found."\n log "Contents of bin directory:"\n ls -la "$VENV_PATH/bin/" || true\n log "Check the log file for details: ${LOG_FILE}"\n error_exit "Installation failed. Binary not installed properly."\nfi\n\nlog "Installation completed successfully at $(date)"\n\n# Clean up temporary files now that installation is complete\ncleanup\n\n# Print final instructions\nlog ""\nlog "=== INSTALLATION COMPLETE ==="\nlog "You can now use the Local Operator backend by running: ${VENV_PATH}/bin/local-operator"\nlog "Installation directory: ${VENV_PATH}"\nlog "Log file: ${LOG_FILE}"\nlog "================================"\n';
2935
- const macosInstallScriptRaw = `#!/bin/bash
2936
- # Local Operator Backend Installation Script for macOS
2937
- # This script uses the bundled standalone Python and sets up a virtual environment for the Local Operator backend.
2938
-
2939
- set -e # Exit immediately if a command exits with a non-zero status
2940
-
2941
- # Configuration
2942
- APP_NAME="Local Operator"
2943
- VENV_NAME="local-operator-venv"
2944
- APP_DATA_DIR="$HOME/Library/Application Support/$APP_NAME"
2945
- VENV_PATH="$APP_DATA_DIR/$VENV_NAME"
2946
- LOG_FILE="$APP_DATA_DIR/backend-install.log"
2947
-
2948
- # Keep CPython's bytecode cache out of the application bundle.
2949
- #
2950
- # The bundled interpreter writes __pycache__/*.pyc beside the stdlib sources it
2951
- # imports, and those sources live inside the code-signed .app - which is the
2952
- # build's own extraResource. Every such write is a change to a sealed resource:
2953
- # measured on an installed 0.17.3, \`codesign --verify --deep\` reported 308
2954
- # \`file added:\` violations, all of them .pyc, and ShipIt then refuses the
2955
- # in-place update with -67028 errSecCSBadBundleFormat. A file codesign reports
2956
- # as *added* can be deleted and the seal comes back; a *modified* one cannot,
2957
- # which is why nothing may ship a .pyc at all. The app sets this variable when
2958
- # it spawns us; defaulting it here keeps a standalone run of this script in
2959
- # agreement with the app about where bytecode goes. It is not a complete answer:
2960
- # the app does not spawn every python that runs this interpreter, and a python
2961
- # started by something else - a shell, a CLI script, an agent - has no prefix at
2962
- # all (measured: a venv over this tree wrote 25 .pyc into it that way). The half
2963
- # that covers those is the app's seal on the tree itself
2964
- # (src/main/python-bytecode-cache.ts), which a standalone run of this script does
2965
- # not apply. $HOME is the right place for the cache for the same reason the venv
2966
- # lives there: it is never inside the thing that gets signed and swapped.
2967
- : "\${PYTHONPYCACHEPREFIX:=$APP_DATA_DIR/python-bytecode-cache}"
2968
- export PYTHONPYCACHEPREFIX
2969
-
2970
- # Determine CPU Architecture and Python Directory Name
2971
- ARCH=$(uname -m)
2972
- PYTHON_DIR_NAME="python" # Default for x86_64
2973
- PYTHON_ARCH_NAME="x86_64" # For logging
2974
-
2975
- if [[ "$ARCH" == "x86_64" ]]; then
2976
- PYTHON_DIR_NAME="python"
2977
- PYTHON_ARCH_NAME="x86_64"
2978
- elif [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then # arm64 is what uname -m returns on Apple Silicon
2979
- PYTHON_DIR_NAME="python_aarch64"
2980
- PYTHON_ARCH_NAME="aarch64"
2981
- else
2982
- echo "Error: Unsupported CPU architecture: $ARCH"
2983
- exit 1
2984
- fi
2985
- echo "Detected CPU architecture: $ARCH, using Python directory name: $PYTHON_DIR_NAME"
2986
-
2987
- # Check if PYTHON_BIN is already set by the installer
2988
- if [[ -n "$PYTHON_BIN" ]]; then
2989
- echo "Using Python executable provided by installer: $PYTHON_BIN"
2990
- else
2991
- # Get the path to the bundled Python based on architecture
2992
- # Try multiple possible locations to find Python
2993
- POSSIBLE_PYTHON_PATHS=(
2994
- # From environment variable (set by the installer)
2995
- "$ELECTRON_RESOURCE_PATH/$PYTHON_DIR_NAME/bin/python3"
2996
- # Absolute paths for packaged app
2997
- "/Applications/Local Operator.app/Contents/Resources/$PYTHON_DIR_NAME/bin/python3"
2998
- "$HOME/Applications/Local Operator.app/Contents/Resources/$PYTHON_DIR_NAME/bin/python3"
2999
- # Development paths
3000
- "$(dirname "$0")/../../../resources/$PYTHON_DIR_NAME/bin/python3"
3001
- "$(pwd)/resources/$PYTHON_DIR_NAME/bin/python3"
3002
- # System Python as last resort (less ideal as it might not be the version we tested with)
3003
- "/usr/bin/python3"
3004
- )
3005
-
3006
- # Find the first Python that exists
3007
- PYTHON_BIN=""
3008
- echo "Searching for Python in the following locations for $PYTHON_ARCH_NAME architecture (using directory $PYTHON_DIR_NAME):"
3009
- for path in "\${POSSIBLE_PYTHON_PATHS[@]}"; do
3010
- echo " - Checking $path"
3011
- if [[ -f "$path" && -x "$path" ]]; then
3012
- PYTHON_BIN="$path"
3013
- echo "Found Python at $path"
3014
- break
3015
- else
3016
- echo " Not found or not executable."
3017
- fi
3018
- done
3019
-
3020
- # If we couldn't find Python, try to use the system Python
3021
- if [[ -z "$PYTHON_BIN" ]]; then
3022
- echo "Bundled Python for $PYTHON_ARCH_NAME not found in directory $PYTHON_DIR_NAME. Attempting to use system Python..."
3023
- if command -v python3 &>/dev/null; then
3024
- PYTHON_BIN=$(command -v python3)
3025
- echo "Warning: Using system Python at $PYTHON_BIN. This may lead to unexpected behavior."
3026
- else
3027
- echo "Error: Could not find a suitable Python executable. Tried the following paths for $PYTHON_ARCH_NAME (expected in $PYTHON_DIR_NAME):"
3028
- for path in "\${POSSIBLE_PYTHON_PATHS[@]}"; do
3029
- echo " - $path (failed)"
3030
- done
3031
- exit 1
3032
- fi
3033
- fi
3034
- fi
3035
-
3036
- # Create app data directory if it doesn't exist
3037
- mkdir -p "$APP_DATA_DIR"
3038
-
3039
- # Start logging - capture everything and ensure it's visible to parent process
3040
- exec > >(tee -a "$LOG_FILE") 2> >(tee -a "$LOG_FILE" >&2)
3041
- echo "=============================================="
3042
- echo "$(date): Starting Local Operator backend installation..."
3043
- echo "=============================================="
3044
- echo "Python bin path: $PYTHON_BIN"
3045
- echo "Virtual environment path: $VENV_PATH"
3046
- echo "App data directory: $APP_DATA_DIR"
3047
- echo "Log file: $LOG_FILE"
3048
- echo "=============================================="
3049
-
3050
- # --- FFmpeg Installation ---
3051
- BIN_DIR="$APP_DATA_DIR/bin"
3052
- FFMPEG_BIN="$BIN_DIR/ffmpeg"
3053
-
3054
- echo "Ensuring bin directory exists: $BIN_DIR"
3055
- mkdir -p "$BIN_DIR"
3056
-
3057
- # Check if FFmpeg is already installed and executable
3058
- if [ -f "$FFMPEG_BIN" ] && [ -x "$FFMPEG_BIN" ]; then
3059
- echo "FFmpeg already installed at $FFMPEG_BIN. Skipping download."
3060
- else
3061
- echo "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."
3062
-
3063
- FFMPEG_DOWNLOAD_URL=""
3064
-
3065
- if [[ "$ARCH" == "x86_64" ]]; then
3066
- FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-x64"
3067
- elif [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then
3068
- FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-arm64"
3069
- else
3070
- echo "Error: Unsupported CPU architecture for FFmpeg download: $ARCH"
3071
- exit 1
3072
- fi
3073
-
3074
- echo "Downloading FFmpeg from: $FFMPEG_DOWNLOAD_URL"
3075
- if curl -L "$FFMPEG_DOWNLOAD_URL" -o "$FFMPEG_BIN"; then
3076
- echo "FFmpeg downloaded successfully to $FFMPEG_BIN"
3077
- chmod +x "$FFMPEG_BIN"
3078
- echo "Set executable permissions for $FFMPEG_BIN"
3079
- else
3080
- echo "Error: Failed to download FFmpeg from $FFMPEG_DOWNLOAD_URL"
3081
- exit 1
3082
- fi
3083
-
3084
- # Verify FFmpeg is executable after download
3085
- if [ ! -f "$FFMPEG_BIN" ] || [ ! -x "$FFMPEG_BIN" ]; then
3086
- echo "Error: FFmpeg binary not found or not executable after download."
3087
- exit 1
3088
- fi
3089
- fi
3090
-
3091
- echo "FFmpeg installation complete. FFmpeg binary is at: $FFMPEG_BIN"
3092
- # --- End FFmpeg Installation ---
3093
-
3094
- # Verify bundled Python exists
3095
- if [ ! -f "$PYTHON_BIN" ]; then
3096
- echo "Error: Bundled Python not found at $PYTHON_BIN"
3097
- echo "Please ensure standalone Python is properly installed in the application resources."
3098
- exit 1
3099
- fi
3100
-
3101
- # Make sure Python binary is executable
3102
- chmod +x "$PYTHON_BIN"
3103
- echo "Using bundled Python: $PYTHON_BIN"
3104
- "$PYTHON_BIN" --version
3105
-
3106
- # Check if Python has venv module available
3107
- echo "Checking for venv module availability..."
3108
- "$PYTHON_BIN" -m venv --help > /dev/null 2>&1 || {
3109
- echo "ERROR: Python venv module not available in the Python installation"
3110
- echo "Python details:"
3111
- "$PYTHON_BIN" --version
3112
- "$PYTHON_BIN" -c "import sys; print('Prefix:', sys.prefix); print('Exec Prefix:', sys.exec_prefix)"
3113
- "$PYTHON_BIN" -c "import sys; print('Modules path:'); print('\\n'.join(sys.path))"
3114
- exit 1
3115
- }
3116
- echo "venv module is available"
3117
-
3118
- # Create virtual environment if it doesn't exist
3119
- if [ ! -d "$VENV_PATH" ]; then
3120
- echo "Creating virtual environment at $VENV_PATH..."
3121
- # Remove any potentially corrupted virtual environment
3122
- if [ -e "$VENV_PATH" ]; then
3123
- echo "Removing existing but potentially corrupted venv directory..."
3124
- rm -rf "$VENV_PATH"
3125
- fi
3126
-
3127
- # Make sure parent directory exists and is writable
3128
- mkdir -p "$(dirname "$VENV_PATH")"
3129
-
3130
- # Create the virtual environment with verbosity
3131
- "$PYTHON_BIN" -m venv "$VENV_PATH" || {
3132
- echo "ERROR: Failed to create virtual environment. Exit code: $?"
3133
- echo "Virtual environment path: $VENV_PATH"
3134
- echo "Python binary used: $PYTHON_BIN"
3135
- ls -la "$(dirname "$VENV_PATH")"
3136
- echo "Python executable permissions:"
3137
- ls -la "$PYTHON_BIN"
3138
- exit 1
3765
+ })().finally(() => {
3766
+ this.restartPromise = null;
3767
+ });
3768
+ return this.restartPromise;
3139
3769
  }
3140
- echo "Successfully created virtual environment"
3141
- fi
3142
-
3143
- # Verify the virtual environment structure
3144
- echo "Verifying virtual environment structure..."
3145
- if [ ! -f "$VENV_PATH/bin/python" ] || [ ! -f "$VENV_PATH/bin/pip" ]; then
3146
- echo "ERROR: Virtual environment is missing critical components"
3147
- echo "Contents of virtual environment directory:"
3148
- ls -la "$VENV_PATH"
3149
- if [ -d "$VENV_PATH/bin" ]; then
3150
- echo "Contents of bin directory:"
3151
- ls -la "$VENV_PATH/bin"
3152
- fi
3153
- exit 1
3154
- fi
3155
- echo "Virtual environment structure verified"
3156
-
3157
- # Activate virtual environment and install local-operator
3158
- echo "Installing local-operator in virtual environment..."
3159
- source "$VENV_PATH/bin/activate"
3160
-
3161
- echo "Upgrading pip..."
3162
- python -m pip install --upgrade pip || {
3163
- echo "ERROR: Failed to upgrade pip. Exit code: $?"
3164
- echo "pip version before failing:"
3165
- pip --version
3166
- exit 1
3167
- }
3168
- echo "pip upgrade successful:"
3169
- pip --version
3170
-
3171
- # Check network connectivity to PyPI
3172
- echo "Checking network connectivity to PyPI..."
3173
- curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {
3174
- echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."
3175
- echo "Attempting to ping common domains to diagnose network issues:"
3176
- ping -c 1 google.com || echo "Cannot ping google.com"
3177
- ping -c 1 pypi.org || echo "Cannot ping pypi.org"
3178
3770
  }
3179
-
3180
- echo "Installing local-operator package..."
3181
- python -m pip install --upgrade --verbose local-operator || {
3182
- echo "ERROR: Failed to install local-operator package. Exit code: $?"
3183
- echo "Python version:"
3184
- python --version
3185
- echo "pip version:"
3186
- pip --version
3187
- echo "Available pip packages:"
3188
- pip list
3189
- echo "Pip config:"
3190
- pip config list
3191
- echo "Network diagnosis:"
3192
- curl -I https://pypi.org || echo "Cannot reach PyPI server"
3193
- exit 1
3194
- }
3195
- echo "local-operator installation successful"
3196
-
3197
- # Verify installation
3198
- if [ -f "$VENV_PATH/bin/local-operator" ]; then
3199
- echo "Local Operator backend installed successfully!"
3200
-
3201
- # Show more information about the installed binary
3202
- ls -la "$VENV_PATH/bin/local-operator"
3203
- file "$VENV_PATH/bin/local-operator"
3204
-
3205
- # Try to run the version command with full error output
3206
- echo "Testing local-operator binary..."
3207
- "$VENV_PATH/bin/local-operator" --version || {
3208
- echo "ERROR: local-operator binary exists but failed to execute. Exit code: $?"
3209
- echo "Binary details:"
3210
- file "$VENV_PATH/bin/local-operator"
3211
- echo "Binary permissions:"
3212
- ls -la "$VENV_PATH/bin/local-operator"
3213
- echo "Dependencies:"
3214
- if command -v otool >/dev/null; then
3215
- otool -L "$VENV_PATH/bin/local-operator" || echo "Could not get dependencies with otool"
3216
- fi
3217
- exit 1
3218
- }
3219
- else
3220
- echo "Error: Failed to install Local Operator backend. Binary not found."
3221
- echo "Contents of bin directory:"
3222
- ls -la "$VENV_PATH/bin/"
3223
- exit 1
3224
- fi
3225
-
3226
- echo "$(date): Installation completed successfully."
3227
- `;
3771
+ const linuxInstallScriptRaw = '#!/bin/bash\n# Local Operator Backend Installation Script for Linux\n# This script sets up a virtual environment for the Local Operator backend\n# without requiring sudo or admin privileges.\n\n# Exit on error, undefined variables, and failed pipe commands\nset -e # Exit immediately if a command exits with a non-zero status\nset -u # Error on unset variables\nset -o pipefail # Fail if any command in a pipe fails\n\n# Configuration\n# Variables APP_NAME and MIN_PYTHON_VERSION removed as they were unused\nVENV_NAME="local-operator-venv"\nAPP_DATA_DIR="${HOME}/.config/local-operator"\nVENV_PATH="${APP_DATA_DIR}/${VENV_NAME}"\nLOG_FILE="${APP_DATA_DIR}/backend-install.log"\nTIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")\n\n# Which environment this installs into - the app\'s decision, handed in. See the\n# macOS script for why the script must not re-derive it (a packaged and an\n# unpackaged instance use different environments, and only the app knows which\n# it is); `LOCAL_OPERATOR_VENV_PATH` is set from `managedVenvPath`.\n: "${LOCAL_OPERATOR_VENV_PATH:=$VENV_PATH}"\nVENV_PATH="$LOCAL_OPERATOR_VENV_PATH"\n\n# Keep CPython\'s bytecode cache out of the application directory.\n#\n# Same reason as the macOS script: an interpreter writing __pycache__/*.pyc\n# beside its own stdlib sources writes into the installed tree. Linux does not\n# code-seal the app the way macOS does, so this is uniformity and hygiene here\n# rather than the load-bearing fix it is on macOS - but it is also the value a\n# standalone run of this script has to agree with, because the app and the\n# script must not disagree about where bytecode goes.\n: "${PYTHONPYCACHEPREFIX:=${APP_DATA_DIR}/python-bytecode-cache}"\nexport PYTHONPYCACHEPREFIX\n\n# The refusal half of the pair, set rather than merged with whatever the caller\n# had: CPython reads the flag before its first import, so nothing this script\n# runs - `-m venv`, pip, the venv they create - can write a `__pycache__` at\n# all. Kept in step with the app\'s `withPythonBytecodeCache`, which sets both\n# variables on every python it spawns.\nexport PYTHONDONTWRITEBYTECODE=1\n\n# Create app data directory if it doesn\'t exist\nif ! mkdir -p "${APP_DATA_DIR}"; then\n echo "ERROR: Unable to create app data directory at ${APP_DATA_DIR}"\n echo "Please check permissions and try again."\n exit 1\nfi\n\n# Handle cleanup on script exit\ncleanup() {\n # Remove any temporary files created during execution\n if [ -f "${APP_DATA_DIR}/get-pip.py" ]; then\n rm -f "${APP_DATA_DIR}/get-pip.py"\n fi\n if [ -n "${FFMPEG_TEMP_ARCHIVE:-}" ] && [ -f "${FFMPEG_TEMP_ARCHIVE}" ]; then\n log "Removing temporary FFmpeg archive: ${FFMPEG_TEMP_ARCHIVE}"\n rm -f "${FFMPEG_TEMP_ARCHIVE}"\n fi\n echo "$(date): Cleanup completed."\n}\n\n# Set trap only for manual interruption (SIGINT, SIGTERM), not normal exits\ntrap cleanup SIGINT SIGTERM\n\n\n# Start logging\nexec > >(tee -a "${LOG_FILE}") 2>&1\necho "[${TIMESTAMP}]: Starting Local Operator backend installation..."\necho "[${TIMESTAMP}]: System information: $(uname -a)"\n\n# Function to log messages with timestamp (defined later, but used by FFmpeg section)\n_log_internal() {\n local internal_timestamp\n internal_timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${internal_timestamp}] $1"\n}\n\n# --- FFmpeg Installation ---\nBIN_DIR="${APP_DATA_DIR}/bin"\nFFMPEG_BIN="${BIN_DIR}/ffmpeg" # Path to ffmpeg binary\n\n_log_internal "Ensuring bin directory exists: ${BIN_DIR}"\nif ! mkdir -p "${BIN_DIR}"; then\n _log_internal "ERROR: Unable to create bin directory at ${BIN_DIR}"\n exit 1\nfi\n\n# Check if FFmpeg is already installed and executable\nif [ -f "${FFMPEG_BIN}" ] && [ -x "${FFMPEG_BIN}" ]; then\n _log_internal "FFmpeg already installed at ${FFMPEG_BIN}. Skipping download."\nelse\n _log_internal "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."\n\n FFMPEG_DOWNLOAD_URL=""\n\n LINUX_ARCH=$(uname -m)\n if [[ "${LINUX_ARCH}" == "x86_64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-x64"\n elif [[ "${LINUX_ARCH}" == "aarch64" ]] || [[ "${LINUX_ARCH}" == "arm64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-arm64"\n else\n _log_internal "Error: Unsupported CPU architecture for FFmpeg download: ${LINUX_ARCH}"\n exit 1\n fi\n\n _log_internal "Downloading FFmpeg from: ${FFMPEG_DOWNLOAD_URL}"\n if command -v curl >/dev/null 2>&1; then\n if ! curl -L "${FFMPEG_DOWNLOAD_URL}" -o "${FFMPEG_BIN}"; then\n _log_internal "Error: Failed to download FFmpeg using curl from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n elif command -v wget >/dev/null 2>&1; then\n if ! wget -O "${FFMPEG_BIN}" "${FFMPEG_DOWNLOAD_URL}"; then\n _log_internal "Error: Failed to download FFmpeg using wget from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n else\n _log_internal "Error: Neither curl nor wget found. Cannot download FFmpeg."\n exit 1\n fi\n _log_internal "FFmpeg binary downloaded successfully to ${FFMPEG_BIN}"\n\n chmod +x "${FFMPEG_BIN}"\n _log_internal "Set executable permissions for ${FFMPEG_BIN}"\n\n # Verify FFmpeg is executable after download\n if [ ! -f "${FFMPEG_BIN}" ] || [ ! -x "${FFMPEG_BIN}" ]; then\n _log_internal "Error: FFmpeg binary not found or not executable after download."\n exit 1\n fi\nfi\n\n_log_internal "FFmpeg installation complete. FFmpeg binary is at: ${FFMPEG_BIN}"\n\n# Function to check if a command exists\ncommand_exists() {\n command -v "$1" >/dev/null 2>&1\n}\n\n# Function to log messages with timestamp\nlog() {\n local timestamp\n timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${timestamp}] $1"\n}\n\n# Function to display error messages and exit\nerror_exit() {\n log "ERROR: $1" >&2\n exit "${2:-1}"\n}\n\n# Function to check if Python binary is valid for current architecture\nis_valid_python_binary() {\n local python_path="$1"\n \n # First check if file exists and is executable\n if [ ! -f "${python_path}" ] || [ ! -x "${python_path}" ]; then\n return 1\n fi\n \n # Try to run a simple Python command to verify it works on current architecture\n "${python_path}" -c "print(\'Testing Python executable\')" >/dev/null 2>&1\n return $?\n}\n\n# Check for network connectivity to key servers\ncheck_connectivity() {\n log "Checking network connectivity..."\n local servers=("pypi.org" "bootstrap.pypa.io")\n local has_connectivity=false\n \n for server in "${servers[@]}"; do\n if command_exists curl; then\n if curl --connect-timeout 5 -s "https://${server}" -o /dev/null; then\n has_connectivity=true\n break\n fi\n elif command_exists wget; then\n if wget --timeout=5 -q --spider "https://${server}"; then\n has_connectivity=true\n break\n fi\n fi\n done\n \n if [ "$has_connectivity" = false ]; then\n log "WARNING: Network connectivity issues detected. This may affect installation."\n else\n log "Network connectivity confirmed."\n fi\n}\n\n# Call connectivity check\ncheck_connectivity\n\n# Check if PYTHON_BIN is already set by the installer\nif [ -n "${PYTHON_BIN:-}" ]; then\n log "Using Python executable provided by installer: ${PYTHON_BIN}"\n # Verify the provided Python binary works on this architecture\n if ! is_valid_python_binary "${PYTHON_BIN}"; then\n log "Warning: The provided Python binary is not compatible with this system architecture."\n log "Will attempt to find a system Python installation instead."\n PYTHON_BIN=""\n fi\nfi\n\n# If PYTHON_BIN is empty or invalid, try to find a suitable Python installation\nif [ -z "${PYTHON_BIN:-}" ] || ! is_valid_python_binary "${PYTHON_BIN:-}"; then\n # Try to find a suitable Python installation\n log "Looking for a suitable Python installation..."\n \n # Try multiple possible locations to find Python\n POSSIBLE_PYTHON_PATHS=(\n # System paths first (more likely to be compatible with current architecture)\n "/usr/local/bin/python3.12"\n "/usr/bin/python3.12"\n "/usr/local/bin/python3"\n "/usr/bin/python3"\n # From environment variable (set by the installer)\n "${ELECTRON_RESOURCE_PATH:-}/python/bin/python3"\n # Development paths\n "$(dirname "$0")/../../../resources/python/bin/python3"\n "$(pwd)/resources/python/bin/python3"\n )\n\n # Find the first Python that exists and is version 3.12+ and works on this architecture\n PYTHON_BIN=""\n for path in "${POSSIBLE_PYTHON_PATHS[@]}"; do\n # Skip empty paths that might come from unset environment variables\n if [ -z "${path}" ]; then\n continue\n fi\n \n if is_valid_python_binary "${path}"; then\n # Check Python version\n if PY_VERSION=$("${path}" -c "import sys; print(f\'{sys.version_info.major}.{sys.version_info.minor}\')" 2>/dev/null); then\n MAJOR=$(echo "${PY_VERSION}" | cut -d. -f1)\n MINOR=$(echo "${PY_VERSION}" | cut -d. -f2)\n if [ "${MAJOR}" -eq 3 ] && [ "${MINOR}" -ge 12 ]; then\n PYTHON_BIN="${path}"\n log "Found suitable Python ${PY_VERSION} at ${path}"\n break\n else\n log "Python at ${path} is version ${PY_VERSION}, which is below the required 3.12+"\n fi\n fi\n else\n if [ -f "${path}" ]; then\n log "Python at ${path} exists but is not compatible with this system architecture"\n fi\n fi\n done\n\n # If we couldn\'t find a suitable Python, exit with error\n if [ -z "${PYTHON_BIN:-}" ]; then\n error_exit "No suitable Python installation found (version 3.12 or higher required).\\nPlease install Python 3.12 or higher before running this script.\\nYou can install Python from https://www.python.org/downloads/"\n fi\nfi\n\necho "Using Python: $PYTHON_BIN"\n"$PYTHON_BIN" --version || {\n echo "Error: Failed to run Python. Please ensure it is executable and accessible."\n echo "System architecture: $(uname -m)"\n echo "Python binary architecture: $(file "$PYTHON_BIN" 2>/dev/null || echo \'Unable to determine\')"\n exit 1\n}\n\n# Detect the Linux distribution to provide specific instructions\nif command_exists lsb_release; then\n DISTRO=$(lsb_release -is)\n DISTRO_VERSION=$(lsb_release -rs)\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelif [ -f /etc/os-release ]; then\n DISTRO=$(grep -oP \'(?<=^ID=).+\' /etc/os-release | tr -d \'"\')\n DISTRO_VERSION=$(grep -oP \'(?<=^VERSION_ID=).+\' /etc/os-release | tr -d \'"\')\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelse\n DISTRO="unknown"\n echo "Unable to detect Linux distribution"\nfi\n\n# Check if Python has venv module and ensurepip available\nlog "Checking for venv module and ensurepip availability..."\nVENV_AVAILABLE=0\nENSUREPIP_AVAILABLE=0\n\n"${PYTHON_BIN}" -c "import venv" > /dev/null 2>&1 || VENV_AVAILABLE=1\n"${PYTHON_BIN}" -c "import ensurepip" > /dev/null 2>&1 || ENSUREPIP_AVAILABLE=1\n\n# If venv is still not available, try to use virtualenv as a fallback\nif [ $VENV_AVAILABLE -ne 0 ]; then\n echo "venv module is not available. Checking for virtualenv as an alternative..."\n \n # Check if virtualenv is already installed\n if "$PYTHON_BIN" -c "import virtualenv" > /dev/null 2>&1; then\n echo "virtualenv is available, will use it instead of venv"\n else\n echo "ERROR: Neither venv nor virtualenv is available."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n exit 1\n fi\nfi\n\n# Create virtual environment if it doesn\'t exist\nif [ ! -d "$VENV_PATH" ]; then\n echo "Creating virtual environment at $VENV_PATH..."\n # Remove any potentially corrupted virtual environment\n if [ -e "$VENV_PATH" ]; then\n echo "Removing existing but potentially corrupted venv directory..."\n rm -rf "$VENV_PATH"\n fi\n \n # Make sure parent directory exists and is writable\n mkdir -p "$(dirname "$VENV_PATH")"\n \n # Try different methods to create a virtual environment\n VENV_CREATE_STATUS=1\n \n # First try with venv if available\n if [ $VENV_AVAILABLE -eq 0 ] && [ $ENSUREPIP_AVAILABLE -eq 0 ]; then\n echo "Creating virtual environment using venv module..."\n "$PYTHON_BIN" -m venv "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n fi\n \n # If venv failed, try virtualenv\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n echo "venv creation failed with status $VENV_CREATE_STATUS. Falling back to virtualenv..."\n if command_exists virtualenv; then\n virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n elif "$PYTHON_BIN" -m pip list | grep -q virtualenv; then\n "$PYTHON_BIN" -m virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n else\n echo "ERROR: Both venv and virtualenv are unavailable or failed."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n echo "You can install Python 3.12 from https://www.python.org/downloads/"\n exit 1\n fi\n fi\n \n # Check if virtual environment creation was successful\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n log "ERROR: Failed to create virtual environment. Exit code: $VENV_CREATE_STATUS"\n log "Virtual environment path: $VENV_PATH"\n log "Python binary used: $PYTHON_BIN"\n ls -la "$(dirname "$VENV_PATH")" || true\n log "Python executable permissions:"\n ls -la "$PYTHON_BIN" || true\n \n # Check for common issues\n log "Checking for common virtual environment creation issues..."\n \n # Check disk space\n df -h "$(dirname "$VENV_PATH")" || true\n \n # Check if directory is writable\n if [ ! -w "$(dirname "$VENV_PATH")" ]; then\n log "ERROR: Directory $(dirname "$VENV_PATH") is not writable"\n fi\n \n # Try to create a minimal virtual environment manually as a last resort\n log "Attempting to create a minimal virtual environment manually..."\n mkdir -p "$VENV_PATH/bin" || error_exit "Could not create directory $VENV_PATH/bin"\n echo "#!/bin/bash" > "$VENV_PATH/bin/activate"\n echo "export VIRTUAL_ENV=\\"$VENV_PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "export PATH=\\"$VENV_PATH/bin:\\$PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "unset PYTHONHOME" >> "$VENV_PATH/bin/activate"\n chmod +x "$VENV_PATH/bin/activate" || error_exit "Could not set execute permissions on activate script"\n \n # Create symlinks to the system Python\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python" || log "WARNING: Failed to create symlink for python"\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python3" || log "WARNING: Failed to create symlink for python3"\n \n # Try to bootstrap pip\n log "Bootstrapping pip in the minimal virtual environment..."\n if command_exists curl; then\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py" || log "WARNING: Failed to download get-pip.py"\n elif command_exists wget; then\n wget -q -O "$APP_DATA_DIR/get-pip.py" https://bootstrap.pypa.io/get-pip.py || log "WARNING: Failed to download get-pip.py"\n else\n log "ERROR: Neither curl nor wget available to download get-pip.py"\n error_exit "Installation cannot continue without being able to download pip"\n fi\n \n # shellcheck disable=SC1090\n source "$VENV_PATH/bin/activate" && python "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n error_exit "Failed to create even a minimal virtual environment."\n else\n log "Created a minimal virtual environment as a fallback."\n fi\n else\n log "Successfully created virtual environment"\n fi\nfi\n\n# Verify the virtual environment structure\necho "Verifying virtual environment structure..."\nif [ ! -f "$VENV_PATH/bin/python" ]; then\n echo "Python executable missing in virtual environment, creating symlink..."\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python"\nfi\n\nif [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "pip missing in virtual environment, attempting to bootstrap it..."\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py"\n "$VENV_PATH/bin/python" "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "ERROR: Failed to bootstrap pip in the virtual environment"\n exit 1\n fi\nfi\n\necho "Virtual environment structure verified"\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\necho "Upgrading pip..."\npython -m pip install --upgrade pip || {\n echo "WARNING: Failed to upgrade pip. Will try to continue with existing pip version."\n pip --version\n}\n\n# Check network connectivity to PyPI\necho "Checking network connectivity to PyPI..."\nif command_exists curl; then\n curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nelif command_exists wget; then\n wget -q --spider https://pypi.org/pypi/local-operator/json || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nfi\n\necho "Installing local-operator package..."\npython -m pip install --verbose local-operator || {\n echo "ERROR: Failed to install local-operator package. Exit code: $?"\n echo "Python version:"\n python --version\n echo "pip version:"\n pip --version\n echo "Available pip packages:"\n pip list\n exit 1\n}\necho "local-operator installation successful"\n\n# Verify installation\nif [ -f "$VENV_PATH/bin/local-operator" ]; then\n log "Local Operator backend installed successfully!"\n \n # Show more information about the installed binary\n ls -la "$VENV_PATH/bin/local-operator" || true\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n \n # Try to run the version command with full error output\n log "Testing local-operator binary..."\n "$VENV_PATH/bin/local-operator" --version || {\n log "ERROR: local-operator binary exists but failed to execute. Exit code: $?"\n log "Binary details:"\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n log "Binary permissions:"\n ls -la "$VENV_PATH/bin/local-operator" || true\n log ""\n log "This could be due to missing dependencies or architecture incompatibility."\n log "Try running the binary manually to see specific errors."\n \n # Don\'t exit here - just warn the user\n log "WARNING: Installation completed but binary verification failed."\n }\n \n # Create a summary of the installation\n SUMMARY_FILE="${APP_DATA_DIR}/installation-summary-${TIMESTAMP}.txt"\n {\n echo "=== Local Operator Backend Installation Summary ==="\n echo "Timestamp: $(date)"\n echo "Python Version: $("${PYTHON_BIN}" --version 2>&1)"\n echo "Virtual Environment: ${VENV_PATH}"\n echo "Binary Location: ${VENV_PATH}/bin/local-operator"\n echo "To activate the virtual environment, run: source ${VENV_PATH}/bin/activate"\n echo "To test the installation, run: ${VENV_PATH}/bin/local-operator --version"\n echo "Log file: ${LOG_FILE}"\n echo "=================================================="\n } > "${SUMMARY_FILE}"\n \n log "Installation summary saved to: ${SUMMARY_FILE}"\nelse\n log "Error: Failed to install Local Operator backend. Binary not found."\n log "Contents of bin directory:"\n ls -la "$VENV_PATH/bin/" || true\n log "Check the log file for details: ${LOG_FILE}"\n error_exit "Installation failed. Binary not installed properly."\nfi\n\nlog "Installation completed successfully at $(date)"\n\n# Clean up temporary files now that installation is complete\ncleanup\n\n# Print final instructions\nlog ""\nlog "=== INSTALLATION COMPLETE ==="\nlog "You can now use the Local Operator backend by running: ${VENV_PATH}/bin/local-operator"\nlog "Installation directory: ${VENV_PATH}"\nlog "Log file: ${LOG_FILE}"\nlog "================================"\n';
3772
+ const macosInstallScriptRaw = '#!/bin/bash\n# Local Operator Backend Installation Script for macOS\n# This script uses the bundled standalone Python and sets up a virtual environment for the Local Operator backend.\n\nset -e # Exit immediately if a command exits with a non-zero status\n\n# Configuration\nAPP_NAME="Local Operator"\nVENV_NAME="local-operator-venv"\nAPP_DATA_DIR="${LOCAL_OPERATOR_SUPPORT_PATH:-$HOME/Library/Application Support/$APP_NAME}"\nVENV_PATH="$APP_DATA_DIR/$VENV_NAME"\nLOG_FILE="$APP_DATA_DIR/backend-install.log"\n\n# Which environment this installs into. The path is the app\'s decision, not this\n# script\'s: a packaged install and an unpackaged one must not share an\n# environment (the venv is built on whatever interpreter the instance resolves,\n# and the installed bundle\'s stdlib lives inside the code-sealed .app), and only\n# the app knows which one it is. The app passes its answer in\n# (`LOCAL_OPERATOR_VENV_PATH`, set from `managedVenvPath` in\n# src/main/backend/venv-paths.ts).\n#\n# macOS REFUSES a standalone run rather than falling back, and the asymmetry with\n# the Linux and Windows scripts is deliberate. Their default is the packaged name,\n# which is harmless there; here it is the exact environment this split exists to\n# stop a second instance from writing into - the measured failure is a dev-venv\n# interpreter whose stdlib is `/Applications/Local Operator.app/Contents/\n# Resources/python_aarch64`, so a silent default would rebuild that venv and\n# `pip install` into it, which is what R1 of the review caught. A caller that\n# cannot name the environment is a caller that should not be installing into one.\n: "${LOCAL_OPERATOR_VENV_PATH:?Pass the resolved managed environment path (see venv-paths.ts); this script will not guess which instance it belongs to}"\nVENV_PATH="$LOCAL_OPERATOR_VENV_PATH"\n\n# Keep CPython\'s bytecode cache out of the application bundle.\n#\n# The bundled interpreter writes __pycache__/*.pyc beside the stdlib sources it\n# imports, and those sources live inside the code-signed .app - which is the\n# build\'s own extraResource. Every such write is a change to a sealed resource:\n# measured on an installed 0.17.3, `codesign --verify --deep` reported 308\n# `file added:` violations, all of them .pyc, and ShipIt then refuses the\n# in-place update with -67028 errSecCSBadBundleFormat. A file codesign reports\n# as *added* can be deleted and the seal comes back; a *modified* one cannot,\n# which is why nothing may ship a .pyc at all. The app sets this variable when\n# it spawns us; defaulting it here keeps a standalone run of this script in\n# agreement with the app about where bytecode goes. It is not a complete answer:\n# the app does not spawn every python that runs this interpreter, and a python\n# started by something else - a shell, a CLI script, an agent - has no prefix at\n# all (measured: a venv over this tree wrote 25 .pyc into it that way). The half\n# that covers those is the app\'s seal on the tree itself\n# (src/main/python-bytecode-cache.ts), which a standalone run of this script does\n# not apply. $HOME is the right place for the cache for the same reason the venv\n# lives there: it is never inside the thing that gets signed and swapped.\n: "${PYTHONPYCACHEPREFIX:=$APP_DATA_DIR/python-bytecode-cache}"\nexport PYTHONPYCACHEPREFIX\n\n# And the refusal half of the same pair, so a standalone run of this script\n# cannot write bytecode into the bundle even where the redirect above does not\n# apply - a relative or unwritable prefix, or a child that drops the variable.\n# CPython reads the flag before its first import, so this script\'s own\n# `python -m venv`, its pip runs and the venv they create all compile without\n# writing a `__pycache__` anywhere. Measured on CPython: `0` and the empty\n# string are the two falsy spellings, so the value is set rather than merged\n# with whatever the caller had.\nexport PYTHONDONTWRITEBYTECODE=1\n\n# The architecture block that used to live here computed and logged the name of\n# a directory for an in-bundle search this script no longer performs: every run\n# announced which architecture-named directory it was about to use, and then\n# installed from `PYTHON_BIN` anyway - the line stated the opposite of how the\n# script finds Python, and the names it computed were used nowhere else (review\n# N1). Which interpreter to build the environment with is the caller\'s decision,\n# checked immediately below.\n\n# The app prepares a complete external runtime before invoking this script.\n# Searching /Applications here would reintroduce legacy-bundle execution during\n# migration. Standalone callers must make the same explicit path decision.\n: "${PYTHON_BIN:?Pass an external prepared Python executable}"\ncase "$PYTHON_BIN" in\n *.app/*) echo "Refusing to execute Python inside an application bundle" >&2; exit 1 ;;\nesac\n\n# Create app data directory if it doesn\'t exist\nmkdir -p "$APP_DATA_DIR"\n\n# Start logging - capture everything and ensure it\'s visible to parent process\nexec > >(tee -a "$LOG_FILE") 2> >(tee -a "$LOG_FILE" >&2)\necho "=============================================="\necho "$(date): Starting Local Operator backend installation..."\necho "=============================================="\necho "Python bin path: $PYTHON_BIN"\necho "Virtual environment path: $VENV_PATH"\necho "App data directory: $APP_DATA_DIR"\necho "Log file: $LOG_FILE"\necho "=============================================="\n\n# --- FFmpeg Installation ---\nBIN_DIR="$APP_DATA_DIR/bin"\nFFMPEG_BIN="$BIN_DIR/ffmpeg"\n\necho "Ensuring bin directory exists: $BIN_DIR"\nmkdir -p "$BIN_DIR"\n\n# Check if FFmpeg is already installed and executable\nif [ -f "$FFMPEG_BIN" ] && [ -x "$FFMPEG_BIN" ]; then\n echo "FFmpeg already installed at $FFMPEG_BIN. Skipping download."\nelse\n echo "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."\n\n # The architecture, read here because this URL is the one place left that needs\n # it: the environment is built on `PYTHON_BIN`, handed in above, so nothing\n # else in this script derives anything from `uname -m`. The block that used to\n # compute it at the top of the file was removed with the in-bundle interpreter\n # search it existed for (review N1) - and that removal took this variable with\n # it while this block still read it, so `$ARCH` was empty for every caller,\n # including the app, and any install on a machine without a cached ffmpeg\n # exited 1 here, before the venv was ever created. Measured: `bash\n # src/main/backend/scripts/macos-install-script.sh` with `PYTHON_BIN` set stops\n # on "Unsupported CPU architecture for FFmpeg download:".\n ARCH=$(uname -m)\n\n FFMPEG_DOWNLOAD_URL=""\n\n if [[ "$ARCH" == "x86_64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-x64"\n elif [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-arm64"\n else\n echo "Error: Unsupported CPU architecture for FFmpeg download: $ARCH"\n exit 1\n fi\n\n echo "Downloading FFmpeg from: $FFMPEG_DOWNLOAD_URL"\n if curl -L "$FFMPEG_DOWNLOAD_URL" -o "$FFMPEG_BIN"; then\n echo "FFmpeg downloaded successfully to $FFMPEG_BIN"\n chmod +x "$FFMPEG_BIN"\n echo "Set executable permissions for $FFMPEG_BIN"\n else\n echo "Error: Failed to download FFmpeg from $FFMPEG_DOWNLOAD_URL"\n exit 1\n fi\n\n # Verify FFmpeg is executable after download\n if [ ! -f "$FFMPEG_BIN" ] || [ ! -x "$FFMPEG_BIN" ]; then\n echo "Error: FFmpeg binary not found or not executable after download."\n exit 1\n fi\nfi\n\necho "FFmpeg installation complete. FFmpeg binary is at: $FFMPEG_BIN"\n# --- End FFmpeg Installation ---\n\n# Verify bundled Python exists\nif [ ! -f "$PYTHON_BIN" ]; then\n echo "Error: Bundled Python not found at $PYTHON_BIN"\n echo "Please ensure standalone Python is properly installed in the application resources."\n exit 1\nfi\n\n# Make sure Python binary is executable\necho "Using bundled Python: $PYTHON_BIN"\n"$PYTHON_BIN" --version\n\n# Check if Python has venv module available\necho "Checking for venv module availability..."\n"$PYTHON_BIN" -m venv --help > /dev/null 2>&1 || {\n echo "ERROR: Python venv module not available in the Python installation"\n echo "Python details:"\n "$PYTHON_BIN" --version\n "$PYTHON_BIN" -c "import sys; print(\'Prefix:\', sys.prefix); print(\'Exec Prefix:\', sys.exec_prefix)"\n "$PYTHON_BIN" -c "import sys; print(\'Modules path:\'); print(\'\\n\'.join(sys.path))"\n exit 1\n}\necho "venv module is available"\n\n# Create virtual environment if it doesn\'t exist\nif [ ! -d "$VENV_PATH" ]; then\n echo "Creating virtual environment at $VENV_PATH..."\n # Never repair a path we did not create. Preparation allocates a fresh final\n # pathname; a collision is evidence to preserve, not a reason to delete it.\n if [ -e "$VENV_PATH" ] || [ -L "$VENV_PATH" ]; then\n echo "Refusing to replace an existing environment path: $VENV_PATH" >&2\n exit 1\n fi\n \n # Make sure parent directory exists and is writable\n mkdir -p "$(dirname "$VENV_PATH")"\n \n # Create the virtual environment with verbosity\n "$PYTHON_BIN" -m venv "$VENV_PATH" || {\n echo "ERROR: Failed to create virtual environment. Exit code: $?"\n echo "Virtual environment path: $VENV_PATH"\n echo "Python binary used: $PYTHON_BIN"\n ls -la "$(dirname "$VENV_PATH")"\n echo "Python executable permissions:"\n ls -la "$PYTHON_BIN"\n exit 1\n }\n echo "Successfully created virtual environment"\nfi\n\n# Verify the virtual environment structure\necho "Verifying virtual environment structure..."\nif [ ! -f "$VENV_PATH/bin/python" ] || [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "ERROR: Virtual environment is missing critical components"\n echo "Contents of virtual environment directory:"\n ls -la "$VENV_PATH"\n if [ -d "$VENV_PATH/bin" ]; then\n echo "Contents of bin directory:"\n ls -la "$VENV_PATH/bin"\n fi\n exit 1\nfi\necho "Virtual environment structure verified"\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\necho "Upgrading pip..."\npython -m pip install --upgrade pip || {\n echo "ERROR: Failed to upgrade pip. Exit code: $?"\n echo "pip version before failing:"\n pip --version\n exit 1\n}\necho "pip upgrade successful:"\npip --version\n\n# Check network connectivity to PyPI\necho "Checking network connectivity to PyPI..."\ncurl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n echo "Attempting to ping common domains to diagnose network issues:"\n ping -c 1 google.com || echo "Cannot ping google.com"\n ping -c 1 pypi.org || echo "Cannot ping pypi.org"\n}\n\necho "Installing local-operator package..."\npython -m pip install --upgrade --verbose local-operator || {\n echo "ERROR: Failed to install local-operator package. Exit code: $?"\n echo "Python version:"\n python --version\n echo "pip version:"\n pip --version\n echo "Available pip packages:"\n pip list\n echo "Pip config:"\n pip config list\n echo "Network diagnosis:"\n curl -I https://pypi.org || echo "Cannot reach PyPI server"\n exit 1\n}\necho "local-operator installation successful"\n\n# Verify installation\nif [ -f "$VENV_PATH/bin/local-operator" ]; then\n echo "Local Operator backend installed successfully!"\n \n # Show more information about the installed binary\n ls -la "$VENV_PATH/bin/local-operator"\n file "$VENV_PATH/bin/local-operator"\n \n # Try to run the version command with full error output\n echo "Testing local-operator binary..."\n "$VENV_PATH/bin/local-operator" --version || {\n echo "ERROR: local-operator binary exists but failed to execute. Exit code: $?"\n echo "Binary details:"\n file "$VENV_PATH/bin/local-operator"\n echo "Binary permissions:"\n ls -la "$VENV_PATH/bin/local-operator"\n echo "Dependencies:"\n if command -v otool >/dev/null; then\n otool -L "$VENV_PATH/bin/local-operator" || echo "Could not get dependencies with otool"\n fi\n exit 1\n }\nelse\n echo "Error: Failed to install Local Operator backend. Binary not found."\n echo "Contents of bin directory:"\n ls -la "$VENV_PATH/bin/"\n exit 1\nfi\n\necho "$(date): Installation completed successfully."\n';
3228
3773
  const windowsInstallScriptRaw = `# Local Operator Backend Installation Script for Windows
3229
3774
  # This script installs pyenv-win, Python 3.12, and sets up a virtual environment for the Local Operator backend.
3230
3775
 
@@ -3237,6 +3782,17 @@ $VenvPath = "$AppDataDir\\\\$VenvName"
3237
3782
  $LogFile = "$AppDataDir\\\\backend-install-shell.log"
3238
3783
  $PyenvDir = "$env:USERPROFILE\\\\.pyenv"
3239
3784
 
3785
+ # Which environment this installs into - the app's decision, handed in rather
3786
+ # than re-derived. A packaged install and an unpackaged one must not share an
3787
+ # environment (the venv is built on whatever interpreter the instance resolves),
3788
+ # and only the app knows which it is: LOCAL_OPERATOR_VENV_PATH carries
3789
+ # managedVenvPath's answer (src/main/backend/venv-paths.ts). The default above is
3790
+ # what a standalone run uses - the packaged name, because that is what every
3791
+ # install on a disk today has.
3792
+ if ($env:LOCAL_OPERATOR_VENV_PATH) {
3793
+ $VenvPath = $env:LOCAL_OPERATOR_VENV_PATH
3794
+ }
3795
+
3240
3796
  # Keep CPython's bytecode cache out of the application directory.
3241
3797
  #
3242
3798
  # Same rule as the macOS script, and stated here for the same reason: an
@@ -3250,6 +3806,11 @@ if (-not $env:PYTHONPYCACHEPREFIX) {
3250
3806
  $env:PYTHONPYCACHEPREFIX = "$AppDataDir\\\\python-bytecode-cache"
3251
3807
  }
3252
3808
 
3809
+ # The refusal half of the same pair, kept in step with the app's
3810
+ # \`withPythonBytecodeCache\`: CPython reads the flag before its first import, so
3811
+ # nothing this script runs can write a __pycache__ at all.
3812
+ $env:PYTHONDONTWRITEBYTECODE = "1"
3813
+
3253
3814
  # Create app data directory if it doesn't exist
3254
3815
  if (-not (Test-Path $AppDataDir)) {
3255
3816
  New-Item -ItemType Directory -Path $AppDataDir -Force | Out-Null
@@ -3542,45 +4103,60 @@ try {
3542
4103
  const macosInstallScript = macosInstallScriptRaw;
3543
4104
  const linuxInstallScript = linuxInstallScriptRaw;
3544
4105
  const windowsInstallScript = windowsInstallScriptRaw;
4106
+ const SETUP_FAILURE_CAUSES = [
4107
+ [
4108
+ /ENOSPC|No space left|not enough (?:free )?space/i,
4109
+ "This Mac ran out of disk space while setting up the backend. Free some space and retry."
4110
+ ],
4111
+ [
4112
+ /Another Local Operator instance is preparing/i,
4113
+ "Another copy of Local Operator is setting up its backend right now. Retry in a moment."
4114
+ ],
4115
+ [
4116
+ /*
4117
+ * The smoke arm only. `did not complete` used to be in here, and it is the
4118
+ * phrase `prepareManagedPython` throws when the install callback returns
4119
+ * false - a pip or network failure, where nothing was installed at all - so
4120
+ * the user read "The backend was installed but did not start correctly" about
4121
+ * an install that never happened (review round 2, N6). It now falls through
4122
+ * to the generic cause, which is true of it, and the app's own sentence still
4123
+ * follows under `The app recorded:`.
4124
+ */
4125
+ /did not become healthy|Backend smoke|exited with/i,
4126
+ "The backend was installed but did not start correctly."
4127
+ ],
4128
+ [
4129
+ /ENOENT|no such file or directory|could not find|did not match its signed seed/i,
4130
+ "A file the setup needed was missing, which usually means the download or the copy did not finish."
4131
+ ]
4132
+ ];
4133
+ function backendSetupFailureDetail(error, support) {
4134
+ const raw = error instanceof Error ? error.message : String(error);
4135
+ const cause = SETUP_FAILURE_CAUSES.find(([pattern]) => pattern.test(raw))?.[1] ?? "The backend could not be set up on this Mac.";
4136
+ return [
4137
+ `What happened: ${cause}`,
4138
+ `The app recorded: ${raw}`,
4139
+ `Where its environment lives: ${support}/managed-python`
4140
+ ].join("\n\n");
4141
+ }
3545
4142
  class BackendInstaller {
3546
4143
  appDataPath = electron.app.getPath("userData");
3547
4144
  venvPath;
3548
4145
  resourcesPath;
3549
4146
  pythonPath = null;
4147
+ preparationWindow = null;
3550
4148
  /**
3551
4149
  * Constructor
3552
4150
  */
3553
4151
  constructor() {
3554
- if (process.platform === "win32") {
3555
- this.venvPath = path$1.join(this.appDataPath, "local-operator-venv");
3556
- } else if (process.platform === "darwin") {
3557
- this.venvPath = path$1.join(
3558
- electron.app.getPath("home"),
3559
- "Library",
3560
- "Application Support",
3561
- "Local Operator",
3562
- "local-operator-venv"
3563
- );
3564
- } else {
3565
- this.venvPath = path$1.join(
3566
- electron.app.getPath("home"),
3567
- ".config",
3568
- "local-operator",
3569
- "local-operator-venv"
3570
- );
3571
- }
3572
- this.resourcesPath = process.env.NODE_ENV === "development" ? path$1.join(process.cwd(), "resources") : path$1.join(process.resourcesPath);
3573
- if (electron.app.isPackaged) {
3574
- const seal = sealPythonInterpreterTrees(
3575
- bundledPythonTreePaths(process.resourcesPath)
3576
- );
3577
- const message = describePythonTreeSeal(seal);
3578
- if (seal.supported && seal.failures.length > 0) {
3579
- logger.warn(message, LogFileType.INSTALLER);
3580
- } else {
3581
- logger.info(message, LogFileType.INSTALLER);
3582
- }
3583
- }
4152
+ this.venvPath = managedVenvPath({
4153
+ platform: process.platform,
4154
+ home: electron.app.getPath("home"),
4155
+ appDataPath: this.appDataPath,
4156
+ packaged: electron.app.isPackaged
4157
+ });
4158
+ this.resourcesPath = !electron.app.isPackaged ? path$1.join(process.cwd(), "resources") : path$1.join(process.resourcesPath);
4159
+ if (process.platform !== "darwin") this.guardVenvBytecode();
3584
4160
  this.pythonPath = this.findPython();
3585
4161
  logger.info(
3586
4162
  `Backend Installer initialized. Virtual environment path: ${this.venvPath}`,
@@ -3597,6 +4173,7 @@ class BackendInstaller {
3597
4173
  * @returns Path to Python executable or null if not found
3598
4174
  */
3599
4175
  findPython() {
4176
+ if (process.platform === "darwin") return null;
3600
4177
  const possibilities = [];
3601
4178
  if (process.arch === "arm64") {
3602
4179
  possibilities.push(
@@ -3639,12 +4216,31 @@ class BackendInstaller {
3639
4216
  );
3640
4217
  return null;
3641
4218
  }
4219
+ /**
4220
+ * Make the app-managed venv refuse bytecode writes for every process that
4221
+ * uses it, whoever started it.
4222
+ *
4223
+ * Logged rather than silent in both directions: a guard that was already
4224
+ * there, one this call wrote, and one that could not be written (no venv yet,
4225
+ * or a `sitecustomize.py` the operator owns) are three different states of the
4226
+ * same guarantee, and the field diagnosis starts from which one this machine
4227
+ * is in. `ensureVenvBytecodeGuard` never replaces a file it did not write.
4228
+ */
4229
+ guardVenvBytecode() {
4230
+ const guard = ensureVenvBytecodeGuard(this.venvPath);
4231
+ logger.info(
4232
+ `Bytecode guard in the app-managed venv: ${guard.reason}`,
4233
+ LogFileType.INSTALLER
4234
+ );
4235
+ }
3642
4236
  /**
3643
4237
  * Check if backend is installed
3644
4238
  * @returns Promise resolving to true if the backend is installed, false otherwise
3645
4239
  */
3646
4240
  async isInstalled() {
3647
4241
  try {
4242
+ if (process.platform === "darwin")
4243
+ return await managedSelectionReady(this.managedOptions());
3648
4244
  if (!fs.existsSync(this.venvPath)) {
3649
4245
  logger.info(
3650
4246
  `Virtual environment not found at ${this.venvPath}`,
@@ -3691,30 +4287,90 @@ class BackendInstaller {
3691
4287
  * Install backend
3692
4288
  * @returns Promise resolving to true if the backend was installed successfully, false otherwise
3693
4289
  */
4290
+ managedOptions() {
4291
+ return {
4292
+ support: path$1.join(
4293
+ electron.app.getPath("home"),
4294
+ "Library",
4295
+ "Application Support",
4296
+ "Local Operator"
4297
+ ),
4298
+ resources: this.resourcesPath,
4299
+ packaged: electron.app.isPackaged,
4300
+ arch: process.arch
4301
+ };
4302
+ }
3694
4303
  async install() {
3695
- try {
3696
- const { response } = await electron.dialog.showMessageBox({
3697
- type: "info",
3698
- title: "First-Time Setup Required",
3699
- message: "Local Operator needs to set up some components to work properly. This one-time process will take just a few moments.",
3700
- buttons: ["Set Up Now", "Cancel"],
3701
- defaultId: 0,
3702
- cancelId: 1
3703
- });
3704
- if (response === 1) {
3705
- logger.info(
3706
- "User cancelled backend installation",
3707
- LogFileType.INSTALLER
4304
+ if (process.platform !== "darwin") return this.installEnvironment();
4305
+ for (; ; ) {
4306
+ try {
4307
+ const state = inspectManagedSelection(this.managedOptions());
4308
+ if (state.kind === "missing")
4309
+ logger.warn(
4310
+ `Published backend environment is not usable and will be rebuilt: ${state.detail}`,
4311
+ LogFileType.INSTALLER
4312
+ );
4313
+ await prepareManagedPython(
4314
+ this.managedOptions(),
4315
+ async (venv, python) => {
4316
+ this.venvPath = venv;
4317
+ this.pythonPath = python;
4318
+ return this.installEnvironment();
4319
+ }
3708
4320
  );
3709
- setTimeout(() => {
3710
- electron.app.exit(0);
3711
- }, 500);
3712
- return false;
4321
+ return true;
4322
+ } catch (error) {
4323
+ logger.error(
4324
+ "Backend preparation failed",
4325
+ LogFileType.INSTALLER,
4326
+ error
4327
+ );
4328
+ const { response } = await electron.dialog.showMessageBox({
4329
+ type: "error",
4330
+ title: "Backend setup could not finish",
4331
+ message: "Your existing environment and data were preserved. Retry setup to start this version of Local Operator.",
4332
+ detail: backendSetupFailureDetail(
4333
+ error,
4334
+ this.managedOptions().support
4335
+ ),
4336
+ buttons: ["Retry setup", "Quit"],
4337
+ defaultId: 0,
4338
+ cancelId: 1
4339
+ });
4340
+ if (response !== 0) return false;
4341
+ } finally {
4342
+ if (this.preparationWindow && !this.preparationWindow.isDestroyed())
4343
+ this.preparationWindow.close();
4344
+ this.preparationWindow = null;
4345
+ }
4346
+ }
4347
+ }
4348
+ async installEnvironment() {
4349
+ try {
4350
+ if (process.platform !== "darwin") {
4351
+ const { response } = await electron.dialog.showMessageBox({
4352
+ type: "info",
4353
+ title: "First-Time Setup Required",
4354
+ message: "Local Operator needs to set up some components to work properly. This one-time process will take just a few moments.",
4355
+ buttons: ["Set Up Now", "Cancel"],
4356
+ defaultId: 0,
4357
+ cancelId: 1
4358
+ });
4359
+ if (response === 1) {
4360
+ logger.info(
4361
+ "User cancelled backend installation",
4362
+ LogFileType.INSTALLER
4363
+ );
4364
+ setTimeout(() => {
4365
+ electron.app.exit(0);
4366
+ }, 500);
4367
+ return false;
4368
+ }
3713
4369
  }
3714
4370
  let scriptPath;
3715
4371
  let cmd;
3716
4372
  let args;
3717
- const tempDir = os.tmpdir();
4373
+ const tempDir = fs.mkdtempSync(path$1.join(os.tmpdir(), "local-operator-install-"));
3718
4374
  if (process.platform === "win32") {
3719
4375
  scriptPath = path$1.join(tempDir, "install-backend-windows.ps1");
3720
4376
  fs.writeFileSync(scriptPath, windowsInstallScript);
@@ -3759,6 +4415,8 @@ class BackendInstaller {
3759
4415
  sandbox: false
3760
4416
  }
3761
4417
  });
4418
+ if (process.platform === "darwin")
4419
+ this.preparationWindow = progressWindow;
3762
4420
  if (utils.is.dev && process.env.ELECTRON_RENDERER_URL) {
3763
4421
  progressWindow.loadURL(
3764
4422
  `${process.env.ELECTRON_RENDERER_URL}/installer`
@@ -3833,6 +4491,13 @@ class BackendInstaller {
3833
4491
  try {
3834
4492
  const env = withPythonBytecodeCache({ ...process.env }, this.appDataPath);
3835
4493
  env.ELECTRON_RESOURCE_PATH = this.resourcesPath;
4494
+ env[VENV_PATH_ENV] = this.venvPath;
4495
+ env.HOME = electron.app.getPath("home");
4496
+ env.LOCAL_OPERATOR_SUPPORT_PATH = process.platform === "darwin" ? this.managedOptions().support : this.appDataPath;
4497
+ logger.info(
4498
+ `Setting ${VENV_PATH_ENV} to ${this.venvPath}`,
4499
+ LogFileType.INSTALLER
4500
+ );
3836
4501
  logger.info(
3837
4502
  `Setting ELECTRON_RESOURCE_PATH to ${this.resourcesPath}`,
3838
4503
  LogFileType.INSTALLER
@@ -3912,7 +4577,7 @@ class BackendInstaller {
3912
4577
  });
3913
4578
  ipcMain.removeListener("cancel-installation", cancelHandler);
3914
4579
  electron.app.removeListener("before-quit", appQuitHandler);
3915
- if (!progressWindow.isDestroyed()) {
4580
+ if (process.platform !== "darwin" && !progressWindow.isDestroyed()) {
3916
4581
  progressWindow.close();
3917
4582
  }
3918
4583
  if (installationCancelled) {
@@ -3928,6 +4593,7 @@ class BackendInstaller {
3928
4593
  "Backend installation completed successfully",
3929
4594
  LogFileType.INSTALLER
3930
4595
  );
4596
+ this.guardVenvBytecode();
3931
4597
  const localOperatorPath = process.platform === "win32" ? path$1.join(this.venvPath, "Scripts", "local-operator.exe") : path$1.join(this.venvPath, "bin", "local-operator");
3932
4598
  if (!fs.existsSync(localOperatorPath)) {
3933
4599
  logger.error(
@@ -3942,23 +4608,25 @@ class BackendInstaller {
3942
4608
  "Installation verified successfully",
3943
4609
  LogFileType.INSTALLER
3944
4610
  );
3945
- const { response: successResponse } = await electron.dialog.showMessageBox({
3946
- type: "info",
3947
- title: "Setup Complete",
3948
- message: "Local Operator is ready to use. Everything has been set up successfully.",
3949
- buttons: ["Let's Go!", "Cancel"],
3950
- defaultId: 0,
3951
- cancelId: 1
3952
- });
3953
- if (successResponse === 1) {
3954
- logger.info(
3955
- "User ended session after successful installation",
3956
- LogFileType.INSTALLER
3957
- );
3958
- setTimeout(() => {
3959
- electron.app.exit(0);
3960
- }, 500);
3961
- return false;
4611
+ if (process.platform !== "darwin") {
4612
+ const { response: successResponse } = await electron.dialog.showMessageBox({
4613
+ type: "info",
4614
+ title: "Setup Complete",
4615
+ message: "Local Operator is ready to use. Everything has been set up successfully.",
4616
+ buttons: ["Let's Go!", "Cancel"],
4617
+ defaultId: 0,
4618
+ cancelId: 1
4619
+ });
4620
+ if (successResponse === 1) {
4621
+ logger.info(
4622
+ "User ended session after successful installation",
4623
+ LogFileType.INSTALLER
4624
+ );
4625
+ setTimeout(() => {
4626
+ electron.app.exit(0);
4627
+ }, 500);
4628
+ return false;
4629
+ }
3962
4630
  }
3963
4631
  await new Promise((resolve) => setTimeout(resolve, 500));
3964
4632
  if (process.platform === "win32") {
@@ -8378,15 +9046,20 @@ ${probe.stderr}`.trim();
8378
9046
  detail: marker ? `${marker}: ${output || "no output"}` : `codesign --verify exited ${probe.exitCode}: ${output || "no output"}`
8379
9047
  };
8380
9048
  }
8381
- function installedBundleSealBlock(appBundlePath, detail, version) {
9049
+ function installedBundleSealBlock(appBundlePath, detail, version, context = "update") {
9050
+ const message = context === "startup" ? "This copy of Local Operator did not pass its integrity check, so it can't repair itself." : version ? `This install of Local Operator can't be updated in place, so the update to version ${version} was stopped before the app quit.` : "This install of Local Operator can't be updated in place, so the update was stopped before the app quit.";
8382
9051
  return {
8383
9052
  code: "installed-bundle-not-sealed",
8384
- message: version ? `This install of Local Operator can't be updated in place, so the update to version ${version} was stopped before the app quit.` : "This install of Local Operator can't be updated in place, so the update was stopped before the app quit.",
9053
+ message,
8385
9054
  remedy: {
8386
9055
  text: "Quit Local Operator, then download a fresh copy and replace the app in Applications.",
8387
9056
  url: DOWNLOAD_PAGE_URL
8388
9057
  },
8389
- detail: `${appBundlePath}: ${detail}`
9058
+ detail: `${appBundlePath}: ${detail}`,
9059
+ ...context === "startup" ? {
9060
+ heading: "This copy of Local Operator needs replacing",
9061
+ dismissLabel: "Not now"
9062
+ } : {}
8390
9063
  };
8391
9064
  }
8392
9065
  const VIOLATION_LINE = /^file (added|modified|missing):\s*(.+)$/;
@@ -8418,7 +9091,12 @@ function canonicalPath(path2) {
8418
9091
  }
8419
9092
  }
8420
9093
  }
8421
- const BUNDLED_PYTHON_DIRS = ["python", "python_aarch64"];
9094
+ const BUNDLED_PYTHON_DIRS = [
9095
+ ...BUNDLED_PYTHON_LAYOUT.legacyResourceNames,
9096
+ ...BUNDLED_PYTHON_LAYOUT.architectures.map(
9097
+ (arch) => `${BUNDLED_PYTHON_LAYOUT.seedNamespace}/${arch}`
9098
+ )
9099
+ ];
8422
9100
  function isPythonBytecodePath(bundlePath, candidate) {
8423
9101
  const bundle = canonicalPath(bundlePath);
8424
9102
  const path2 = canonicalPath(candidate);
@@ -9379,8 +10057,8 @@ function readInstallIdentity(shimPath) {
9379
10057
  }
9380
10058
  let shebang = null;
9381
10059
  try {
9382
- const firstLine = fs.readFileSync(shimPath, "utf8").split("\n", 1)[0] ?? "";
9383
- shebang = firstLine.startsWith("#!") ? firstLine : null;
10060
+ const firstLine2 = fs.readFileSync(shimPath, "utf8").split("\n", 1)[0] ?? "";
10061
+ shebang = firstLine2.startsWith("#!") ? firstLine2 : null;
9384
10062
  } catch {
9385
10063
  shebang = null;
9386
10064
  }
@@ -9465,7 +10143,7 @@ function resolveGlobalInstallPlan(input) {
9465
10143
  sourceBuild: false
9466
10144
  };
9467
10145
  }
9468
- function buildPipUpgradeCommand(pythonPath) {
10146
+ function buildPipUpgradeCommand(pythonPath2) {
9469
10147
  const args = [
9470
10148
  "-m",
9471
10149
  "pip",
@@ -9476,9 +10154,9 @@ function buildPipUpgradeCommand(pythonPath) {
9476
10154
  "local-operator"
9477
10155
  ];
9478
10156
  return {
9479
- command: pythonPath,
10157
+ command: pythonPath2,
9480
10158
  args,
9481
- display: `"${pythonPath}" ${args.join(" ")}`
10159
+ display: `"${pythonPath2}" ${args.join(" ")}`
9482
10160
  };
9483
10161
  }
9484
10162
  const PIP_SHOW_VERSION_REGEX = /^Version:\s*(.+)$/m;
@@ -9503,6 +10181,7 @@ const BUNDLE_ID_REGEX = /^[\w.-]+$/;
9503
10181
  const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
9504
10182
  const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
9505
10183
  const SEAL_PROBE_TIMEOUT_MS = 45e3;
10184
+ const STARTUP_SEAL_PROBE_DELAY_MS = 15e3;
9506
10185
  function versionSuffix(version) {
9507
10186
  return version ? ` to version ${version}` : "";
9508
10187
  }
@@ -9585,6 +10264,19 @@ class UpdateService {
9585
10264
  /** A failed install detected from the marker on this start, if any. */
9586
10265
  pendingInstallFailure = null;
9587
10266
  installFailureDelivered = false;
10267
+ /**
10268
+ * A refusal this process found at start-up, if any.
10269
+ *
10270
+ * Kept apart from `pendingInstallFailure` because they are different facts with
10271
+ * different remedies - that one is an install that did not finish, this one is a
10272
+ * bundle that can no longer be replaced in place - and both can be true of the
10273
+ * same start. Delivered through the same scheduler shape for the same reason:
10274
+ * the window is loading when this is produced (the service is constructed as
10275
+ * the window is created), and a bare `webContents.send` with no subscriber
10276
+ * still reports success.
10277
+ */
10278
+ pendingInstallBlock = null;
10279
+ installBlockDelivered = false;
9588
10280
  /**
9589
10281
  * An install this process found still running, if any.
9590
10282
  *
@@ -9651,6 +10343,15 @@ class UpdateService {
9651
10343
  electronUpdater.autoUpdater.autoInstallOnAppQuit = false;
9652
10344
  this.setupUpdateEvents();
9653
10345
  this.recoverPendingInstall();
10346
+ const sealProbe = setTimeout(() => {
10347
+ void this.repairReachableBundleSeals().catch((error) => {
10348
+ logger.warn(
10349
+ `Start-up seal check could not run: ${error instanceof Error ? error.message : String(error)}`,
10350
+ LogFileType.UPDATE_SERVICE
10351
+ );
10352
+ });
10353
+ }, STARTUP_SEAL_PROBE_DELAY_MS);
10354
+ sealProbe.unref();
9654
10355
  this.startPeriodicUpdateChecks();
9655
10356
  }
9656
10357
  /**
@@ -9718,13 +10419,32 @@ class UpdateService {
9718
10419
  `Update refused (${block.code}): ${block.detail}`,
9719
10420
  LogFileType.UPDATE_SERVICE
9720
10421
  );
9721
- this.sendToRenderer("update-install-blocked", {
10422
+ this.sendToRenderer(
10423
+ "update-install-blocked",
10424
+ this.installBlockPayload(block, version ?? null)
10425
+ );
10426
+ }
10427
+ /**
10428
+ * The refusal payload the renderer's panel reads.
10429
+ *
10430
+ * One builder, so a refusal found at start-up and one found by the pre-flight
10431
+ * cannot arrive in two shapes: the renderer keys the heading off `code` and the
10432
+ * remedy off `remedy`, and a field missing in one path is a panel that renders
10433
+ * nothing where the user was told what to do.
10434
+ */
10435
+ installBlockPayload(block, version) {
10436
+ return {
9722
10437
  code: block.code,
9723
- version: version ?? null,
10438
+ version,
9724
10439
  message: block.message,
9725
10440
  remedy: block.remedy,
9726
- detail: block.detail
9727
- });
10441
+ detail: block.detail,
10442
+ // Both optional, and both must travel: the payload is built field by
10443
+ // field, so a copy the main process owns and this builder forgets is a
10444
+ // panel that renders the default heading (design D2, D3).
10445
+ heading: block.heading ?? null,
10446
+ dismissLabel: block.dismissLabel ?? null
10447
+ };
9728
10448
  }
9729
10449
  /**
9730
10450
  * Read the marker a previous run may have left, and act on it.
@@ -9744,6 +10464,14 @@ class UpdateService {
9744
10464
  * alone entirely, and the re-check below reports what really happens to it.
9745
10465
  */
9746
10466
  recoverPendingInstall() {
10467
+ if (!electron.app.isPackaged) {
10468
+ const marker2 = readPendingInstallMarker(this.markerDir());
10469
+ logger.info(
10470
+ marker2 ? `Unpackaged instance: leaving the packaged app's pending install marker for version ${marker2.targetVersion} alone, with its install job and staging tree; this process cannot act on an install of a bundle it is not.` : "Unpackaged instance: no packaged install state to leave alone.",
10471
+ LogFileType.UPDATE_SERVICE
10472
+ );
10473
+ return;
10474
+ }
9747
10475
  const marker = readPendingInstallMarker(this.markerDir());
9748
10476
  const outcome = evaluatePendingInstall({
9749
10477
  marker,
@@ -9956,6 +10684,43 @@ class UpdateService {
9956
10684
  LogFileType.UPDATE_SERVICE
9957
10685
  );
9958
10686
  }
10687
+ /**
10688
+ * Deliver a refusal found at start-up once the renderer can hear it.
10689
+ *
10690
+ * Same shape as the failure's delivery, for the same measured reason: the
10691
+ * refusal is produced while the window is loading (the service is constructed
10692
+ * as the window is created), the panel subscribes from a React effect that can
10693
+ * run after `did-finish-load`, and `sendToRenderer` answers true for a push
10694
+ * that no subscriber saw. Unlike the failure it clears nothing on the way out -
10695
+ * this refusal is not tied to any on-disk state, and the pre-flight's own
10696
+ * direct push (`sendInstallBlock`) stays the path used when the window is
10697
+ * already up.
10698
+ */
10699
+ scheduleInstallBlockDelivery(block) {
10700
+ this.pendingInstallBlock = block;
10701
+ if (this.installBlockDelivered) return;
10702
+ const deliver = () => this.deliverPendingInstallBlock();
10703
+ const webContents = this.mainWindow?.webContents;
10704
+ if (webContents && !webContents.isDestroyed()) {
10705
+ webContents.once("did-finish-load", deliver);
10706
+ }
10707
+ setTimeout(deliver, 5e3);
10708
+ }
10709
+ deliverPendingInstallBlock() {
10710
+ const block = this.pendingInstallBlock;
10711
+ if (!block || this.installBlockDelivered) return;
10712
+ if (!this.sendToRenderer(
10713
+ "update-install-blocked",
10714
+ this.installBlockPayload(block, null)
10715
+ )) {
10716
+ return;
10717
+ }
10718
+ this.installBlockDelivered = true;
10719
+ logger.info(
10720
+ "Reported a start-up refusal to the renderer",
10721
+ LogFileType.UPDATE_SERVICE
10722
+ );
10723
+ }
9959
10724
  /**
9960
10725
  * A substituted answer to "is this app's install job loaded", or null.
9961
10726
  *
@@ -10200,17 +10965,68 @@ class UpdateService {
10200
10965
  * backend. Those writes are `file added:` violations and deleting exactly
10201
10966
  * those files restores the seal (measured; see `healPythonBytecode`), where a
10202
10967
  * bundle broken any other way still gets the reinstall refusal below.
10968
+ *
10969
+ * The probe, the retry and the heal are `readBundleSeal` and `repairBundleSeal`
10970
+ * below, shared with the start-up repair pass: this method is the update-time
10971
+ * policy on top of them (an unanswerable probe proceeds, an unhealable bundle
10972
+ * refuses the install), and it must not grow a second copy of either.
10203
10973
  */
10204
10974
  async probeInstalledBundleSeal(version) {
10975
+ const bundlePath = this.runningBundlePath();
10976
+ if (!bundlePath) return null;
10977
+ const { seal, heal, block } = await this.repairBundleSeal(
10978
+ bundlePath,
10979
+ version
10980
+ );
10981
+ if (seal.kind === "unavailable") {
10982
+ logger.warn(
10983
+ `Seal check could not run twice, continuing with the install: ${seal.detail}`,
10984
+ LogFileType.UPDATE_SERVICE
10985
+ );
10986
+ return null;
10987
+ }
10988
+ if (seal.kind === "sealed") {
10989
+ logger.info(
10990
+ heal ? `Installed bundle sealed again after removing ${heal.removed.length} added bytecode file(s); continuing with the install.` : `Installed bundle passed its seal check: ${bundlePath}`,
10991
+ LogFileType.UPDATE_SERVICE
10992
+ );
10993
+ }
10994
+ return block;
10995
+ }
10996
+ /**
10997
+ * The `.app` this process runs from, or null when it is not a bundle layout.
10998
+ *
10999
+ * Shared by the pre-flight and the start-up repair pass so both ask about the
11000
+ * same bundle, and so the "unpacked or dev run" answer is stated once: not a
11001
+ * bundle we recognise means nothing to verify, and refusing every install
11002
+ * because the path did not parse would be worse than the risk it guards
11003
+ * against.
11004
+ */
11005
+ runningBundlePath() {
10205
11006
  if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
10206
11007
  const bundlePath = appBundleFromExecutable(process.execPath);
10207
11008
  if (!bundlePath) {
10208
11009
  logger.warn(
10209
- `Could not derive an app bundle from ${process.execPath}; skipping the seal pre-flight.`,
11010
+ `Could not derive an app bundle from ${process.execPath}; skipping the seal checks.`,
10210
11011
  LogFileType.UPDATE_SERVICE
10211
11012
  );
10212
11013
  return null;
10213
11014
  }
11015
+ return bundlePath;
11016
+ }
11017
+ /**
11018
+ * One `codesign --verify` of `bundlePath`, retried once when it could not run.
11019
+ *
11020
+ * The retry lives here rather than at either caller because "did not complete"
11021
+ * is a fact about the probe, not about the install being offered: measured on
11022
+ * macOS 26.5, a 1 GiB bundle answers in 6.1 s warm, and a timeout or a probe
11023
+ * that died silently used to arrive as exit code 1 - which the pre-flight read
11024
+ * as "codesign rejected this bundle" and turned into a permanent reinstall
11025
+ * message (review R5). `probe` is returned beside the verdict because the
11026
+ * heal reads the violations out of the raw stdout, and a second probe for them
11027
+ * would be a second answer about one bundle.
11028
+ */
11029
+ async readBundleSeal(bundlePath) {
10214
11030
  const probeBundle = () => runCommand(
10215
11031
  "/usr/bin/codesign",
10216
11032
  ["--verify", "--deep", "--verbose=2", bundlePath],
@@ -10226,20 +11042,29 @@ class UpdateService {
10226
11042
  probe = await probeBundle();
10227
11043
  seal = evaluateBundleSeal(probe);
10228
11044
  }
10229
- if (seal.kind === "unavailable") {
10230
- logger.warn(
10231
- `Seal check could not run twice, continuing with the install: ${seal.detail}`,
10232
- LogFileType.UPDATE_SERVICE
10233
- );
10234
- return null;
10235
- }
10236
- if (seal.kind === "sealed") {
10237
- logger.info(
10238
- `Installed bundle passed its seal check: ${bundlePath}`,
10239
- LogFileType.UPDATE_SERVICE
10240
- );
10241
- return null;
10242
- }
11045
+ return { seal, probe };
11046
+ }
11047
+ /**
11048
+ * Probe the bundle, heal the one break we cause, and probe again.
11049
+ *
11050
+ * The whole mechanism, run identically by the update pre-flight and by the
11051
+ * start-up pass: a sealed bundle is returned untouched, an unsealed one is
11052
+ * healed only when the plan says the break is ours (`planPythonBytecodeHeal`),
11053
+ * and the verdict that follows the heal is a fresh probe rather than an
11054
+ * assumption about what a deletion did. `block` is the refusal to show when the
11055
+ * bundle cannot be put back together, and it carries the version the caller was
11056
+ * asked about (null at start-up, where no update is being offered yet).
11057
+ *
11058
+ * Why start-up also runs this: the break is introduced by the app running, not
11059
+ * by the update, so by the time an install is attempted the bundle may already
11060
+ * have been refused by macOS at launch - measured on 2026-09-14, a bundle
11061
+ * signed at 09:31:53 with one added
11062
+ * `lib/python3.12/__pycache__/webbrowser.cpython-312.pyc` at 10:11:29, and no
11063
+ * packaged instance ever came back to report it.
11064
+ */
11065
+ async repairBundleSeal(bundlePath, version, context = "update") {
11066
+ const { seal, probe } = await this.readBundleSeal(bundlePath);
11067
+ if (seal.kind !== "unsealed") return { seal, heal: null, block: null };
10243
11068
  logger.error(
10244
11069
  `Installed bundle failed its seal check: ${seal.detail}`,
10245
11070
  LogFileType.UPDATE_SERVICE
@@ -10251,28 +11076,117 @@ class UpdateService {
10251
11076
  `Installed bundle is not healable in place: ${heal.reason}`,
10252
11077
  LogFileType.UPDATE_SERVICE
10253
11078
  );
10254
- return installedBundleSealBlock(bundlePath, seal.detail, version);
11079
+ return {
11080
+ seal,
11081
+ heal,
11082
+ block: installedBundleSealBlock(
11083
+ bundlePath,
11084
+ seal.detail,
11085
+ version,
11086
+ context
11087
+ )
11088
+ };
10255
11089
  }
10256
11090
  logger.info(
10257
11091
  `Removed ${heal.removed.length} bytecode file(s) our own interpreter had written into the sealed bundle: ${heal.reason}. Paths:
10258
11092
  ${heal.removed.join("\n")}`,
10259
11093
  LogFileType.UPDATE_SERVICE
10260
11094
  );
10261
- const healed = evaluateBundleSeal(await probeBundle());
11095
+ const { probe: healedProbe } = await this.readBundleSeal(bundlePath);
11096
+ const healed = evaluateBundleSeal(healedProbe);
10262
11097
  if (healed.kind === "sealed") {
10263
- logger.info(
10264
- `Installed bundle sealed again after removing ${heal.removed.length} added bytecode file(s); continuing with the install.`,
10265
- LogFileType.UPDATE_SERVICE
10266
- );
10267
- return null;
11098
+ return { seal: healed, heal, block: null };
10268
11099
  }
10269
11100
  logger.error(
10270
11101
  `Installed bundle still fails its seal check after healing: ${healed.detail}`,
10271
11102
  LogFileType.UPDATE_SERVICE
10272
11103
  );
10273
- return installedBundleSealBlock(bundlePath, healed.detail, version);
11104
+ return {
11105
+ seal: healed,
11106
+ heal,
11107
+ block: installedBundleSealBlock(
11108
+ bundlePath,
11109
+ healed.detail,
11110
+ version,
11111
+ context
11112
+ )
11113
+ };
11114
+ }
11115
+ /** Inspect only the app this process runs from. An unpackaged instance must
11116
+ * never traverse, heal, seal or otherwise mutate another installed bundle. */
11117
+ async repairReachableBundleSeals() {
11118
+ this.reportLegacyVenvInterpreters();
11119
+ const running = this.runningBundlePath();
11120
+ if (running) await this.repairRunningBundleSeal(running);
11121
+ }
11122
+ /**
11123
+ * Say what the pre-split environments resolve to, and change nothing.
11124
+ *
11125
+ * An install from before this change has a venv whose `pyvenv.cfg` `home` is
11126
+ * inside an `.app`, and one whose bundle is already gone is a state this
11127
+ * machine has actually been in. Both are reported because they explain the log
11128
+ * a support conversation reads - "this venv belongs to a bundle that is not
11129
+ * here any more" and "this venv is still built on the installed app's
11130
+ * interpreter" are different facts, and silence makes them look like the same
11131
+ * healthy one. Neither is repaired: those environments are left byte-identical,
11132
+ * deliberately, so a rollback to an older build still finds what it built.
11133
+ *
11134
+ * Darwin only, and by construction rather than by choice: what is reported is a
11135
+ * venv built on the interpreter inside a code-sealed `.app`, and no other
11136
+ * platform has one (`BUNDLED_INTERPRETER_HOME` requires the `.app` component).
11137
+ *
11138
+ * It reads `legacyVenvPaths`, NOT `managedVenvPath`. That was the bug: on darwin
11139
+ * `managedVenvPath` answers with the post-split selection venv - whose
11140
+ * `pyvenv.cfg` names the external runtime by construction - or with the
11141
+ * `no-environment-selected` sentinel, so both iterations took the `continue` and
11142
+ * nothing was ever logged for the state this exists to describe (review R7).
11143
+ */
11144
+ reportLegacyVenvInterpreters() {
11145
+ if (process.platform !== "darwin") return;
11146
+ for (const line of legacyEnvironmentReport(
11147
+ managedSupportRoot(electron.app.getPath("home"))
11148
+ ))
11149
+ logger.info(line, LogFileType.UPDATE_SERVICE);
11150
+ }
11151
+ /**
11152
+ * Repair this bundle's seal at start-up, and say so either way.
11153
+ *
11154
+ * What this catches: a bundle that already carries bytecode its own interpreter
11155
+ * wrote after it was signed. macOS refuses such a bundle at launch
11156
+ * ("damaged and can't be opened") and ShipIt refuses the next in-place update
11157
+ * with -67028, so a break found here is repaired before either can happen, and
11158
+ * one that cannot be repaired is reported with the same refusal/remedy panel
11159
+ * the pre-flight uses - the user has to replace the app by hand, and finding
11160
+ * that out at the next update is worse than being told now.
11161
+ *
11162
+ * Nothing is reported when the bundle is sealed, which is every ordinary
11163
+ * start: the log line is the evidence that the pass ran at all.
11164
+ */
11165
+ async repairRunningBundleSeal(bundlePath) {
11166
+ const { seal, heal, block } = await this.repairBundleSeal(
11167
+ bundlePath,
11168
+ null,
11169
+ "startup"
11170
+ );
11171
+ if (seal.kind === "sealed") {
11172
+ logger.info(
11173
+ heal ? `Start-up seal repair: removed ${heal.removed.length} added bytecode file(s) from ${bundlePath}, and the bundle verifies again.` : `Start-up seal check: ${bundlePath} is a sealed code object.`,
11174
+ LogFileType.UPDATE_SERVICE
11175
+ );
11176
+ return;
11177
+ }
11178
+ if (seal.kind === "unavailable") {
11179
+ logger.warn(
11180
+ `Start-up seal check could not run: ${seal.detail}`,
11181
+ LogFileType.UPDATE_SERVICE
11182
+ );
11183
+ return;
11184
+ }
11185
+ if (block) this.scheduleInstallBlockDelivery(block);
10274
11186
  }
10275
- /** File names the updater's metadata knows this update by. */
11187
+ /**
11188
+ * File names the updater's metadata knows this update by.
11189
+ */
10276
11190
  stagedArtifactCandidates(info) {
10277
11191
  const names = /* @__PURE__ */ new Set();
10278
11192
  if (info?.path) names.add(path$1.basename(info.path));
@@ -10491,86 +11405,6 @@ ${heal.removed.join("\n")}`,
10491
11405
  electron.ipcMain.removeHandler("quit-for-update-install");
10492
11406
  this.mainWindow = null;
10493
11407
  }
10494
- /**
10495
- * Register the backend service for proper shutdown when app quits
10496
- * This ensures that restarted backend services are properly shut down
10497
- */
10498
- registerBackendShutdown() {
10499
- if (!this.backendService || this.backendService.isUsingExternalBackend()) {
10500
- return;
10501
- }
10502
- const shutdownHandler = async () => {
10503
- logger.info(
10504
- "Shutting down backend service before app quit...",
10505
- LogFileType.UPDATE_SERVICE
10506
- );
10507
- try {
10508
- await this.backendService?.stop(false);
10509
- logger.info(
10510
- "Backend service successfully shut down before app quit",
10511
- LogFileType.UPDATE_SERVICE
10512
- );
10513
- } catch (error) {
10514
- logger.error(
10515
- "Error shutting down backend service before app quit:",
10516
- LogFileType.UPDATE_SERVICE,
10517
- error
10518
- );
10519
- try {
10520
- logger.info(
10521
- "Attempting forced shutdown of backend service...",
10522
- LogFileType.UPDATE_SERVICE
10523
- );
10524
- await this.forceTerminateBackendProcess();
10525
- logger.info(
10526
- "Forced shutdown of backend service completed",
10527
- LogFileType.UPDATE_SERVICE
10528
- );
10529
- } catch (forceError) {
10530
- logger.error(
10531
- "Error during forced shutdown of backend service:",
10532
- LogFileType.UPDATE_SERVICE,
10533
- forceError
10534
- );
10535
- }
10536
- }
10537
- };
10538
- electron.app.removeAllListeners("before-quit");
10539
- electron.app.once("before-quit", shutdownHandler);
10540
- electron.app.once("will-quit", shutdownHandler);
10541
- logger.info(
10542
- "Registered backend service for proper shutdown on app quit",
10543
- LogFileType.UPDATE_SERVICE
10544
- );
10545
- }
10546
- /**
10547
- * Force terminate the backend process using platform-specific commands
10548
- * This is a last resort method when normal termination fails
10549
- */
10550
- async forceTerminateBackendProcess() {
10551
- if (!this.backendService) {
10552
- return;
10553
- }
10554
- const execAsync = node_util.promisify(node_child_process.exec);
10555
- try {
10556
- if (process.platform === "win32") {
10557
- await execAsync('taskkill /f /im "local-operator serve" /t');
10558
- await execAsync(
10559
- `wmic process where "commandline like '%local-operator serve%'" call terminate`
10560
- );
10561
- } else {
10562
- await execAsync('pkill -f "local-operator serve"');
10563
- await new Promise((resolve) => setTimeout(resolve, 1e3));
10564
- await execAsync('pkill -9 -f "local-operator serve"');
10565
- }
10566
- } catch (error) {
10567
- logger.warn(
10568
- "Error during force termination (this may be normal if process was already terminated):",
10569
- LogFileType.UPDATE_SERVICE,
10570
- error
10571
- );
10572
- }
10573
- }
10574
11408
  /**
10575
11409
  * Set up event handlers for the autoUpdater
10576
11410
  */
@@ -10809,6 +11643,13 @@ ${heal.removed.join("\n")}`,
10809
11643
  );
10810
11644
  return false;
10811
11645
  }
11646
+ if (!electron.app.isPackaged) {
11647
+ logger.info(
11648
+ "Refusing to start an update install: this instance is unpackaged, so it is not the bundle an install replaces.",
11649
+ LogFileType.UPDATE_SERVICE
11650
+ );
11651
+ return false;
11652
+ }
10812
11653
  logger.info(
10813
11654
  "Preparing to quit and install the update...",
10814
11655
  LogFileType.UPDATE_SERVICE
@@ -10818,6 +11659,15 @@ ${heal.removed.join("\n")}`,
10818
11659
  let block = null;
10819
11660
  try {
10820
11661
  block = await this.runInstallPreflight(this.lastUpdateInfo);
11662
+ if (!block) await this.backendService?.stop(false);
11663
+ } catch (error) {
11664
+ this.updateStage = "idle";
11665
+ logger.error(
11666
+ "Update handoff refused: owned backend cleanup failed",
11667
+ LogFileType.UPDATE_SERVICE,
11668
+ error
11669
+ );
11670
+ return false;
10821
11671
  } finally {
10822
11672
  this.installPreflightInFlight = false;
10823
11673
  }
@@ -11440,9 +12290,9 @@ ${heal.removed.join("\n")}`,
11440
12290
  * @returns Promise resolving to true if update was successful, false otherwise
11441
12291
  */
11442
12292
  /** The version the bundled environment has installed, via `pip show`. */
11443
- async readBundledBackendVersion(pythonPath) {
12293
+ async readBundledBackendVersion(pythonPath2) {
11444
12294
  const probe = await runCommand(
11445
- pythonPath,
12295
+ pythonPath2,
11446
12296
  ["-m", "pip", "show", "local-operator"],
11447
12297
  { timeoutMs: 12e4, env: this.pythonSpawnEnv() }
11448
12298
  );
@@ -11582,16 +12432,19 @@ ${heal.removed.join("\n")}`,
11582
12432
  LogFileType.UPDATE_SERVICE
11583
12433
  );
11584
12434
  }
11585
- let pythonPath = "";
12435
+ let pythonPath2 = "";
11586
12436
  const venvPath = this.backendService?.getVenvPath();
11587
12437
  if (venvPath && electron.app.isPackaged) {
11588
12438
  const venvBinDir = process.platform === "win32" ? "Scripts" : "bin";
11589
12439
  const pythonName = process.platform === "win32" ? "python.exe" : "python3";
11590
- pythonPath = path$1.join(venvPath, venvBinDir, pythonName);
12440
+ pythonPath2 = path$1.join(venvPath, venvBinDir, pythonName);
11591
12441
  }
11592
- if (!pythonPath || !fs.existsSync(pythonPath)) {
12442
+ if (!pythonPath2 || !fs.existsSync(pythonPath2)) {
11593
12443
  logger.error(
11594
- `Cannot update the bundled backend: no Python at ${pythonPath || "an unknown path"}`,
12444
+ // `managedVenvPath` answers with a sentinel when no environment has
12445
+ // been published, and "no Python at …/no-environment-selected" sent a
12446
+ // reader to a directory-shaped path that nothing creates (review N3).
12447
+ isUnpreparedVenvPath(venvPath ?? "") ? "Cannot update the bundled backend: no environment has been selected for this instance yet" : `Cannot update the bundled backend: no Python at ${pythonPath2 || "an unknown path"}`,
11595
12448
  LogFileType.UPDATE_SERVICE
11596
12449
  );
11597
12450
  await this.restartBackendAfterFailedUpgrade();
@@ -11601,8 +12454,8 @@ ${heal.removed.join("\n")}`,
11601
12454
  );
11602
12455
  return false;
11603
12456
  }
11604
- const pip = buildPipUpgradeCommand(pythonPath);
11605
- const versionBefore = await this.readBundledBackendVersion(pythonPath);
12457
+ const pip = buildPipUpgradeCommand(pythonPath2);
12458
+ const versionBefore = await this.readBundledBackendVersion(pythonPath2);
11606
12459
  logger.info(
11607
12460
  `Executing pip command: ${pip.display}`,
11608
12461
  LogFileType.UPDATE_SERVICE
@@ -11623,7 +12476,7 @@ ${pipRun.stderr.trim()}`,
11623
12476
  LogFileType.UPDATE_SERVICE
11624
12477
  );
11625
12478
  }
11626
- const versionAfter = await this.readBundledBackendVersion(pythonPath);
12479
+ const versionAfter = await this.readBundledBackendVersion(pythonPath2);
11627
12480
  const upgradeLanded = pipRun.exitCode === 0 && didUpgradeLand({
11628
12481
  before: versionBefore,
11629
12482
  after: versionAfter,
@@ -11649,16 +12502,6 @@ ${pipRun.stderr.trim()}`,
11649
12502
  "Restarting backend service after update...",
11650
12503
  LogFileType.UPDATE_SERVICE
11651
12504
  );
11652
- await new Promise((resolve) => setTimeout(resolve, 2e3));
11653
- const isHealthy = await this.checkBackendHealth();
11654
- if (isHealthy) {
11655
- logger.warn(
11656
- "Backend service is still running after stop command, attempting force termination",
11657
- LogFileType.UPDATE_SERVICE
11658
- );
11659
- await this.forceTerminateBackendProcess();
11660
- await new Promise((resolve) => setTimeout(resolve, 2e3));
11661
- }
11662
12505
  const restartSuccess = await this.backendService.restart();
11663
12506
  if (restartSuccess) {
11664
12507
  logger.info(
@@ -11692,7 +12535,6 @@ ${pipRun.stderr.trim()}`,
11692
12535
  return false;
11693
12536
  }
11694
12537
  }
11695
- this.registerBackendShutdown();
11696
12538
  } else {
11697
12539
  logger.error(
11698
12540
  "Failed to restart backend service after update",
@@ -12280,6 +13122,16 @@ function createWindow() {
12280
13122
  }
12281
13123
  const backendService = new BackendServiceManager();
12282
13124
  const backendInstaller = new BackendInstaller();
13125
+ const reportBackendFailure = (message, fileType) => {
13126
+ if (backendService.isShuttingDown()) {
13127
+ logger.error(
13128
+ `Backend Error (not shown; the app is shutting down): ${message}`,
13129
+ fileType
13130
+ );
13131
+ return;
13132
+ }
13133
+ electron.dialog.showErrorBox("Backend Error", message);
13134
+ };
12283
13135
  const windowLaunch = resolveWindowLaunchPlan({
12284
13136
  env: process.env,
12285
13137
  argv: process.argv
@@ -12644,9 +13496,9 @@ electron.app.whenReady().then(async () => {
12644
13496
  "Failed to start backend after installation, quitting app",
12645
13497
  LogFileType.INSTALLER
12646
13498
  );
12647
- electron.dialog.showErrorBox(
12648
- "Backend Error",
12649
- "Failed to start the Local Operator backend service after installation. Please restart the application."
13499
+ reportBackendFailure(
13500
+ "Failed to start the Local Operator backend service after installation. Please restart the application.",
13501
+ LogFileType.INSTALLER
12650
13502
  );
12651
13503
  electron.app.quit();
12652
13504
  return;
@@ -12658,9 +13510,9 @@ electron.app.whenReady().then(async () => {
12658
13510
  "Failed to start backend with existing installation, quitting app",
12659
13511
  LogFileType.BACKEND
12660
13512
  );
12661
- electron.dialog.showErrorBox(
12662
- "Backend Error",
12663
- "Failed to start the Local Operator backend service. Please restart the application."
13513
+ reportBackendFailure(
13514
+ "Failed to start the Local Operator backend service. Please restart the application.",
13515
+ LogFileType.BACKEND
12664
13516
  );
12665
13517
  electron.app.quit();
12666
13518
  return;
@@ -12784,161 +13636,34 @@ electron.app.on("window-all-closed", () => {
12784
13636
  );
12785
13637
  electron.app.quit();
12786
13638
  });
12787
- electron.app.on("will-quit", async (event) => {
12788
- const isBackendManagerDisabled = process.env.VITE_DISABLE_BACKEND_MANAGER === "true";
12789
- if (!isBackendManagerDisabled && !backendService.isUsingExternalBackend()) {
12790
- event.preventDefault();
12791
- try {
12792
- logger.info(
12793
- "App is quitting, stopping the Local Operator backend service...",
12794
- LogFileType.BACKEND
12795
- );
12796
- await backendService.stop(false);
12797
- logger.info(
12798
- "Local Operator backend service successfully stopped",
12799
- LogFileType.BACKEND
12800
- );
12801
- logger.info(
12802
- "Performing additional cleanup to ensure complete termination",
12803
- LogFileType.BACKEND
12804
- );
12805
- if (process.platform === "win32") {
12806
- try {
12807
- require("node:child_process").execSync(
12808
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t',
12809
- { stdio: "ignore" }
12810
- );
12811
- require("node:child_process").execSync(
12812
- "taskkill /f /im local-operator.exe /t",
12813
- { stdio: "ignore" }
12814
- );
12815
- } catch (err) {
12816
- logger.error(
12817
- "Error during Windows process cleanup (may be normal):",
12818
- LogFileType.BACKEND,
12819
- err
12820
- );
12821
- }
12822
- } else {
12823
- try {
12824
- require("node:child_process").execSync(
12825
- 'pkill -f "local-operator serve"',
12826
- { stdio: "ignore" }
12827
- );
12828
- await new Promise((resolve) => setTimeout(resolve, 1e3));
12829
- require("node:child_process").execSync(
12830
- 'pkill -9 -f "local-operator serve"',
12831
- { stdio: "ignore" }
12832
- );
12833
- } catch (err) {
12834
- logger.error(
12835
- "Error during Unix process cleanup (may be normal):",
12836
- LogFileType.BACKEND,
12837
- err
12838
- );
12839
- }
12840
- }
12841
- let allProcessesTerminated = true;
12842
- try {
12843
- if (process.platform === "win32") {
12844
- const { stdout: pythonOutput } = require("node:child_process").execSync(
12845
- `tasklist /fi "imagename eq python.exe" /fo csv`,
12846
- { encoding: "utf8" }
12847
- );
12848
- const hasPythonProcesses = pythonOutput?.trim().includes("python.exe");
12849
- const { stdout: localOperatorOutput } = require("node:child_process").execSync(
12850
- `tasklist /fi "imagename eq local-operator.exe" /fo csv`,
12851
- { encoding: "utf8" }
12852
- );
12853
- const hasLocalOperatorProcesses = localOperatorOutput?.trim().includes("local-operator.exe");
12854
- allProcessesTerminated = !hasPythonProcesses && !hasLocalOperatorProcesses;
12855
- } else {
12856
- const { stdout } = require("node:child_process").execSync(
12857
- `pgrep -f "local-operator serve" || echo ""`,
12858
- { encoding: "utf8" }
12859
- );
12860
- allProcessesTerminated = !stdout?.trim();
12861
- }
12862
- } catch (err) {
12863
- logger.error(
12864
- "Error checking for remaining processes:",
12865
- LogFileType.BACKEND,
12866
- err
12867
- );
12868
- allProcessesTerminated = true;
12869
- }
12870
- if (!allProcessesTerminated) {
12871
- logger.error(
12872
- "Some backend processes may still be running, attempting final cleanup",
12873
- LogFileType.BACKEND
12874
- );
12875
- try {
12876
- if (process.platform === "win32") {
12877
- require("node:child_process").execSync(
12878
- "taskkill /f /im python.exe /t",
12879
- { stdio: "ignore" }
12880
- );
12881
- } else {
12882
- require("node:child_process").execSync("pkill -9 -f python", {
12883
- stdio: "ignore"
12884
- });
12885
- }
12886
- } catch (finalErr) {
12887
- logger.error(
12888
- "Error during final cleanup (may be normal):",
12889
- LogFileType.BACKEND,
12890
- finalErr
12891
- );
12892
- }
12893
- }
12894
- } catch (error) {
12895
- logger.error(
12896
- "Error stopping the Local Operator backend service:",
12897
- LogFileType.BACKEND,
12898
- error
12899
- );
12900
- logger.info(
12901
- "Trying alternative termination approach",
12902
- LogFileType.BACKEND
12903
- );
12904
- if (process.platform === "win32") {
12905
- try {
12906
- require("node:child_process").execSync(
12907
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t',
12908
- { stdio: "ignore" }
12909
- );
12910
- require("node:child_process").execSync(
12911
- "taskkill /f /im local-operator.exe /t",
12912
- { stdio: "ignore" }
12913
- );
12914
- } catch (_err) {
12915
- }
12916
- } else {
12917
- try {
12918
- require("node:child_process").execSync(
12919
- 'pkill -f "local-operator serve"',
12920
- { stdio: "ignore" }
12921
- );
12922
- require("node:child_process").execSync(
12923
- 'sleep 1 && pkill -9 -f "local-operator serve"',
12924
- { stdio: "ignore" }
12925
- );
12926
- } catch (_err) {
12927
- }
12928
- }
12929
- } finally {
12930
- logger.info("Exiting application...", LogFileType.BACKEND);
12931
- setTimeout(() => {
12932
- logger.info("Forcing app exit", LogFileType.BACKEND);
12933
- electron.app.exit(0);
12934
- }, 2e3);
12935
- }
12936
- } else if (!isBackendManagerDisabled && backendService.isUsingExternalBackend()) {
12937
- logger.info(
12938
- "Using external backend, skipping termination on app quit",
13639
+ const QUIT_FAILSAFE_MARGIN_MS = 5e3;
13640
+ const QUIT_CLEANUP_FAILSAFE_MS = CONSOLE_RESOLUTION_WORST_MS + INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
13641
+ let backendQuitPending = false;
13642
+ electron.app.on("will-quit", (event) => {
13643
+ if (backendService.isOwnedCleanupComplete()) return;
13644
+ event.preventDefault();
13645
+ if (backendQuitPending) return;
13646
+ backendQuitPending = true;
13647
+ const failsafe = setTimeout(() => {
13648
+ logger.error(
13649
+ `Owned backend cleanup did not finish within ${QUIT_CLEANUP_FAILSAFE_MS} ms; exiting with failure`,
12939
13650
  LogFileType.BACKEND
12940
13651
  );
12941
- }
13652
+ electron.app.exit(1);
13653
+ }, QUIT_CLEANUP_FAILSAFE_MS);
13654
+ failsafe.unref();
13655
+ void backendService.stop(false).then(() => {
13656
+ clearTimeout(failsafe);
13657
+ electron.app.quit();
13658
+ }).catch((error) => {
13659
+ clearTimeout(failsafe);
13660
+ logger.error(
13661
+ "Owned backend cleanup failed; exiting with failure",
13662
+ LogFileType.BACKEND,
13663
+ error
13664
+ );
13665
+ electron.app.exit(1);
13666
+ });
12942
13667
  });
12943
13668
  electron.app.on("before-quit", () => {
12944
13669
  logger.info("App is about to quit", LogFileType.BACKEND);
@@ -12947,114 +13672,24 @@ electron.app.on("before-quit", () => {
12947
13672
  void stopBrowserHost();
12948
13673
  });
12949
13674
  process.on("exit", () => {
12950
- logger.info(
12951
- "Process exit event detected, ensuring the Local Operator backend service is terminated",
12952
- LogFileType.BACKEND
12953
- );
12954
13675
  try {
12955
- if (!process.env.VITE_DISABLE_BACKEND_MANAGER && !backendService.isUsingExternalBackend()) {
12956
- logger.info(
12957
- "Forcing termination of the Local Operator backend service",
12958
- LogFileType.BACKEND
12959
- );
12960
- logger.info(
12961
- "Performing final cleanup of any remaining backend processes",
12962
- LogFileType.BACKEND
12963
- );
12964
- if (process.platform === "win32") {
12965
- try {
12966
- require("node:child_process").spawnSync("cmd.exe", [
12967
- "/c",
12968
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
12969
- ]);
12970
- require("node:child_process").spawnSync("cmd.exe", [
12971
- "/c",
12972
- "taskkill /f /im local-operator.exe /t"
12973
- ]);
12974
- } catch (_err) {
12975
- }
12976
- } else {
12977
- try {
12978
- require("node:child_process").spawnSync("bash", [
12979
- "-c",
12980
- `ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -15`
12981
- ]);
12982
- require("node:child_process").spawnSync("bash", [
12983
- "-c",
12984
- `sleep 1 && ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -9`
12985
- ]);
12986
- } catch (_err) {
12987
- }
12988
- }
12989
- } else if (!process.env.VITE_DISABLE_BACKEND_MANAGER && backendService.isUsingExternalBackend()) {
12990
- logger.info(
12991
- "Using external backend, skipping force termination",
12992
- LogFileType.BACKEND
12993
- );
12994
- }
13676
+ backendService.emergencyStopOwned();
12995
13677
  } catch (error) {
12996
- logger.error("Error in exit handler", LogFileType.BACKEND, error);
13678
+ logger.error(
13679
+ "Owned backend emergency cleanup failed",
13680
+ LogFileType.BACKEND,
13681
+ error
13682
+ );
12997
13683
  }
12998
13684
  posthogClient.shutdown();
12999
13685
  });
13000
13686
  process.on("uncaughtException", (error) => {
13001
13687
  logger.error("Uncaught exception", LogFileType.BACKEND, error);
13002
- if (backendService && !process.env.VITE_DISABLE_BACKEND_MANAGER && !backendService.isUsingExternalBackend()) {
13003
- logger.info(
13004
- "Attempting to stop our backend service due to uncaught exception",
13005
- LogFileType.BACKEND
13006
- );
13007
- backendService.stop().catch((stopError) => {
13008
- logger.error(
13009
- "Error stopping the Local Operator backend service:",
13010
- LogFileType.BACKEND,
13011
- stopError
13012
- );
13013
- logger.info(
13014
- "Trying alternative termination approach",
13015
- LogFileType.BACKEND
13016
- );
13017
- if (process.platform === "win32") {
13018
- try {
13019
- require("node:child_process").spawnSync("cmd.exe", [
13020
- "/c",
13021
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
13022
- ]);
13023
- require("node:child_process").spawnSync("cmd.exe", [
13024
- "/c",
13025
- "taskkill /f /im local-operator.exe /t"
13026
- ]);
13027
- } catch (_err) {
13028
- }
13029
- } else {
13030
- try {
13031
- require("node:child_process").spawnSync("bash", [
13032
- "-c",
13033
- `ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -15`
13034
- ]);
13035
- require("node:child_process").spawnSync("bash", [
13036
- "-c",
13037
- `sleep 1 && ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -9`
13038
- ]);
13039
- } catch (_err) {
13040
- }
13041
- }
13042
- }).finally(() => {
13043
- setTimeout(() => {
13044
- logger.info(
13045
- "Forcing app exit after uncaught exception",
13046
- LogFileType.BACKEND
13047
- );
13048
- process.exit(1);
13049
- }, 1e3);
13050
- });
13051
- } else if (backendService && !process.env.VITE_DISABLE_BACKEND_MANAGER && backendService.isUsingExternalBackend()) {
13052
- logger.info(
13053
- "Using external backend, skipping termination on uncaught exception",
13054
- LogFileType.BACKEND
13688
+ void backendService.stop(false).catch((stopError) => {
13689
+ logger.error(
13690
+ "Owned backend cleanup failed",
13691
+ LogFileType.BACKEND,
13692
+ stopError
13055
13693
  );
13056
- process.exit(1);
13057
- } else {
13058
- process.exit(1);
13059
- }
13694
+ }).finally(() => process.exit(1));
13060
13695
  });