Files
bherbruck 7a6c884418 Change the app id to io.tangencylabs.solidcraft, with aliases for existing installs (#7681)
The app id moves from ai.storyteller.solidcraft to io.tangencylabs.solidcraft: macOS bundle id
(the CLI signs as io.tangencylabs.solidcraft.cli), Linux desktop/metainfo/MIME file names, icon
name, Wayland app_id / X11 class, Flatpak, Snap common-id, nfpm/FreeBSD/Nix/AppImage staging,
the web app's icon paths and the docs.

Existing installs keep working: the Flatpak repository marks both old ids
(ai.storyteller.solidcraft and ai.storyteller.solvecraft) end-of-life with a rebase straight to
the new id, re-marking the solvecraft ref that pointed at the 0.8 id; the manifest grants
read-only access to both old ~/.var/app folders and the first launch copies settings and
autosaves from the newest one; AppStream <replaces>/<provides> and X-Flatpak-RenamedFrom list
both; the AppImage removes menu entries it wrote under either old id. The data and settings
folders (solidcraft / SolidCraft) are unchanged. The MSI UpgradeCode and ProgIDs are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:16:40 -04:00

275 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build and package SolidCraft for macOS:
#
# $DIST/solidcraft-<version>-macos-<arch>.dmg SolidCraft.app (with solidcraft-cli inside) on a
# drag-to-Applications DMG with a drawn window
# (assets/platforms/macos/dmg)
# $DIST/solidcraft-cli-<version>-macos-<arch>.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 <rust-target>
#
# --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/<target>/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 <path> [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 <path>: 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 <path>: 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"