[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) <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-04-27 22:37:17 -07:00
co-authored by Claude Opus 4.7
parent f4cded781b
commit f0ea0fd1bc
9 changed files with 410 additions and 46 deletions
+46 -15
View File
@@ -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..."
+87 -20
View File
@@ -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..."
+81
View File
@@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>python3</string>
<key>CFBundleIdentifier</key>
<string>com.clusterlabs.openswarm.python</string>
<key>CFBundleName</key>
<string>OpenSwarm Backend</string>
<key>CFBundleDisplayName</key>
<string>OpenSwarm Backend</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>3.13</string>
<key>CFBundleVersion</key>
<string>3.13</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>
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 ""
+92
View File
@@ -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 "============================================================"