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.
- package/out/main/index.js +1241 -492
- package/out/renderer/assets/{_basePickBy-mmFlh3ho.js → _basePickBy-BVR8NHkD.js} +1 -1
- package/out/renderer/assets/{_baseUniq-DAWnt4WG.js → _baseUniq-onZkstHq.js} +1 -1
- package/out/renderer/assets/{agent-details-page-CO72YkSX.js → agent-details-page-DC13Jibh.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-DhYc5_gP.js → agent-hub-page-iulwy94G.js} +1 -1
- package/out/renderer/assets/{agents-page-BopOzax_.js → agents-page-BSegINKc.js} +2 -2
- package/out/renderer/assets/{arc-BgHsUFIt.js → arc-DAb06JtB.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-BWxIiACo.js → architectureDiagram-IEHRJDOE-sSe348HD.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-CoJDUfWc.js → blockDiagram-JOT3LUYC-DB0hhoP8.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-B8vXmmLx.js → c4Diagram-VJAJSXHY-CcMWb5_5.js} +1 -1
- package/out/renderer/assets/channel-Dl22yt_O.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-rO5ve7XY.js → chunk-4BMEZGHF-SRkv1Dzw.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-CTPb1xgY.js → chunk-A2AXSNBT-BBdcJhkk.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-5VpTdLBm.js → chunk-AEK57VVT-BFLmAuIk.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-C3CEf0kC.js → chunk-D6G4REZN--QHGDmI-.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-B7nn7Euk.js → chunk-RZ5BOZE2-Cy8U6l3Q.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-CnPH3vBT.js → chunk-XZIHB7SX-DFfpTr7O.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-BguqLYlr.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-BguqLYlr.js +1 -0
- package/out/renderer/assets/clone-C0R1R8sa.js +1 -0
- package/out/renderer/assets/{compact-pagination-Bu8vshU-.js → compact-pagination-DERWPPOM.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-DUX1RPbJ.js → dagre-OKDRZEBW-DfjKcX6M.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-Cpv42kzr.js → diagram-SSKATNLV-Bm1j7Y3r.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-CGkUnKEL.js → diagram-VNBRO52H-BRx5TiDX.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-iBbJbyJZ.js → erDiagram-Q7BY3M3F-BSxbIo11.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-B569Tv1u.js → flowDiagram-4HSFHLVR-R2ILhUKR.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-nGbcgsgD.js → ganttDiagram-APWFNJXF-Gd7TZJFy.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-vv_MhteL.js → gitGraphDiagram-7IBYFJ6S-BAs2K2WV.js} +1 -1
- package/out/renderer/assets/{graph-krOMwYSa.js → graph-zm35SWuR.js} +1 -1
- package/out/renderer/assets/{index-CT6w0yXj.js → index-CqIP2mt3.js} +1 -1
- package/out/renderer/assets/{index-BqWgqUKZ.js → index-DF2zdD6_.js} +5 -5
- package/out/renderer/assets/{index-BgGl2-0g.js → index-DXx2j7ov.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-DHDA5253.js → infoDiagram-PH2N3AL5-BcAWGJ1_.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-CIrQ218m.js → journeyDiagram-U35MCT3I-VfDkAwaY.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DG_YsZbL.js → kanban-definition-NDS4AKOZ-BVgW6OQK.js} +1 -1
- package/out/renderer/assets/{layout-CB5zJSjt.js → layout-jfvNOQNg.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-B8pvrWy_.js → legacy-agents-page-yObswBd2.js} +1 -1
- package/out/renderer/assets/{mermaid.core-CYc-R2hQ.js → mermaid.core-w7CiwIS9.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-C22fFThl.js → mindmap-definition-ALO5MXBD-Ck5fBAvP.js} +1 -1
- package/out/renderer/assets/{parseISO-vr2fT-wq.js → parseISO-BfcDJeSX.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-DKRytw7K.js → pieDiagram-IB7DONF6-xuxf3Is5.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CWdnqGKr.js → quadrantDiagram-7GDLP6J5-B4UU5d5n.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-B0XgopGa.js → radar-MK3ICKWK-BUD9nsvK.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-RrBw8fkd.js → radient-auth-buttons-BtcEZuSY.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CXL8GuIo.js → requirementDiagram-KVF5MWMF-0fsS7ohi.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BQKCHfmN.js → sankeyDiagram-QLVOVGJD-CUjpA8gC.js} +1 -1
- package/out/renderer/assets/{schedules-page-DtNhlycC.js → schedules-page-xXMGrdrg.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-awR0fjHu.js → sequenceDiagram-X6HHIX6F-CX6mfjEg.js} +1 -1
- package/out/renderer/assets/{settings-page-DCLaspsn.js → settings-page-PLBi7GEM.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-BjhA_mJe.js → stateDiagram-DGXRK772-DlOrLmRF.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CsUzDnIT.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-BFEDYBl8.js → timeline-definition-BDJGKUSR-B6_9nD67.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-Bxx_WHvo.js → use-agent-like-mutation-Ckg2HQQI.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CwLeRa1a.js → xychartDiagram-VJFVF3MP-B2JoquLl.js} +1 -1
- package/out/renderer/index.html +1 -1
- package/package.json +2 -2
- package/out/renderer/assets/channel-DAvIgUhZ.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CP5aSNYY.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CP5aSNYY.js +0 -1
- package/out/renderer/assets/clone-CeahOD_w.js +0 -1
- 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
|
-
|
|
1552
|
-
return {
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
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
|
|
1598
|
+
return candidates.filter((candidate) => fs.existsSync(candidate));
|
|
1608
1599
|
}
|
|
1609
|
-
function
|
|
1610
|
-
const
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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
|
-
|
|
1620
|
-
|
|
1616
|
+
if (existing === source) {
|
|
1617
|
+
return {
|
|
1618
|
+
path: path2,
|
|
1619
|
+
written: false,
|
|
1620
|
+
reason: `${path2} already refuses bytecode writes`
|
|
1621
|
+
};
|
|
1621
1622
|
}
|
|
1622
|
-
if (
|
|
1623
|
-
|
|
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
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
this.
|
|
2287
|
-
|
|
2288
|
-
|
|
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
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
this.
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
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
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
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
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
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
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
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
|
|
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 = [
|
|
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(
|
|
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:
|
|
10157
|
+
command: pythonPath2,
|
|
9655
10158
|
args,
|
|
9656
|
-
display: `"${
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
10405
|
-
|
|
10406
|
-
|
|
10407
|
-
|
|
10408
|
-
|
|
10409
|
-
|
|
10410
|
-
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
|
|
10416
|
-
|
|
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
|
|
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
|
|
11095
|
+
const { probe: healedProbe } = await this.readBundleSeal(bundlePath);
|
|
11096
|
+
const healed = evaluateBundleSeal(healedProbe);
|
|
10437
11097
|
if (healed.kind === "sealed") {
|
|
10438
|
-
|
|
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
|
|
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
|
-
/**
|
|
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(
|
|
12293
|
+
async readBundledBackendVersion(pythonPath2) {
|
|
11548
12294
|
const probe = await runCommand(
|
|
11549
|
-
|
|
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
|
|
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
|
-
|
|
12440
|
+
pythonPath2 = path$1.join(venvPath, venvBinDir, pythonName);
|
|
11695
12441
|
}
|
|
11696
|
-
if (!
|
|
12442
|
+
if (!pythonPath2 || !fs.existsSync(pythonPath2)) {
|
|
11697
12443
|
logger.error(
|
|
11698
|
-
`
|
|
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(
|
|
11709
|
-
const versionBefore = await this.readBundledBackendVersion(
|
|
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(
|
|
12479
|
+
const versionAfter = await this.readBundledBackendVersion(pythonPath2);
|
|
11731
12480
|
const upgradeLanded = pipRun.exitCode === 0 && didUpgradeLand({
|
|
11732
12481
|
before: versionBefore,
|
|
11733
12482
|
after: versionAfter,
|