#!/bin/bash set -euo pipefail # 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 # 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/.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// 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" </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" < # 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 ""