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