From f0ea0fd1bc894e9edeecb62423eb112c7b9902b0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 27 Apr 2026 22:37:17 -0700 Subject: [PATCH] [eric] 1.0.27 production push: fixes the weird "exec" icon next to OpenSwarm on fresh Macs, faster startup (~10s less from shipping a real Node binary instead of running Electron as Node), and Google Workspace + other uvx-based MCPs work again on machines without uv installed. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/apps/nine_router.py | 21 +++++- backend/apps/tools_lib/tools_lib.py | 27 +++++-- electron/main.js | 54 ++++++++++++++ electron/package-lock.json | 4 +- electron/package.json | 9 ++- scripts/build-app-win.ps1 | 61 ++++++++++++---- scripts/build-app.sh | 107 ++++++++++++++++++++++------ scripts/build-python-env.sh | 81 +++++++++++++++++++++ scripts/build-test-dmg.sh | 92 ++++++++++++++++++++++++ 9 files changed, 410 insertions(+), 46 deletions(-) create mode 100755 scripts/build-test-dmg.sh diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py index 14cc1c12..a7e0e78a 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router.py @@ -88,13 +88,28 @@ def _find_9router_dir() -> str | None: def _find_node() -> str | None: - """Find a Node.js binary (works in both dev and packaged mode).""" - # Check system node first + """Find a Node.js binary (works in both dev and packaged mode). + + Priority order: + 1. OPENSWARM_NODE_PATH — set by electron/main.js when a real Node + binary is bundled in extraResources. Always preferred on user + machines because it (a) avoids the bouncing "exec" Dock icon + that ELECTRON_RUN_AS_NODE produces on fresh Macs and (b) starts + in ~50ms vs Electron-as-Node's 5–15s cold-start, shrinking the + splash window the user stares at. + 2. System `node` on PATH — dev convenience. + 3. ELECTRON_RUN_AS_NODE fallback — last resort. Only hits this on + packaged builds that for some reason shipped without the bundled + node payload. + """ + bundled = os.environ.get("OPENSWARM_NODE_PATH") + if bundled and os.path.exists(bundled): + return bundled + node = shutil.which("node") if node: return node - # In packaged Electron app, use the Electron binary with ELECTRON_RUN_AS_NODE=1 electron_path = os.environ.get("OPENSWARM_ELECTRON_PATH") if electron_path and os.path.exists(electron_path): return electron_path diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index 594e0721..ba0cfd52 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -460,7 +460,17 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]: bundle_path = bundle_dir_path elif os.path.isfile(bundle_file_path): bundle_path = bundle_file_path - if bundle_path and electron_path: + # Prefer the bundled real-Node binary over Electron-as-Node: + # avoids the bouncing "exec" Dock icon on fresh user Macs + + # spawns ~10x faster than re-execing the OpenSwarm Electron + # binary as Node. Falls back to Electron-as-Node only if + # the bundled node payload wasn't shipped (legacy builds). + bundled_node = os.environ.get("OPENSWARM_NODE_PATH") + if bundle_path and bundled_node and os.path.exists(bundled_node): + config["command"] = bundled_node + config["args"] = [bundle_path] + logger.info(f"Using bundled MCP server for {pkg_name} via bundled node ({bundle_path})") + elif bundle_path and electron_path: config["command"] = electron_path config["args"] = [bundle_path] config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1" @@ -476,11 +486,14 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]: pkg_meta = _json.load(f) bin_field = pkg_meta.get("bin", {}) entry = list(bin_field.values())[0] if isinstance(bin_field, dict) else bin_field - node_cmd = electron_path or shutil.which("node") + # Same priority as 9Router / MCP-bundle paths: bundled node > system node > Electron-as-Node. + node_cmd = (bundled_node if bundled_node and os.path.exists(bundled_node) else None) \ + or shutil.which("node") \ + or electron_path if node_cmd: config["command"] = node_cmd config["args"] = [os.path.join(npm_dir, "node_modules", pkg_name, entry)] - if electron_path: + if node_cmd == electron_path: config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1" logger.info(f"Using pre-installed npm MCP server for {pkg_name}") @@ -929,14 +942,18 @@ async def m365_device_login(tool_id: str): if not os.path.isfile(script): raise HTTPException(status_code=500, detail="M365 MCP server not installed") + # Same priority as MCP-bundle / 9Router paths: bundled real node first + # (clean, no Dock flicker, fast cold-start), then system node, then + # Electron-as-Node as last resort. + bundled = os.environ.get("OPENSWARM_NODE_PATH") node = shutil.which("node") electron = os.environ.get("OPENSWARM_ELECTRON_PATH") - cmd = electron or node + cmd = (bundled if bundled and os.path.exists(bundled) else None) or node or electron if not cmd: raise HTTPException(status_code=500, detail="No node/electron found") env = {**os.environ, **_m365_cache_env()} - if electron: + if cmd == electron: env["ELECTRON_RUN_AS_NODE"] = "1" # Kill any existing login process for this tool diff --git a/electron/main.js b/electron/main.js index b9342ca1..53eeb349 100644 --- a/electron/main.js +++ b/electron/main.js @@ -296,11 +296,27 @@ function getPythonPath() { // python-build-standalone layout differs by OS: // macOS / Linux: /bin/python3 // Windows: \python.exe (no bin/, no python3) + // + // macOS extra: invoke via Python.app/Contents/MacOS/python3 instead of + // bin/python3 so LaunchServices reads LSUIElement=1 from the wrapper + // bundle's Info.plist and skips the Dock entry. Without this, the + // bundleless python3.13 binary appears as a generic "exec" placeholder + // in the Dock on fresh user Macs, bouncing for the entire boot window. + // sys.prefix / sys.executable still resolve via realpath so all stdlib + // and site-packages discovery is unchanged. See scripts/build-python-env.sh + // for the wrapper layout invariants. if (isPackaged) { const envPath = path.join(process.resourcesPath, 'python-env'); if (process.platform === 'win32') { return path.join(envPath, 'python.exe'); } + if (process.platform === 'darwin') { + const wrapped = path.join(envPath, 'Python.app', 'Contents', 'MacOS', 'python3'); + // Defensive fallback: if the wrapper is missing for any reason + // (e.g. older build cache), fall back to the bare binary so boot + // still succeeds — only the Dock-icon suppression is lost. + if (fs.existsSync(wrapped)) return wrapped; + } return path.join(envPath, 'bin', 'python3'); } if (process.platform === 'win32') { @@ -309,6 +325,32 @@ function getPythonPath() { return path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3'); } +// Path to a real Node.js binary bundled in extraResources, or null if not +// shipped (dev mode, or build that skipped the node-fetch step). Backend +// reads OPENSWARM_NODE_PATH env var to prefer this over both system `node` +// (which fresh user Macs lack) and the ELECTRON_RUN_AS_NODE fallback +// (which has flaky Dock behavior + slow cold-start). Used by 9Router and +// MCP bundle spawning. +// +// Layout shipped by scripts/build-app.sh: +// /node/arm64/bin/node +// /node/x64/bin/node +// Both arches are staged so a single extraResources entry covers +// publish-mode dual-arch builds without per-arch staging hooks; the +// runtime picks the matching one by process.arch. Wasted ~25 MB per +// DMG of cross-arch payload is the cost of avoiding electron-builder's +// per-arch beforePack complexity. Windows uses node.exe at the root of +// the per-arch subdir. +function getBundledNodePath() { + if (!isPackaged) return null; + const arch = process.arch === 'x64' ? 'x64' : (process.arch === 'arm64' ? 'arm64' : null); + if (!arch) return null; + const candidate = process.platform === 'win32' + ? path.join(process.resourcesPath, 'node', arch, 'node.exe') + : path.join(process.resourcesPath, 'node', arch, 'bin', 'node'); + return fs.existsSync(candidate) ? candidate : null; +} + // Polls /api/health/check until the backend answers 200, or the spawned // python process exits non-zero (real failure). Never times out by wall // clock — on a cold-Defender Windows install this can take several @@ -409,6 +451,18 @@ async function startBackend() { PYTHONUTF8: '1', }; + // Tell the backend where to find a real Node binary for 9Router and + // bundled MCP servers. Preferring this over ELECTRON_RUN_AS_NODE avoids + // (a) the second OpenSwarm-as-Node process briefly registering in the + // Dock on fresh Macs, and (b) the slow Electron cold-start tail (~5-15s) + // that Electron-as-Node adds vs. native node (~1-2s). Falls back to the + // existing system-node / Electron-as-Node chain in nine_router._find_node() + // if the env var is unset (dev mode, or build without node fetch). + const bundledNode = getBundledNodePath(); + if (bundledNode) { + env.OPENSWARM_NODE_PATH = bundledNode; + } + if (isPackaged) { // site-packages location differs by OS — Windows has no lib/python3.13/. const pythonEnvSitePackages = process.platform === 'win32' diff --git a/electron/package-lock.json b/electron/package-lock.json index 477cb906..4513aa63 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.0.26", + "version": "1.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.0.26", + "version": "1.0.27", "hasInstallScript": true, "dependencies": { "electron-updater": "^6.3.0", diff --git a/electron/package.json b/electron/package.json index d642411b..21ab89b0 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.0.26", + "version": "1.0.27", "description": "OpenSwarm — AI Agent Orchestrator", "main": "main.js", "scripts": { @@ -122,6 +122,13 @@ "filter": [ "**/*" ] + }, + { + "from": "build-staging/node", + "to": "node", + "filter": [ + "**/*" + ] } ], "publish": { diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index fc5466d7..009904f3 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -60,34 +60,33 @@ if ($Sign) { } } -# --- Step 0: Bundled uvx for Windows --- -# We deliberately ship ONLY uvx.exe (~700KB), NOT uv.exe (~30MB). Tools -# only ever invoke uvx; bare uv is a build-time installer/manager. Saves -# ~30MB from the Windows installer. See scripts/build-app.sh for context. +# --- Step 0: Bundled uv + uvx for Windows --- +# IMPORTANT: uvx.exe is a tiny shim that requires sibling uv.exe at runtime. +# Without uv.exe, MCPs that use `command: uvx` (e.g. Google Workspace) fail +# with "Could not find the `uv` binary". A prior revision shipped only uvx +# to save ~30MB; that broke first-launch MCP discovery on fresh Macs and +# Windows machines without a system uv install. Ship both. $UvBinDir = Join-Path $ProjectRoot 'backend\uv-bin' -# Defensively drop a stale uv.exe from prior builds. -if (Test-Path (Join-Path $UvBinDir 'uv.exe')) { - Write-Host "[0] Removing legacy uv.exe (we now ship uvx only)..." - Remove-Item -Force (Join-Path $UvBinDir 'uv.exe') -} -if (-not (Test-Path (Join-Path $UvBinDir 'uvx.exe'))) { - Write-Host "[0] Downloading uvx for Windows..." - New-Item -ItemType Directory -Force -Path $UvBinDir | Out-Null +New-Item -ItemType Directory -Force -Path $UvBinDir | Out-Null +$NeedUv = -not (Test-Path (Join-Path $UvBinDir 'uv.exe')) -or ` + -not (Test-Path (Join-Path $UvBinDir 'uvx.exe')) +if ($NeedUv) { + Write-Host "[0] Downloading uv + uvx for Windows..." $UvUrl = 'https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip' $TmpZip = Join-Path $env:TEMP "uv-win-$([guid]::NewGuid()).zip" $TmpExtract = Join-Path $env:TEMP "uv-win-extract-$([guid]::NewGuid())" try { Invoke-WebRequest -Uri $UvUrl -OutFile $TmpZip -UseBasicParsing Expand-Archive -Path $TmpZip -DestinationPath $TmpExtract -Force - # Only copy uvx.exe -- skip uv.exe entirely. + Get-ChildItem -Path $TmpExtract -Recurse -Filter 'uv.exe' | Select-Object -First 1 | ForEach-Object { Copy-Item $_.FullName (Join-Path $UvBinDir 'uv.exe') -Force } Get-ChildItem -Path $TmpExtract -Recurse -Filter 'uvx.exe' | Select-Object -First 1 | ForEach-Object { Copy-Item $_.FullName (Join-Path $UvBinDir 'uvx.exe') -Force } - Write-Host "uvx.exe downloaded and bundled." + Write-Host "uv.exe + uvx.exe downloaded and bundled." } finally { Remove-Item -Force $TmpZip -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $TmpExtract -ErrorAction SilentlyContinue } } else { - Write-Host "[0] uvx.exe already present." + Write-Host "[0] uv.exe + uvx.exe already present." } Write-Host "" @@ -280,6 +279,38 @@ if (-not (Test-Path (Join-Path $Staging 'router\server.js'))) { Write-Host "Router staged." Write-Host "" +# --- 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. Wins: (1) avoids the bouncing "exec" Dock icon (irrelevant on +# Windows but matches the macOS build for consistency); (2) shrinks +# 9Router cold-start from ~10s (Electron-as-Node) to ~1-2s (real node), +# which directly shrinks the splash window the user sees during boot. +# Pinned to Node 20 LTS, NODE_MODULE_VERSION 115. 9router 0.3.60 has +# no native bindings (sql.js, not better-sqlite3) so any Node 18+ works. +Write-Host "[3b/5] Bundling Node.js runtime..." +$NodeVersion = 'v20.18.1' +$NodeStageDir = Join-Path $Staging 'node\x64' +New-Item -ItemType Directory -Force -Path $NodeStageDir | Out-Null +$NodeZip = Join-Path $env:TEMP "node-win-$([guid]::NewGuid()).zip" +$NodeExtract = Join-Path $env:TEMP "node-win-extract-$([guid]::NewGuid())" +try { + $NodeUrl = "https://nodejs.org/dist/$NodeVersion/node-$NodeVersion-win-x64.zip" + Write-Host "[3b] Downloading $NodeUrl..." + Invoke-WebRequest -Uri $NodeUrl -OutFile $NodeZip -UseBasicParsing + Expand-Archive -Path $NodeZip -DestinationPath $NodeExtract -Force + # Ship just node.exe — npm/npx are unused at runtime (router + MCP + # bundles are pre-built). Saves ~70 MB from the installer. + $SrcNode = Join-Path $NodeExtract "node-$NodeVersion-win-x64\node.exe" + if (-not (Test-Path $SrcNode)) { throw "node.exe not found at $SrcNode after extract" } + Copy-Item -Force $SrcNode (Join-Path $NodeStageDir 'node.exe') + $Size = (Get-Item (Join-Path $NodeStageDir 'node.exe')).Length / 1MB + Write-Host ("[3b] Node {0} (x64) staged ({1:N1} MB)" -f $NodeVersion, $Size) +} finally { + if (Test-Path $NodeZip) { Remove-Item -Force $NodeZip } + if (Test-Path $NodeExtract) { Remove-Item -Recurse -Force $NodeExtract } +} +Write-Host "" + # --- Step 4: Snapshot source dirs into electron\build-staging\ --- # (Router was already staged in step 3; do not wipe or re-copy it here.) Write-Host "[4/5] Snapshotting source directories..." diff --git a/scripts/build-app.sh b/scripts/build-app.sh index d04926e8..c50534c5 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -68,32 +68,31 @@ if $SIGN_MODE; then fi fi -# Step 0: Ensure bundled uvx binary exists. -# We deliberately ship ONLY uvx (~681KB universal), NOT uv (~97MB universal). -# tools_lib.py probes for both, but the only tool config that uses either is -# Google Workspace (cmd: "uvx"). Bare `uv` (the package installer/manager) is -# never invoked at runtime by any shipped tool — it's a build-time tool. -# Saves 97MB from the Mac DMG. If a future MCP needs `uv`, this is a one-line -# add-back here. +# Step 0: Ensure bundled uv + uvx binaries exist. +# IMPORTANT: uvx is a tiny ~700KB shim that just resolves to a sibling `uv` +# binary on disk. It does NOT contain the package-installer logic itself; +# at runtime `uvx` errors with "Could not find the `uv` binary at either of: +# .../uv-bin/uv .../uv-bin/uv" if `uv` is missing. So we must ship both, +# even though only Google Workspace MCP uses uvx as its `command`. A prior +# revision tried to save ~30MB by shipping only uvx — that broke MCP boot +# on fresh Macs. Don't repeat the mistake. UV_BIN_DIR="$PROJECT_ROOT/backend/uv-bin" -# Defensively drop a stale `uv` from prior builds so it doesn't ride along. -if [[ -f "$UV_BIN_DIR/uv" ]]; then - echo "[0] Removing legacy uv binary (we now ship uvx only)..." - rm -f "$UV_BIN_DIR/uv" -fi -if [[ ! -f "$UV_BIN_DIR/uvx" ]]; then - echo "[0] Downloading uvx binary..." - mkdir -p "$UV_BIN_DIR" +mkdir -p "$UV_BIN_DIR" +NEED_UV=false +[[ ! -f "$UV_BIN_DIR/uv" ]] && NEED_UV=true +[[ ! -f "$UV_BIN_DIR/uvx" ]] && NEED_UV=true +if $NEED_UV; then + echo "[0] Downloading uv + uvx binaries (universal arm64+x64)..." TMPDIR_UV=$(mktemp -d) curl -sL "https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz" | tar xz -C "$TMPDIR_UV" - curl -sL "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-apple-darwin.tar.gz" | tar xz -C "$TMPDIR_UV" - # Only lipo uvx — skip uv entirely. + curl -sL "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-apple-darwin.tar.gz" | tar xz -C "$TMPDIR_UV" + lipo -create "$TMPDIR_UV/uv-aarch64-apple-darwin/uv" "$TMPDIR_UV/uv-x86_64-apple-darwin/uv" -output "$UV_BIN_DIR/uv" lipo -create "$TMPDIR_UV/uv-aarch64-apple-darwin/uvx" "$TMPDIR_UV/uv-x86_64-apple-darwin/uvx" -output "$UV_BIN_DIR/uvx" - chmod +x "$UV_BIN_DIR/uvx" + chmod +x "$UV_BIN_DIR/uv" "$UV_BIN_DIR/uvx" rm -rf "$TMPDIR_UV" - echo "uvx downloaded and bundled." + echo "uv + uvx downloaded and bundled." else - echo "[0] uvx binary already present." + echo "[0] uv + uvx already present." fi echo "" @@ -279,6 +278,74 @@ fi echo "Router staged." 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: +# 1. Dock cleanliness — Electron-as-Node fallback is the second probable +# source of the bouncing "exec" icon next to OpenSwarm on fresh Macs +# (Python.app wrapping addresses the first). Real node is a clean +# background process that LaunchServices never registers in the dock. +# 2. Cold-start speed — re-execing the OpenSwarm Electron binary as Node +# pays the full Electron startup cost (~5-15s on first launch incl. +# Gatekeeper/XProtect verification), then more for the Next.js server +# to boot. Real node starts in ~50ms. Shrinks the splash window +# proportionally and reduces the "frontend up but nothing works" +# tail (analytics.py:196 awaits 9Router during backend lifespan). +# Pinned to Node 20 LTS (NODE_MODULE_VERSION 115). 9router 0.3.60 has zero +# native bindings (sql.js, not better-sqlite3), so any Node 18+ works +# regardless. The bundled MCP servers (mcp-bundles/) are esbuild outputs +# with target=node22 — Node 20 covers the syntax + builtins they use. +echo "[3b/5] Bundling Node.js runtime..." +NODE_VERSION="v20.18.1" +NODE_STAGE_DIR="$STAGING_DIR/node" +mkdir -p "$NODE_STAGE_DIR" + +# Per-arch download helper. Stages each arch under its own subdir so the +# .app can ship both and pick at runtime via process.arch (see +# electron/main.js getBundledNodePath). Slightly larger DMG (~25MB extra +# per arch we ship) but eliminates any beforePack-hook complexity in +# electron-builder's publish-mode dual-arch flow. +download_node_for_arch() { + local arch="$1" # arm64 | x64 + local out_dir="$NODE_STAGE_DIR/$arch" + if [[ -f "$out_dir/bin/node" ]]; then + echo "[3b] Node $NODE_VERSION ($arch) already cached" + return 0 + fi + rm -rf "$out_dir" + mkdir -p "$out_dir/bin" + local tarball="node-${NODE_VERSION}-darwin-${arch}.tar.gz" + local url="https://nodejs.org/dist/${NODE_VERSION}/${tarball}" + echo "[3b] Downloading $tarball..." + local tmp; tmp=$(mktemp -d) + curl -fsSL --progress-bar -o "$tmp/node.tar.gz" "$url" + tar xzf "$tmp/node.tar.gz" -C "$tmp" + # Ship just the `node` binary. We don't need npm/npx/corepack at runtime — + # all router + MCP code is pre-bundled. ~50 MB per arch -> ~25 MB after + # gzip/dmg compression. + cp "$tmp/node-${NODE_VERSION}-darwin-${arch}/bin/node" "$out_dir/bin/node" + chmod +x "$out_dir/bin/node" + rm -rf "$tmp" + 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 +echo "" + # Step 4: Snapshot source directories for packaging # (Router was already staged in step 3; do not touch STAGING_DIR/router/ here.) echo "[4/5] Snapshotting source directories..." diff --git a/scripts/build-python-env.sh b/scripts/build-python-env.sh index a4c450ac..a7150da0 100755 --- a/scripts/build-python-env.sh +++ b/scripts/build-python-env.sh @@ -186,6 +186,87 @@ echo "Pre-compiling bytecode..." "$PYTHON_BIN" -m compileall -q -j 4 "$PYTHON_ENV_DIR/lib" || \ echo "WARNING: some files failed to compile; runtime will fall back to in-memory compile." +# ----- macOS: hide bundled python from the Dock ----- +# python-build-standalone ships a bare Mach-O at bin/python3.13 with NO +# embedded __TEXT,__info_plist section, but libpython3.13.dylib is linked +# against AppKit / Cocoa / ApplicationServices. On a fresh user Mac (with +# .app quarantine attrs + first-launch XProtect inspection), spawning that +# binary from Electron causes LaunchServices to register it as a generic +# bundleless GUI process and render the macOS "exec" placeholder dock icon +# (bouncing for the entire boot window). Wrapping the binary in a tiny .app +# whose Info.plist sets LSUIElement=1 tells LaunchServices to skip the dock +# entry entirely. Python.org's framework Python uses the same trick. +# +# Invariants this layout depends on (don't break them): +# - codesign rejects symlinks as CFBundleExecutable ("the main executable +# or Info.plist must be a regular file (no symlinks, etc.)") — so +# python3 inside Python.app MUST be a real Mach-O copy, not a symlink. +# We copy bin/python3.13 in and rewrite its LC_LOAD_DYLIB so it still +# finds the single libpython3.13.dylib at python-env/lib/. +# - Python.getpath() calls realpath() on argv[0], so sys.prefix still +# resolves to python-env/ even though the launcher lives inside the +# wrapper bundle. All stdlib + site-packages discovery is unchanged. +# - The launcher binary is tiny (~50 KB), so the duplicate copy is +# negligible. We deliberately do NOT duplicate libpython3.13.dylib +# (~18 MB) — only the launcher. +if [[ "$(uname)" == "Darwin" ]]; then + echo "Creating Python.app launcher (LSUIElement=1, hides from Dock)..." + PY_APP="$PYTHON_ENV_DIR/Python.app" + rm -rf "$PY_APP" + mkdir -p "$PY_APP/Contents/MacOS" + cat > "$PY_APP/Contents/Info.plist" <<'PLIST' + + + + + CFBundleExecutable + python3 + CFBundleIdentifier + com.clusterlabs.openswarm.python + CFBundleName + OpenSwarm Backend + CFBundleDisplayName + OpenSwarm Backend + CFBundlePackageType + APPL + CFBundleShortVersionString + 3.13 + CFBundleVersion + 3.13 + LSUIElement + + + +PLIST + # Copy the launcher binary into the bundle. codesign requires a + # regular file here (symlinks are rejected outright). Then rewrite + # the LC_LOAD_DYLIB so @executable_path resolves correctly from + # Python.app/Contents/MacOS/ — three levels up reaches python-env/, + # then ../lib gets us to libpython3.13.dylib without duplication. + cp "$PYTHON_ENV_DIR/bin/python3.13" "$PY_APP/Contents/MacOS/python3" + chmod +x "$PY_APP/Contents/MacOS/python3" + install_name_tool \ + -change "@executable_path/../lib/libpython3.13.dylib" \ + "@executable_path/../../../lib/libpython3.13.dylib" \ + "$PY_APP/Contents/MacOS/python3" + # install_name_tool invalidates the existing adhoc signature; re-sign + # ad-hoc so the binary loads cleanly during the build's self-test. + # electron-builder's full sign pass will replace this with a proper + # Developer ID signature later. + codesign --force --sign - "$PY_APP/Contents/MacOS/python3" 2>/dev/null + + # Sanity-check: the wrapper actually runs, sys.prefix resolves to + # 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 + 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 + fi + echo "Python.app wrapper installed at $PY_APP" +fi + TOTAL_SIZE=$(du -sh "$PYTHON_ENV_DIR" | cut -f1) PYC_COUNT=$(find "$PYTHON_ENV_DIR" -name '*.pyc' -type f | wc -l | tr -d ' ') echo "" diff --git a/scripts/build-test-dmg.sh b/scripts/build-test-dmg.sh new file mode 100755 index 00000000..3fbdd020 --- /dev/null +++ b/scripts/build-test-dmg.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Build a signed + notarized arm64-only DMG for testing on a fresh Mac. +# No publish, no Windows, no x64. Just the one DMG you can drag to a USB +# / send to your other Mac and verify the Python.app + bundled-node fix. +# +# Usage: +# bash scripts/build-mac-arm64-signed.sh +# +# Output: +# electron/dist/OpenSwarm-arm64.dmg + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$PROJECT_ROOT/backend/.env" + +if [[ -f "$ENV_FILE" ]]; then + set -a + source "$ENV_FILE" + set +a +fi + +# Pre-flight checks. Fail fast with a clear message before kicking off +# the 15-30 minute build + notarization round-trip. +missing=() +[[ -z "${APPLE_ID:-}" ]] && missing+=("APPLE_ID") +[[ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] && missing+=("APPLE_APP_SPECIFIC_PASSWORD") +[[ -z "${APPLE_TEAM_ID:-}" ]] && missing+=("APPLE_TEAM_ID") +if [[ ${#missing[@]} -gt 0 ]]; then + echo "ERROR: Missing required env vars in $ENV_FILE:" >&2 + printf ' - %s\n' "${missing[@]}" >&2 + exit 1 +fi + +# Verify the Developer ID cert is in the keychain. Without it, codesign +# silently falls back to ad-hoc and notarization will reject the bundle. +if ! security find-identity -v -p codesigning 2>/dev/null | grep -q "Developer ID Application"; then + echo "ERROR: No 'Developer ID Application' code-signing identity found in keychain." >&2 + echo " Install it from your Apple Developer account before running this script." >&2 + exit 1 +fi + +# Force arm64-only by overriding the dual-arch publish path inside +# build-app.sh. We pass --sign which triggers SIGN_MODE (sign + notarize, +# no publish) and on arm64 hosts already builds arm64-only via the +# `if [[ "$ARCH" == "arm64" ]]` branch in the existing script. +HOST_ARCH=$(uname -m) +if [[ "$HOST_ARCH" != "arm64" ]]; then + echo "WARNING: Host arch is $HOST_ARCH, not arm64. The DMG will still be" >&2 + echo " built for arm64 because we're invoking electron-builder with" >&2 + echo " --mac --arm64 explicitly, but the bundled python-env / node will" >&2 + echo " be x64 — wrong arch for an M4 Mac. Run this on an arm64 host." >&2 + exit 1 +fi + +echo "============================================================" +echo " OpenSwarm arm64 signed+notarized DMG (test build)" +echo " apple_id: $APPLE_ID" +echo " team_id: $APPLE_TEAM_ID" +echo " output: $PROJECT_ROOT/electron/dist/OpenSwarm-arm64.dmg" +echo "============================================================" +echo "" + +# Run the existing master build script in --sign mode. On an arm64 host +# this produces arm64-only artifacts (DMG + zip + blockmap + latest-mac.yml). +# Notarization happens automatically via electron/scripts/notarize.js when +# APPLE_ID + APPLE_TEAM_ID are set. +bash "$SCRIPT_DIR/build-app.sh" --sign + +DMG_PATH="$PROJECT_ROOT/electron/dist/OpenSwarm-arm64.dmg" + +echo "" +echo "============================================================" +if [[ -f "$DMG_PATH" ]]; then + echo " Build complete." + echo "" + ls -lh "$DMG_PATH" + echo "" + echo " Verification:" + echo " spctl -a -vvv -t open --context context:primary-signature \"$DMG_PATH\"" + echo " xcrun stapler validate \"$DMG_PATH\"" + echo "" + echo " Transfer to your other Mac (AirDrop, USB, scp, etc.) and" + echo " double-click. The first launch will be Gatekeeper-checked" + echo " but should open without the right-click 'Open' workaround." +else + echo " ERROR: Expected DMG not found at $DMG_PATH" + echo " Check the build log above for the actual output path." + exit 1 +fi +echo "============================================================"