local-operator-ui 0.28.0 → 0.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/out/main/index.js +524 -249
  2. package/out/renderer/assets/{_basePickBy-BOvo3PMo.js → _basePickBy-BiVlVvaz.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-DM6bolHj.js → _baseUniq-B7hmLjHp.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-DeUDUxv_.js → agent-details-page-Bo1iXiw2.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-FxmBdQhI.js → agent-hub-page-DpYsf3xz.js} +1 -1
  6. package/out/renderer/assets/{agents-page-DYKw2vvx.js → agents-page-DpmiwHFn.js} +2 -2
  7. package/out/renderer/assets/{arc-DpH8XBJW.js → arc-zQfKMj24.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-ANhkj_oc.js → architectureDiagram-IEHRJDOE-B7ZATeGZ.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CTB00Gt3.js → blockDiagram-JOT3LUYC-CHlXcfDj.js} +1 -1
  10. package/out/renderer/assets/browser-page-XN0o_4Fc.js +1 -0
  11. package/out/renderer/assets/{c4Diagram-VJAJSXHY-D2Hb_5e0.js → c4Diagram-VJAJSXHY-Cqc7SCB2.js} +1 -1
  12. package/out/renderer/assets/channel-DZNoINil.js +1 -0
  13. package/out/renderer/assets/{chunk-4BMEZGHF-Ccc3GaXX.js → chunk-4BMEZGHF-Bsl_7NtA.js} +1 -1
  14. package/out/renderer/assets/{chunk-A2AXSNBT-AjsU0EV0.js → chunk-A2AXSNBT-ggoGKGXQ.js} +1 -1
  15. package/out/renderer/assets/{chunk-AEK57VVT-CcKioR3D.js → chunk-AEK57VVT-DqWWmU8P.js} +1 -1
  16. package/out/renderer/assets/{chunk-D6G4REZN-BKiedEgU.js → chunk-D6G4REZN-BoMbjS6y.js} +1 -1
  17. package/out/renderer/assets/{chunk-RZ5BOZE2-mHQEQ22d.js → chunk-RZ5BOZE2-is_C2hBC.js} +1 -1
  18. package/out/renderer/assets/{chunk-XZIHB7SX-ClLoX_HY.js → chunk-XZIHB7SX-ld86pApa.js} +1 -1
  19. package/out/renderer/assets/classDiagram-GIVACNV2-CWYh2jD8.js +1 -0
  20. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CWYh2jD8.js +1 -0
  21. package/out/renderer/assets/clone-3puJuEer.js +1 -0
  22. package/out/renderer/assets/{compact-pagination-CPdPUvIX.js → compact-pagination-BSC2TmbH.js} +1 -1
  23. package/out/renderer/assets/{dagre-OKDRZEBW-Bl82QNX5.js → dagre-OKDRZEBW-32aJrB_O.js} +1 -1
  24. package/out/renderer/assets/{diagram-SSKATNLV-Dinlao46.js → diagram-SSKATNLV-Amr2xP2t.js} +1 -1
  25. package/out/renderer/assets/{diagram-VNBRO52H-B7G1ezKY.js → diagram-VNBRO52H-Bc_-ILS5.js} +1 -1
  26. package/out/renderer/assets/{erDiagram-Q7BY3M3F-BRaUaIlI.js → erDiagram-Q7BY3M3F-ZwSCjJ6e.js} +1 -1
  27. package/out/renderer/assets/{flowDiagram-4HSFHLVR-B1EbnkEl.js → flowDiagram-4HSFHLVR-Dql7kx-Z.js} +1 -1
  28. package/out/renderer/assets/{ganttDiagram-APWFNJXF-1k6P03ll.js → ganttDiagram-APWFNJXF-DSWWoa_C.js} +1 -1
  29. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-Dx-_n5Pt.js → gitGraphDiagram-7IBYFJ6S-cbUhz4PY.js} +1 -1
  30. package/out/renderer/assets/{graph-Dp1vYuhs.js → graph-CojCeg8k.js} +1 -1
  31. package/out/renderer/assets/icon-B9Dq7y-Q.css +1 -0
  32. package/out/renderer/assets/{index-CGVkf3q5.js → index-DP42nWkP.js} +1 -1
  33. package/out/renderer/assets/{index-DqnrrTlH.js → index-DgY41JzN.js} +1 -1
  34. package/out/renderer/assets/{index-Cy6Q4C9i.js → index-YYBGshw-.js} +369 -369
  35. package/out/renderer/assets/{infoDiagram-PH2N3AL5-GxhMS_dh.js → infoDiagram-PH2N3AL5-DCWE8TqR.js} +1 -1
  36. package/out/renderer/assets/{installer-tV2_PPwX.js → installer-BH6UzMkw.js} +1 -1
  37. package/out/renderer/assets/{journeyDiagram-U35MCT3I-Co_F8-ZL.js → journeyDiagram-U35MCT3I-BVApHQpT.js} +1 -1
  38. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-wd1As6Op.js → kanban-definition-NDS4AKOZ-BHlQbvEk.js} +1 -1
  39. package/out/renderer/assets/{layout-DkYEorlQ.js → layout-C68weM2q.js} +1 -1
  40. package/out/renderer/assets/{legacy-agents-page-CAu6rhIe.js → legacy-agents-page-DySZFeGz.js} +1 -1
  41. package/out/renderer/assets/{mermaid.core-ApDCzSRO.js → mermaid.core-mZpI-6tM.js} +5 -5
  42. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BAA5mWWQ.js → mindmap-definition-ALO5MXBD-CUBn2plb.js} +1 -1
  43. package/out/renderer/assets/{page-header-BhuuVNah.js → page-header-BkCnUAVS.js} +1 -1
  44. package/out/renderer/assets/{parseISO-BsCynSEy.js → parseISO-BUcHeIn4.js} +1 -1
  45. package/out/renderer/assets/{pieDiagram-IB7DONF6-g3FOzz2y.js → pieDiagram-IB7DONF6-D9khOoXJ.js} +1 -1
  46. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-v4gWrVZb.js → quadrantDiagram-7GDLP6J5-Ckc_HLn6.js} +1 -1
  47. package/out/renderer/assets/{radar-MK3ICKWK-D2i-sp6E.js → radar-MK3ICKWK-E_AG5HZR.js} +1 -1
  48. package/out/renderer/assets/{radient-auth-buttons-DcQ-N2DX.js → radient-auth-buttons-DWMBn27v.js} +1 -1
  49. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-BM486FmB.js → requirementDiagram-KVF5MWMF-D4z3bPKG.js} +1 -1
  50. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CQzgAFhw.js → sankeyDiagram-QLVOVGJD-CdqXkAPm.js} +1 -1
  51. package/out/renderer/assets/{schedules-page-CFlqNgdV.js → schedules-page-DKdkQ-bz.js} +1 -1
  52. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-CbGnx919.js → sequenceDiagram-X6HHIX6F-cRXMF1u2.js} +1 -1
  53. package/out/renderer/assets/{settings-page-Cw6CMB-C.js → settings-page-DxOCG1h9.js} +1 -1
  54. package/out/renderer/assets/{stateDiagram-DGXRK772-BG6tAf1B.js → stateDiagram-DGXRK772-rwTz9XiH.js} +1 -1
  55. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DK-O9XV3.js +1 -0
  56. package/out/renderer/assets/{timeline-definition-BDJGKUSR-m44yI4Ta.js → timeline-definition-BDJGKUSR-DjpmacOZ.js} +1 -1
  57. package/out/renderer/assets/{use-agent-like-mutation-geCz91fs.js → use-agent-like-mutation-BgxlHPcf.js} +1 -1
  58. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-coRt_1qL.js → xychartDiagram-VJFVF3MP-C-PscNet.js} +1 -1
  59. package/out/renderer/index.html +3 -3
  60. package/out/renderer/installer.html +3 -3
  61. package/package.json +1 -1
  62. package/out/renderer/assets/browser-page-D0RwIsvw.js +0 -1
  63. package/out/renderer/assets/channel-DIw4otfK.js +0 -1
  64. package/out/renderer/assets/classDiagram-GIVACNV2-nMUSU4hp.js +0 -1
  65. package/out/renderer/assets/classDiagram-v2-COTLJTTW-nMUSU4hp.js +0 -1
  66. package/out/renderer/assets/clone-Sp7u6U6w.js +0 -1
  67. package/out/renderer/assets/icon-DqiwmYFW.css +0 -1
  68. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-gZkDK_c7.js +0 -1
  69. /package/out/renderer/assets/{icon-DucQIiEv.js → icon-BJ1diQ13.js} +0 -0
package/out/main/index.js CHANGED
@@ -5511,7 +5511,7 @@ function reapSupersededGenerations(options, keep, now = Date.now()) {
5511
5511
  } catch {
5512
5512
  continue;
5513
5513
  }
5514
- const survivors = new Set(kept ? [kept] : []);
5514
+ const survivors = new Set(kept ?? []);
5515
5515
  const generations = [];
5516
5516
  for (const name of names) {
5517
5517
  const path2 = path$1.join(root, name);
@@ -5673,40 +5673,73 @@ async function prepareManagedPython(options, install) {
5673
5673
  const state = inspectManagedSelection(options);
5674
5674
  if (state.kind === "ready") return state.selection;
5675
5675
  const superseded = state.kind === "missing" ? state.detail : null;
5676
- const published = state.kind === "missing" ? state.published : void 0;
5677
- const root = managedPythonRoot(options);
5678
- reapSupersededGenerations(options, {
5679
- runtime: published && publishedRuntimeIsReusable(options, published) ? published.runtime : void 0
5680
- });
5681
- const { runtime, id: id2 } = await prepareRuntime(options);
5682
- await promises.mkdir(environmentsRoot(options), { recursive: true, mode: 448 });
5683
- realDirectory(environmentsRoot(options));
5684
- const venv = path$1.join(environmentsRoot(options), `${id2}-${node_crypto.randomUUID()}`);
5685
- if (!await install(venv, pythonPath(runtime)))
5686
- throw new Error(
5687
- "Backend preparation did not complete. Your previous environment and data were preserved."
5688
- );
5689
- const env = withPythonBytecodeCache({ ...process.env }, options.support);
5690
- const backendVersion = await smokeEnvironment(venv, env);
5691
- const selection = {
5692
- format: FORMAT,
5693
- runtimeId: id2,
5694
- runtime,
5695
- venv,
5696
- backendVersion
5697
- };
5698
- const bytes = `${JSON.stringify(selection)}
5676
+ const published = state.kind === "missing" ? state.published : null;
5677
+ return publishGeneration(options, install, published, superseded, false);
5678
+ });
5679
+ }
5680
+ async function publishGeneration(options, install, published, superseded, keepPublished) {
5681
+ const root = managedPythonRoot(options);
5682
+ reapSupersededGenerations(options, {
5683
+ runtime: published && (keepPublished || publishedRuntimeIsReusable(options, published)) ? [published.runtime] : [],
5684
+ venv: keepPublished && published ? [published.venv] : []
5685
+ });
5686
+ const { runtime, id: id2 } = await prepareRuntime(options);
5687
+ await promises.mkdir(environmentsRoot(options), { recursive: true, mode: 448 });
5688
+ realDirectory(environmentsRoot(options));
5689
+ const venv = path$1.join(environmentsRoot(options), `${id2}-${node_crypto.randomUUID()}`);
5690
+ if (!await install(venv, pythonPath(runtime)))
5691
+ throw new Error(
5692
+ "Backend preparation did not complete. Your previous environment and data were preserved."
5693
+ );
5694
+ const env = withPythonBytecodeCache({ ...process.env }, options.support);
5695
+ const backendVersion = await smokeEnvironment(venv, env);
5696
+ const selection = {
5697
+ format: FORMAT,
5698
+ runtimeId: id2,
5699
+ runtime,
5700
+ venv,
5701
+ backendVersion
5702
+ };
5703
+ const bytes = `${JSON.stringify(selection)}
5699
5704
  `;
5700
- await promises.writeFile(path$1.join(venv, READY), bytes, { flag: "wx", mode: 384 });
5701
- const temporary = path$1.join(root, `.selection-${node_crypto.randomUUID()}.json`);
5702
- await promises.writeFile(temporary, bytes, { flag: "wx", mode: 384 });
5703
- await promises.rename(temporary, path$1.join(root, POINTER));
5704
- reapSupersededGenerations(options, { runtime, venv });
5705
- if (superseded)
5706
- console.warn(
5707
- `Replaced an unusable managed Python selection: ${superseded}`
5708
- );
5709
- return selection;
5705
+ await promises.writeFile(path$1.join(venv, READY), bytes, { flag: "wx", mode: 384 });
5706
+ const temporary = path$1.join(root, `.selection-${node_crypto.randomUUID()}.json`);
5707
+ await promises.writeFile(temporary, bytes, { flag: "wx", mode: 384 });
5708
+ await promises.rename(temporary, path$1.join(root, POINTER));
5709
+ reapSupersededGenerations(options, {
5710
+ runtime: keepPublished && published ? [runtime, published.runtime] : [runtime],
5711
+ venv: keepPublished && published ? [venv, published.venv] : [venv]
5712
+ });
5713
+ if (superseded)
5714
+ console.warn(
5715
+ `Replaced an unusable managed Python selection: ${superseded}`
5716
+ );
5717
+ return selection;
5718
+ }
5719
+ function publishedBackendVersion(options) {
5720
+ return readManagedSelection(options)?.backendVersion ?? null;
5721
+ }
5722
+ function managedSelectionSatisfies(options, target2, isNewer) {
5723
+ if (!target2) return true;
5724
+ const published = publishedBackendVersion(options);
5725
+ if (published === null) return false;
5726
+ return !isNewer(target2, published);
5727
+ }
5728
+ async function updateManagedPython(options, install, decision) {
5729
+ return locked(managedPythonRoot(options), async () => {
5730
+ const state = inspectManagedSelection(options);
5731
+ const published = state.kind === "ready" ? state.selection : null;
5732
+ if (published && managedSelectionSatisfies(options, decision.target, decision.isNewer))
5733
+ return { selection: published, replaced: false, previous: null };
5734
+ const previous = published ?? (state.kind === "missing" ? state.published : null);
5735
+ const selection = await publishGeneration(
5736
+ options,
5737
+ install,
5738
+ previous,
5739
+ state.kind === "missing" ? state.detail : null,
5740
+ true
5741
+ );
5742
+ return { selection, replaced: true, previous };
5710
5743
  });
5711
5744
  }
5712
5745
  function isLegacyManagedCommand(command, support) {
@@ -8308,6 +8341,19 @@ class BackendServiceManager {
8308
8341
  return this.restartPromise;
8309
8342
  }
8310
8343
  }
8344
+ function managedPythonOptions() {
8345
+ return {
8346
+ support: path$1.join(
8347
+ electron.app.getPath("home"),
8348
+ "Library",
8349
+ "Application Support",
8350
+ "Local Operator"
8351
+ ),
8352
+ resources: !electron.app.isPackaged ? path$1.join(process.cwd(), "resources") : path$1.join(process.resourcesPath),
8353
+ packaged: electron.app.isPackaged,
8354
+ arch: process.arch
8355
+ };
8356
+ }
8311
8357
  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';
8312
8358
  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';
8313
8359
  const windowsInstallScriptRaw = `# Local Operator Backend Installation Script for Windows
@@ -8670,9 +8716,13 @@ const SETUP_FAILURE_CAUSES = [
8670
8716
  "A file the setup needed was missing, which usually means the download or the copy did not finish."
8671
8717
  ]
8672
8718
  ];
8719
+ function setupFailureCause(error) {
8720
+ const raw = error instanceof Error ? error.message : String(error);
8721
+ return SETUP_FAILURE_CAUSES.find(([pattern]) => pattern.test(raw))?.[1] ?? null;
8722
+ }
8673
8723
  function backendSetupFailureDetail(error, support) {
8674
8724
  const raw = error instanceof Error ? error.message : String(error);
8675
- const cause = SETUP_FAILURE_CAUSES.find(([pattern]) => pattern.test(raw))?.[1] ?? "The backend could not be set up on this Mac.";
8725
+ const cause = setupFailureCause(error) ?? "The backend could not be set up on this Mac.";
8676
8726
  return [
8677
8727
  `What happened: ${cause}`,
8678
8728
  `The app recorded: ${raw}`,
@@ -8828,17 +8878,7 @@ class BackendInstaller {
8828
8878
  * @returns Promise resolving to true if the backend was installed successfully, false otherwise
8829
8879
  */
8830
8880
  managedOptions() {
8831
- return {
8832
- support: path$1.join(
8833
- electron.app.getPath("home"),
8834
- "Library",
8835
- "Application Support",
8836
- "Local Operator"
8837
- ),
8838
- resources: this.resourcesPath,
8839
- packaged: electron.app.isPackaged,
8840
- arch: process.arch
8841
- };
8881
+ return managedPythonOptions();
8842
8882
  }
8843
8883
  async install() {
8844
8884
  if (process.platform !== "darwin") return this.installEnvironment();
@@ -16520,6 +16560,7 @@ const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
16520
16560
  const BUNDLE_ID_REGEX = /^[\w.-]+$/;
16521
16561
  const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
16522
16562
  const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
16563
+ const APP_OWNED_PUBLISH_HEADROOM_BYTES = 450 * 1024 * 1024;
16523
16564
  const SEAL_PROBE_TIMEOUT_MS = 45e3;
16524
16565
  const STARTUP_SEAL_PROBE_DELAY_MS = 15e3;
16525
16566
  const POST_WAKE_CHECK_DELAY_MS = 2e4;
@@ -16559,6 +16600,9 @@ function runCommand(command, args, options = {}) {
16559
16600
  );
16560
16601
  });
16561
16602
  }
16603
+ function installerOutputOf(run) {
16604
+ return [run.stderr.trim(), run.stdout.trim()].filter((part) => part.length > 0).join("\n").trim();
16605
+ }
16562
16606
  function serverUpdateLogPath() {
16563
16607
  return path$1.join(logger.getLogPath(), LogFileType.UPDATE_SERVICE);
16564
16608
  }
@@ -18837,7 +18881,17 @@ ${heal.removed.join("\n")}`,
18837
18881
  return {
18838
18882
  canManageUpdate: true,
18839
18883
  updateCommand: "pip install --upgrade local-operator",
18840
- remedy: "Updating the server will improve AI functionality.",
18884
+ /*
18885
+ * A REAL CONSEQUENCE, not the benefit sentence a second time (UX U2). This
18886
+ * string rendered through `managedCostSentence`, and the panel above it
18887
+ * already carries the static line about AI functionality, security and
18888
+ * bugs - so the offer showed the same filler twice, in two lengths, and the
18889
+ * one sentence that has to be here (what the press does to the server the
18890
+ * reader is talking to) was missing on the only arm whose press now
18891
+ * publishes a generation and restarts a daemon. It is the managed global
18892
+ * arm's own wording, aimed at this arm's mechanism.
18893
+ */
18894
+ remedy: "The app publishes a new server environment and then restarts the server it started, so a turn that is in flight is dropped while the server comes back. This can take a minute or two.",
18841
18895
  detail: `The app started this server itself (${startupMode}).`,
18842
18896
  sourceBuild: false,
18843
18897
  /*
@@ -18991,7 +19045,14 @@ ${heal.removed.join("\n")}`,
18991
19045
  */
18992
19046
  eventRunning: this.eventRunningVersion(runningVersion),
18993
19047
  silent,
18994
- releaseRead: false
19048
+ releaseRead: false,
19049
+ /*
19050
+ * The ownership reading is local too, so the offline pass carries it as
19051
+ * well: the control this event may offer must be offered, or withheld,
19052
+ * on exactly the reading an online check uses. An offline machine whose
19053
+ * environment is the app's own still gets its restart.
19054
+ */
19055
+ appOwnedEnvironment: this.appOwnsInstall(startupMode, serving)
18995
19056
  });
18996
19057
  return { status: "unavailable", info: null };
18997
19058
  }
@@ -19111,6 +19172,16 @@ ${heal.removed.join("\n")}`,
19111
19172
  * other two events already carry. It travels here too.
19112
19173
  */
19113
19174
  restartable: this.backendIsAppOwned(),
19175
+ /*
19176
+ * AND WHOSE INSTALL A PRESS WOULD MOVE (design D5), which is the other
19177
+ * ownership reading: `restartable` is true here on a global install too,
19178
+ * where the app supervises a daemon it did not install. The mode alone
19179
+ * cannot answer it either - a GLOBAL_INSTALL launch can be serving from
19180
+ * the app's OWN managed environment, and since #318 landed that arm IS
19181
+ * routed to the publish path below - so the reading is the mode plus the
19182
+ * serving install's own root.
19183
+ */
19184
+ appOwnedEnvironment: this.appOwnsInstall(startupMode, serving),
19114
19185
  /* `silent` is the whole difference between the two callers: the periodic
19115
19186
  and start-up checks pass `true`, the IPC handlers behind the buttons
19116
19187
  pass `false`. The renderer needs to know which one it is answering,
@@ -19139,7 +19210,8 @@ ${heal.removed.join("\n")}`,
19139
19210
  * affirmation above both turn on it - so the panel may call the install
19140
19211
  * current. The offline caller passes false for exactly that reason.
19141
19212
  */
19142
- releaseRead: true
19213
+ releaseRead: true,
19214
+ appOwnedEnvironment: this.appOwnsInstall(startupMode, serving)
19143
19215
  });
19144
19216
  return { status: channel.status, info: null };
19145
19217
  } catch (error) {
@@ -19398,36 +19470,6 @@ ${heal.removed.join("\n")}`,
19398
19470
  return false;
19399
19471
  }
19400
19472
  }
19401
- /**
19402
- * Bring the previous server back after a failed upgrade, and say whether it
19403
- * actually came back.
19404
- *
19405
- * A failure here must not leave the user with no backend at all: whatever pip
19406
- * did or did not do, the environment usually still holds a working install, so
19407
- * start it again and then ask /health rather than assuming. The answer is not
19408
- * cosmetic: `pip install --upgrade` uninstalls before it installs, so a
19409
- * mid-install failure can leave the package absent, and telling the user "the
19410
- * previous server is still running" when it is not is a claim we had no
19411
- * evidence for (review R6).
19412
- */
19413
- async restartBackendAfterFailedUpgrade() {
19414
- try {
19415
- await this.backendService?.start();
19416
- const healthy = await this.checkBackendHealth();
19417
- logger.info(
19418
- healthy ? "Restarted the previous backend after a failed update" : "The backend did not answer its health check after a failed update",
19419
- LogFileType.UPDATE_SERVICE
19420
- );
19421
- return healthy;
19422
- } catch (error) {
19423
- logger.error(
19424
- "Could not restart the backend after a failed update:",
19425
- LogFileType.UPDATE_SERVICE,
19426
- error
19427
- );
19428
- return false;
19429
- }
19430
- }
19431
19473
  /**
19432
19474
  * Poll /health until it reports `targetVersion`, bounded by a timeout.
19433
19475
  *
@@ -19573,6 +19615,14 @@ ${heal.removed.join("\n")}`,
19573
19615
  * panel's own rule - "a reading that cannot be taken is silence" - applied to one
19574
19616
  * clause of it). Every caller that did read the release, and every pre-existing
19575
19617
  * reader of this event, sees the payload it always saw.
19618
+ *
19619
+ * AND WHOSE INSTALL A PRESS WOULD MOVE (design D5) travels with the pair, because
19620
+ * this event opens the one panel that ACTS: `restartable` says who started the
19621
+ * DAEMON, which is also true in GLOBAL_INSTALL mode, while the control the panel may
19622
+ * offer is a publish-and-restart the app performs only on its own environment. So
19623
+ * both ownership readings ride one payload, and BOTH callers pass both - the offline
19624
+ * pass measures this reading locally like the rest of the pair, so a machine that
19625
+ * reports no network is not the machine that loses its restart control.
19576
19626
  */
19577
19627
  sendBackendSkewNotice(options) {
19578
19628
  const { installedVersion, eventRunning, silent, releaseRead } = options;
@@ -19582,12 +19632,56 @@ ${heal.removed.join("\n")}`,
19582
19632
  version: installedVersion,
19583
19633
  runningVersion: eventRunning,
19584
19634
  /** Whether the app may restart the daemon that is behind. */
19585
- restartable: this.backendIsAppOwned()
19635
+ restartable: this.backendIsAppOwned(),
19636
+ /*
19637
+ * WHOSE INSTALL THE PANEL'S PRESS WOULD MOVE (design D5). `restartable`
19638
+ * above answers who started the DAEMON and is true on a global install too,
19639
+ * where the press behind that control runs the install's own updater rather
19640
+ * than the restart its label promises - so the control the renderer may offer
19641
+ * needs this second reading beside it. It travels on this event rather than in
19642
+ * a second send, because this is the event whose panel acts.
19643
+ */
19644
+ appOwnedEnvironment: options.appOwnedEnvironment
19586
19645
  };
19587
19646
  if (!releaseRead) payload.releaseRead = false;
19588
19647
  this.mainWindow.webContents.send("backend-update-not-available", payload);
19589
19648
  }
19590
19649
  }
19650
+ /**
19651
+ * Whether the INSTALL this app would move is the app's own managed environment.
19652
+ *
19653
+ * THE SECOND OWNERSHIP READING, and it is deliberately not `backendIsAppOwned`
19654
+ * (design D5). That one asks who started the DAEMON, and it answers yes in
19655
+ * `GLOBAL_INSTALL` mode as well: the app spawns and supervises a daemon whose
19656
+ * install is a uv tool / pipx / global one, so the app owns the process and not
19657
+ * the environment. The two questions diverge on exactly that machine, and the
19658
+ * skew panel's restart control is decided by this one, because `update-backend`
19659
+ * on a global install runs the install's own updater (an install, not a restart)
19660
+ * or lands on the by-hand panel - a control whose label promises a restart the
19661
+ * press does not perform.
19662
+ *
19663
+ * `APP_BUNDLED_VENV` is the app's own environment by construction and
19664
+ * `NOT_STARTED` has no daemon at all, so neither has anything to ask the serving
19665
+ * root about. `GLOBAL_INSTALL` and `EXISTING_SERVER` BOTH consult it rather than
19666
+ * being decided by the mode (review R8, which caught this paragraph saying the
19667
+ * opposite of the switch below): the mode answers who LAUNCHED the process, and
19668
+ * a process launched elsewhere can still read the app's managed tree - an
19669
+ * adopted daemon serving out of it is an install this app may move, and that was
19670
+ * the operator's own machine (design D5, #318). Both arms are stated rather than
19671
+ * left to a default so a new mode has to be classified here on purpose instead of
19672
+ * inheriting an answer.
19673
+ */
19674
+ appOwnsInstall(startupMode, serving) {
19675
+ switch (startupMode) {
19676
+ case LocalOperatorStartupMode.APP_BUNDLED_VENV:
19677
+ return true;
19678
+ case LocalOperatorStartupMode.GLOBAL_INSTALL:
19679
+ case LocalOperatorStartupMode.EXISTING_SERVER:
19680
+ return serving?.appOwned === true;
19681
+ case LocalOperatorStartupMode.NOT_STARTED:
19682
+ return false;
19683
+ }
19684
+ }
19591
19685
  /**
19592
19686
  * The version the resolved global install reports on disk, or null.
19593
19687
  *
@@ -19647,16 +19741,19 @@ ${heal.removed.join("\n")}`,
19647
19741
  return run;
19648
19742
  }
19649
19743
  /**
19650
- * True while an app-driven update of the global install is running.
19744
+ * True while an app-driven server update is running, whichever install it moves.
19651
19745
  *
19652
19746
  * The guard is here and not on the surfaces that ask for the update, because
19653
19747
  * their flags are per-surface: the offer holds `checking` and the run panel holds
19654
- * `updatingBackend`, so a press on each of them reached `updateGlobalInstall`
19655
- * twice and the second `lop update` ran beside the first against one install root
19656
- * (review R1-8). The bundled path never needed one because it stops the backend
19657
- * first, which serialises it by accident; nothing on this path stops anything.
19748
+ * `updatingBackend`, so a press on each of them reached an updater twice and the
19749
+ * second install ran beside the first against one tree (review R1-8).
19750
+ *
19751
+ * Both layouts need it now. The global path never stopped anything; the app-owned
19752
+ * path used to serialise itself by accident (it stopped the daemon first) and
19753
+ * stops nothing since it began publishing a generation instead of writing into
19754
+ * the live tree.
19658
19755
  */
19659
- globalUpdateInFlight = false;
19756
+ serverUpdateInFlight = false;
19660
19757
  /**
19661
19758
  * Update the resolved global install, then restart the daemon this app owns.
19662
19759
  *
@@ -19673,7 +19770,7 @@ ${heal.removed.join("\n")}`,
19673
19770
  * exactly as the pip path does (`didUpgradeLand`).
19674
19771
  */
19675
19772
  async updateGlobalInstall(backend, plan, target2) {
19676
- if (this.globalUpdateInFlight) {
19773
+ if (this.serverUpdateInFlight) {
19677
19774
  logger.warn(
19678
19775
  "A global install update is already running; refusing to start a second beside it",
19679
19776
  LogFileType.UPDATE_SERVICE
@@ -19685,11 +19782,11 @@ ${heal.removed.join("\n")}`,
19685
19782
  });
19686
19783
  return false;
19687
19784
  }
19688
- this.globalUpdateInFlight = true;
19785
+ this.serverUpdateInFlight = true;
19689
19786
  try {
19690
19787
  return await this.runGlobalUpdateAttempt(backend, plan, target2);
19691
19788
  } finally {
19692
- this.globalUpdateInFlight = false;
19789
+ this.serverUpdateInFlight = false;
19693
19790
  }
19694
19791
  }
19695
19792
  /** The attempt itself, under the guard above. */
@@ -19745,7 +19842,13 @@ ${tail}` : ""}`,
19745
19842
  installVersion: after,
19746
19843
  runningVersion: running,
19747
19844
  restarted: false,
19748
- restartable: this.backendIsAppOwned()
19845
+ restartable: this.backendIsAppOwned(),
19846
+ /*
19847
+ * FALSE BY CONSTRUCTION, and stated rather than left absent: this arm is
19848
+ * the global install's own updater, so the environment it moved is never
19849
+ * the app's managed one (design D5).
19850
+ */
19851
+ appOwnedEnvironment: false
19749
19852
  });
19750
19853
  return true;
19751
19854
  }
@@ -19849,6 +19952,13 @@ ${tail}` : ""}`,
19849
19952
  restarted: false,
19850
19953
  unattended: true,
19851
19954
  restartable: this.backendIsAppOwned(),
19955
+ /*
19956
+ * False, and it is also the honest answer to UX U3: the marker is written
19957
+ * on the global path only, and this reader compares it against
19958
+ * `readGlobalInstallVersion()` - the install the global updater moves -
19959
+ * so nothing here is a statement about the app's own environment.
19960
+ */
19961
+ appOwnedEnvironment: false,
19852
19962
  before: marker.before
19853
19963
  });
19854
19964
  }
@@ -19893,6 +20003,9 @@ ${tail}` : ""}`,
19893
20003
  serving
19894
20004
  );
19895
20005
  const target2 = targetVersion ?? this.lastPublishedBackendVersion ?? null;
20006
+ if (serving.appOwned) {
20007
+ return await this.updateAppOwnedEnvironment(targetVersion ?? null);
20008
+ }
19896
20009
  if (plan.canManageUpdate) {
19897
20010
  return await this.updateGlobalInstall(
19898
20011
  this.backendService,
@@ -19938,198 +20051,360 @@ ${tail}` : ""}`,
19938
20051
  });
19939
20052
  return false;
19940
20053
  }
19941
- if (!this.backendService.isUsingExternalBackend()) {
19942
- logger.info(
19943
- "Stopping backend service before update...",
19944
- LogFileType.UPDATE_SERVICE
19945
- );
19946
- this.backendService.setAutoUpdating(true);
19947
- await this.backendService.stop(true);
19948
- logger.info(
19949
- "Backend service stopped successfully",
19950
- LogFileType.UPDATE_SERVICE
19951
- );
19952
- }
19953
- let pythonPath2 = "";
19954
- const venvPath = this.backendService?.getVenvPath();
19955
- if (venvPath && electron.app.isPackaged) {
19956
- const venvBinDir = process.platform === "win32" ? "Scripts" : "bin";
19957
- const pythonName = process.platform === "win32" ? "python.exe" : "python3";
19958
- pythonPath2 = path$1.join(venvPath, venvBinDir, pythonName);
20054
+ return await this.updateAppOwnedEnvironment(targetVersion ?? null);
20055
+ } catch (error) {
20056
+ logger.error(
20057
+ "Error updating backend:",
20058
+ LogFileType.UPDATE_SERVICE,
20059
+ error
20060
+ );
20061
+ if (this.backendService && !this.backendService.isUsingExternalBackend()) {
20062
+ try {
20063
+ logger.info(
20064
+ "Attempting to restart backend service after update failure...",
20065
+ LogFileType.UPDATE_SERVICE
20066
+ );
20067
+ await this.backendService.start();
20068
+ } catch (restartError) {
20069
+ logger.error(
20070
+ "Failed to restart backend service after update failure:",
20071
+ LogFileType.UPDATE_SERVICE,
20072
+ restartError
20073
+ );
20074
+ }
19959
20075
  }
19960
- if (!pythonPath2 || !fs.existsSync(pythonPath2)) {
19961
- logger.error(
19962
- // `managedVenvPath` answers with a sentinel when no environment has
19963
- // been published, and "no Python at …/no-environment-selected" sent a
19964
- // reader to a directory-shaped path that nothing creates (review N3).
19965
- 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"}`,
19966
- LogFileType.UPDATE_SERVICE
19967
- );
19968
- await this.restartBackendAfterFailedUpgrade();
19969
- this.sendToRenderer("backend-update-error", {
19970
- message: "The bundled server's Python environment could not be found, so the update did not run. Please reinstall the application.",
20076
+ if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
20077
+ this.mainWindow.webContents.send("backend-update-error", {
20078
+ // Cleaned at the source, for the reason written on the check-phase
20079
+ // report above.
20080
+ message: stripErrorPrefixes(error.message),
19971
20081
  phase: "update",
19972
20082
  logPath: serverUpdateLogPath()
19973
20083
  });
19974
- return false;
19975
20084
  }
19976
- const pip = buildPipUpgradeCommand(pythonPath2, targetVersion ?? null);
19977
- const versionBefore = await this.readBundledBackendVersion(pythonPath2);
19978
- logger.info(
19979
- `Executing pip command: ${pip.display}`,
20085
+ return false;
20086
+ }
20087
+ }
20088
+ /**
20089
+ * Move the app's OWN managed environment onto the release the app means to run.
20090
+ *
20091
+ * The shape, and its order is load-bearing (design section 5): land the new build
20092
+ * in a tree nothing is reading, tell the consumer the build moved, let it leave
20093
+ * at its own boundary. `updateManagedPython` builds a NEW generation beside the
20094
+ * published one, smokes it there, and only then flips `selected-environment.json`
20095
+ * atomically - so a daemon that is serving keeps reading the tree it loaded, and
20096
+ * no file it has open is ever written.
20097
+ *
20098
+ * The guard is the one the global path uses, for the reason that one's own
20099
+ * comment gives: the surfaces that ask for an update hold per-surface flags, so a
20100
+ * press on each reaches an updater twice. The old bundled path was serialised by
20101
+ * accident - it stopped the daemon first - and this one stops nothing, so it
20102
+ * needs the guard on purpose.
20103
+ */
20104
+ async updateAppOwnedEnvironment(targetVersion) {
20105
+ if (this.serverUpdateInFlight) {
20106
+ logger.warn(
20107
+ "A server update is already running; refusing to start a second beside it",
19980
20108
  LogFileType.UPDATE_SERVICE
19981
20109
  );
19982
- const pipRun = await runCommand(pip.command, pip.args, {
19983
- timeoutMs: 15 * 60 * 1e3,
19984
- env: this.pythonSpawnEnv()
20110
+ this.sendToRenderer("backend-update-error", {
20111
+ message: "A server update is already running. Let it finish before starting another.",
20112
+ phase: "update",
20113
+ logPath: serverUpdateLogPath()
19985
20114
  });
19986
- logger.info(
19987
- `pip stdout:
19988
- ${pipRun.stdout.trim()}`,
20115
+ return false;
20116
+ }
20117
+ this.serverUpdateInFlight = true;
20118
+ try {
20119
+ return await this.runAppOwnedUpdateAttempt(targetVersion);
20120
+ } finally {
20121
+ this.serverUpdateInFlight = false;
20122
+ }
20123
+ }
20124
+ /** The attempt itself, under the guard above. */
20125
+ async runAppOwnedUpdateAttempt(targetVersion) {
20126
+ const backend = this.backendService;
20127
+ if (!backend) {
20128
+ logger.error(
20129
+ "An app-managed environment update was asked for with no backend service reference",
19989
20130
  LogFileType.UPDATE_SERVICE
19990
20131
  );
19991
- if (pipRun.stderr.trim().length > 0) {
19992
- logger.warn(
19993
- `pip stderr:
19994
- ${pipRun.stderr.trim()}`,
19995
- LogFileType.UPDATE_SERVICE
19996
- );
19997
- }
19998
- const versionAfter = await this.readBundledBackendVersion(pythonPath2);
19999
- const upgradeLanded = pipRun.exitCode === 0 && didUpgradeLand({
20000
- before: versionBefore,
20001
- after: versionAfter,
20002
- target: targetVersion ?? null
20132
+ this.sendToRenderer("backend-update-error", {
20133
+ message: "The app has no server to update right now. Restart Local Operator and try again.",
20134
+ phase: "update",
20135
+ logPath: serverUpdateLogPath()
20136
+ });
20137
+ return false;
20138
+ }
20139
+ const options = managedPythonOptions();
20140
+ const target2 = targetVersion ?? this.lastPublishedBackendVersion ?? await this.getLatestPypiVersion() ?? null;
20141
+ if (!target2) {
20142
+ logger.error(
20143
+ "An app-managed environment update was asked for with no release to move to: no caller target, no published reading, and PyPI could not be read",
20144
+ LogFileType.UPDATE_SERVICE
20145
+ );
20146
+ this.sendToRenderer("backend-update-error", {
20147
+ message: "The app could not read which release to install, so it left its server environment alone. Check the connection and try again.",
20148
+ phase: "update",
20149
+ logPath: serverUpdateLogPath()
20003
20150
  });
20004
- if (!upgradeLanded) {
20151
+ return false;
20152
+ }
20153
+ const before = publishedBackendVersion(options);
20154
+ const willPublish = before === null || this.isNewerVersion(target2, before);
20155
+ if (willPublish) {
20156
+ const free = this.freeBytesAt(managedPythonRoot(options));
20157
+ if (free !== null && free < APP_OWNED_PUBLISH_HEADROOM_BYTES) {
20158
+ const neededMb = Math.ceil(
20159
+ APP_OWNED_PUBLISH_HEADROOM_BYTES / 1024 / 1024
20160
+ );
20161
+ const freeMb = Math.floor(free / 1024 / 1024);
20005
20162
  logger.error(
20006
- `Backend upgrade did not land: pip exited ${pipRun.exitCode}; version ${versionBefore ?? "unknown"} -> ${versionAfter ?? "unknown"}`,
20163
+ `Refusing the app-managed environment update: ${freeMb} MB free where ${neededMb} MB is needed`,
20007
20164
  LogFileType.UPDATE_SERVICE
20008
20165
  );
20009
- const serverIsBack = await this.restartBackendAfterFailedUpgrade();
20010
20166
  this.sendToRenderer("backend-update-error", {
20011
- message: pipRun.exitCode !== 0 ? serverIsBack ? "The server update failed to install. The previously installed server is running again; see the update service log for pip's output." : "The server update failed to install and the server did not come back up. Restart Local Operator, and see the update service log for pip's output." : `The server update to ${targetVersion ?? "the new release"} did not take effect: the server is still on ${versionAfter ?? "its previous version"}. See the update service log for pip's output, then try again.`,
20167
+ message: `The server update needs about ${neededMb} MB of free space on this disk, and ${freeMb} MB is free, so the app left the environment serving this app alone. Free some space and try again.`,
20012
20168
  phase: "update",
20013
20169
  logPath: serverUpdateLogPath()
20014
20170
  });
20015
20171
  return false;
20016
20172
  }
20017
- logger.info(
20018
- `Backend package upgraded: ${versionBefore ?? "unknown"} -> ${versionAfter}`,
20173
+ }
20174
+ logger.info(
20175
+ `Updating the app-managed environment (published ${before ?? "no reading"}, target ${target2}, publishes: ${willPublish})`,
20176
+ LogFileType.UPDATE_SERVICE
20177
+ );
20178
+ this.sendToRenderer("backend-update-progress", {
20179
+ phase: willPublish ? "installing" : "restarting"
20180
+ });
20181
+ let outcome;
20182
+ try {
20183
+ outcome = await updateManagedPython(
20184
+ options,
20185
+ (venv, python) => this.installEnvironmentInto(venv, python, target2),
20186
+ {
20187
+ target: target2,
20188
+ isNewer: (candidate, subject) => this.isNewerVersion(candidate, subject)
20189
+ }
20190
+ );
20191
+ } catch (error) {
20192
+ const reason = error instanceof Error ? error.message : String(error);
20193
+ const cause = setupFailureCause(error);
20194
+ logger.error(
20195
+ `The app-managed environment update published nothing: ${reason}`,
20019
20196
  LogFileType.UPDATE_SERVICE
20020
20197
  );
20198
+ this.sendToRenderer("backend-update-error", {
20199
+ message: `The server update did not install, so the environment serving this app was left as it was.${cause ? ` ${cause}` : ""}${reason ? `
20200
+
20201
+ ${reason}` : ""}`,
20202
+ phase: "update",
20203
+ logPath: serverUpdateLogPath()
20204
+ });
20205
+ return false;
20206
+ }
20207
+ const installVersion = outcome.selection.backendVersion;
20208
+ logger.info(
20209
+ outcome.replaced ? `Published a new app-managed environment generation: ${before ?? "none"} -> ${installVersion}; the generation it supersedes stays on disk at ${outcome.previous?.venv ?? "no previous generation"}` : `The published app-managed environment already satisfies ${target2}; nothing was installed (published ${installVersion})`,
20210
+ LogFileType.UPDATE_SERVICE
20211
+ );
20212
+ const servingBeforeRestart = await this.getInstalledBackendVersion();
20213
+ const movedTheServer = outcome.replaced || servingBeforeRestart !== installVersion;
20214
+ if (!movedTheServer) {
20021
20215
  logger.info(
20022
- "Restarting backend service after update...",
20216
+ `The published environment already satisfies ${target2} and the server serving this app already reports ${servingBeforeRestart}; nothing to move, so it was not restarted`,
20023
20217
  LogFileType.UPDATE_SERVICE
20024
20218
  );
20025
- const restartSuccess = await this.backendService.restart();
20026
- if (restartSuccess) {
20027
- logger.info(
20028
- "Backend service restarted successfully after update",
20029
- LogFileType.UPDATE_SERVICE
20030
- );
20031
- this.backendService.setAutoUpdating(false);
20032
- const isRunningAfterRestart = await this.checkBackendHealth();
20033
- if (!isRunningAfterRestart) {
20034
- logger.error(
20035
- "Backend service reported successful restart but health check failed",
20036
- LogFileType.UPDATE_SERVICE
20037
- );
20038
- logger.info(
20039
- "Attempting to start backend service again...",
20040
- LogFileType.UPDATE_SERVICE
20041
- );
20042
- await this.backendService.start();
20043
- const finalHealthCheck = await this.checkBackendHealth();
20044
- if (!finalHealthCheck) {
20045
- logger.error(
20046
- "Backend service failed to start after multiple attempts",
20047
- LogFileType.UPDATE_SERVICE
20048
- );
20049
- if (this.mainWindow) {
20050
- this.mainWindow.webContents.send("backend-update-error", {
20051
- message: "Backend was updated but failed to restart properly. Please restart the application.",
20052
- phase: "update",
20053
- logPath: serverUpdateLogPath()
20054
- });
20055
- }
20056
- return false;
20057
- }
20058
- }
20059
- } else {
20060
- logger.error(
20061
- "Failed to restart backend service after update",
20062
- LogFileType.UPDATE_SERVICE
20063
- );
20064
- this.backendService.setAutoUpdating(false);
20065
- if (this.mainWindow) {
20066
- this.mainWindow.webContents.send("backend-update-error", {
20067
- message: "Backend was updated but failed to restart. Please restart the application.",
20068
- phase: "update",
20069
- logPath: serverUpdateLogPath()
20070
- });
20071
- }
20072
- return false;
20073
- }
20074
- const reportedVersion = await this.waitForBackendVersion(
20075
- targetVersion ?? null
20076
- );
20077
- if (reportedVersion == null) {
20078
- logger.error(
20079
- `Backend did not report version ${targetVersion ?? "the new one"} after the update`,
20080
- LogFileType.UPDATE_SERVICE
20081
- );
20082
- this.sendToRenderer("backend-update-error", {
20083
- message: `The server restarted but did not report ${targetVersion ? `version ${targetVersion}` : "the updated version"}. Check the update service log, then try again.`,
20084
- phase: "update",
20085
- logPath: serverUpdateLogPath()
20086
- });
20087
- return false;
20088
- }
20089
- logger.info(
20090
- `Backend reports version ${reportedVersion} after the update`,
20219
+ this.sendToRenderer("backend-update-completed", {
20220
+ installVersion,
20221
+ runningVersion: servingBeforeRestart,
20222
+ restarted: false,
20223
+ restartable: this.backendIsAppOwned(),
20224
+ appOwnedEnvironment: true
20225
+ });
20226
+ return true;
20227
+ }
20228
+ logger.info(
20229
+ "Restarting backend service onto the published environment...",
20230
+ LogFileType.UPDATE_SERVICE
20231
+ );
20232
+ if (willPublish) {
20233
+ this.sendToRenderer("backend-update-progress", { phase: "restarting" });
20234
+ }
20235
+ backend.setAutoUpdating(true);
20236
+ const restarted = await backend.restart();
20237
+ backend.setAutoUpdating(false);
20238
+ const healthy = restarted ? await this.checkBackendHealth() : false;
20239
+ if (!restarted || !healthy) {
20240
+ logger.error(
20241
+ `The environment is published at ${installVersion} but the backend did not come back onto it (restart ${restarted ? "succeeded" : "failed"}, health ${healthy ? "ok" : "did not answer"})`,
20091
20242
  LogFileType.UPDATE_SERVICE
20092
20243
  );
20093
- logger.info(
20094
- "Backend update and restart completed successfully",
20244
+ const serving = await this.getInstalledBackendVersion();
20245
+ this.sendToRenderer("backend-update-completed", {
20246
+ installVersion,
20247
+ runningVersion: serving,
20248
+ restarted: false,
20249
+ restartable: this.backendIsAppOwned(),
20250
+ appOwnedEnvironment: true,
20251
+ /*
20252
+ * THE MISSING READING IS THE NEWS (UX U1). `serving === null` is not only
20253
+ * "no comparison available": it is a restart the app performed ITSELF, on
20254
+ * an environment it owns, after which the process it stopped did not
20255
+ * answer. Without this field the renderer's funnel declined - correctly,
20256
+ * by its own rule, for want of a reading - and the press was answered by
20257
+ * the fall-through success toast, "Server update completed successfully",
20258
+ * about a server that is not running. That is the same class of claim as
20259
+ * the operator's original report (a surface asserting what it had not
20260
+ * proved), which is why this branch exists rather than a softer sentence
20261
+ * on it.
20262
+ *
20263
+ * AND WITH `restartable` READ RATHER THAN ASSUMED (review R8) this field is
20264
+ * no longer only a shape a future producer could send: on an adopted daemon
20265
+ * whose restart did not take, `restartable` is false here, so the renderer's
20266
+ * `!actionRestartsTheServer` arm - "Nothing is serving this conversation
20267
+ * until that server is running again" - PAINTS. Design D14 filed that string
20268
+ * as copy no producer could reach; the arm is now reachable through this one.
20269
+ */
20270
+ serverDidNotComeBack: serving === null
20271
+ });
20272
+ return true;
20273
+ }
20274
+ const running = await this.waitForBackendVersion(installVersion);
20275
+ if (running === null) {
20276
+ const serving = await this.getInstalledBackendVersion();
20277
+ logger.error(
20278
+ `The environment is published at ${installVersion} but the server serving this app reports ${serving ?? "no reading"}`,
20095
20279
  LogFileType.UPDATE_SERVICE
20096
20280
  );
20097
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
20098
- this.mainWindow.webContents.send("backend-update-completed");
20099
- }
20281
+ this.sendToRenderer("backend-update-completed", {
20282
+ installVersion,
20283
+ runningVersion: serving,
20284
+ restarted: false,
20285
+ restartable: this.backendIsAppOwned(),
20286
+ appOwnedEnvironment: true
20287
+ /*
20288
+ * This arm is NOT `serverDidNotComeBack` even when `serving` is null, and
20289
+ * the difference is what the app knows: the daemon answered the health
20290
+ * probe a moment ago (that is the branch above this one), so a missing
20291
+ * reading here is a version read that failed rather than a server that
20292
+ * never came back, and the panel may not claim the second.
20293
+ *
20294
+ * AND THE SUCCESS TOAST IS DELIBERATE HERE (UX U7, which asked for this
20295
+ * decision to be RECORDED where the reason already is rather than for a new
20296
+ * state). The completion is a claim about the update, and the update is what
20297
+ * happened: the environment moved, the app restarted the daemon onto it and
20298
+ * the daemon answered the probe - the only thing unread is WHICH build that
20299
+ * running process reports. The alternatives are worse rather than more
20300
+ * honest: a panel keyed on "a reading failed" would put a standing failed-
20301
+ * tone surface in front of a server that is up and serving, and it would
20302
+ * have to be dismissed by a user who has nothing to do about it, while the
20303
+ * sentence the toast carries says nothing about the build either way. What
20304
+ * the app may not do is CLAIM a build it did not read, and it does not.
20305
+ */
20306
+ });
20100
20307
  return true;
20101
- } catch (error) {
20308
+ }
20309
+ logger.info(
20310
+ `The server serving this app reports ${running} after the app-managed environment update`,
20311
+ LogFileType.UPDATE_SERVICE
20312
+ );
20313
+ this.sendToRenderer("backend-update-completed", {
20314
+ installVersion,
20315
+ runningVersion: running,
20316
+ restarted: true
20317
+ });
20318
+ return true;
20319
+ }
20320
+ /**
20321
+ * Install a release into a freshly created environment, and say whether it landed
20322
+ * there.
20323
+ *
20324
+ * The two commands are the ones this app's own install script runs for the same
20325
+ * steps - create the environment on the managed runtime's interpreter, then
20326
+ * `pip install` into it - aimed at the generation the caller has just minted, and
20327
+ * pinned to the release the user was offered.
20328
+ *
20329
+ * It is deliberately NOT the install script itself. That one is the interactive
20330
+ * first run: it builds a modal progress window and it calls `app.exit(1)` when
20331
+ * the user cancels that window, so reaching for it here would put a second window
20332
+ * and a quit path inside an update - and an update that can quit the app is not
20333
+ * an update that leaves the runtimes running.
20334
+ *
20335
+ * `didUpgradeLand` rather than the exit code, for the reason it exists: pip
20336
+ * reports success when the requirement is already satisfied, and it can exit 0
20337
+ * having installed for a different interpreter, so the version read back out of
20338
+ * THIS environment is the only thing that answers whether the user got the
20339
+ * release they were offered.
20340
+ *
20341
+ * EVERY FAILING STEP HERE THROWS, and the caller's `false` is not this arm's to
20342
+ * send (review R6). The callback contract is `Promise<boolean>` because the
20343
+ * first-run installer shares it, but a boolean carries no words, and the words are
20344
+ * the whole remedy on a full disk: `publishGeneration` turns `false` into a fixed
20345
+ * sentence that `SETUP_FAILURE_CAUSES` does not match, so a publish that ran out of
20346
+ * space reached the reader with no disk-space remedy on it. Throwing the child's
20347
+ * captured output keeps that table's matching text intact end to end.
20348
+ */
20349
+ async installEnvironmentInto(venv, python, target2) {
20350
+ const created = await runCommand(python, ["-m", "venv", venv], {
20351
+ timeoutMs: 10 * 60 * 1e3,
20352
+ env: this.pythonSpawnEnv()
20353
+ });
20354
+ if (created.exitCode !== 0) {
20355
+ const output = installerOutputOf(created);
20102
20356
  logger.error(
20103
- "Error updating backend:",
20104
- LogFileType.UPDATE_SERVICE,
20105
- error
20357
+ `Could not create the update environment at ${venv}: ${output}`,
20358
+ LogFileType.UPDATE_SERVICE
20359
+ );
20360
+ throw new Error(
20361
+ `Could not create the update environment at ${venv}.
20362
+
20363
+ ${output}`
20364
+ );
20365
+ }
20366
+ const interpreter = path$1.join(
20367
+ venv,
20368
+ process.platform === "win32" ? "Scripts" : "bin",
20369
+ process.platform === "win32" ? "python.exe" : "python"
20370
+ );
20371
+ const pip = buildPipUpgradeCommand(interpreter, target2);
20372
+ logger.info(
20373
+ `Installing into the new environment: ${pip.display}`,
20374
+ LogFileType.UPDATE_SERVICE
20375
+ );
20376
+ const run = await runCommand(pip.command, pip.args, {
20377
+ timeoutMs: 15 * 60 * 1e3,
20378
+ env: this.pythonSpawnEnv()
20379
+ });
20380
+ logger.info(
20381
+ `Install stdout:
20382
+ ${run.stdout.trim()}`,
20383
+ LogFileType.UPDATE_SERVICE
20384
+ );
20385
+ if (run.stderr.trim().length > 0) {
20386
+ logger.warn(
20387
+ `Install stderr:
20388
+ ${run.stderr.trim()}`,
20389
+ LogFileType.UPDATE_SERVICE
20106
20390
  );
20107
- if (this.backendService && !this.backendService.isUsingExternalBackend()) {
20108
- try {
20109
- logger.info(
20110
- "Attempting to restart backend service after update failure...",
20111
- LogFileType.UPDATE_SERVICE
20112
- );
20113
- await this.backendService.start();
20114
- } catch (restartError) {
20115
- logger.error(
20116
- "Failed to restart backend service after update failure:",
20117
- LogFileType.UPDATE_SERVICE,
20118
- restartError
20119
- );
20120
- }
20121
- }
20122
- if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
20123
- this.mainWindow.webContents.send("backend-update-error", {
20124
- // Cleaned at the source, for the reason written on the check-phase
20125
- // report above.
20126
- message: stripErrorPrefixes(error.message),
20127
- phase: "update",
20128
- logPath: serverUpdateLogPath()
20129
- });
20130
- }
20131
- return false;
20132
20391
  }
20392
+ const installed = await this.readBundledBackendVersion(interpreter);
20393
+ if (!didUpgradeLand({ before: null, after: installed, target: target2 })) {
20394
+ const output = installerOutputOf(run);
20395
+ logger.error(
20396
+ `The new environment is not on ${target2}: it reports ${installed ?? "no readable version"}; the installer's own output follows:
20397
+ ${output}`,
20398
+ LogFileType.UPDATE_SERVICE
20399
+ );
20400
+ throw new Error(
20401
+ `The environment was created but ${target2} did not land in it; the installer reports ${installed ?? "no readable version"}.
20402
+
20403
+ ${output}`
20404
+ );
20405
+ }
20406
+ ensureVenvBytecodeGuard(venv);
20407
+ return true;
20133
20408
  }
20134
20409
  /**
20135
20410
  * Check for all updates (UI and backend)