diff --git a/electron/package.json b/electron/package.json index 87c0541e..72213ded 100644 --- a/electron/package.json +++ b/electron/package.json @@ -72,6 +72,13 @@ "filter": [ "**/*" ] + }, + { + "from": "build-staging/python-env/${arch}", + "to": "python-env", + "filter": [ + "**/*" + ] } ] }, @@ -94,6 +101,15 @@ }, "win": { "icon": "build/icon.ico", + "extraResources": [ + { + "from": "python-env", + "to": "python-env", + "filter": [ + "**/*" + ] + } + ], "target": [ { "target": "squirrel", @@ -148,13 +164,6 @@ "**/*" ] }, - { - "from": "python-env", - "to": "python-env", - "filter": [ - "**/*" - ] - }, { "from": "build-staging/router", "to": "router", diff --git a/scripts/build-app.sh b/scripts/build-app.sh index bca64678..87f73ce9 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -26,6 +26,22 @@ elif [[ "${1:-}" == "--sign" ]]; then SIGN_MODE=true fi +# Arch targets for this run. Publish always builds both DMGs; otherwise +# OSW_BUILD_ARCH (arm64|x64|both) overrides, defaulting to the host. Node, +# python-env, and the electron-builder flags below all derive from this ONE +# list, so a staged-arch vs packed-arch mismatch can't happen (the class of +# bug that shipped arm64 python inside the x64 DMG). +if $PUBLISH_MODE; then + BUILD_ARCHS=(arm64 x64) +else + case "${OSW_BUILD_ARCH:-host}" in + both) BUILD_ARCHS=(arm64 x64) ;; + x64) BUILD_ARCHS=(x64) ;; + arm64) BUILD_ARCHS=(arm64) ;; + *) if [[ "$(uname -m)" == "x86_64" ]]; then BUILD_ARCHS=(x64); else BUILD_ARCHS=(arm64); fi ;; + esac +fi + # Defensive: detach any leftover OpenSwarm DMG volumes from prior failed builds. # hdiutil's "Resource busy" / volume-name-collision errors almost always trace # back to a stale mount in /Volumes (e.g. after a build crash or a still-open @@ -265,17 +281,6 @@ fi echo "Frontend build complete." echo "" -# Step 2: Build Python environment -echo "[2/4] Building Python environment..." -bash "$SCRIPT_DIR/build-python-env.sh" - -if [[ ! -d "$PROJECT_ROOT/electron/python-env" ]]; then - echo "ERROR: Python environment not found at electron/python-env/" - exit 1 -fi -echo "Python environment ready." -echo "" - # Step 3: Fetch Router from npm # The 9router Next.js server is published as an npm package with a pre-built # standalone output. We install it into a scratch dir and stage it directly @@ -293,6 +298,19 @@ fi echo "Router staged." echo "" +# Step 3a: Bundled Python env, one per target arch (must run AFTER the +# build-staging reset above or the freshly staged envs get wiped). +echo "[3a] Building bundled Python env(s): ${BUILD_ARCHS[*]}" +for A in "${BUILD_ARCHS[@]}"; do + bash "$SCRIPT_DIR/build-python-env.sh" "$A" + if [[ ! -f "$STAGING_DIR/python-env/$A/bin/python3.13" ]]; then + echo "ERROR: python-env ($A) missing at $STAGING_DIR/python-env/$A" + exit 1 + fi +done +echo "Python environment(s) ready." +echo "" + # Step 3b: Bundle a real Node.js binary so 9Router and MCP servers don't # fall back to ELECTRON_RUN_AS_NODE on user machines without system node. # Two wins: @@ -351,21 +369,10 @@ NPMSH echo "[3b] Node $NODE_VERSION ($arch) staged ($(du -h "$out_dir/bin/node" | cut -f1))" } -# Publish mode builds both DMGs from one invocation, so always stage both. -# Single-arch local/sign builds only need the host arch. -if $PUBLISH_MODE; then - download_node_for_arch arm64 - download_node_for_arch x64 -else - HOST_ARCH=$(uname -m) - if [[ "$HOST_ARCH" == "arm64" ]]; then - download_node_for_arch arm64 - elif [[ "$HOST_ARCH" == "x86_64" ]]; then - download_node_for_arch x64 - else - echo "WARNING: unknown host arch $HOST_ARCH — skipping node bundle (will fall back to ELECTRON_RUN_AS_NODE)" - fi -fi +# Stage node for every arch this run packs (BUILD_ARCHS decides, top of file). +for A in "${BUILD_ARCHS[@]}"; do + download_node_for_arch "$A" +done echo "" # Step 3c: Pre-build the webapp-template node_modules archive so first-app @@ -488,27 +495,19 @@ fi # Caller's NODE_OPTIONS is respected if already set. export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=12288}" +# Pack exactly the arches we staged for (BUILD_ARCHS, top of file). +EB_ARCH_FLAGS=() +for A in "${BUILD_ARCHS[@]}"; do + EB_ARCH_FLAGS+=("--$A") +done + if $PUBLISH_MODE; then - npx electron-builder --mac --arm64 --x64 --publish always + npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish always elif $SIGN_MODE; then - ARCH=$(uname -m) - if [[ "$ARCH" == "arm64" ]]; then - npx electron-builder --mac --arm64 --publish never - elif [[ "$ARCH" == "x86_64" ]]; then - npx electron-builder --mac --x64 --publish never - else - npx electron-builder --mac --publish never - fi + npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never else export CSC_IDENTITY_AUTO_DISCOVERY=false - ARCH=$(uname -m) - if [[ "$ARCH" == "arm64" ]]; then - npx electron-builder --mac --arm64 --publish never - elif [[ "$ARCH" == "x86_64" ]]; then - npx electron-builder --mac --x64 --publish never - else - npx electron-builder --mac --publish never - fi + npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never fi rm -rf "$PROJECT_ROOT/electron/build-staging" diff --git a/scripts/build-python-env.sh b/scripts/build-python-env.sh index 9dbe5c90..0de2a7bc 100755 --- a/scripts/build-python-env.sh +++ b/scripts/build-python-env.sh @@ -1,28 +1,42 @@ #!/bin/bash set -euo pipefail -# Build an embedded Python environment for the Electron app. +# Build an embedded Python environment for the Electron app (macOS). # # Downloads a standalone Python build from python-build-standalone, # creates a venv, and installs all backend dependencies. -# The resulting python-env/ directory is bundled into the Electron app. +# +# Usage: build-python-env.sh [arm64|x64] (default: host arch) +# +# The env stages under electron/build-staging/python-env/ so +# electron-builder's ${arch} macro bundles the MATCHING env per pack, same +# pattern as node/${arch}. Never bundle one host-arch env into both DMGs: +# that shipped arm64 python inside the x64 app and killed every Intel Mac. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" ELECTRON_DIR="$PROJECT_ROOT/electron" -PYTHON_ENV_DIR="$ELECTRON_DIR/python-env" PYTHON_VERSION="3.13" PYTHON_FULL_VERSION="3.13.2" -ARCH="$(uname -m)" +ARCH="${1:-$(uname -m)}" -if [[ "$ARCH" == "arm64" ]]; then - PLATFORM_TAG="aarch64-apple-darwin" -elif [[ "$ARCH" == "x86_64" ]]; then - PLATFORM_TAG="x86_64-apple-darwin" -else - echo "Unsupported architecture: $ARCH" - exit 1 +case "$ARCH" in + arm64|aarch64) ARCH="arm64"; PLATFORM_TAG="aarch64-apple-darwin" ;; + x64|x86_64) ARCH="x64"; PLATFORM_TAG="x86_64-apple-darwin" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +PYTHON_ENV_DIR="$ELECTRON_DIR/build-staging/python-env/$ARCH" + +# Cross-building x64 on Apple Silicon runs the x64 python under Rosetta +# (pip then resolves x86_64 wheels, including the SDK's bundled claude CLI). +if [[ "$ARCH" == "x64" && "$(uname -m)" == "arm64" ]]; then + if ! arch -x86_64 /usr/bin/true 2>/dev/null; then + echo "ERROR: building the x64 python-env on Apple Silicon requires Rosetta 2." + echo " Install it with: softwareupdate --install-rosetta --agree-to-license" + exit 1 + fi fi RELEASE_TAG="20250212" @@ -44,6 +58,7 @@ if [[ -d "$PYTHON_ENV_DIR" ]]; then echo "Removing old python-env..." rm -rf "$PYTHON_ENV_DIR" fi +mkdir -p "$(dirname "$PYTHON_ENV_DIR")" # Download standalone Python echo "Downloading standalone Python from python-build-standalone..." @@ -301,7 +316,7 @@ PLIST # python-env/ via realpath, and libpython loads via the rewritten # @executable_path path. if ! "$PY_APP/Contents/MacOS/python3" -c \ - "import sys; assert sys.prefix.endswith('python-env'), sys.prefix" 2>/dev/null; then + "import sys, os; assert os.path.realpath(sys.prefix) == os.path.realpath('$PYTHON_ENV_DIR'), sys.prefix" 2>/dev/null; then echo "ERROR: Python.app wrapper failed self-test (libpython or stdlib not findable)" >&2 echo " Try: $PY_APP/Contents/MacOS/python3 -c 'import sys; print(sys.prefix)'" >&2 exit 1 diff --git a/scripts/ci/verify-python-health.js b/scripts/ci/verify-python-health.js index 43cf626b..02ec7d75 100644 --- a/scripts/ci/verify-python-health.js +++ b/scripts/ci/verify-python-health.js @@ -54,6 +54,33 @@ function main() { } process.stdout.write(` ok python ${versionLine}\n`); + // 1b) macOS: the bundled python's arch slices must cover the app's. An arm64 + // python inside the x64 app RUNS on an arm64 build host (native, not Rosetta), + // so --version alone can never catch the cross-arch bundle bug that bricked + // every Intel Mac. lipo compares what the file IS, not what the host can run. + if (process.platform === 'darwin') { + const i = appExe.indexOf('.app'); + const appRoot = i === -1 ? appExe : appExe.slice(0, i + 4); + const mainBin = path.join(appRoot, 'Contents', 'MacOS', path.basename(appRoot, '.app')); + const archsOf = (bin) => { + const r = spawnSync('lipo', ['-archs', bin], { encoding: 'utf8', timeout: 15000 }); + if (r.status !== 0) return null; + return (r.stdout || '').trim().split(/\s+/).filter(Boolean); + }; + const appArchs = archsOf(mainBin); + const pyArchs = archsOf(fs.realpathSync(py)); + if (!appArchs || !pyArchs) { + process.stderr.write(`\nPYTHON-HEALTH FAIL: lipo could not read archs (app=${appArchs}, python=${pyArchs})\n`); + process.exit(1); + } + const missing = appArchs.filter((a) => !pyArchs.includes(a)); + if (missing.length > 0) { + process.stderr.write(`\nPYTHON-HEALTH FAIL: app is [${appArchs}] but bundled python is [${pyArchs}] (missing ${missing}). This build would brick ${missing.join('/')} Macs.\n`); + process.exit(1); + } + process.stdout.write(` ok arch match (app [${appArchs}] / python [${pyArchs}])\n`); + } + // 2) Import smoke: load the heaviest deps to catch a half-extracted site-packages tree (rare but lethal). const smoke = spawnSync(py, ['-c', 'import sys, fastapi, anthropic, pydantic, httpx, jsonschema; print(sys.version_info[:3])'], { encoding: 'utf8', timeout: 30000 }); if (smoke.status !== 0) {