[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
+18 -3
View File
@@ -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 515s 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
+22 -5
View File
@@ -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
+54
View File
@@ -296,11 +296,27 @@ function getPythonPath() {
// python-build-standalone layout differs by OS:
// macOS / Linux: <env>/bin/python3
// Windows: <env>\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:
// <resources>/node/arm64/bin/node
// <resources>/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'
+2 -2
View File
@@ -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",
+8 -1
View File
@@ -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": {
+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 "============================================================"