Files
openswarm/scripts/build-app.sh
T
ciregenzandClaude Opus 5 18b83d9697 [eric] build: the signing preflight must not assume a keychain exists
The pin added earlier read the local keychain unconditionally, which was right
for publish.sh and wrong for release-macos.yml: on CI the cert arrives as a
base64 .p12 in CSC_LINK and electron-builder imports it into a temporary
keychain LATER than this preflight runs, so the probe would have found nothing
and aborted every CI mac build before it started. Fixed by branching on where
the cert actually comes from -- keychain locally, the .p12 itself on CI -- and
running the profile check against whichever cert that resolves to, since both
sources can drift from the embedded profile.

Verified with controls on both paths: the .p12 reader returns the true SHA-1 of
a synthetic cert and returns nothing (rather than a bogus hash) under a wrong
password, and the local path still passes against the live cert and profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wtspwSFzZmjCx9UNPAorQ
2026-08-20 11:49:01 -07:00

662 lines
32 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
# Killing this build (Ctrl-C, pkill) used to orphan electron-builder's 7za child, which then sat
# for HOURS holding a ~300MB archive open (found one aged 20h). On INT/TERM, walk and kill the whole
# descendant tree. Deliberately NOT on EXIT: a trap that fires on normal completion flips a green
# build's exit code under set -e and publish.sh would read success as failure (ENG-247).
kill_descendants() {
local pid kids
for pid in "$@"; do
kids=$(pgrep -P "$pid" 2>/dev/null || true)
[ -n "$kids" ] && kill_descendants $kids
kill -TERM "$pid" 2>/dev/null || true
done
}
trap 'kill_descendants $(pgrep -P $$ 2>/dev/null || true); exit 130' INT TERM
# Master build script for the OpenSwarm desktop app.
#
# Usage:
# bash scripts/build-app.sh Local dev build (unsigned)
# bash scripts/build-app.sh --publish Production build (signed, notarized, published to GitHub Releases)
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
PUBLISH_MODE=false
SIGN_MODE=false
if [[ "${1:-}" == "--publish" ]]; then
PUBLISH_MODE=true
SIGN_MODE=true
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
# Finder window from the last run).
shopt -s nullglob
for vol in /Volumes/OpenSwarm*; do
if [[ -d "$vol" ]]; then
echo "Detaching leftover DMG mount: $vol"
hdiutil detach -force "$vol" 2>/dev/null || hdiutil detach "$vol" 2>/dev/null || true
fi
done
shopt -u nullglob
echo "========================================"
echo " OpenSwarm Desktop App Builder"
if $PUBLISH_MODE; then
echo " Mode: PRODUCTION (sign + notarize + publish)"
elif $SIGN_MODE; then
echo " Mode: SIGNED (sign + notarize, no publish)"
else
echo " Mode: LOCAL (unsigned)"
fi
echo "========================================"
echo ""
if $SIGN_MODE; then
missing_vars=()
[[ -z "${APPLE_ID:-}" ]] && missing_vars+=("APPLE_ID")
[[ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] && missing_vars+=("APPLE_APP_SPECIFIC_PASSWORD")
[[ -z "${APPLE_TEAM_ID:-}" ]] && missing_vars+=("APPLE_TEAM_ID")
if $PUBLISH_MODE; then
[[ -z "${GH_TOKEN:-}" ]] && missing_vars+=("GH_TOKEN")
fi
if [[ ${#missing_vars[@]} -gt 0 ]]; then
echo "ERROR: Missing required environment variables:"
printf ' - %s\n' "${missing_vars[@]}"
echo ""
echo "See script header for details."
exit 1
fi
# Where the signing cert comes from decides how it can be checked, and getting this
# backwards breaks the OTHER platform silently. Locally there is a keychain to inspect.
# On CI (release-macos.yml) there is not: electron-builder imports CSC_LINK's .p12 into a
# temporary keychain of its own, LATER than this preflight runs, so a keychain probe here
# would find nothing and abort every CI build before it started.
if [[ -z "${CSC_LINK:-}" ]]; then
# Local keychain path. Pin by SHA-1, never by name: `security find-identity` reports
# FIVE entries all called "Developer ID Application: Haik Decie (Y26NUZH4NG)" -- one
# live cert and four copies of the one whose private key died with the old Mac. The
# dead copies still list as valid and only fail at the moment they are asked to sign,
# so letting auto-discovery guess is a coin flip between a real build and a crash
# forty minutes into packing a 4.8 GB .app.
: "${CSC_NAME:=DDA5A43B430F88DE89A34B4EA908355E3C3274C6}"
export CSC_NAME
if ! security find-identity -v -p codesigning | grep -q "$CSC_NAME"; then
echo "ERROR: signing identity $CSC_NAME is not in the keychain."
echo " Import the Developer ID Application cert AND its private key, then retry."
exit 1
fi
# Presence is not usability, and that distinction is the whole reason this exists: a
# cert whose private key is missing or locked still reports valid. The only honest
# test is a signature, so spend one here rather than discovering it at the end.
_sigprobe_dir="$(mktemp -d)"
cp /bin/echo "$_sigprobe_dir/probe"
if ! codesign --force --sign "$CSC_NAME" --timestamp=none "$_sigprobe_dir/probe" 2>/dev/null; then
rm -rf "$_sigprobe_dir"
echo "ERROR: identity $CSC_NAME is present but cannot sign."
echo " Its private key is missing or the login keychain is locked."
echo " Try: security unlock-keychain ~/Library/Keychains/login.keychain-db"
exit 1
fi
rm -rf "$_sigprobe_dir"
_signing_sha1="$CSC_NAME"
echo "==> Signing identity pinned and proven from keychain: $CSC_NAME"
else
# CI path. The .p12 is the source of truth, so read the cert out of it instead of
# guessing, and let electron-builder do the actual import.
if [[ -z "${CSC_KEY_PASSWORD:-}" ]]; then
echo "ERROR: CSC_LINK is set but CSC_KEY_PASSWORD is empty."
echo " electron-builder cannot open the .p12 without it."
exit 1
fi
_p12_dir="$(mktemp -d)"
if ! printf '%s' "$CSC_LINK" | base64 --decode > "$_p12_dir/cert.p12" 2>/dev/null; then
rm -rf "$_p12_dir"
echo "ERROR: CSC_LINK is not valid base64."
exit 1
fi
_signing_sha1="$(openssl pkcs12 -in "$_p12_dir/cert.p12" -nokeys -clcerts \
-passin env:CSC_KEY_PASSWORD 2>/dev/null \
| openssl x509 -noout -fingerprint -sha1 2>/dev/null \
| sed 's/.*=//; s/://g' | tr '[:lower:]' '[:upper:]')"
rm -rf "$_p12_dir"
if [[ -z "$_signing_sha1" ]]; then
echo "ERROR: could not read a certificate out of CSC_LINK."
echo " The .p12 is corrupt or CSC_KEY_PASSWORD is wrong."
exit 1
fi
echo "==> Signing identity read from CSC_LINK: $_signing_sha1"
fi
# The embedded profile is load-bearing, not decorative: entitlements request
# keychain-access-groups, which macOS honors only when an embedded profile lists the very
# cert that signed the app. A profile built for a DIFFERENT cert signs without a single
# complaint and then loses passkeys and Touch ID at runtime -- a green build that ships
# broken, which is the worst shape a failure can take. Same check on both paths, because
# both can drift.
_pp="$PROJECT_ROOT/electron/build/embedded.provisionprofile"
if [[ -f "$_pp" ]]; then
if ! security cms -D -i "$_pp" 2>/dev/null | python3 -c 'import sys,plistlib,hashlib; d=plistlib.loads(sys.stdin.buffer.read()); sys.exit(0 if sys.argv[1].upper() in [hashlib.sha1(c).hexdigest().upper() for c in d.get("DeveloperCertificates",[])] else 1)' "$_signing_sha1"; then
echo "ERROR: $_pp does not list the signing certificate $_signing_sha1."
echo " The profile was issued for a different cert, so keychain-access-groups"
echo " (passkeys / Touch ID) would silently stop working in the shipped app."
echo " Regenerate it: developer.apple.com -> Profiles -> OpenSwarm Developer ID"
echo " -> Edit -> select the certificate matching $_signing_sha1 -> Save."
exit 1
fi
echo "==> Embedded profile matches the signing certificate"
fi
# A signed build is a build users actually run, so its Widevine VMP signature
# is mandatory: the afterPack hook hard-fails on a missing/failed signature
# instead of shipping a DMG whose Spotify/Netflix audio is silently dead.
# Respect an explicit outer VMP_REQUIRE_SIGN=0: a cred-less local cut may consciously ship DRM-limited (CI always has EVS secrets, so releases from CI keep the hard gate).
export VMP_REQUIRE_SIGN=${VMP_REQUIRE_SIGN:-1}
fi
# 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"
mkdir -p "$UV_BIN_DIR"
NEED_UV=false
[[ ! -f "$UV_BIN_DIR/uv" ]] && NEED_UV=true
[[ ! -f "$UV_BIN_DIR/uvx" ]] && NEED_UV=true
# A dev flow can leave a THIN host-arch uv here and the per-arch slice then dies 20 minutes in;
# present is not enough, it must be universal.
if ! $NEED_UV && [[ "$(uname)" == "Darwin" ]]; then
for B in uv uvx; do
ARCHS=$(lipo -archs "$UV_BIN_DIR/$B" 2>/dev/null || echo "")
if [[ "$ARCHS" != *arm64* || "$ARCHS" != *x86_64* ]]; then
echo "[0] $B is not universal (archs: ${ARCHS:-unreadable}), re-downloading."
NEED_UV=true
fi
done
fi
if $NEED_UV; then
# Pinned uv version. "latest" used to mean a fresh uv could appear in any
# build with zero warning, breaking reproducibility (pillar 3). Override
# with UV_VERSION when deliberately bumping; keep Windows
# (build-app-win.ps1) in lockstep. 0.11.16 is what "latest" resolved to
# when this was pinned.
UV_VERSION="${UV_VERSION:-0.11.16}"
echo "[0] Downloading uv + uvx $UV_VERSION binaries (universal arm64+x64)..."
TMPDIR_UV=$(mktemp -d)
curl -sL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-aarch64-apple-darwin.tar.gz" | tar xz -C "$TMPDIR_UV"
curl -sL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/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/uv" "$UV_BIN_DIR/uvx"
rm -rf "$TMPDIR_UV"
echo "uv + uvx downloaded and bundled."
else
echo "[0] uv + uvx already present."
fi
echo ""
# Step 0a: Sync the splash icon. electron-builder excludes `build/` from
# the shipped asar (it's the icon-source directory used to generate .icns/.ico),
# so the splash window can't read build/icon.png at runtime. We keep a copy
# at electron/splash/icon.png which IS shipped. See electron/main.js comment
# at iconPngPath for context.
cp "$PROJECT_ROOT/electron/build/icon.png" "$PROJECT_ROOT/electron/splash/icon.png"
# Step 0b: Bundle npm MCP servers via esbuild
# Each bundle compiles down to a single ~5-15 MB CommonJS file under
# backend/mcp-bundles/, runs on Electron's bundled Node at runtime
# (ELECTRON_RUN_AS_NODE=1), and is preferred by tools_lib.py:521 over
# any pre-installed node_modules tree. Bundling instead of shipping
# node_modules cuts the installer file count from ~28k -> ~9k, the
# dominant lever on NSIS install time + Defender scan cost.
MCP_BUNDLE_DIR="$PROJECT_ROOT/backend/mcp-bundles"
mkdir -p "$MCP_BUNDLE_DIR"
# Single-file CJS bundles. Output path is mcp-bundles/<output>.js. Use for
# packages that don't read sibling files at runtime. The import.meta.url
# polyfill is applied uniformly because nearly every modern ESM package
# uses createRequire(import.meta.url) somewhere in its dependency tree —
# without the polyfill, esbuild's ESM->CJS transform leaves import.meta.url
# as undefined and the bundle crashes at module load.
build_mcp_bundle_single() {
local pkg_name="$1"
local entry_subpath="$2"
local output_name="$3"
local out_file="$MCP_BUNDLE_DIR/$output_name"
if [[ -f "$out_file" && -z "${OPENSWARM_REBUILD_BUNDLES:-}" ]]; then
echo "[0b] $pkg_name bundle already present (set OPENSWARM_REBUILD_BUNDLES=1 to force rebuild)."
return
fi
echo "[0b] Bundling $pkg_name -> $output_name ..."
local tmp_dir; tmp_dir=$(mktemp -d)
(
cd "$tmp_dir"
npm install "$pkg_name" --silent 2>/dev/null
local entry="node_modules/$entry_subpath"
if [[ ! -f "$entry" ]]; then echo "ERROR: $pkg_name entry not found at $entry" >&2; exit 1; fi
local banner='const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;'
npx esbuild "$entry" --bundle --platform=node --format=cjs --target=node22 --legal-comments=none \
--define:import.meta.url=__OPENSWARM_IMPORT_META_URL__ \
"--banner:js=$banner" \
--outfile="$out_file"
)
rm -rf "$tmp_dir"
echo "$pkg_name bundled ($(du -h "$out_file" | cut -f1))."
}
# Multi-file bundle. Output is a directory mcp-bundles/<dir>/ that mirrors the
# upstream SDK's "package_root/dist/index.js + ../package.json" layout. Use this
# for packages whose source reads __dirname/../package.json (for --version) or
# other sibling data files (e.g. @softeria/ms-365-mcp-server reads endpoints.json).
# `extras` is a space-separated list of "src=dst" pairs relative to node_modules
# and the bundle dir respectively (e.g. "@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json").
# `external` is a comma-separated list of npm package names to leave unbundled
# (e.g. "keytar" — the SDK gracefully degrades when keytar can't be imported).
build_mcp_bundle_dir() {
local pkg_name="$1"
local entry_subpath="$2"
local out_dir_name="$3"
local extras="$4" # e.g. "@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json"
local external="$5" # comma-separated package names
local out_dir="$MCP_BUNDLE_DIR/$out_dir_name"
if [[ -f "$out_dir/dist/index.js" && -z "${OPENSWARM_REBUILD_BUNDLES:-}" ]]; then
echo "[0b] $pkg_name bundle dir already present."
return
fi
echo "[0b] Bundling $pkg_name -> $out_dir_name/ ..."
local tmp_dir; tmp_dir=$(mktemp -d)
rm -rf "$out_dir"
mkdir -p "$out_dir/dist"
(
cd "$tmp_dir"
npm install "$pkg_name" --silent 2>/dev/null
local entry="node_modules/$entry_subpath"
if [[ ! -f "$entry" ]]; then echo "ERROR: $pkg_name entry not found at $entry" >&2; exit 1; fi
# Stripped sibling package.json — the SDK reads packageJson.version.
# Critically OMIT "type":"module" so Node treats the CJS bundle correctly.
local sdk_version
sdk_version=$(node -e "console.log(require('./node_modules/$pkg_name/package.json').version)")
printf '{"name":"%s","version":"%s"}' "$pkg_name" "$sdk_version" > "$out_dir/package.json"
# Copy any sibling data files the SDK reads at runtime
if [[ -n "$extras" ]]; then
for pair in $extras; do
local src="${pair%%=*}"
local dst="${pair##*=}"
mkdir -p "$(dirname "$out_dir/$dst")"
cp "node_modules/$src" "$out_dir/$dst"
done
fi
# Banner polyfills `require` for the import.meta.url polyfill.
local banner='const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;'
local external_args=""
if [[ -n "$external" ]]; then
# Portable comma-split (works in bash and zsh) — `read -ra` is bash-only.
local _old_ifs="$IFS"
IFS=','
local ext
for ext in $external; do external_args="$external_args --external:$ext"; done
IFS="$_old_ifs"
fi
npx esbuild "$entry" --bundle --platform=node --format=cjs --target=node22 --legal-comments=none \
--define:import.meta.url=__OPENSWARM_IMPORT_META_URL__ \
"--banner:js=$banner" \
$external_args \
--outfile="$out_dir/dist/index.js"
)
rm -rf "$tmp_dir"
echo "$pkg_name bundled ($(du -sh "$out_dir" | cut -f1))."
}
build_mcp_bundle_single 'reddit-mcp-buddy' 'reddit-mcp-buddy/dist/index.js' 'reddit-mcp-buddy.js'
# YouTube gets its own build: upstream imports the 526-API googleapis monolith (plus a mongodb
# driver rider) for exactly ONE surface, google.youtube. Aliasing googleapis to a single-API shim
# shrinks the bundle 32.6M -> 3.7M, MCP-handshake-verified identical (same serverInfo, same 8 tools).
build_youtube_mcp_bundle() {
local out_file="$MCP_BUNDLE_DIR/kirbah-mcp-youtube.js"
if [[ -f "$out_file" && -z "${OPENSWARM_REBUILD_BUNDLES:-}" ]]; then
echo "[0b] @kirbah/mcp-youtube bundle already present (set OPENSWARM_REBUILD_BUNDLES=1 to force rebuild)."
return
fi
echo "[0b] Bundling @kirbah/mcp-youtube (googleapis -> @googleapis/youtube shim) ..."
local tmp_dir; tmp_dir=$(mktemp -d)
(
cd "$tmp_dir"
npm install '@kirbah/mcp-youtube' '@googleapis/youtube' --silent 2>/dev/null
printf '%s\n' 'const { youtube } = require("@googleapis/youtube");' 'module.exports = { google: { youtube } };' > googleapis-shim.js
local banner='const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;'
npx esbuild 'node_modules/@kirbah/mcp-youtube/dist/index.js' --bundle --platform=node --format=cjs --target=node22 --legal-comments=none \
--define:import.meta.url=__OPENSWARM_IMPORT_META_URL__ \
"--banner:js=$banner" \
--alias:googleapis=./googleapis-shim.js \
--outfile="$out_file"
)
rm -rf "$tmp_dir"
echo "@kirbah/mcp-youtube bundled ($(du -h "$out_file" | cut -f1))."
}
build_youtube_mcp_bundle
build_mcp_bundle_dir '@notionhq/notion-mcp-server' '@notionhq/notion-mcp-server/bin/cli.mjs' \
'notionhq-notion-mcp-server' \
'@notionhq/notion-mcp-server/scripts/notion-openapi.json=scripts/notion-openapi.json' \
''
build_mcp_bundle_dir '@softeria/ms-365-mcp-server' '@softeria/ms-365-mcp-server/dist/index.js' \
'softeria-ms-365-mcp-server' \
'@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json' \
'keytar'
# Wipe the legacy single-file Notion bundle if the dir bundle now supersedes it.
if [[ -f "$MCP_BUNDLE_DIR/notionhq-notion-mcp-server.js" && -d "$MCP_BUNDLE_DIR/notionhq-notion-mcp-server" ]]; then
rm -f "$MCP_BUNDLE_DIR/notionhq-notion-mcp-server.js"
fi
# Defensively wipe any legacy npm-servers/ tree from prior builds so it
# doesn't ride along into the installer (would re-introduce ~19k files).
LEGACY_NPM_SERVERS="$PROJECT_ROOT/backend/npm-servers"
if [[ -d "$LEGACY_NPM_SERVERS" ]]; then
echo "[0b] Removing legacy backend/npm-servers/ (superseded by mcp-bundles)..."
rm -rf "$LEGACY_NPM_SERVERS"
fi
echo ""
# Step 1: Build frontend
echo "[1/4] Building frontend..."
cd "$PROJECT_ROOT/frontend"
# npm ci (not install): installs exactly what package-lock.json pins, never
# silently mutates the lock, and fails loudly on any drift. Reproducible builds
# (pillar 3) depend on the lock being boss.
npm ci
npm run build
if [[ ! -f "$PROJECT_ROOT/frontend/dist/index.html" ]]; then
echo "ERROR: Frontend build failed — dist/index.html not found"
exit 1
fi
echo "Frontend build complete."
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
# rather than vendoring the source + rebuilding here.
echo "[3/5] Fetching Router from npm..."
STAGING_DIR="$PROJECT_ROOT/electron/build-staging"
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"
bash "$PROJECT_ROOT/scripts/fetch-router.sh" "$STAGING_DIR/router"
if [[ ! -f "$STAGING_DIR/router/server.js" ]]; then
echo "ERROR: Router fetch failed — server.js not found in staged dir"
exit 1
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:
# 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"
cp "$tmp/node-${NODE_VERSION}-darwin-${arch}/bin/node" "$out_dir/bin/node"
chmod +x "$out_dir/bin/node"
# Bundle npm too so packaged apps with custom deps can `npm install` them (the bare node can't).
mkdir -p "$out_dir/lib/node_modules"
cp -R "$tmp/node-${NODE_VERSION}-darwin-${arch}/lib/node_modules/npm" "$out_dir/lib/node_modules/npm"
# An explicit wrapper, not the dist's bin/npm symlink (packaging may not preserve symlinks): always runs our bundled node + npm-cli.js, no PATH/system-node reliance. run.sh picks it up as $NODE_DIR/npm.
cat > "$out_dir/bin/npm" <<'NPMSH'
#!/bin/sh
here="$(cd "$(dirname "$0")" && pwd)"
exec "$here/node" "$here/../lib/node_modules/npm/bin/npm-cli.js" "$@"
NPMSH
chmod +x "$out_dir/bin/npm"
rm -rf "$tmp"
echo "[3b] Node $NODE_VERSION ($arch) staged ($(du -h "$out_dir/bin/node" | cut -f1))"
}
# 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
# create on a fresh user install decompresses (~3 s) instead of running a
# live `npm install` (~22 s). The backend's _try_extract_bundled_archive
# is sha-tagged + falls through cleanly if the archive is missing or
# stale, so this step is purely an optimization — skip silently if the
# template snapshot or npm aren't available.
if [[ -f "$PROJECT_ROOT/backend/apps/outputs/webapp_template/frontend/package.json" ]] \
&& command -v npm >/dev/null 2>&1; then
echo "[3c/5] Pre-building webapp-template node_modules archive..."
bash "$PROJECT_ROOT/scripts/build-template-archive.sh"
echo ""
fi
# 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..."
rsync -a \
--exclude='__pycache__' --exclude='**/__pycache__' \
--exclude='*.pyc' --exclude='.venv' \
--exclude='/data' \
--exclude='/uv-bin' \
--exclude='apps/outputs/webapp_template_cache' \
--exclude='tests' --exclude='**/tests' \
--exclude='/.env' --exclude='/.env.*' \
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
# /data: backend/config/paths.py points DATA_ROOT at ~/Library/Application Support/OpenSwarm/data
# in packaged mode and no code seeds from the bundle, so the entire shipped
# backend/data/ tree was dead weight (and was leaking the dev machine's
# auth.token + install_id + dev session artifacts).
# /uv-bin: source dir holds the universal binary so `bash run.sh` works on either
# host arch; we stage per-arch thin slices below so each DMG ships only its slice.
# webapp_template_cache: a pre-built node_modules.tar.gz that gets shipped
# to speed up first-app-create. Apple notarization extracts it and rejects
# the build because upstream native binaries inside (esbuild, fsevents, etc.)
# aren't signed with our Developer ID. Backend's _try_extract_bundled_archive
# in view_builder_templates.py falls through cleanly when the archive is
# missing, so first-app create just runs `npm install` (about 90s extra).
# Long-term fix: sign native binaries before tarring in build-template-archive.sh.
# Note: .env exclude is anchored to the backend/ source root (`/.env` /
# `/.env.*`), not recursive. The vendored webapp-template snapshot at
# backend/apps/outputs/webapp_template/.env.example MUST be shipped so
# new App workspaces can seed from it; recursive `**/.env*` excludes
# would strip it. The top-level backend/.env is still excluded (it's
# (re)generated at the production .env step below).
# Production .env: just the OAuth helper base URL. Google client_id/secret are no
# longer shipped: nothing in backend/ or frontend/ reads GOOGLE_OAUTH_CLIENT_{ID,
# SECRET} at runtime, so we don't bake a secret into the packaged app.
SHIP_OAUTH_BASE_URL="${OPENSWARM_OAUTH_BASE_URL_OVERRIDE:-https://api.openswarm.com}"
mkdir -p "$STAGING_DIR/backend"
cat > "$STAGING_DIR/backend/.env" <<EOF
# OAuth helper base URL.
OPENSWARM_OAUTH_BASE_URL=${SHIP_OAUTH_BASE_URL}
EOF
echo "Staged production .env"
# Per-arch slice of the universal uv/uvx for shipping. The source-tree uv-bin/
# stays universal so dev (`bash run.sh`) works on either host arch; thinning
# into staging means each per-arch DMG ships only its slice (~48 MB savings
# vs the 97 MB universal binary the build used to put in both DMGs).
echo "Slicing uv per-arch into staging..."
for arch in arm64 x64; do
lipo_arch=$arch
[[ "$arch" == "x64" ]] && lipo_arch=x86_64
mkdir -p "$STAGING_DIR/uv-bin/$arch"
lipo "$UV_BIN_DIR/uv" -thin "$lipo_arch" -output "$STAGING_DIR/uv-bin/$arch/uv"
lipo "$UV_BIN_DIR/uvx" -thin "$lipo_arch" -output "$STAGING_DIR/uv-bin/$arch/uvx"
chmod +x "$STAGING_DIR/uv-bin/$arch/uv" "$STAGING_DIR/uv-bin/$arch/uvx"
done
rsync -a \
--exclude='__pycache__' --exclude='**/__pycache__' \
--exclude='*.pyc' --exclude='.venv' --exclude='**/.venv' \
--exclude='**/node_modules' \
"$PROJECT_ROOT/debugger/" "$STAGING_DIR/debugger/"
rsync -a "$PROJECT_ROOT/frontend/dist/" "$STAGING_DIR/frontend/"
echo ""
printf '\033[1;42;97m%s\033[0m\n' "========================================"
printf '\033[1;42;97m%s\033[0m\n' " ✅ SOURCE SNAPSHOT COMPLETE "
printf '\033[1;42;97m%s\033[0m\n' " It is now safe to modify your codebase."
printf '\033[1;42;97m%s\033[0m\n' "========================================"
echo ""
# Provenance stamp: record the exact commit this artifact was built from.
# electron/build-info.json ships inside the asar; main.js reads it for the
# startup [provenance] log line and the About panel. Gitignored + regenerated.
BUILD_SHA=$(git -C "$PROJECT_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)
BUILD_VERSION=$(node -e "console.log(require('$PROJECT_ROOT/electron/package.json').version)")
BUILD_CHANNEL=stable; [[ "$BUILD_VERSION" == *-* ]] && BUILD_CHANNEL=experimental
cat > "$PROJECT_ROOT/electron/build-info.json" <<EOF
{"sha":"$BUILD_SHA","shortSha":"${BUILD_SHA:0:12}","builtAt":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","channel":"$BUILD_CHANNEL","version":"$BUILD_VERSION"}
EOF
echo "Stamped build-info.json: sha=${BUILD_SHA:0:12} channel=$BUILD_CHANNEL"
# Step 5: Package with electron-builder
echo "[5/5] Packaging with electron-builder..."
cd "$PROJECT_ROOT/electron"
# npm ci: lockfile-exact, no drift. See frontend note above.
npm ci
# macOS mouse-clamp native addon: compile both arches into build-staging/mouseclamp/<arch>
# so extraResources (mouseclamp/${arch}) is populated whichever target gets packed.
# Cheap (~2s each); fails the build loudly if a slice can't compile rather than
# silently shipping the crash. macOS-only.
if [[ "$(uname)" == "Darwin" ]]; then
echo "Building mouse-clamp native addon (arm64 + x64)..."
bash scripts/build-mouseclamp.sh arm64
bash scripts/build-mouseclamp.sh x64
bash scripts/build-haptics.sh arm64
bash scripts/build-haptics.sh x64
echo "Building whisper-server for dictation (arm64 + x64)..."
bash scripts/build-whisper.sh arm64
bash scripts/build-whisper.sh x64
echo "Building fn-watcher for dictation's fn key (arm64 + x64)..."
bash scripts/build-fn-watcher.sh arm64
bash scripts/build-fn-watcher.sh x64
fi
# Node's default ~4 GB heap OOMs while codesign'ing the .app on dual-arch
# publish runs (the .app is ~4.8 GB and electron-builder walks every file
# to hash + sign, holding paths + metadata in memory). Bump the old-space
# ceiling so V8 has headroom; 12 GB covers both arches in one invocation.
# 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 "${EB_ARCH_FLAGS[@]}" --publish always
elif $SIGN_MODE; then
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never
else
export CSC_IDENTITY_AUTO_DISCOVERY=false
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never
fi
rm -rf "$PROJECT_ROOT/electron/build-staging"
echo ""
echo "========================================"
echo " Build Complete!"
echo "========================================"
echo ""
echo "Output files:"
ls -lh "$PROJECT_ROOT/electron/dist/"*.dmg 2>/dev/null || true
ls -lh "$PROJECT_ROOT/electron/dist/"*.zip 2>/dev/null || true
echo ""