#!/usr/bin/env bash # Build and package SolidCraft for macOS: # # $DIST/solidcraft--macos-.dmg SolidCraft.app (with solidcraft-cli inside) on a # drag-to-Applications DMG with a drawn window # (assets/platforms/macos/dmg) # $DIST/solidcraft-cli--macos-.zip the headless CLI (and its solvecraft-cli # compatibility alias) with the licence files # # Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build] [--sign] # packaging/macos/package.sh --build-only # # --build-only builds just that target (aarch64-apple-darwin or x86_64-apple-darwin) and stops, # so the release workflow can build the two halves of the universal binary on separate runners; # a later `--arch universal --skip-build` run joins the binaries it finds under # $CARGO_TARGET_DIR//release/ and packages them. # # Signing: with --sign, MACOS_SIGN_IDENTITY (a "Developer ID Application: ..." name or its SHA-1 # hash, found in the keychain) signs the app, the CLI and the DMG with the hardened runtime and a # secure timestamp. With the App Store Connect API key as well (APPLE_API_KEY_PATH = the .p8 file, # APPLE_API_KEY_ID, APPLE_API_ISSUER) the app, the DMG and the CLI zip are notarized; the app # and the DMG are stapled. Without --sign the app is ad-hoc signed, not notarized: first launch needs # right-click > Open. # Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md). set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" # The same reverse-DNS id as the Linux APP_ID. MACOS_BUNDLE_ID=io.tangencylabs.solidcraft # sign_code [identifier] [entitlements]: Developer ID with the hardened runtime and a secure # timestamp, or the ad-hoc signature (Apple silicon refuses unsigned code) without an identity. sign_code() { local path="$1" identifier="${2:-}" entitlements="${3:-}" local args=(--force) if [ -n "$SIGN_IDENTITY" ]; then args+=(--sign "$SIGN_IDENTITY" --options runtime --timestamp) else args+=(--sign - --timestamp=none) fi if [ -n "$identifier" ]; then args+=(--identifier "$identifier"); fi if [ -n "$entitlements" ] && [ -n "$SIGN_IDENTITY" ]; then args+=(--entitlements "$entitlements"); fi codesign "${args[@]}" "$path" } # notarize : submit to Apple and wait up to 30 minutes; a rejection or timeout fails the # build, and a rejection prints Apple's log. notarize() { local out # Not fatal here: the status check below is the only failure path, so the log is always fetched. out="$(xcrun notarytool submit "$1" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" \ --issuer "$APPLE_API_ISSUER" --wait --timeout 30m --output-format json || true)" echo "$out" local id id="$(printf '%s' "$out" | /usr/bin/plutil -extract id raw -o - - 2>/dev/null || true)" if [ "$(printf '%s' "$out" | /usr/bin/plutil -extract status raw -o - - 2>/dev/null || true)" != Accepted ]; then if [ -n "$id" ]; then xcrun notarytool log "$id" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" >&2 || true fi echo "notarization of $1 was not accepted" >&2 return 1 fi } # staple : stapling can fail with error 65 for a while after Apple accepts a submission, so # try a few times. staple() { local i for i in 1 2 3 4 5 6; do if xcrun stapler staple "$1"; then return 0; fi echo "stapler staple failed (attempt $i), retrying" >&2 sleep "${STAPLE_RETRY_SLEEP:-20}" done echo "could not staple $1" >&2 return 1 } ARCH=universal SKIP_BUILD=0 BUILD_ONLY= SIGN=0 while [ $# -gt 0 ]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; --build-only) BUILD_ONLY="$2"; shift 2 ;; --sign) SIGN=1; shift ;; -h | --help) sed -n '2,23p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done case "$ARCH" in universal) TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) ;; aarch64) TARGETS=(aarch64-apple-darwin) ;; x86_64) TARGETS=(x86_64-apple-darwin) ;; *) echo "unknown --arch $ARCH" >&2; exit 2 ;; esac if [ -n "$BUILD_ONLY" ] && [ "$SKIP_BUILD" = 1 ]; then echo "--skip-build and --build-only exclude each other" >&2; exit 2 fi if [ -n "$BUILD_ONLY" ] && [ "$SIGN" = 1 ]; then echo "--build-only builds nothing to sign: drop --sign" >&2; exit 2 fi SIGN_IDENTITY= NOTARIZE=0 if [ "$SIGN" = 1 ]; then SIGN_IDENTITY="${MACOS_SIGN_IDENTITY:-}" if [ -z "$SIGN_IDENTITY" ]; then echo "--sign needs MACOS_SIGN_IDENTITY" >&2; exit 2; fi if [ -n "${APPLE_API_KEY_PATH:-}" ] && [ -n "${APPLE_API_KEY_ID:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ]; then NOTARIZE=1 else echo "==> Developer ID signing without notarization: the APPLE_API_* settings are incomplete" fi else echo "==> no --sign: ad-hoc signing, no notarization" fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Keep in sync with LSMinimumSystemVersion in Info.plist.in. export MACOSX_DEPLOYMENT_TARGET=11.0 SHORT_VERSION="${VERSION%%-*}" WORK="$CARGO_TARGET_DIR/macos-package" APP="$WORK/SolidCraft.app" DMG="$DIST/solidcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/solidcraft-cli-$VERSION-macos-$ARCH.zip" if [ -n "$BUILD_ONLY" ]; then case "$BUILD_ONLY" in aarch64-apple-darwin | x86_64-apple-darwin) ;; *) echo "unknown --build-only target $BUILD_ONLY" >&2; exit 2 ;; esac echo "==> SolidCraft $VERSION for macOS ($BUILD_ONLY, build only)" (cd "$ROOT" && cargo build --release --locked -p solidcraft -p solidcraft-cli --target "$BUILD_ONLY") exit 0 fi echo "==> SolidCraft $VERSION for macOS ($ARCH)" if [ "$SKIP_BUILD" = 0 ]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p solidcraft -p solidcraft-cli "${args[@]}") fi rm -rf "$WORK" mkdir -p "$WORK/bin" for bin in solidcraft solidcraft-cli solvecraft-cli; do inputs=() for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done lipo -create -output "$WORK/bin/$bin" "${inputs[@]}" lipo -info "$WORK/bin/$bin" done # ---- SolidCraft.app ---------------------------------------------------------------------------- echo "==> assembling $APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$WORK/bin/solidcraft" "$APP/Contents/MacOS/SolidCraft" # solidcraft-cli next to the app, as every other package has it: the agent chat starts it as the # agent's MCP server (`solidcraft-cli mcp --connect`) and looks for it beside the running program. cp "$WORK/bin/solidcraft-cli" "$APP/Contents/MacOS/solidcraft-cli" # Apple-compiled layered icon and older-macOS fallback (regeneration: assets/README.md). # Keep compiled resources so packaging also works on runners with older Xcode versions. cp "$ROOT/assets/icons/app/macos/exports/Assets.car" "$APP/Contents/Resources/Assets.car" cp "$ROOT/assets/icons/app/macos/exports/icon.icns" "$APP/Contents/Resources/SolidCraft.icns" mkdir -p "$APP/Contents/Resources/Licenses" copy_docs "$APP/Contents/Resources/Licenses" sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" "$ROOT/assets/platforms/macos/Info.plist.in" >"$APP/Contents/Info.plist" plutil -lint "$APP/Contents/Info.plist" printf 'APPL????' >"$APP/Contents/PkgInfo" # Signature over the whole bundle. Nested code is signed first: the bundle signature refuses an # unsigned helper in Contents/MacOS. The entitlements go on the bundle (they apply to the main # executable): the 3D mouse driver is loaded by the app, not by the CLI. sign_code "$APP/Contents/MacOS/solidcraft-cli" "$MACOS_BUNDLE_ID.cli" sign_code "$APP" "" "$SCRIPT_DIR/entitlements.plist" codesign --verify --strict --deep --verbose=2 "$APP" if [ -n "$SIGN_IDENTITY" ]; then # The hardened runtime and the library-validation exception must be on the signed app. # Match a captured string, not a pipe: grep -q closing the pipe can SIGPIPE codesign under pipefail. info="$(codesign --display --verbose=2 "$APP" 2>&1)" grep -q 'flags=.*runtime' <<<"$info" info="$(codesign --display --entitlements - --xml "$APP" 2>/dev/null)" grep -q 'com.apple.security.cs.disable-library-validation' <<<"$info" fi test -x "$APP/Contents/MacOS/solidcraft-cli" || { echo "solidcraft-cli is missing from $APP" >&2; exit 1; } if [ "$NOTARIZE" = 1 ]; then # Staple the app itself, so a copy dragged out of the DMG opens offline too. echo "==> notarizing $APP" ditto -c -k --keepParent "$APP" "$WORK/SolidCraft-app.zip" notarize "$WORK/SolidCraft-app.zip" staple "$APP" xcrun stapler validate "$APP" fi # ---- DMG --------------------------------------------------------------------------------------- echo "==> building $DMG" DMG_ART="$ROOT/assets/platforms/macos/dmg" # dmgbuild (hash-pinned, packaging/macos/dmg-requirements.txt) writes the window layout, the # background and the icon positions into the image without a Finder session. VENV="$WORK/dmg-venv" python3 -m venv "$VENV" "$VENV/bin/pip" install --quiet --disable-pip-version-check --require-hashes -r "$ROOT/packaging/macos/dmg-requirements.txt" rm -f "$DMG" "$VENV/bin/dmgbuild" -s "$DMG_ART/settings.py" \ -D "here=$DMG_ART" -D "app=$APP" -D "readme=$ROOT/assets/platforms/macos/README.txt" \ -D "icns=$ROOT/assets/icons/app/macos/exports/icon.icns" \ SolidCraft "$DMG" # Check the image as a user gets it: mount it and look inside. MNT="$(mktemp -d)" hdiutil attach -readonly -nobrowse -mountpoint "$MNT" "$DMG" ls -la "$MNT" "$MNT/SolidCraft.app/Contents" "$MNT/SolidCraft.app/Contents/MacOS" "$MNT/SolidCraft.app/Contents/Resources" test -L "$MNT/Applications" && test "$(readlink "$MNT/Applications")" = /Applications # The window's look: Finder layout, background art and the volume icon. test -s "$MNT/.DS_Store" && test -s "$MNT/.VolumeIcon.icns" test -s "$MNT/.background.tiff" plutil -lint "$MNT/SolidCraft.app/Contents/Info.plist" # Check the names Finder/LaunchServices actually use, and the bundle identity. for key in CFBundleName CFBundleDisplayName CFBundleExecutable CFBundleIconFile CFBundleIconName; do test "$(/usr/libexec/PlistBuddy -c "Print :$key" "$MNT/SolidCraft.app/Contents/Info.plist")" = SolidCraft done test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$MNT/SolidCraft.app/Contents/Info.plist")" = "$MACOS_BUNDLE_ID" test -s "$MNT/SolidCraft.app/Contents/Resources/SolidCraft.icns" cmp "$ROOT/assets/icons/app/macos/exports/icon.icns" "$MNT/SolidCraft.app/Contents/Resources/SolidCraft.icns" cmp "$ROOT/assets/icons/app/macos/exports/Assets.car" "$MNT/SolidCraft.app/Contents/Resources/Assets.car" cmp "$ROOT/assets/platforms/macos/README.txt" "$MNT/README.txt" case "$ARCH" in universal) WANT=(arm64 x86_64) ;; aarch64) WANT=(arm64) ;; *) WANT=(x86_64) ;; esac for arch in "${WANT[@]}"; do lipo "$MNT/SolidCraft.app/Contents/MacOS/SolidCraft" -verify_arch "$arch" done test -s "$MNT/SolidCraft.app/Contents/Resources/Licenses/THIRD-PARTY-LICENSES.txt" # Not --strict: the mounted HFS volume adds Finder info, which strict verification rejects. codesign --verify --deep --verbose=2 "$MNT/SolidCraft.app" "$MNT/SolidCraft.app/Contents/MacOS/SolidCraft" --version hdiutil detach "$MNT" if [ -n "$SIGN_IDENTITY" ]; then sign_code "$DMG" codesign --verify --verbose=2 "$DMG" fi if [ "$NOTARIZE" = 1 ]; then echo "==> notarizing $DMG" notarize "$DMG" staple "$DMG" xcrun stapler validate "$DMG" # Gatekeeper's verdict on the DMG and on the app as a user gets it. spctl -a -t open --context context:primary-signature -vv "$DMG" MNT="$(mktemp -d)" hdiutil attach -readonly -nobrowse -mountpoint "$MNT" "$DMG" spctl --assess --type execute --verbose=4 "$MNT/SolidCraft.app" hdiutil detach "$MNT" fi # ---- CLI --------------------------------------------------------------------------------------- echo "==> building $CLI_ZIP" CLI_DIR="$WORK/solidcraft-cli-$VERSION-macos-$ARCH" mkdir -p "$CLI_DIR" cp "$WORK/bin/solidcraft-cli" "$CLI_DIR/" sign_code "$CLI_DIR/solidcraft-cli" "$MACOS_BUNDLE_ID.cli" # Compatibility shim (docs/compatibility.md): the old CLI name forwards to solidcraft-cli. cp "$WORK/bin/solvecraft-cli" "$CLI_DIR/" sign_code "$CLI_DIR/solvecraft-cli" "$MACOS_BUNDLE_ID.cli-alias" copy_docs "$CLI_DIR" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" if [ "$NOTARIZE" = 1 ]; then # A zip of bare executables can't be stapled; Gatekeeper finds the ticket online. echo "==> notarizing $CLI_ZIP" notarize "$CLI_ZIP" fi "$WORK/bin/solidcraft-cli" --version echo "==> done" ls -lh "$DMG" "$CLI_ZIP"