mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
275 lines
15 KiB
Bash
Executable File
275 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Build the macOS desktop app + a drag-to-install .dmg.
|
|
#
|
|
# 1. PyInstaller-bundle the server into a standalone onedir folder (no venv at runtime).
|
|
# 2. Stage it at binaries/sidecar/ for Tauri's `resources` slot (+ sign its Mach-Os).
|
|
# 3. `tauri build --bundles app` → OpenWorker.app (resources are copied in).
|
|
# 4. Wrap the .app in a compressed .dmg via hdiutil (reliable + headless; Tauri's own
|
|
# bundle_dmg.sh uses Finder AppleScript and fails in non-interactive sessions).
|
|
#
|
|
# Prerequisites (mirrors build_windows.ps1's header):
|
|
# - Rust (rustup) + Node/npm, and the GUI deps installed (npm ci in surfaces/gui).
|
|
# - A Python venv at .venv (repo root) with this package installed editable, plus the
|
|
# build-only deps:
|
|
# python3 -m venv .venv
|
|
# .venv/bin/pip install -e '.[bedrock]' pyinstaller tzdata typer
|
|
# `typer` is needed only at BUILD time: PyInstaller walks the `mcp` package and
|
|
# `mcp.cli` calls sys.exit() at import if typer is absent, which aborts the freeze.
|
|
# (aisuite installs like any other dependency — git-pinned in pyproject.toml.)
|
|
#
|
|
# SIGNING: set APPLE_SIGNING_IDENTITY to a "Developer ID Application: … (TEAMID)" identity and
|
|
# `tauri build` signs the .app + the bundled sidecar with it. Left unset → UNSIGNED (first launch
|
|
# needs right-click → Open).
|
|
#
|
|
# NOTARIZATION (step 5, runs only when the identity is set): signs the .dmg CONTAINER, submits
|
|
# to Apple's notary service, staples the ticket, and verifies with spctl. Signing alone is NOT
|
|
# enough for public downloads — un-notarized apps get macOS's "Apple could not verify… Move to
|
|
# Trash?" dialog. Auth is an App Store Connect API key via NOTARYTOOL_API_KEY_PATH /
|
|
# NOTARYTOOL_API_KEY_ID / NOTARYTOOL_API_ISSUER_ID — exported, or in $OCW_NOTARY_ENV, or in
|
|
# `.ocw-notary.env` one directory ABOVE the repo (shared by every clone/worktree on a machine,
|
|
# never committed). Vars missing → the DMG is still produced, with a loud warning.
|
|
#
|
|
# LOCAL ITERATION: leave APPLE_SIGNING_IDENTITY unset for a fully unsigned dev build, or set
|
|
# OCW_SKIP_NOTARIZE=1 to sign but skip the slow notary round-trip. Neither is distributable.
|
|
#
|
|
# Experimental (use-at-your-own-risk) connectors are EXCLUDED from this build by default —
|
|
# the spec strips coworker.connectors.experimental. Self-builders can opt in with:
|
|
# COWORKER_EXPERIMENTAL=1 ./build_dmg.sh
|
|
# VENV PREREQS (a fresh worktree's venv, discovered the hard way 2026-08-21):
|
|
# .venv/bin/pip install -e ".[dev,messaging,browser,bedrock]" pyinstaller typer
|
|
# (`typer` because PyInstaller's submodule collection imports mcp.cli, which
|
|
# sys.exit(1)s without it.)
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
PLATFORM="$(cd "$HERE/.." && pwd)"
|
|
GUI="$PLATFORM/surfaces/gui"
|
|
APP="OpenWorker"
|
|
# Single source of truth for the version: tauri.conf.json (also stamps the bundle).
|
|
VERSION="$(node -p "require('$GUI/src-tauri/tauri.conf.json').version")"
|
|
TRIPLE="$(rustc -vV | sed -n 's/host: //p')" # e.g. aarch64-apple-darwin
|
|
ARCH="${TRIPLE%%-*}"
|
|
|
|
# CI keychain bootstrap: on a fresh runner the Developer ID cert exists only as the
|
|
# APPLE_CERTIFICATE secret (base64 .p12) — import it into a throwaway keychain so the
|
|
# sidecar codesign calls below can find the identity ("no identity found", v0.1.3 run
|
|
# 29773913622). tauri build does its OWN import later; this covers our signing, which
|
|
# runs first. Local builds never set APPLE_CERTIFICATE — the identity already lives in
|
|
# the login keychain, and this block is skipped.
|
|
if [ -n "${APPLE_CERTIFICATE:-}" ] && [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
|
echo "==> importing signing certificate into a temporary keychain"
|
|
KC_DIR="$(mktemp -d)"
|
|
KC="$KC_DIR/ocw-signing.keychain-db"
|
|
KC_PASS="$(openssl rand -hex 16)"
|
|
security create-keychain -p "$KC_PASS" "$KC"
|
|
security set-keychain-settings -lut 21600 "$KC"
|
|
security unlock-keychain -p "$KC_PASS" "$KC"
|
|
echo "$APPLE_CERTIFICATE" | base64 -d > "$KC_DIR/cert.p12"
|
|
security import "$KC_DIR/cert.p12" -P "${APPLE_CERTIFICATE_PASSWORD:-}" \
|
|
-A -t cert -f pkcs12 -k "$KC"
|
|
rm -f "$KC_DIR/cert.p12"
|
|
# Allow codesign to use the key headlessly (no UI prompt exists on a runner).
|
|
security set-key-partition-list -S "apple-tool:,apple:" -s -k "$KC_PASS" "$KC" >/dev/null
|
|
security list-keychains -d user -s "$KC" login.keychain-db
|
|
fi
|
|
|
|
echo "==> [1/5] PyInstaller: bundling openworker-server ($TRIPLE)"
|
|
"$PLATFORM/.venv/bin/pyinstaller" --noconfirm --clean \
|
|
--distpath "$HERE/dist" --workpath "$HERE/build" "$HERE/openworker-server.spec"
|
|
|
|
echo "==> [2/5] staging sidecar resources"
|
|
# Onedir bundle (exe + _internal/) ships via Tauri `resources` as Contents/Resources/sidecar/
|
|
# — onefile's per-launch self-extraction cost 6-7s of boot splash. rm -rf first: cp WRITES
|
|
# THROUGH a symlink at the destination (a dev-convenience symlink in the old externalBin slot
|
|
# once clobbered another worktree's venv console script, caught 2026-07-11); also clears any
|
|
# stale onefile binary from pre-onedir builds.
|
|
mkdir -p "$GUI/src-tauri/binaries"
|
|
rm -rf "$GUI/src-tauri/binaries/sidecar" "$GUI/src-tauri/binaries/openworker-server-$TRIPLE"
|
|
# -L (dereference): Tauri's resource bundler flattens symlinks into duplicate REAL files.
|
|
# Python.framework's symlinks (Python -> Versions/Current/Python, …) therefore arrive in
|
|
# the .app as standalone copies whose framework-context signatures don't validate outside
|
|
# the bundle — notarization rejected them twice (submissions f73463f3, ca30027a,
|
|
# 2026-07-16). Dereferencing at staging makes what we SIGN byte-identical to what tauri
|
|
# COPIES, and every Mach-O below gets a plain file signature that stands alone.
|
|
cp -RL "$HERE/dist/openworker-server" "$GUI/src-tauri/binaries/sidecar"
|
|
if [ -n "$(find "$GUI/src-tauri/binaries/sidecar" -type l | head -1)" ]; then
|
|
echo "ERROR: symlinks survived sidecar staging — tauri would flatten them into unsigned copies" >&2
|
|
exit 1
|
|
fi
|
|
# Drop the pseudo-framework: after dereferencing, Python.framework is just a duplicate of
|
|
# _internal/Python (which the PyInstaller bootloader actually loads — verified by running
|
|
# the sidecar without it) plus an Info.plist. Any file living under a *.framework/ path
|
|
# triggers codesign/notary bundle inference, which can NEVER validate this flattened
|
|
# layout — three Invalid notarization verdicts (f73463f3, ca30027a, + one more) before
|
|
# this removal. No .framework may ever ship inside the sidecar resources.
|
|
rm -rf "$GUI/src-tauri/binaries/sidecar/_internal/Python.framework"
|
|
if [ -n "$(find "$GUI/src-tauri/binaries/sidecar" -type d -name "*.framework" | head -1)" ]; then
|
|
echo "ERROR: a .framework appeared in the sidecar — it cannot pass notarization in this layout" >&2
|
|
exit 1
|
|
fi
|
|
chmod +x "$GUI/src-tauri/binaries/sidecar/openworker-server"
|
|
|
|
# Sign the sidecar's Mach-O files BEFORE tauri build: `tauri build` signs the .app (sealing
|
|
# resources into its signature) but does NOT sign nested binaries inside resources — unsigned
|
|
# Mach-Os there fail notarization. Hardened runtime + timestamp on every one, same identity,
|
|
# entitlements on the executable (disable-library-validation: the bundled python dylibs carry
|
|
# other Team IDs). externalBin used to get this from tauri itself.
|
|
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
|
echo " signing sidecar binaries"
|
|
SIDECAR="$GUI/src-tauri/binaries/sidecar"
|
|
# Every Mach-O gets a plain FILE signature (no framework-bundle signing: the staged
|
|
# tree is fully dereferenced, so each file must validate standalone — that is exactly
|
|
# what the notary service checks). Entitlements only on the entrypoint
|
|
# (disable-library-validation: the bundled python.org dylibs carry another Team ID).
|
|
find "$SIDECAR" -type f ! -name "openworker-server" \
|
|
! -name "*.py" ! -name "*.pyc" ! -name "*.txt" ! -name "*.pem" ! -name "*.json" \
|
|
-print0 | while IFS= read -r -d '' f; do
|
|
file -b "$f" | grep -q "Mach-O" || continue
|
|
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp --options runtime "$f"
|
|
done
|
|
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp --options runtime \
|
|
--entitlements "$GUI/src-tauri/entitlements.plist" "$SIDECAR/openworker-server"
|
|
fi
|
|
|
|
echo "==> [3/5] tauri build (.app)"
|
|
# Auto-update artifacts (.app.tar.gz + minisign .sig): produced only when the updater
|
|
# signing key is available — from the env (CI secret TAURI_SIGNING_PRIVATE_KEY), or from
|
|
# `.ocw-updater.env` one directory above the repo (same convention as the notary env).
|
|
# Keyless builds skip the overlay entirely so dev/fork builds keep working; keyless
|
|
# RELEASES would strand every install without auto-update, hence the loud warning.
|
|
UPDATER_ENV="${OCW_UPDATER_ENV:-$PLATFORM/../.ocw-updater.env}"
|
|
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ] && [ -f "$UPDATER_ENV" ]; then
|
|
# shellcheck disable=SC1090
|
|
source "$UPDATER_ENV"
|
|
fi
|
|
UPDATER_OVERLAY=()
|
|
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
|
UPDATER_OVERLAY=(--config '{"bundle":{"createUpdaterArtifacts":true}}')
|
|
else
|
|
echo " WARNING: no updater signing key — building WITHOUT auto-update artifacts (not releasable)."
|
|
fi
|
|
# ${arr[@]+…} guard: plain "${arr[@]}" on an EMPTY array is an "unbound variable"
|
|
# under set -u on macOS's stock bash 3.2 — hit by keyless (fresh-clone) builds.
|
|
( cd "$GUI" && npm run tauri build -- --bundles app ${UPDATER_OVERLAY[@]+"${UPDATER_OVERLAY[@]}"} )
|
|
|
|
echo "==> [4/5] hdiutil: wrapping into .dmg"
|
|
BUNDLE="$GUI/src-tauri/target/release/bundle"
|
|
STAGING="$(mktemp -d)"
|
|
cp -R "$BUNDLE/macos/$APP.app" "$STAGING/"
|
|
ln -s /Applications "$STAGING/Applications"
|
|
# Background art (arrow + "drag to Applications") — hidden folder Finder reads for the window.
|
|
# A HiDPI TIFF (1x + native 2x reps) so text/arrow stay crisp on Retina; a plain 1x PNG would
|
|
# be upscaled and look hazy/pixelated.
|
|
mkdir "$STAGING/.background"
|
|
cp "$HERE/dmg-background.tiff" "$STAGING/.background/bg.tiff"
|
|
DMG="$BUNDLE/dmg/${APP}_${VERSION}_${ARCH}.dmg"
|
|
mkdir -p "$(dirname "$DMG")"
|
|
rm -f "$DMG"
|
|
|
|
# A styled install window (fixed size, icons in place, arrow background) instead of Finder's
|
|
# default oversized bare window. Needs Finder (AppleScript); if it isn't available (headless CI),
|
|
# fall back to the plain compressed image so the build still produces a working .dmg.
|
|
#
|
|
# Two hard-won correctness points (both caused a *silently* unstyled .dmg before):
|
|
# 1. A stale "$APP" volume already mounted → our RW image mounts as "$APP 1", and a hardcoded
|
|
# `tell disk "$APP"` then styles the WRONG (stale) volume, so our image never gets a
|
|
# .DS_Store. Detach any pre-existing mount first, and target the ACTUAL mounted name.
|
|
# 2. Finder writes .DS_Store asynchronously — detaching too soon drops it. Poll until it lands.
|
|
style_dmg() {
|
|
# Clear any earlier mount of this volume so we don't collide into "$APP 1".
|
|
[ -d "/Volumes/$APP" ] && hdiutil detach "/Volumes/$APP" -force >/dev/null 2>&1 || true
|
|
local rw; rw="$(mktemp -u).dmg"
|
|
hdiutil create -volname "$APP" -srcfolder "$STAGING" -fs HFS+ -format UDRW -ov "$rw" >/dev/null
|
|
local info dev mnt vol
|
|
info="$(hdiutil attach -readwrite -noverify -noautoopen "$rw")"
|
|
dev="$(echo "$info" | grep -Eo '^/dev/disk[0-9]+' | head -1)"
|
|
mnt="$(echo "$info" | grep -Eo '/Volumes/.*$' | head -1)"
|
|
[ -n "$dev" ] && [ -n "$mnt" ] || return 1
|
|
vol="$(basename "$mnt")" # the real mounted name — what `tell disk` must target
|
|
sleep 1
|
|
# Icons at y≈190 to sit on the background's arrow: app left of it, Applications right. Background
|
|
# via the relative HFS path (`file ".background:bg.tiff"`) so the alias survives a rename; the
|
|
# close→open→update dance forces Finder to actually write the .DS_Store.
|
|
osascript <<OSA || { hdiutil detach "$dev" -force >/dev/null 2>&1 || true; return 1; }
|
|
tell application "Finder"
|
|
tell disk "$vol"
|
|
open
|
|
delay 1
|
|
set current view of container window to icon view
|
|
set toolbar visible of container window to false
|
|
set statusbar visible of container window to false
|
|
set the bounds of container window to {200, 120, 840, 543}
|
|
set opts to the icon view options of container window
|
|
set arrangement of opts to not arranged
|
|
set icon size of opts to 96
|
|
set text size of opts to 12
|
|
set background picture of opts to file ".background:bg.tiff"
|
|
set position of item "$APP.app" of container window to {172, 190}
|
|
set position of item "Applications" of container window to {468, 190}
|
|
close
|
|
open
|
|
update without registering applications
|
|
delay 3
|
|
end tell
|
|
end tell
|
|
OSA
|
|
# Wait for Finder to flush .DS_Store into the image (else the layout is lost).
|
|
local i; for i in $(seq 1 15); do [ -f "$mnt/.DS_Store" ] && break; sleep 1; done
|
|
[ -f "$mnt/.DS_Store" ] || { hdiutil detach "$dev" -force >/dev/null 2>&1 || true; return 1; }
|
|
sync; sync
|
|
hdiutil detach "$dev" -force >/dev/null
|
|
hdiutil convert "$rw" -format UDZO -imagekey zlib-level=9 -o "$DMG" >/dev/null
|
|
rm -f "$rw"
|
|
}
|
|
|
|
if ! style_dmg; then
|
|
echo " (Finder styling unavailable — writing a plain .dmg)"
|
|
hdiutil create -volname "$APP" -srcfolder "$STAGING" -ov -format UDZO "$DMG" >/dev/null
|
|
fi
|
|
rm -rf "$STAGING"
|
|
|
|
if [ "${OCW_SKIP_NOTARIZE:-}" = "1" ] && [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
|
# Local-iteration escape hatch: sign (seconds) but skip the notary round-trip
|
|
# (minutes). Locally built DMGs carry no quarantine flag, so Gatekeeper never
|
|
# prompts on this machine anyway. NEVER distribute a build made this way.
|
|
echo "==> [5/5] OCW_SKIP_NOTARIZE=1 — signing container, SKIPPING notarize/staple (do not distribute)"
|
|
codesign --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$DMG"
|
|
elif [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
|
echo "==> [5/5] release finishing: sign container → notarize → staple"
|
|
codesign --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$DMG"
|
|
|
|
# CI provides the App Store Connect key under tauri's APPLE_API_* names (release.yml)
|
|
# — reuse the same key for the DMG-container notarization below.
|
|
NOTARYTOOL_API_KEY_PATH="${NOTARYTOOL_API_KEY_PATH:-${APPLE_API_KEY_PATH:-}}"
|
|
NOTARYTOOL_API_KEY_ID="${NOTARYTOOL_API_KEY_ID:-${APPLE_API_KEY:-}}"
|
|
NOTARYTOOL_API_ISSUER_ID="${NOTARYTOOL_API_ISSUER_ID:-${APPLE_API_ISSUER:-}}"
|
|
|
|
NOTARY_ENV="${OCW_NOTARY_ENV:-$PLATFORM/../.ocw-notary.env}"
|
|
if [ -z "${NOTARYTOOL_API_KEY_PATH:-}" ] && [ -f "$NOTARY_ENV" ]; then
|
|
set -a; # shellcheck disable=SC1090
|
|
source "$NOTARY_ENV"; set +a
|
|
fi
|
|
if [ -n "${NOTARYTOOL_API_KEY_PATH:-}" ] && [ -n "${NOTARYTOOL_API_KEY_ID:-}" ] \
|
|
&& [ -n "${NOTARYTOOL_API_ISSUER_ID:-}" ]; then
|
|
xcrun notarytool submit "$DMG" \
|
|
--key "$NOTARYTOOL_API_KEY_PATH" \
|
|
--key-id "$NOTARYTOOL_API_KEY_ID" \
|
|
--issuer "$NOTARYTOOL_API_ISSUER_ID" \
|
|
--wait
|
|
xcrun stapler staple "$DMG"
|
|
# The same check Gatekeeper runs on download — fail the build rather than ship a
|
|
# DMG that greets users with the "Move to Trash" malware dialog.
|
|
spctl -a -t open --context context:primary-signature "$DMG"
|
|
echo " Gatekeeper: accepted (notarized + stapled)"
|
|
else
|
|
echo " WARNING: DMG is signed but NOT notarized — public downloads will see the"
|
|
echo " 'Move to Trash' dialog. Provide NOTARYTOOL_API_KEY_PATH/_KEY_ID/_ISSUER_ID"
|
|
echo " (env, \$OCW_NOTARY_ENV, or $NOTARY_ENV)."
|
|
fi
|
|
else
|
|
echo " (unsigned dev build — set APPLE_SIGNING_IDENTITY for a distributable DMG)"
|
|
fi
|
|
|
|
echo ""
|
|
echo "Done → $DMG"
|