local-operator-ui 0.23.1 → 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 +1241 -492
  2. package/out/renderer/assets/{_basePickBy-mmFlh3ho.js → _basePickBy-BVR8NHkD.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-DAWnt4WG.js → _baseUniq-onZkstHq.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-CO72YkSX.js → agent-details-page-DC13Jibh.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-DhYc5_gP.js → agent-hub-page-iulwy94G.js} +1 -1
  6. package/out/renderer/assets/{agents-page-BopOzax_.js → agents-page-BSegINKc.js} +2 -2
  7. package/out/renderer/assets/{arc-BgHsUFIt.js → arc-DAb06JtB.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-BWxIiACo.js → architectureDiagram-IEHRJDOE-sSe348HD.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CoJDUfWc.js → blockDiagram-JOT3LUYC-DB0hhoP8.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-B8vXmmLx.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-rO5ve7XY.js → chunk-4BMEZGHF-SRkv1Dzw.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-CTPb1xgY.js → chunk-A2AXSNBT-BBdcJhkk.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-5VpTdLBm.js → chunk-AEK57VVT-BFLmAuIk.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-C3CEf0kC.js → chunk-D6G4REZN--QHGDmI-.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-B7nn7Euk.js → chunk-RZ5BOZE2-Cy8U6l3Q.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-CnPH3vBT.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-Bu8vshU-.js → compact-pagination-DERWPPOM.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-DUX1RPbJ.js → dagre-OKDRZEBW-DfjKcX6M.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-Cpv42kzr.js → diagram-SSKATNLV-Bm1j7Y3r.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-CGkUnKEL.js → diagram-VNBRO52H-BRx5TiDX.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-iBbJbyJZ.js → erDiagram-Q7BY3M3F-BSxbIo11.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-B569Tv1u.js → flowDiagram-4HSFHLVR-R2ILhUKR.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-nGbcgsgD.js → ganttDiagram-APWFNJXF-Gd7TZJFy.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-vv_MhteL.js → gitGraphDiagram-7IBYFJ6S-BAs2K2WV.js} +1 -1
  29. package/out/renderer/assets/{graph-krOMwYSa.js → graph-zm35SWuR.js} +1 -1
  30. package/out/renderer/assets/{index-CT6w0yXj.js → index-CqIP2mt3.js} +1 -1
  31. package/out/renderer/assets/{index-BqWgqUKZ.js → index-DF2zdD6_.js} +5 -5
  32. package/out/renderer/assets/{index-BgGl2-0g.js → index-DXx2j7ov.js} +1 -1
  33. package/out/renderer/assets/{infoDiagram-PH2N3AL5-DHDA5253.js → infoDiagram-PH2N3AL5-BcAWGJ1_.js} +1 -1
  34. package/out/renderer/assets/{journeyDiagram-U35MCT3I-CIrQ218m.js → journeyDiagram-U35MCT3I-VfDkAwaY.js} +1 -1
  35. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DG_YsZbL.js → kanban-definition-NDS4AKOZ-BVgW6OQK.js} +1 -1
  36. package/out/renderer/assets/{layout-CB5zJSjt.js → layout-jfvNOQNg.js} +1 -1
  37. package/out/renderer/assets/{legacy-agents-page-B8pvrWy_.js → legacy-agents-page-yObswBd2.js} +1 -1
  38. package/out/renderer/assets/{mermaid.core-CYc-R2hQ.js → mermaid.core-w7CiwIS9.js} +5 -5
  39. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-C22fFThl.js → mindmap-definition-ALO5MXBD-Ck5fBAvP.js} +1 -1
  40. package/out/renderer/assets/{parseISO-vr2fT-wq.js → parseISO-BfcDJeSX.js} +1 -1
  41. package/out/renderer/assets/{pieDiagram-IB7DONF6-DKRytw7K.js → pieDiagram-IB7DONF6-xuxf3Is5.js} +1 -1
  42. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CWdnqGKr.js → quadrantDiagram-7GDLP6J5-B4UU5d5n.js} +1 -1
  43. package/out/renderer/assets/{radar-MK3ICKWK-B0XgopGa.js → radar-MK3ICKWK-BUD9nsvK.js} +1 -1
  44. package/out/renderer/assets/{radient-auth-buttons-RrBw8fkd.js → radient-auth-buttons-BtcEZuSY.js} +1 -1
  45. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CXL8GuIo.js → requirementDiagram-KVF5MWMF-0fsS7ohi.js} +1 -1
  46. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BQKCHfmN.js → sankeyDiagram-QLVOVGJD-CUjpA8gC.js} +1 -1
  47. package/out/renderer/assets/{schedules-page-DtNhlycC.js → schedules-page-xXMGrdrg.js} +1 -1
  48. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-awR0fjHu.js → sequenceDiagram-X6HHIX6F-CX6mfjEg.js} +1 -1
  49. package/out/renderer/assets/{settings-page-DCLaspsn.js → settings-page-PLBi7GEM.js} +1 -1
  50. package/out/renderer/assets/{stateDiagram-DGXRK772-BjhA_mJe.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-BFEDYBl8.js → timeline-definition-BDJGKUSR-B6_9nD67.js} +1 -1
  53. package/out/renderer/assets/{use-agent-like-mutation-Bxx_WHvo.js → use-agent-like-mutation-Ckg2HQQI.js} +1 -1
  54. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CwLeRa1a.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-DAvIgUhZ.js +0 -1
  58. package/out/renderer/assets/classDiagram-GIVACNV2-CP5aSNYY.js +0 -1
  59. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CP5aSNYY.js +0 -1
  60. package/out/renderer/assets/clone-CeahOD_w.js +0 -1
  61. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-nAxLNCrm.js +0 -1
package/out/main/index.js CHANGED
@@ -13,6 +13,8 @@ const os = require("node:os");
13
13
  const node_util = require("node:util");
14
14
  const dotenv = require("dotenv");
15
15
  const electronLog = require("electron-log");
16
+ const promises = require("node:fs/promises");
17
+ const node_net = require("node:net");
16
18
  const node_http = require("node:http");
17
19
  const https = require("node:https");
18
20
  const electronUpdater = require("electron-updater");
@@ -1548,83 +1550,97 @@ function pythonBytecodeCacheDir(userDataDir) {
1548
1550
  }
1549
1551
  function withPythonBytecodeCache(env, userDataDir) {
1550
1552
  const existing = env.PYTHONPYCACHEPREFIX?.trim();
1551
- if (existing && !insideAppBundle(existing)) return { ...env };
1552
- return { ...env, PYTHONPYCACHEPREFIX: pythonBytecodeCacheDir(userDataDir) };
1553
- }
1554
- const BUNDLED_PYTHON_TREE_NAMES = ["python", "python_aarch64"];
1555
- function bundledPythonTreePaths(resourcesPath) {
1556
- return BUNDLED_PYTHON_TREE_NAMES.map((name) => path$1.join(resourcesPath, name));
1557
- }
1558
- const FIND = "/usr/bin/find";
1559
- const CHMOD = "/bin/chmod";
1560
- const DIRECTORY_ACE = "everyone deny add_file,add_subdirectory";
1561
- const BYTECODE_ACE = "everyone deny write,append";
1562
- const MAX_FIND_OUTPUT_BYTES = 16 * 1024 * 1024;
1563
- const PARTIAL_SEAL_NAMED = 3;
1564
- function describePythonTreeSeal(seal) {
1565
- if (!seal.supported) {
1566
- return "Bundled interpreter bytecode seal is macOS-only (there is no code seal to protect elsewhere); the bytecode cache prefix still applies";
1567
- }
1568
- const trees = seal.sealed.join(", ") || "(none present)";
1569
- if (seal.failures.length === 0) {
1570
- return `Bundled interpreter trees sealed against bytecode writes: ${trees}; ${seal.directories} directory path(s) selected and now refusing new entries, ${seal.bytecodeFiles} bytecode file(s) selected and now refusing rewrites`;
1571
- }
1572
- const named = seal.failures.slice(0, PARTIAL_SEAL_NAMED).join("; ");
1573
- const rest = seal.failures.length - PARTIAL_SEAL_NAMED;
1574
- return `Bundled interpreter bytecode seal INCOMPLETE: ${trees}; ${seal.directories} directory path(s) and ${seal.bytecodeFiles} bytecode file(s) selected, ${seal.failures.length} refused and left with the access they had - bytecode writes into the bundle are not prevented on every path. First refusals: ${named}${rest > 0 ? ` (+${rest} more)` : ""}`;
1575
- }
1576
- function sealPythonInterpreterTrees(trees) {
1577
- const result = {
1578
- sealed: [],
1579
- directories: 0,
1580
- bytecodeFiles: 0,
1581
- failures: [],
1582
- supported: process.platform === "darwin"
1553
+ const prefix = existing && !insideAppBundle(existing) ? existing : pythonBytecodeCacheDir(userDataDir);
1554
+ return {
1555
+ ...env,
1556
+ PYTHONPYCACHEPREFIX: prefix,
1557
+ PYTHONDONTWRITEBYTECODE: "1"
1583
1558
  };
1584
- if (!result.supported) return result;
1585
- for (const tree of trees) {
1586
- let root;
1587
- try {
1588
- root = fs.lstatSync(tree);
1589
- } catch {
1590
- continue;
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
+ }
1591
1595
  }
1592
- if (!root.isDirectory()) continue;
1593
- result.sealed.push(tree);
1594
- result.directories += applyAce(
1595
- tree,
1596
- ["-type", "d"],
1597
- DIRECTORY_ACE,
1598
- result.failures
1599
- );
1600
- result.bytecodeFiles += applyAce(
1601
- tree,
1602
- ["-type", "f", "-name", "*.pyc"],
1603
- BYTECODE_ACE,
1604
- result.failures
1605
- );
1596
+ } catch {
1606
1597
  }
1607
- return result;
1598
+ return candidates.filter((candidate) => fs.existsSync(candidate));
1608
1599
  }
1609
- function applyAce(tree, selector, ace, failures) {
1610
- const run = node_child_process.spawnSync(
1611
- FIND,
1612
- [tree, ...selector, "-exec", CHMOD, "+a", ace, "{}", "+", "-print0"],
1613
- { encoding: "buffer", maxBuffer: MAX_FIND_OUTPUT_BYTES }
1614
- );
1615
- if (run.error) {
1616
- failures.push(`${tree}: ${String(run.error)}`);
1617
- return 0;
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
+ };
1608
+ }
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 {
1618
1615
  }
1619
- for (const line of String(run.stderr).split("\n")) {
1620
- if (line.trim().length > 0) failures.push(line.trim());
1616
+ if (existing === source) {
1617
+ return {
1618
+ path: path2,
1619
+ written: false,
1620
+ reason: `${path2} already refuses bytecode writes`
1621
+ };
1621
1622
  }
1622
- if (run.status !== 0 && String(run.stderr).trim().length === 0) {
1623
- failures.push(`${tree}: ${FIND} exited ${run.status} with no explanation`);
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
+ };
1624
1629
  }
1625
- let selected = 0;
1626
- for (const byte of run.stdout) if (byte === 0) selected += 1;
1627
- return selected;
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
+ };
1628
1644
  }
1629
1645
  var LogFileType = /* @__PURE__ */ ((LogFileType2) => {
1630
1646
  LogFileType2["INSTALLER"] = "backend-installer.log";
@@ -1848,6 +1864,643 @@ const backendConfig = loadConfig();
1848
1864
  const apiConfig = {
1849
1865
  baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
1850
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
+ }
1851
2504
  const SHEBANG = /^#!([^\r\n]+)\r?\n/;
1852
2505
  const PYTHON_NAME = /[/\\]python(?:\d+(?:\.\d+)*)?$/;
1853
2506
  const SERVE_ENTRYPOINT = "from local_operator.cli import main; main()";
@@ -2280,24 +2933,12 @@ class BackendServiceManager {
2280
2933
  error
2281
2934
  );
2282
2935
  }
2283
- if (process.platform === "win32") {
2284
- this.venvPath = path$1.join(this.appDataPath, "local-operator-venv");
2285
- } else if (process.platform === "darwin") {
2286
- this.venvPath = path$1.join(
2287
- electron.app.getPath("home"),
2288
- "Library",
2289
- "Application Support",
2290
- "Local Operator",
2291
- "local-operator-venv"
2292
- );
2293
- } else {
2294
- this.venvPath = path$1.join(
2295
- electron.app.getPath("home"),
2296
- ".config",
2297
- "local-operator",
2298
- "local-operator-venv"
2299
- );
2300
- }
2936
+ this.venvPath = managedVenvPath({
2937
+ platform: process.platform,
2938
+ home: electron.app.getPath("home"),
2939
+ appDataPath: this.appDataPath,
2940
+ packaged: electron.app.isPackaged
2941
+ });
2301
2942
  this.loadShellEnvironment();
2302
2943
  logger.info(
2303
2944
  `Backend Service Manager initialized. Disabled: ${this.isDisabled}`,
@@ -2551,6 +3192,21 @@ class BackendServiceManager {
2551
3192
  windowsHide: true
2552
3193
  });
2553
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
+ }
2554
3210
  logger.info(
2555
3211
  `local-operator command found at: ${stdout.trim()}`,
2556
3212
  LogFileType.BACKEND
@@ -2720,6 +3376,12 @@ class BackendServiceManager {
2720
3376
  this.desktopToken = node_crypto.randomBytes(32).toString("hex");
2721
3377
  let captured = null;
2722
3378
  try {
3379
+ this.venvPath = managedVenvPath({
3380
+ platform: process.platform,
3381
+ home: electron.app.getPath("home"),
3382
+ appDataPath: this.appDataPath,
3383
+ packaged: electron.app.isPackaged
3384
+ });
2723
3385
  const globalInstall = await this.checkLocalOperatorExists();
2724
3386
  const env = this.backendSpawnEnv();
2725
3387
  let interpreters;
@@ -3106,300 +3768,8 @@ class BackendServiceManager {
3106
3768
  return this.restartPromise;
3107
3769
  }
3108
3770
  }
3109
- const linuxInstallScriptRaw = '#!/bin/bash\n# Local Operator Backend Installation Script for Linux\n# This script sets up a virtual environment for the Local Operator backend\n# without requiring sudo or admin privileges.\n\n# Exit on error, undefined variables, and failed pipe commands\nset -e # Exit immediately if a command exits with a non-zero status\nset -u # Error on unset variables\nset -o pipefail # Fail if any command in a pipe fails\n\n# Configuration\n# Variables APP_NAME and MIN_PYTHON_VERSION removed as they were unused\nVENV_NAME="local-operator-venv"\nAPP_DATA_DIR="${HOME}/.config/local-operator"\nVENV_PATH="${APP_DATA_DIR}/${VENV_NAME}"\nLOG_FILE="${APP_DATA_DIR}/backend-install.log"\nTIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")\n\n# Keep CPython\'s bytecode cache out of the application directory.\n#\n# Same reason as the macOS script: an interpreter writing __pycache__/*.pyc\n# beside its own stdlib sources writes into the installed tree. Linux does not\n# code-seal the app the way macOS does, so this is uniformity and hygiene here\n# rather than the load-bearing fix it is on macOS - but it is also the value a\n# standalone run of this script has to agree with, because the app and the\n# script must not disagree about where bytecode goes.\n: "${PYTHONPYCACHEPREFIX:=${APP_DATA_DIR}/python-bytecode-cache}"\nexport PYTHONPYCACHEPREFIX\n\n# Create app data directory if it doesn\'t exist\nif ! mkdir -p "${APP_DATA_DIR}"; then\n echo "ERROR: Unable to create app data directory at ${APP_DATA_DIR}"\n echo "Please check permissions and try again."\n exit 1\nfi\n\n# Handle cleanup on script exit\ncleanup() {\n # Remove any temporary files created during execution\n if [ -f "${APP_DATA_DIR}/get-pip.py" ]; then\n rm -f "${APP_DATA_DIR}/get-pip.py"\n fi\n if [ -n "${FFMPEG_TEMP_ARCHIVE:-}" ] && [ -f "${FFMPEG_TEMP_ARCHIVE}" ]; then\n log "Removing temporary FFmpeg archive: ${FFMPEG_TEMP_ARCHIVE}"\n rm -f "${FFMPEG_TEMP_ARCHIVE}"\n fi\n echo "$(date): Cleanup completed."\n}\n\n# Set trap only for manual interruption (SIGINT, SIGTERM), not normal exits\ntrap cleanup SIGINT SIGTERM\n\n\n# Start logging\nexec > >(tee -a "${LOG_FILE}") 2>&1\necho "[${TIMESTAMP}]: Starting Local Operator backend installation..."\necho "[${TIMESTAMP}]: System information: $(uname -a)"\n\n# Function to log messages with timestamp (defined later, but used by FFmpeg section)\n_log_internal() {\n local internal_timestamp\n internal_timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${internal_timestamp}] $1"\n}\n\n# --- FFmpeg Installation ---\nBIN_DIR="${APP_DATA_DIR}/bin"\nFFMPEG_BIN="${BIN_DIR}/ffmpeg" # Path to ffmpeg binary\n\n_log_internal "Ensuring bin directory exists: ${BIN_DIR}"\nif ! mkdir -p "${BIN_DIR}"; then\n _log_internal "ERROR: Unable to create bin directory at ${BIN_DIR}"\n exit 1\nfi\n\n# Check if FFmpeg is already installed and executable\nif [ -f "${FFMPEG_BIN}" ] && [ -x "${FFMPEG_BIN}" ]; then\n _log_internal "FFmpeg already installed at ${FFMPEG_BIN}. Skipping download."\nelse\n _log_internal "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."\n\n FFMPEG_DOWNLOAD_URL=""\n\n LINUX_ARCH=$(uname -m)\n if [[ "${LINUX_ARCH}" == "x86_64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-x64"\n elif [[ "${LINUX_ARCH}" == "aarch64" ]] || [[ "${LINUX_ARCH}" == "arm64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-arm64"\n else\n _log_internal "Error: Unsupported CPU architecture for FFmpeg download: ${LINUX_ARCH}"\n exit 1\n fi\n\n _log_internal "Downloading FFmpeg from: ${FFMPEG_DOWNLOAD_URL}"\n if command -v curl >/dev/null 2>&1; then\n if ! curl -L "${FFMPEG_DOWNLOAD_URL}" -o "${FFMPEG_BIN}"; then\n _log_internal "Error: Failed to download FFmpeg using curl from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n elif command -v wget >/dev/null 2>&1; then\n if ! wget -O "${FFMPEG_BIN}" "${FFMPEG_DOWNLOAD_URL}"; then\n _log_internal "Error: Failed to download FFmpeg using wget from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n else\n _log_internal "Error: Neither curl nor wget found. Cannot download FFmpeg."\n exit 1\n fi\n _log_internal "FFmpeg binary downloaded successfully to ${FFMPEG_BIN}"\n\n chmod +x "${FFMPEG_BIN}"\n _log_internal "Set executable permissions for ${FFMPEG_BIN}"\n\n # Verify FFmpeg is executable after download\n if [ ! -f "${FFMPEG_BIN}" ] || [ ! -x "${FFMPEG_BIN}" ]; then\n _log_internal "Error: FFmpeg binary not found or not executable after download."\n exit 1\n fi\nfi\n\n_log_internal "FFmpeg installation complete. FFmpeg binary is at: ${FFMPEG_BIN}"\n\n# Function to check if a command exists\ncommand_exists() {\n command -v "$1" >/dev/null 2>&1\n}\n\n# Function to log messages with timestamp\nlog() {\n local timestamp\n timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${timestamp}] $1"\n}\n\n# Function to display error messages and exit\nerror_exit() {\n log "ERROR: $1" >&2\n exit "${2:-1}"\n}\n\n# Function to check if Python binary is valid for current architecture\nis_valid_python_binary() {\n local python_path="$1"\n \n # First check if file exists and is executable\n if [ ! -f "${python_path}" ] || [ ! -x "${python_path}" ]; then\n return 1\n fi\n \n # Try to run a simple Python command to verify it works on current architecture\n "${python_path}" -c "print(\'Testing Python executable\')" >/dev/null 2>&1\n return $?\n}\n\n# Check for network connectivity to key servers\ncheck_connectivity() {\n log "Checking network connectivity..."\n local servers=("pypi.org" "bootstrap.pypa.io")\n local has_connectivity=false\n \n for server in "${servers[@]}"; do\n if command_exists curl; then\n if curl --connect-timeout 5 -s "https://${server}" -o /dev/null; then\n has_connectivity=true\n break\n fi\n elif command_exists wget; then\n if wget --timeout=5 -q --spider "https://${server}"; then\n has_connectivity=true\n break\n fi\n fi\n done\n \n if [ "$has_connectivity" = false ]; then\n log "WARNING: Network connectivity issues detected. This may affect installation."\n else\n log "Network connectivity confirmed."\n fi\n}\n\n# Call connectivity check\ncheck_connectivity\n\n# Check if PYTHON_BIN is already set by the installer\nif [ -n "${PYTHON_BIN:-}" ]; then\n log "Using Python executable provided by installer: ${PYTHON_BIN}"\n # Verify the provided Python binary works on this architecture\n if ! is_valid_python_binary "${PYTHON_BIN}"; then\n log "Warning: The provided Python binary is not compatible with this system architecture."\n log "Will attempt to find a system Python installation instead."\n PYTHON_BIN=""\n fi\nfi\n\n# If PYTHON_BIN is empty or invalid, try to find a suitable Python installation\nif [ -z "${PYTHON_BIN:-}" ] || ! is_valid_python_binary "${PYTHON_BIN:-}"; then\n # Try to find a suitable Python installation\n log "Looking for a suitable Python installation..."\n \n # Try multiple possible locations to find Python\n POSSIBLE_PYTHON_PATHS=(\n # System paths first (more likely to be compatible with current architecture)\n "/usr/local/bin/python3.12"\n "/usr/bin/python3.12"\n "/usr/local/bin/python3"\n "/usr/bin/python3"\n # From environment variable (set by the installer)\n "${ELECTRON_RESOURCE_PATH:-}/python/bin/python3"\n # Development paths\n "$(dirname "$0")/../../../resources/python/bin/python3"\n "$(pwd)/resources/python/bin/python3"\n )\n\n # Find the first Python that exists and is version 3.12+ and works on this architecture\n PYTHON_BIN=""\n for path in "${POSSIBLE_PYTHON_PATHS[@]}"; do\n # Skip empty paths that might come from unset environment variables\n if [ -z "${path}" ]; then\n continue\n fi\n \n if is_valid_python_binary "${path}"; then\n # Check Python version\n if PY_VERSION=$("${path}" -c "import sys; print(f\'{sys.version_info.major}.{sys.version_info.minor}\')" 2>/dev/null); then\n MAJOR=$(echo "${PY_VERSION}" | cut -d. -f1)\n MINOR=$(echo "${PY_VERSION}" | cut -d. -f2)\n if [ "${MAJOR}" -eq 3 ] && [ "${MINOR}" -ge 12 ]; then\n PYTHON_BIN="${path}"\n log "Found suitable Python ${PY_VERSION} at ${path}"\n break\n else\n log "Python at ${path} is version ${PY_VERSION}, which is below the required 3.12+"\n fi\n fi\n else\n if [ -f "${path}" ]; then\n log "Python at ${path} exists but is not compatible with this system architecture"\n fi\n fi\n done\n\n # If we couldn\'t find a suitable Python, exit with error\n if [ -z "${PYTHON_BIN:-}" ]; then\n error_exit "No suitable Python installation found (version 3.12 or higher required).\\nPlease install Python 3.12 or higher before running this script.\\nYou can install Python from https://www.python.org/downloads/"\n fi\nfi\n\necho "Using Python: $PYTHON_BIN"\n"$PYTHON_BIN" --version || {\n echo "Error: Failed to run Python. Please ensure it is executable and accessible."\n echo "System architecture: $(uname -m)"\n echo "Python binary architecture: $(file "$PYTHON_BIN" 2>/dev/null || echo \'Unable to determine\')"\n exit 1\n}\n\n# Detect the Linux distribution to provide specific instructions\nif command_exists lsb_release; then\n DISTRO=$(lsb_release -is)\n DISTRO_VERSION=$(lsb_release -rs)\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelif [ -f /etc/os-release ]; then\n DISTRO=$(grep -oP \'(?<=^ID=).+\' /etc/os-release | tr -d \'"\')\n DISTRO_VERSION=$(grep -oP \'(?<=^VERSION_ID=).+\' /etc/os-release | tr -d \'"\')\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelse\n DISTRO="unknown"\n echo "Unable to detect Linux distribution"\nfi\n\n# Check if Python has venv module and ensurepip available\nlog "Checking for venv module and ensurepip availability..."\nVENV_AVAILABLE=0\nENSUREPIP_AVAILABLE=0\n\n"${PYTHON_BIN}" -c "import venv" > /dev/null 2>&1 || VENV_AVAILABLE=1\n"${PYTHON_BIN}" -c "import ensurepip" > /dev/null 2>&1 || ENSUREPIP_AVAILABLE=1\n\n# If venv is still not available, try to use virtualenv as a fallback\nif [ $VENV_AVAILABLE -ne 0 ]; then\n echo "venv module is not available. Checking for virtualenv as an alternative..."\n \n # Check if virtualenv is already installed\n if "$PYTHON_BIN" -c "import virtualenv" > /dev/null 2>&1; then\n echo "virtualenv is available, will use it instead of venv"\n else\n echo "ERROR: Neither venv nor virtualenv is available."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n exit 1\n fi\nfi\n\n# Create virtual environment if it doesn\'t exist\nif [ ! -d "$VENV_PATH" ]; then\n echo "Creating virtual environment at $VENV_PATH..."\n # Remove any potentially corrupted virtual environment\n if [ -e "$VENV_PATH" ]; then\n echo "Removing existing but potentially corrupted venv directory..."\n rm -rf "$VENV_PATH"\n fi\n \n # Make sure parent directory exists and is writable\n mkdir -p "$(dirname "$VENV_PATH")"\n \n # Try different methods to create a virtual environment\n VENV_CREATE_STATUS=1\n \n # First try with venv if available\n if [ $VENV_AVAILABLE -eq 0 ] && [ $ENSUREPIP_AVAILABLE -eq 0 ]; then\n echo "Creating virtual environment using venv module..."\n "$PYTHON_BIN" -m venv "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n fi\n \n # If venv failed, try virtualenv\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n echo "venv creation failed with status $VENV_CREATE_STATUS. Falling back to virtualenv..."\n if command_exists virtualenv; then\n virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n elif "$PYTHON_BIN" -m pip list | grep -q virtualenv; then\n "$PYTHON_BIN" -m virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n else\n echo "ERROR: Both venv and virtualenv are unavailable or failed."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n echo "You can install Python 3.12 from https://www.python.org/downloads/"\n exit 1\n fi\n fi\n \n # Check if virtual environment creation was successful\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n log "ERROR: Failed to create virtual environment. Exit code: $VENV_CREATE_STATUS"\n log "Virtual environment path: $VENV_PATH"\n log "Python binary used: $PYTHON_BIN"\n ls -la "$(dirname "$VENV_PATH")" || true\n log "Python executable permissions:"\n ls -la "$PYTHON_BIN" || true\n \n # Check for common issues\n log "Checking for common virtual environment creation issues..."\n \n # Check disk space\n df -h "$(dirname "$VENV_PATH")" || true\n \n # Check if directory is writable\n if [ ! -w "$(dirname "$VENV_PATH")" ]; then\n log "ERROR: Directory $(dirname "$VENV_PATH") is not writable"\n fi\n \n # Try to create a minimal virtual environment manually as a last resort\n log "Attempting to create a minimal virtual environment manually..."\n mkdir -p "$VENV_PATH/bin" || error_exit "Could not create directory $VENV_PATH/bin"\n echo "#!/bin/bash" > "$VENV_PATH/bin/activate"\n echo "export VIRTUAL_ENV=\\"$VENV_PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "export PATH=\\"$VENV_PATH/bin:\\$PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "unset PYTHONHOME" >> "$VENV_PATH/bin/activate"\n chmod +x "$VENV_PATH/bin/activate" || error_exit "Could not set execute permissions on activate script"\n \n # Create symlinks to the system Python\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python" || log "WARNING: Failed to create symlink for python"\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python3" || log "WARNING: Failed to create symlink for python3"\n \n # Try to bootstrap pip\n log "Bootstrapping pip in the minimal virtual environment..."\n if command_exists curl; then\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py" || log "WARNING: Failed to download get-pip.py"\n elif command_exists wget; then\n wget -q -O "$APP_DATA_DIR/get-pip.py" https://bootstrap.pypa.io/get-pip.py || log "WARNING: Failed to download get-pip.py"\n else\n log "ERROR: Neither curl nor wget available to download get-pip.py"\n error_exit "Installation cannot continue without being able to download pip"\n fi\n \n # shellcheck disable=SC1090\n source "$VENV_PATH/bin/activate" && python "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n error_exit "Failed to create even a minimal virtual environment."\n else\n log "Created a minimal virtual environment as a fallback."\n fi\n else\n log "Successfully created virtual environment"\n fi\nfi\n\n# Verify the virtual environment structure\necho "Verifying virtual environment structure..."\nif [ ! -f "$VENV_PATH/bin/python" ]; then\n echo "Python executable missing in virtual environment, creating symlink..."\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python"\nfi\n\nif [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "pip missing in virtual environment, attempting to bootstrap it..."\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py"\n "$VENV_PATH/bin/python" "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "ERROR: Failed to bootstrap pip in the virtual environment"\n exit 1\n fi\nfi\n\necho "Virtual environment structure verified"\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\necho "Upgrading pip..."\npython -m pip install --upgrade pip || {\n echo "WARNING: Failed to upgrade pip. Will try to continue with existing pip version."\n pip --version\n}\n\n# Check network connectivity to PyPI\necho "Checking network connectivity to PyPI..."\nif command_exists curl; then\n curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nelif command_exists wget; then\n wget -q --spider https://pypi.org/pypi/local-operator/json || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nfi\n\necho "Installing local-operator package..."\npython -m pip install --verbose local-operator || {\n echo "ERROR: Failed to install local-operator package. Exit code: $?"\n echo "Python version:"\n python --version\n echo "pip version:"\n pip --version\n echo "Available pip packages:"\n pip list\n exit 1\n}\necho "local-operator installation successful"\n\n# Verify installation\nif [ -f "$VENV_PATH/bin/local-operator" ]; then\n log "Local Operator backend installed successfully!"\n \n # Show more information about the installed binary\n ls -la "$VENV_PATH/bin/local-operator" || true\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n \n # Try to run the version command with full error output\n log "Testing local-operator binary..."\n "$VENV_PATH/bin/local-operator" --version || {\n log "ERROR: local-operator binary exists but failed to execute. Exit code: $?"\n log "Binary details:"\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n log "Binary permissions:"\n ls -la "$VENV_PATH/bin/local-operator" || true\n log ""\n log "This could be due to missing dependencies or architecture incompatibility."\n log "Try running the binary manually to see specific errors."\n \n # Don\'t exit here - just warn the user\n log "WARNING: Installation completed but binary verification failed."\n }\n \n # Create a summary of the installation\n SUMMARY_FILE="${APP_DATA_DIR}/installation-summary-${TIMESTAMP}.txt"\n {\n echo "=== Local Operator Backend Installation Summary ==="\n echo "Timestamp: $(date)"\n echo "Python Version: $("${PYTHON_BIN}" --version 2>&1)"\n echo "Virtual Environment: ${VENV_PATH}"\n echo "Binary Location: ${VENV_PATH}/bin/local-operator"\n echo "To activate the virtual environment, run: source ${VENV_PATH}/bin/activate"\n echo "To test the installation, run: ${VENV_PATH}/bin/local-operator --version"\n echo "Log file: ${LOG_FILE}"\n echo "=================================================="\n } > "${SUMMARY_FILE}"\n \n log "Installation summary saved to: ${SUMMARY_FILE}"\nelse\n log "Error: Failed to install Local Operator backend. Binary not found."\n log "Contents of bin directory:"\n ls -la "$VENV_PATH/bin/" || true\n log "Check the log file for details: ${LOG_FILE}"\n error_exit "Installation failed. Binary not installed properly."\nfi\n\nlog "Installation completed successfully at $(date)"\n\n# Clean up temporary files now that installation is complete\ncleanup\n\n# Print final instructions\nlog ""\nlog "=== INSTALLATION COMPLETE ==="\nlog "You can now use the Local Operator backend by running: ${VENV_PATH}/bin/local-operator"\nlog "Installation directory: ${VENV_PATH}"\nlog "Log file: ${LOG_FILE}"\nlog "================================"\n';
3110
- const macosInstallScriptRaw = `#!/bin/bash
3111
- # Local Operator Backend Installation Script for macOS
3112
- # This script uses the bundled standalone Python and sets up a virtual environment for the Local Operator backend.
3113
-
3114
- set -e # Exit immediately if a command exits with a non-zero status
3115
-
3116
- # Configuration
3117
- APP_NAME="Local Operator"
3118
- VENV_NAME="local-operator-venv"
3119
- APP_DATA_DIR="$HOME/Library/Application Support/$APP_NAME"
3120
- VENV_PATH="$APP_DATA_DIR/$VENV_NAME"
3121
- LOG_FILE="$APP_DATA_DIR/backend-install.log"
3122
-
3123
- # Keep CPython's bytecode cache out of the application bundle.
3124
- #
3125
- # The bundled interpreter writes __pycache__/*.pyc beside the stdlib sources it
3126
- # imports, and those sources live inside the code-signed .app - which is the
3127
- # build's own extraResource. Every such write is a change to a sealed resource:
3128
- # measured on an installed 0.17.3, \`codesign --verify --deep\` reported 308
3129
- # \`file added:\` violations, all of them .pyc, and ShipIt then refuses the
3130
- # in-place update with -67028 errSecCSBadBundleFormat. A file codesign reports
3131
- # as *added* can be deleted and the seal comes back; a *modified* one cannot,
3132
- # which is why nothing may ship a .pyc at all. The app sets this variable when
3133
- # it spawns us; defaulting it here keeps a standalone run of this script in
3134
- # agreement with the app about where bytecode goes. It is not a complete answer:
3135
- # the app does not spawn every python that runs this interpreter, and a python
3136
- # started by something else - a shell, a CLI script, an agent - has no prefix at
3137
- # all (measured: a venv over this tree wrote 25 .pyc into it that way). The half
3138
- # that covers those is the app's seal on the tree itself
3139
- # (src/main/python-bytecode-cache.ts), which a standalone run of this script does
3140
- # not apply. $HOME is the right place for the cache for the same reason the venv
3141
- # lives there: it is never inside the thing that gets signed and swapped.
3142
- : "\${PYTHONPYCACHEPREFIX:=$APP_DATA_DIR/python-bytecode-cache}"
3143
- export PYTHONPYCACHEPREFIX
3144
-
3145
- # Determine CPU Architecture and Python Directory Name
3146
- ARCH=$(uname -m)
3147
- PYTHON_DIR_NAME="python" # Default for x86_64
3148
- PYTHON_ARCH_NAME="x86_64" # For logging
3149
-
3150
- if [[ "$ARCH" == "x86_64" ]]; then
3151
- PYTHON_DIR_NAME="python"
3152
- PYTHON_ARCH_NAME="x86_64"
3153
- elif [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then # arm64 is what uname -m returns on Apple Silicon
3154
- PYTHON_DIR_NAME="python_aarch64"
3155
- PYTHON_ARCH_NAME="aarch64"
3156
- else
3157
- echo "Error: Unsupported CPU architecture: $ARCH"
3158
- exit 1
3159
- fi
3160
- echo "Detected CPU architecture: $ARCH, using Python directory name: $PYTHON_DIR_NAME"
3161
-
3162
- # Check if PYTHON_BIN is already set by the installer
3163
- if [[ -n "$PYTHON_BIN" ]]; then
3164
- echo "Using Python executable provided by installer: $PYTHON_BIN"
3165
- else
3166
- # Get the path to the bundled Python based on architecture
3167
- # Try multiple possible locations to find Python
3168
- POSSIBLE_PYTHON_PATHS=(
3169
- # From environment variable (set by the installer)
3170
- "$ELECTRON_RESOURCE_PATH/$PYTHON_DIR_NAME/bin/python3"
3171
- # Absolute paths for packaged app
3172
- "/Applications/Local Operator.app/Contents/Resources/$PYTHON_DIR_NAME/bin/python3"
3173
- "$HOME/Applications/Local Operator.app/Contents/Resources/$PYTHON_DIR_NAME/bin/python3"
3174
- # Development paths
3175
- "$(dirname "$0")/../../../resources/$PYTHON_DIR_NAME/bin/python3"
3176
- "$(pwd)/resources/$PYTHON_DIR_NAME/bin/python3"
3177
- # System Python as last resort (less ideal as it might not be the version we tested with)
3178
- "/usr/bin/python3"
3179
- )
3180
-
3181
- # Find the first Python that exists
3182
- PYTHON_BIN=""
3183
- echo "Searching for Python in the following locations for $PYTHON_ARCH_NAME architecture (using directory $PYTHON_DIR_NAME):"
3184
- for path in "\${POSSIBLE_PYTHON_PATHS[@]}"; do
3185
- echo " - Checking $path"
3186
- if [[ -f "$path" && -x "$path" ]]; then
3187
- PYTHON_BIN="$path"
3188
- echo "Found Python at $path"
3189
- break
3190
- else
3191
- echo " Not found or not executable."
3192
- fi
3193
- done
3194
-
3195
- # If we couldn't find Python, try to use the system Python
3196
- if [[ -z "$PYTHON_BIN" ]]; then
3197
- echo "Bundled Python for $PYTHON_ARCH_NAME not found in directory $PYTHON_DIR_NAME. Attempting to use system Python..."
3198
- if command -v python3 &>/dev/null; then
3199
- PYTHON_BIN=$(command -v python3)
3200
- echo "Warning: Using system Python at $PYTHON_BIN. This may lead to unexpected behavior."
3201
- else
3202
- echo "Error: Could not find a suitable Python executable. Tried the following paths for $PYTHON_ARCH_NAME (expected in $PYTHON_DIR_NAME):"
3203
- for path in "\${POSSIBLE_PYTHON_PATHS[@]}"; do
3204
- echo " - $path (failed)"
3205
- done
3206
- exit 1
3207
- fi
3208
- fi
3209
- fi
3210
-
3211
- # Create app data directory if it doesn't exist
3212
- mkdir -p "$APP_DATA_DIR"
3213
-
3214
- # Start logging - capture everything and ensure it's visible to parent process
3215
- exec > >(tee -a "$LOG_FILE") 2> >(tee -a "$LOG_FILE" >&2)
3216
- echo "=============================================="
3217
- echo "$(date): Starting Local Operator backend installation..."
3218
- echo "=============================================="
3219
- echo "Python bin path: $PYTHON_BIN"
3220
- echo "Virtual environment path: $VENV_PATH"
3221
- echo "App data directory: $APP_DATA_DIR"
3222
- echo "Log file: $LOG_FILE"
3223
- echo "=============================================="
3224
-
3225
- # --- FFmpeg Installation ---
3226
- BIN_DIR="$APP_DATA_DIR/bin"
3227
- FFMPEG_BIN="$BIN_DIR/ffmpeg"
3228
-
3229
- echo "Ensuring bin directory exists: $BIN_DIR"
3230
- mkdir -p "$BIN_DIR"
3231
-
3232
- # Check if FFmpeg is already installed and executable
3233
- if [ -f "$FFMPEG_BIN" ] && [ -x "$FFMPEG_BIN" ]; then
3234
- echo "FFmpeg already installed at $FFMPEG_BIN. Skipping download."
3235
- else
3236
- echo "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."
3237
-
3238
- FFMPEG_DOWNLOAD_URL=""
3239
-
3240
- if [[ "$ARCH" == "x86_64" ]]; then
3241
- FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-x64"
3242
- elif [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then
3243
- FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-mac-arm64"
3244
- else
3245
- echo "Error: Unsupported CPU architecture for FFmpeg download: $ARCH"
3246
- exit 1
3247
- fi
3248
-
3249
- echo "Downloading FFmpeg from: $FFMPEG_DOWNLOAD_URL"
3250
- if curl -L "$FFMPEG_DOWNLOAD_URL" -o "$FFMPEG_BIN"; then
3251
- echo "FFmpeg downloaded successfully to $FFMPEG_BIN"
3252
- chmod +x "$FFMPEG_BIN"
3253
- echo "Set executable permissions for $FFMPEG_BIN"
3254
- else
3255
- echo "Error: Failed to download FFmpeg from $FFMPEG_DOWNLOAD_URL"
3256
- exit 1
3257
- fi
3258
-
3259
- # Verify FFmpeg is executable after download
3260
- if [ ! -f "$FFMPEG_BIN" ] || [ ! -x "$FFMPEG_BIN" ]; then
3261
- echo "Error: FFmpeg binary not found or not executable after download."
3262
- exit 1
3263
- fi
3264
- fi
3265
-
3266
- echo "FFmpeg installation complete. FFmpeg binary is at: $FFMPEG_BIN"
3267
- # --- End FFmpeg Installation ---
3268
-
3269
- # Verify bundled Python exists
3270
- if [ ! -f "$PYTHON_BIN" ]; then
3271
- echo "Error: Bundled Python not found at $PYTHON_BIN"
3272
- echo "Please ensure standalone Python is properly installed in the application resources."
3273
- exit 1
3274
- fi
3275
-
3276
- # Make sure Python binary is executable
3277
- chmod +x "$PYTHON_BIN"
3278
- echo "Using bundled Python: $PYTHON_BIN"
3279
- "$PYTHON_BIN" --version
3280
-
3281
- # Check if Python has venv module available
3282
- echo "Checking for venv module availability..."
3283
- "$PYTHON_BIN" -m venv --help > /dev/null 2>&1 || {
3284
- echo "ERROR: Python venv module not available in the Python installation"
3285
- echo "Python details:"
3286
- "$PYTHON_BIN" --version
3287
- "$PYTHON_BIN" -c "import sys; print('Prefix:', sys.prefix); print('Exec Prefix:', sys.exec_prefix)"
3288
- "$PYTHON_BIN" -c "import sys; print('Modules path:'); print('\\n'.join(sys.path))"
3289
- exit 1
3290
- }
3291
- echo "venv module is available"
3292
-
3293
- # Create virtual environment if it doesn't exist
3294
- if [ ! -d "$VENV_PATH" ]; then
3295
- echo "Creating virtual environment at $VENV_PATH..."
3296
- # Remove any potentially corrupted virtual environment
3297
- if [ -e "$VENV_PATH" ]; then
3298
- echo "Removing existing but potentially corrupted venv directory..."
3299
- rm -rf "$VENV_PATH"
3300
- fi
3301
-
3302
- # Make sure parent directory exists and is writable
3303
- mkdir -p "$(dirname "$VENV_PATH")"
3304
-
3305
- # Create the virtual environment with verbosity
3306
- "$PYTHON_BIN" -m venv "$VENV_PATH" || {
3307
- echo "ERROR: Failed to create virtual environment. Exit code: $?"
3308
- echo "Virtual environment path: $VENV_PATH"
3309
- echo "Python binary used: $PYTHON_BIN"
3310
- ls -la "$(dirname "$VENV_PATH")"
3311
- echo "Python executable permissions:"
3312
- ls -la "$PYTHON_BIN"
3313
- exit 1
3314
- }
3315
- echo "Successfully created virtual environment"
3316
- fi
3317
-
3318
- # Verify the virtual environment structure
3319
- echo "Verifying virtual environment structure..."
3320
- if [ ! -f "$VENV_PATH/bin/python" ] || [ ! -f "$VENV_PATH/bin/pip" ]; then
3321
- echo "ERROR: Virtual environment is missing critical components"
3322
- echo "Contents of virtual environment directory:"
3323
- ls -la "$VENV_PATH"
3324
- if [ -d "$VENV_PATH/bin" ]; then
3325
- echo "Contents of bin directory:"
3326
- ls -la "$VENV_PATH/bin"
3327
- fi
3328
- exit 1
3329
- fi
3330
- echo "Virtual environment structure verified"
3331
-
3332
- # Activate virtual environment and install local-operator
3333
- echo "Installing local-operator in virtual environment..."
3334
- source "$VENV_PATH/bin/activate"
3335
-
3336
- echo "Upgrading pip..."
3337
- python -m pip install --upgrade pip || {
3338
- echo "ERROR: Failed to upgrade pip. Exit code: $?"
3339
- echo "pip version before failing:"
3340
- pip --version
3341
- exit 1
3342
- }
3343
- echo "pip upgrade successful:"
3344
- pip --version
3345
-
3346
- # Check network connectivity to PyPI
3347
- echo "Checking network connectivity to PyPI..."
3348
- curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {
3349
- echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."
3350
- echo "Attempting to ping common domains to diagnose network issues:"
3351
- ping -c 1 google.com || echo "Cannot ping google.com"
3352
- ping -c 1 pypi.org || echo "Cannot ping pypi.org"
3353
- }
3354
-
3355
- echo "Installing local-operator package..."
3356
- python -m pip install --upgrade --verbose local-operator || {
3357
- echo "ERROR: Failed to install local-operator package. Exit code: $?"
3358
- echo "Python version:"
3359
- python --version
3360
- echo "pip version:"
3361
- pip --version
3362
- echo "Available pip packages:"
3363
- pip list
3364
- echo "Pip config:"
3365
- pip config list
3366
- echo "Network diagnosis:"
3367
- curl -I https://pypi.org || echo "Cannot reach PyPI server"
3368
- exit 1
3369
- }
3370
- echo "local-operator installation successful"
3371
-
3372
- # Verify installation
3373
- if [ -f "$VENV_PATH/bin/local-operator" ]; then
3374
- echo "Local Operator backend installed successfully!"
3375
-
3376
- # Show more information about the installed binary
3377
- ls -la "$VENV_PATH/bin/local-operator"
3378
- file "$VENV_PATH/bin/local-operator"
3379
-
3380
- # Try to run the version command with full error output
3381
- echo "Testing local-operator binary..."
3382
- "$VENV_PATH/bin/local-operator" --version || {
3383
- echo "ERROR: local-operator binary exists but failed to execute. Exit code: $?"
3384
- echo "Binary details:"
3385
- file "$VENV_PATH/bin/local-operator"
3386
- echo "Binary permissions:"
3387
- ls -la "$VENV_PATH/bin/local-operator"
3388
- echo "Dependencies:"
3389
- if command -v otool >/dev/null; then
3390
- otool -L "$VENV_PATH/bin/local-operator" || echo "Could not get dependencies with otool"
3391
- fi
3392
- exit 1
3393
- }
3394
- else
3395
- echo "Error: Failed to install Local Operator backend. Binary not found."
3396
- echo "Contents of bin directory:"
3397
- ls -la "$VENV_PATH/bin/"
3398
- exit 1
3399
- fi
3400
-
3401
- echo "$(date): Installation completed successfully."
3402
- `;
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';
3403
3773
  const windowsInstallScriptRaw = `# Local Operator Backend Installation Script for Windows
3404
3774
  # This script installs pyenv-win, Python 3.12, and sets up a virtual environment for the Local Operator backend.
3405
3775
 
@@ -3412,6 +3782,17 @@ $VenvPath = "$AppDataDir\\\\$VenvName"
3412
3782
  $LogFile = "$AppDataDir\\\\backend-install-shell.log"
3413
3783
  $PyenvDir = "$env:USERPROFILE\\\\.pyenv"
3414
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
+
3415
3796
  # Keep CPython's bytecode cache out of the application directory.
3416
3797
  #
3417
3798
  # Same rule as the macOS script, and stated here for the same reason: an
@@ -3425,6 +3806,11 @@ if (-not $env:PYTHONPYCACHEPREFIX) {
3425
3806
  $env:PYTHONPYCACHEPREFIX = "$AppDataDir\\\\python-bytecode-cache"
3426
3807
  }
3427
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
+
3428
3814
  # Create app data directory if it doesn't exist
3429
3815
  if (-not (Test-Path $AppDataDir)) {
3430
3816
  New-Item -ItemType Directory -Path $AppDataDir -Force | Out-Null
@@ -3717,45 +4103,60 @@ try {
3717
4103
  const macosInstallScript = macosInstallScriptRaw;
3718
4104
  const linuxInstallScript = linuxInstallScriptRaw;
3719
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
+ }
3720
4142
  class BackendInstaller {
3721
4143
  appDataPath = electron.app.getPath("userData");
3722
4144
  venvPath;
3723
4145
  resourcesPath;
3724
4146
  pythonPath = null;
4147
+ preparationWindow = null;
3725
4148
  /**
3726
4149
  * Constructor
3727
4150
  */
3728
4151
  constructor() {
3729
- if (process.platform === "win32") {
3730
- this.venvPath = path$1.join(this.appDataPath, "local-operator-venv");
3731
- } else if (process.platform === "darwin") {
3732
- this.venvPath = path$1.join(
3733
- electron.app.getPath("home"),
3734
- "Library",
3735
- "Application Support",
3736
- "Local Operator",
3737
- "local-operator-venv"
3738
- );
3739
- } else {
3740
- this.venvPath = path$1.join(
3741
- electron.app.getPath("home"),
3742
- ".config",
3743
- "local-operator",
3744
- "local-operator-venv"
3745
- );
3746
- }
3747
- this.resourcesPath = process.env.NODE_ENV === "development" ? path$1.join(process.cwd(), "resources") : path$1.join(process.resourcesPath);
3748
- if (electron.app.isPackaged) {
3749
- const seal = sealPythonInterpreterTrees(
3750
- bundledPythonTreePaths(process.resourcesPath)
3751
- );
3752
- const message = describePythonTreeSeal(seal);
3753
- if (seal.supported && seal.failures.length > 0) {
3754
- logger.warn(message, LogFileType.INSTALLER);
3755
- } else {
3756
- logger.info(message, LogFileType.INSTALLER);
3757
- }
3758
- }
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();
3759
4160
  this.pythonPath = this.findPython();
3760
4161
  logger.info(
3761
4162
  `Backend Installer initialized. Virtual environment path: ${this.venvPath}`,
@@ -3772,6 +4173,7 @@ class BackendInstaller {
3772
4173
  * @returns Path to Python executable or null if not found
3773
4174
  */
3774
4175
  findPython() {
4176
+ if (process.platform === "darwin") return null;
3775
4177
  const possibilities = [];
3776
4178
  if (process.arch === "arm64") {
3777
4179
  possibilities.push(
@@ -3814,12 +4216,31 @@ class BackendInstaller {
3814
4216
  );
3815
4217
  return null;
3816
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
+ }
3817
4236
  /**
3818
4237
  * Check if backend is installed
3819
4238
  * @returns Promise resolving to true if the backend is installed, false otherwise
3820
4239
  */
3821
4240
  async isInstalled() {
3822
4241
  try {
4242
+ if (process.platform === "darwin")
4243
+ return await managedSelectionReady(this.managedOptions());
3823
4244
  if (!fs.existsSync(this.venvPath)) {
3824
4245
  logger.info(
3825
4246
  `Virtual environment not found at ${this.venvPath}`,
@@ -3866,30 +4287,90 @@ class BackendInstaller {
3866
4287
  * Install backend
3867
4288
  * @returns Promise resolving to true if the backend was installed successfully, false otherwise
3868
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
+ }
3869
4303
  async install() {
3870
- try {
3871
- const { response } = await electron.dialog.showMessageBox({
3872
- type: "info",
3873
- title: "First-Time Setup Required",
3874
- message: "Local Operator needs to set up some components to work properly. This one-time process will take just a few moments.",
3875
- buttons: ["Set Up Now", "Cancel"],
3876
- defaultId: 0,
3877
- cancelId: 1
3878
- });
3879
- if (response === 1) {
3880
- logger.info(
3881
- "User cancelled backend installation",
3882
- LogFileType.INSTALLER
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
+ }
3883
4320
  );
3884
- setTimeout(() => {
3885
- electron.app.exit(0);
3886
- }, 500);
3887
- 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
+ }
3888
4369
  }
3889
4370
  let scriptPath;
3890
4371
  let cmd;
3891
4372
  let args;
3892
- const tempDir = os.tmpdir();
4373
+ const tempDir = fs.mkdtempSync(path$1.join(os.tmpdir(), "local-operator-install-"));
3893
4374
  if (process.platform === "win32") {
3894
4375
  scriptPath = path$1.join(tempDir, "install-backend-windows.ps1");
3895
4376
  fs.writeFileSync(scriptPath, windowsInstallScript);
@@ -3934,6 +4415,8 @@ class BackendInstaller {
3934
4415
  sandbox: false
3935
4416
  }
3936
4417
  });
4418
+ if (process.platform === "darwin")
4419
+ this.preparationWindow = progressWindow;
3937
4420
  if (utils.is.dev && process.env.ELECTRON_RENDERER_URL) {
3938
4421
  progressWindow.loadURL(
3939
4422
  `${process.env.ELECTRON_RENDERER_URL}/installer`
@@ -4008,6 +4491,13 @@ class BackendInstaller {
4008
4491
  try {
4009
4492
  const env = withPythonBytecodeCache({ ...process.env }, this.appDataPath);
4010
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
+ );
4011
4501
  logger.info(
4012
4502
  `Setting ELECTRON_RESOURCE_PATH to ${this.resourcesPath}`,
4013
4503
  LogFileType.INSTALLER
@@ -4087,7 +4577,7 @@ class BackendInstaller {
4087
4577
  });
4088
4578
  ipcMain.removeListener("cancel-installation", cancelHandler);
4089
4579
  electron.app.removeListener("before-quit", appQuitHandler);
4090
- if (!progressWindow.isDestroyed()) {
4580
+ if (process.platform !== "darwin" && !progressWindow.isDestroyed()) {
4091
4581
  progressWindow.close();
4092
4582
  }
4093
4583
  if (installationCancelled) {
@@ -4103,6 +4593,7 @@ class BackendInstaller {
4103
4593
  "Backend installation completed successfully",
4104
4594
  LogFileType.INSTALLER
4105
4595
  );
4596
+ this.guardVenvBytecode();
4106
4597
  const localOperatorPath = process.platform === "win32" ? path$1.join(this.venvPath, "Scripts", "local-operator.exe") : path$1.join(this.venvPath, "bin", "local-operator");
4107
4598
  if (!fs.existsSync(localOperatorPath)) {
4108
4599
  logger.error(
@@ -4117,23 +4608,25 @@ class BackendInstaller {
4117
4608
  "Installation verified successfully",
4118
4609
  LogFileType.INSTALLER
4119
4610
  );
4120
- const { response: successResponse } = await electron.dialog.showMessageBox({
4121
- type: "info",
4122
- title: "Setup Complete",
4123
- message: "Local Operator is ready to use. Everything has been set up successfully.",
4124
- buttons: ["Let's Go!", "Cancel"],
4125
- defaultId: 0,
4126
- cancelId: 1
4127
- });
4128
- if (successResponse === 1) {
4129
- logger.info(
4130
- "User ended session after successful installation",
4131
- LogFileType.INSTALLER
4132
- );
4133
- setTimeout(() => {
4134
- electron.app.exit(0);
4135
- }, 500);
4136
- return false;
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
+ }
4137
4630
  }
4138
4631
  await new Promise((resolve) => setTimeout(resolve, 500));
4139
4632
  if (process.platform === "win32") {
@@ -8553,15 +9046,20 @@ ${probe.stderr}`.trim();
8553
9046
  detail: marker ? `${marker}: ${output || "no output"}` : `codesign --verify exited ${probe.exitCode}: ${output || "no output"}`
8554
9047
  };
8555
9048
  }
8556
- 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.";
8557
9051
  return {
8558
9052
  code: "installed-bundle-not-sealed",
8559
- message: version ? `This install of Local Operator can't be updated in place, so the update to version ${version} was stopped before the app quit.` : "This install of Local Operator can't be updated in place, so the update was stopped before the app quit.",
9053
+ message,
8560
9054
  remedy: {
8561
9055
  text: "Quit Local Operator, then download a fresh copy and replace the app in Applications.",
8562
9056
  url: DOWNLOAD_PAGE_URL
8563
9057
  },
8564
- detail: `${appBundlePath}: ${detail}`
9058
+ detail: `${appBundlePath}: ${detail}`,
9059
+ ...context === "startup" ? {
9060
+ heading: "This copy of Local Operator needs replacing",
9061
+ dismissLabel: "Not now"
9062
+ } : {}
8565
9063
  };
8566
9064
  }
8567
9065
  const VIOLATION_LINE = /^file (added|modified|missing):\s*(.+)$/;
@@ -8593,7 +9091,12 @@ function canonicalPath(path2) {
8593
9091
  }
8594
9092
  }
8595
9093
  }
8596
- 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
+ ];
8597
9100
  function isPythonBytecodePath(bundlePath, candidate) {
8598
9101
  const bundle = canonicalPath(bundlePath);
8599
9102
  const path2 = canonicalPath(candidate);
@@ -9640,7 +10143,7 @@ function resolveGlobalInstallPlan(input) {
9640
10143
  sourceBuild: false
9641
10144
  };
9642
10145
  }
9643
- function buildPipUpgradeCommand(pythonPath) {
10146
+ function buildPipUpgradeCommand(pythonPath2) {
9644
10147
  const args = [
9645
10148
  "-m",
9646
10149
  "pip",
@@ -9651,9 +10154,9 @@ function buildPipUpgradeCommand(pythonPath) {
9651
10154
  "local-operator"
9652
10155
  ];
9653
10156
  return {
9654
- command: pythonPath,
10157
+ command: pythonPath2,
9655
10158
  args,
9656
- display: `"${pythonPath}" ${args.join(" ")}`
10159
+ display: `"${pythonPath2}" ${args.join(" ")}`
9657
10160
  };
9658
10161
  }
9659
10162
  const PIP_SHOW_VERSION_REGEX = /^Version:\s*(.+)$/m;
@@ -9678,6 +10181,7 @@ const BUNDLE_ID_REGEX = /^[\w.-]+$/;
9678
10181
  const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
9679
10182
  const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
9680
10183
  const SEAL_PROBE_TIMEOUT_MS = 45e3;
10184
+ const STARTUP_SEAL_PROBE_DELAY_MS = 15e3;
9681
10185
  function versionSuffix(version) {
9682
10186
  return version ? ` to version ${version}` : "";
9683
10187
  }
@@ -9760,6 +10264,19 @@ class UpdateService {
9760
10264
  /** A failed install detected from the marker on this start, if any. */
9761
10265
  pendingInstallFailure = null;
9762
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;
9763
10280
  /**
9764
10281
  * An install this process found still running, if any.
9765
10282
  *
@@ -9826,6 +10343,15 @@ class UpdateService {
9826
10343
  electronUpdater.autoUpdater.autoInstallOnAppQuit = false;
9827
10344
  this.setupUpdateEvents();
9828
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();
9829
10355
  this.startPeriodicUpdateChecks();
9830
10356
  }
9831
10357
  /**
@@ -9893,13 +10419,32 @@ class UpdateService {
9893
10419
  `Update refused (${block.code}): ${block.detail}`,
9894
10420
  LogFileType.UPDATE_SERVICE
9895
10421
  );
9896
- 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 {
9897
10437
  code: block.code,
9898
- version: version ?? null,
10438
+ version,
9899
10439
  message: block.message,
9900
10440
  remedy: block.remedy,
9901
- detail: block.detail
9902
- });
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
+ };
9903
10448
  }
9904
10449
  /**
9905
10450
  * Read the marker a previous run may have left, and act on it.
@@ -9919,6 +10464,14 @@ class UpdateService {
9919
10464
  * alone entirely, and the re-check below reports what really happens to it.
9920
10465
  */
9921
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
+ }
9922
10475
  const marker = readPendingInstallMarker(this.markerDir());
9923
10476
  const outcome = evaluatePendingInstall({
9924
10477
  marker,
@@ -10131,6 +10684,43 @@ class UpdateService {
10131
10684
  LogFileType.UPDATE_SERVICE
10132
10685
  );
10133
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
+ }
10134
10724
  /**
10135
10725
  * A substituted answer to "is this app's install job loaded", or null.
10136
10726
  *
@@ -10375,17 +10965,68 @@ class UpdateService {
10375
10965
  * backend. Those writes are `file added:` violations and deleting exactly
10376
10966
  * those files restores the seal (measured; see `healPythonBytecode`), where a
10377
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.
10378
10973
  */
10379
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() {
10380
11006
  if (process.platform !== "darwin" || !electron.app.isPackaged) return null;
10381
11007
  const bundlePath = appBundleFromExecutable(process.execPath);
10382
11008
  if (!bundlePath) {
10383
11009
  logger.warn(
10384
- `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.`,
10385
11011
  LogFileType.UPDATE_SERVICE
10386
11012
  );
10387
11013
  return null;
10388
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) {
10389
11030
  const probeBundle = () => runCommand(
10390
11031
  "/usr/bin/codesign",
10391
11032
  ["--verify", "--deep", "--verbose=2", bundlePath],
@@ -10401,20 +11042,29 @@ class UpdateService {
10401
11042
  probe = await probeBundle();
10402
11043
  seal = evaluateBundleSeal(probe);
10403
11044
  }
10404
- if (seal.kind === "unavailable") {
10405
- logger.warn(
10406
- `Seal check could not run twice, continuing with the install: ${seal.detail}`,
10407
- LogFileType.UPDATE_SERVICE
10408
- );
10409
- return null;
10410
- }
10411
- if (seal.kind === "sealed") {
10412
- logger.info(
10413
- `Installed bundle passed its seal check: ${bundlePath}`,
10414
- LogFileType.UPDATE_SERVICE
10415
- );
10416
- return null;
10417
- }
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 };
10418
11068
  logger.error(
10419
11069
  `Installed bundle failed its seal check: ${seal.detail}`,
10420
11070
  LogFileType.UPDATE_SERVICE
@@ -10426,28 +11076,117 @@ class UpdateService {
10426
11076
  `Installed bundle is not healable in place: ${heal.reason}`,
10427
11077
  LogFileType.UPDATE_SERVICE
10428
11078
  );
10429
- 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
+ };
10430
11089
  }
10431
11090
  logger.info(
10432
11091
  `Removed ${heal.removed.length} bytecode file(s) our own interpreter had written into the sealed bundle: ${heal.reason}. Paths:
10433
11092
  ${heal.removed.join("\n")}`,
10434
11093
  LogFileType.UPDATE_SERVICE
10435
11094
  );
10436
- const healed = evaluateBundleSeal(await probeBundle());
11095
+ const { probe: healedProbe } = await this.readBundleSeal(bundlePath);
11096
+ const healed = evaluateBundleSeal(healedProbe);
10437
11097
  if (healed.kind === "sealed") {
10438
- logger.info(
10439
- `Installed bundle sealed again after removing ${heal.removed.length} added bytecode file(s); continuing with the install.`,
10440
- LogFileType.UPDATE_SERVICE
10441
- );
10442
- return null;
11098
+ return { seal: healed, heal, block: null };
10443
11099
  }
10444
11100
  logger.error(
10445
11101
  `Installed bundle still fails its seal check after healing: ${healed.detail}`,
10446
11102
  LogFileType.UPDATE_SERVICE
10447
11103
  );
10448
- 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);
10449
11121
  }
10450
- /** File names the updater's metadata knows this update by. */
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);
11186
+ }
11187
+ /**
11188
+ * File names the updater's metadata knows this update by.
11189
+ */
10451
11190
  stagedArtifactCandidates(info) {
10452
11191
  const names = /* @__PURE__ */ new Set();
10453
11192
  if (info?.path) names.add(path$1.basename(info.path));
@@ -10904,6 +11643,13 @@ ${heal.removed.join("\n")}`,
10904
11643
  );
10905
11644
  return false;
10906
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
+ }
10907
11653
  logger.info(
10908
11654
  "Preparing to quit and install the update...",
10909
11655
  LogFileType.UPDATE_SERVICE
@@ -11544,9 +12290,9 @@ ${heal.removed.join("\n")}`,
11544
12290
  * @returns Promise resolving to true if update was successful, false otherwise
11545
12291
  */
11546
12292
  /** The version the bundled environment has installed, via `pip show`. */
11547
- async readBundledBackendVersion(pythonPath) {
12293
+ async readBundledBackendVersion(pythonPath2) {
11548
12294
  const probe = await runCommand(
11549
- pythonPath,
12295
+ pythonPath2,
11550
12296
  ["-m", "pip", "show", "local-operator"],
11551
12297
  { timeoutMs: 12e4, env: this.pythonSpawnEnv() }
11552
12298
  );
@@ -11686,16 +12432,19 @@ ${heal.removed.join("\n")}`,
11686
12432
  LogFileType.UPDATE_SERVICE
11687
12433
  );
11688
12434
  }
11689
- let pythonPath = "";
12435
+ let pythonPath2 = "";
11690
12436
  const venvPath = this.backendService?.getVenvPath();
11691
12437
  if (venvPath && electron.app.isPackaged) {
11692
12438
  const venvBinDir = process.platform === "win32" ? "Scripts" : "bin";
11693
12439
  const pythonName = process.platform === "win32" ? "python.exe" : "python3";
11694
- pythonPath = path$1.join(venvPath, venvBinDir, pythonName);
12440
+ pythonPath2 = path$1.join(venvPath, venvBinDir, pythonName);
11695
12441
  }
11696
- if (!pythonPath || !fs.existsSync(pythonPath)) {
12442
+ if (!pythonPath2 || !fs.existsSync(pythonPath2)) {
11697
12443
  logger.error(
11698
- `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"}`,
11699
12448
  LogFileType.UPDATE_SERVICE
11700
12449
  );
11701
12450
  await this.restartBackendAfterFailedUpgrade();
@@ -11705,8 +12454,8 @@ ${heal.removed.join("\n")}`,
11705
12454
  );
11706
12455
  return false;
11707
12456
  }
11708
- const pip = buildPipUpgradeCommand(pythonPath);
11709
- const versionBefore = await this.readBundledBackendVersion(pythonPath);
12457
+ const pip = buildPipUpgradeCommand(pythonPath2);
12458
+ const versionBefore = await this.readBundledBackendVersion(pythonPath2);
11710
12459
  logger.info(
11711
12460
  `Executing pip command: ${pip.display}`,
11712
12461
  LogFileType.UPDATE_SERVICE
@@ -11727,7 +12476,7 @@ ${pipRun.stderr.trim()}`,
11727
12476
  LogFileType.UPDATE_SERVICE
11728
12477
  );
11729
12478
  }
11730
- const versionAfter = await this.readBundledBackendVersion(pythonPath);
12479
+ const versionAfter = await this.readBundledBackendVersion(pythonPath2);
11731
12480
  const upgradeLanded = pipRun.exitCode === 0 && didUpgradeLand({
11732
12481
  before: versionBefore,
11733
12482
  after: versionAfter,