Files
nucleic/scripts/package-app.sh
T

480 lines
25 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Assemble a macOS .app bundle for a Nucleic build channel.
#
# scripts/package-app.sh {dev|canary|beta|rc|stable}
#
# Wraps the channel's bare SwiftPM executable in a proper .app: writes Info.plist,
# ships the SwiftPM resource bundles (GRDB / SwiftTerm) inside Contents/Resources,
# and ad-hoc code-signs so it launches locally. The channels build to distinct
# app names + bundle IDs, so they coexist:
#
# channel product (process) app bundle bundle id
# dev nucleic-local Nucleic Dev.app xyz.blakeslee.nucleic.desktop.dev
# canary nucleic-canary Nucleic Canary.app xyz.blakeslee.nucleic.desktop.canary
# beta nucleic-beta Nucleic Beta.app xyz.blakeslee.nucleic.desktop.beta
# rc nucleic-rc Nucleic RC.app xyz.blakeslee.nucleic.desktop.rc
# stable nucleic Nucleic.app xyz.blakeslee.nucleic.desktop.release
#
# (The production channel keyword stays "stable" everywhere in the build system; only its
# bundle-id suffix is "release", per the requested xyz.blakeslee.nucleic.desktop.* scheme.)
#
# Version comes from ./VERSION (NUCLEIC_MARKETING_VERSION + NUCLEIC_BUILD_NUMBER), maintained by
# scripts/release-macos.sh. This script only reads it; see the Version section below.
#
# Env overrides:
# NUCLEIC_CONFIG build configuration (default: release)
# NUCLEIC_VERSION marketing version / CFBundleShortVersionString (default: ./VERSION, else 0.1.0)
# NUCLEIC_BUILD build number / CFBundleVersion (default: ./VERSION, else git commit count)
# NUCLEIC_SIGN_ID codesign identity (default: "-", ad-hoc). Set to a Developer ID
# to sign for distribution (notarization/TestFlight is separate).
# NUCLEIC_PROVISIONING_PROFILE Developer ID .provisionprofile for this channel. Required with
# a real identity; overrides the channel-specific profile in signing/.
#
# An app icon is picked up automatically. Two formats, in priority order:
# • Resources/AppIcon.icon — Icon Composer package (macOS 26 Liquid Glass). Carries the
# themed appearances (default/dark/clear/tinted); actool compiles it to Assets.car.
# • Resources/AppIcon.icns — legacy flat icon, no themed variants.
# Per-channel overrides take precedence over the generic names so each channel can ship a
# visually distinct icon. Two override locations, root wins over Resources/:
# • ./app-logo-<channel>.{icon,icns} (repo root — e.g. ./app-logo-canary.icon)
# • Resources/AppIcon-<channel>.{icon,icns} (Resources/)
set -euo pipefail
CHANNEL="${1:-dev}"
CONFIG="${NUCLEIC_CONFIG:-release}"
SIGN_ID="${NUCLEIC_SIGN_ID:--}"
# MARKETING_VERSION / BUILD_NUMBER are resolved below, after ROOT, from ./VERSION.
case "$CHANNEL" in
dev) PRODUCT=nucleic-local; APP_NAME="Nucleic Dev"; BUNDLE_ID="xyz.blakeslee.nucleic.desktop.dev" ;;
canary) PRODUCT=nucleic-canary; APP_NAME="Nucleic Canary"; BUNDLE_ID="xyz.blakeslee.nucleic.desktop.canary" ;;
beta) PRODUCT=nucleic-beta; APP_NAME="Nucleic Beta"; BUNDLE_ID="xyz.blakeslee.nucleic.desktop.beta" ;;
rc) PRODUCT=nucleic-rc; APP_NAME="Nucleic RC"; BUNDLE_ID="xyz.blakeslee.nucleic.desktop.rc" ;;
stable) PRODUCT=nucleic; APP_NAME="Nucleic"; BUNDLE_ID="xyz.blakeslee.nucleic.desktop.release" ;;
*) echo "usage: $0 {dev|canary|beta|rc|stable}" >&2; exit 2 ;;
esac
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# Fail before the expensive Swift build when a distribution signature cannot be provisioned.
if [ "$SIGN_ID" != "-" ]; then
if [ -n "${NUCLEIC_PROVISIONING_PROFILE:-}" ]; then
PROFILE="$NUCLEIC_PROVISIONING_PROFILE"
else
case "$CHANNEL" in
canary) PROFILE="$ROOT/signing/Nucleic_Desktop_Canary__Provisioning.provisionprofile" ;;
beta) PROFILE="$ROOT/signing/Nucleic_Desktop_Beta__Provisioning.provisionprofile" ;;
rc) PROFILE="$ROOT/signing/Nucleic_Desktop_RC__Provisioning.provisionprofile" ;;
stable) PROFILE="$ROOT/signing/Nucleic_Desktop_Release__Provisioning.provisionprofile" ;;
dev)
echo "no default Developer ID provisioning profile for the dev channel; set NUCLEIC_PROVISIONING_PROFILE" >&2
exit 1
;;
esac
fi
"$ROOT/scripts/validate-macos-profile.sh" \
"$PROFILE" "$BUNDLE_ID" "L7UDTQ6F5W.xyz.blakeslee.nucleic" "L7UDTQ6F5W"
fi
# ── Version ──────────────────────────────────────────────────────────────────────
# Single source of truth is ./VERSION (KEY=value, bumped per release by scripts/release-macos.sh).
# A direct env override still wins for one-off builds (NUCLEIC_VERSION / NUCLEIC_BUILD), and a
# checkout with no VERSION file falls back to 0.1.0 and the git commit count — so `make app-beta`
# and old trees never break. This script only READS the version; only release-macos.sh bumps it.
NUCLEIC_MARKETING_VERSION=""; NUCLEIC_BUILD_NUMBER=""
# shellcheck source=/dev/null
[ -f "$ROOT/VERSION" ] && . "$ROOT/VERSION"
MARKETING_VERSION="${NUCLEIC_VERSION:-${NUCLEIC_MARKETING_VERSION:-0.1.0}}"
# iCloud sync drops "conflict copies" beside source files — "Foo 2.swift", "Foo 3.swift"
# — that are byte-identical to the original. SwiftPM globs the whole Sources/Tests tree,
# so each copy compiles as a second definition and the build dies with "invalid
# redeclaration". Sweep them before building. We only touch our own source trees, and
# only files whose name is "<base> <n>.swift" with an existing "<base>.swift" alongside,
# so a deliberately-named file is never removed.
clean_icloud_dupes() {
local dupe base removed=0
while IFS= read -r -d '' dupe; do
base="${dupe% [0-9].swift}.swift"
[ -f "$base" ] || continue
rm -f "$dupe"
echo " removed iCloud conflict copy: ${dupe#"$ROOT/"}"
removed=$((removed + 1))
done < <(find "$ROOT/Sources" "$ROOT/Tests" -type f -name '* [0-9].swift' -print0 2>/dev/null)
[ "$removed" -gt 0 ] && echo "▸ Cleaned $removed iCloud conflict cop$([ "$removed" -eq 1 ] && echo y || echo ies) before build"
return 0
}
clean_icloud_dupes
# Per-mount scratch dir so a build from the VM share doesn't poison the host's .build/
# (and vice versa). Empty on the host; a --scratch-path redirect on the /Volumes share.
# Must be passed to BOTH the build and the --show-bin-path query so they agree. See
# scripts/lib/build-scratch.sh and BUILD.md "Cross-environment builds".
SCRATCH_ARGS="$(bash "$ROOT/scripts/lib/build-scratch.sh" "$ROOT")"
echo "▸ Building $PRODUCT (channel=$CHANNEL, config=$CONFIG)"
NUCLEIC_CHANNEL="$CHANNEL" swift build $SCRATCH_ARGS -c "$CONFIG" --product "$PRODUCT"
NUCLEIC_CHANNEL="$CHANNEL" swift build $SCRATCH_ARGS -c "$CONFIG" \
--product nucleic-power-helper
BIN_DIR="$(NUCLEIC_CHANNEL="$CHANNEL" swift build $SCRATCH_ARGS -c "$CONFIG" --product "$PRODUCT" --show-bin-path)"
# Build number: ./VERSION (release-maintained, monotonic) → env override → git commit count.
BUILD_NUMBER="${NUCLEIC_BUILD:-${NUCLEIC_BUILD_NUMBER:-$(git rev-list --count HEAD 2>/dev/null || echo 1)}}"
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
APP="$ROOT/dist/$APP_NAME.app"
CONTENTS="$APP/Contents"
echo "▸ Assembling $APP"
rm -rf "$APP"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
# Executable — keep the channel's product name so the OS process name is preserved
# even when launched from the bundle.
cp "$BIN_DIR/$PRODUCT" "$CONTENTS/MacOS/$PRODUCT"
# Smart Sleep's root helper. SMAppService installs this bundled launch daemon after one-time
# Background Item approval; its XPC lease toggles pmset's closed-lid SleepDisabled flag only while
# a local agent turn is in flight. Each channel gets an independent service label so dev/canary/
# release builds can coexist.
POWER_SERVICE="xyz.blakeslee.nucleic.power-helper.${BUNDLE_ID##*.}"
POWER_HELPER="$CONTENTS/MacOS/nucleic-power-helper"
POWER_DAEMONS="$CONTENTS/Library/LaunchDaemons"
mkdir -p "$POWER_DAEMONS"
cp "$BIN_DIR/nucleic-power-helper" "$POWER_HELPER"
cat > "$POWER_DAEMONS/$POWER_SERVICE.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>Label</key>
<string>$POWER_SERVICE</string>
<key>BundleProgram</key>
<string>Contents/MacOS/nucleic-power-helper</string>
<key>MachServices</key>
<dict><key>$POWER_SERVICE</key><true/></dict>
<key>AssociatedBundleIdentifiers</key>
<array><string>$BUNDLE_ID</string></array>
</dict>
</plist>
PLIST
echo " • embedded Smart Sleep helper ($POWER_SERVICE)"
# SwiftPM resource bundles must ship inside the app — `Bundle.module` resolves them
# via Bundle.main.resourceURL (= Contents/Resources).
shopt -s nullglob
for b in "$BIN_DIR"/*.bundle; do
cp -R "$b" "$CONTENTS/Resources/"
done
shopt -u nullglob
# ── macOS-VM service: in-guest agent + provisioner ───────────────────────────────────────
# The one-click "Build base image" (Settings ▸ Virtual Machines) provisions the golden base fully:
# it stages the native NucleicVMAgent.app (computer use over vsock) and scripts/provision-macos-guest.sh
# into the guest over SSH. Bake both into Resources/macvm so the shipped app needs no Swift toolchain
# at runtime — MacVMEngine+Provision.swift resolves them from there. Best-effort: a build host without
# the Swift toolchain (or a non-arm64 build) ships without the agent, so computer use is unavailable
# but the exec-only base still works. The .app is signed inside-out below (nested code).
MACVM_RES="$CONTENTS/Resources/macvm"
VMAGENT_OUT="$ROOT/dist/.vmagent"
mkdir -p "$MACVM_RES"
cp "$ROOT/scripts/provision-macos-guest.sh" "$MACVM_RES/"
if NUCLEIC_VMAGENT_SIGN_IDENTITY="$SIGN_ID" "$ROOT/scripts/build-vm-agent.sh" "$VMAGENT_OUT" >/dev/null 2>&1 \
&& [ -d "$VMAGENT_OUT/NucleicVMAgent.app" ]; then
cp -R "$VMAGENT_OUT/NucleicVMAgent.app" "$MACVM_RES/"
cp "$VMAGENT_OUT/xyz.blakeslee.nucleic.vmagent.plist" "$MACVM_RES/" 2>/dev/null || true
echo " • embedded NucleicVMAgent.app + provisioner (macOS VM computer use)"
else
echo " • NucleicVMAgent.app not built (no Swift toolchain?) — computer use unavailable; provisioner still embedded"
fi
# Sparkle.framework — the in-app auto-updater (direct/non-App-Store distribution). SwiftPM
# emits it into the build products dir; it must live in Contents/Frameworks and be reachable
# from the executable via @executable_path/../Frameworks. It is deep-signed inside-out below.
SPARKLE_FW=""
for cand in "$BIN_DIR/Sparkle.framework" "$BIN_DIR/../Sparkle.framework"; do
[ -d "$cand" ] && { SPARKLE_FW="$cand"; break; }
done
if [ -n "$SPARKLE_FW" ]; then
mkdir -p "$CONTENTS/Frameworks"
cp -R "$SPARKLE_FW" "$CONTENTS/Frameworks/"
# The SwiftPM-linked binary may not already carry the embedded-frameworks rpath; add it
# (idempotent — a duplicate just errors harmlessly and is ignored).
install_name_tool -add_rpath "@executable_path/../Frameworks" "$CONTENTS/MacOS/$PRODUCT" 2>/dev/null || true
echo " • embedded Sparkle.framework (auto-update)"
else
echo " • Sparkle.framework not found in build products — auto-update will be inert"
fi
# TailscaleKit.framework — the embedded tsnet node behind the Tailnet sync transport. Present
# only when the build linked the local binary artifact (scripts/build-tailscalekit.sh); a build
# without it simply reports "not built in" from the Settings transport picker. Signed by the
# generic embedded-frameworks pass below.
for cand in "$BIN_DIR/TailscaleKit.framework" "$BIN_DIR/../TailscaleKit.framework"; do
if [ -d "$cand" ]; then
mkdir -p "$CONTENTS/Frameworks"
cp -R "$cand" "$CONTENTS/Frameworks/"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$CONTENTS/MacOS/$PRODUCT" 2>/dev/null || true
echo " • embedded TailscaleKit.framework (Tailnet transport)"
break
fi
done
# Container runtime: NOTHING is required to be bundled. The kernel, the vminitd initfs, and the
# sandbox image all download + cache automatically on first use. Optionally, a kernel staged at
# Resources/vmlinux-arm64 (scripts/fetch-kernel.sh) is bundled here as an offline/dev fast-path so
# the app skips that one download.
if [ -f "$ROOT/Resources/vmlinux-arm64" ]; then
cp "$ROOT/Resources/vmlinux-arm64" "$CONTENTS/Resources/vmlinux-arm64"
echo " • bundled Resources/vmlinux-arm64 (kernel fast-path)"
else
echo " • kernel not bundled — it downloads automatically at runtime (optional: scripts/fetch-kernel.sh)"
fi
# Optional icon. Prefer a themed Icon Composer package (.icon → Assets.car via actool);
# fall back to a flat .icns. Per-channel files win over the generic names.
ICON_PLIST=""
# Icon Composer package (themed). Priority: root per-channel override → Resources/ per-channel
# override → generic. The root ./app-logo-<channel>.icon lets a channel ship a distinct icon
# (e.g. the canary build's ./app-logo-canary.icon) without touching Resources/.
ICON_SET=""
[ -d "$ROOT/app-logo-$CHANNEL.icon" ] && ICON_SET="$ROOT/app-logo-$CHANNEL.icon"
[ -z "$ICON_SET" ] && [ -d "$ROOT/Resources/AppIcon-$CHANNEL.icon" ] && ICON_SET="$ROOT/Resources/AppIcon-$CHANNEL.icon"
[ -z "$ICON_SET" ] && [ -d "$ROOT/Resources/AppIcon.icon" ] && ICON_SET="$ROOT/Resources/AppIcon.icon"
# Legacy flat .icns fallback, same priority order.
ICON_ICNS=""
[ -f "$ROOT/app-logo-$CHANNEL.icns" ] && ICON_ICNS="$ROOT/app-logo-$CHANNEL.icns"
[ -z "$ICON_ICNS" ] && [ -f "$ROOT/Resources/AppIcon-$CHANNEL.icns" ] && ICON_ICNS="$ROOT/Resources/AppIcon-$CHANNEL.icns"
[ -z "$ICON_ICNS" ] && [ -f "$ROOT/Resources/AppIcon.icns" ] && ICON_ICNS="$ROOT/Resources/AppIcon.icns"
if [ -n "$ICON_SET" ]; then
# actool consumes the .icon directly (no .xcassets wrapper) and emits Assets.car into
# Contents/Resources. --app-icon names the icon; CFBundleIconName resolves that name
# against the compiled catalog at launch and is what enables the themed appearances.
# actool's partial plist is unusable on macOS (it omits CFBundleIconName), so we discard
# it and write the key ourselves below. ICON_NAME = the .icon basename (matches per-channel).
ICON_NAME="$(basename "$ICON_SET" .icon)"
echo " • compiling themed app icon: ${ICON_SET#"$ROOT/"} (CFBundleIconName=$ICON_NAME)"
ACTOOL_PLIST="$(mktemp -t actool-partial-plist)"
xcrun actool "$ICON_SET" \
--compile "$CONTENTS/Resources" \
--app-icon "$ICON_NAME" \
--include-all-app-icons \
--output-partial-info-plist "$ACTOOL_PLIST" \
--enable-on-demand-resources NO \
--development-region en \
--target-device mac \
--platform macosx \
--minimum-deployment-target 26.0 \
--output-format human-readable-text --notices --warnings --errors
rm -f "$ACTOOL_PLIST"
ICON_PLIST="
<key>CFBundleIconName</key>
<string>$ICON_NAME</string>"
# Ship a flat .icns alongside if one exists — harmless belt-and-suspenders for contexts
# that look for an icon file rather than the asset catalog.
if [ -n "$ICON_ICNS" ]; then
cp "$ICON_ICNS" "$CONTENTS/Resources/AppIcon.icns"
ICON_PLIST="$ICON_PLIST
<key>CFBundleIconFile</key>
<string>AppIcon</string>"
fi
elif [ -n "$ICON_ICNS" ]; then
cp "$ICON_ICNS" "$CONTENTS/Resources/AppIcon.icns"
ICON_PLIST="
<key>CFBundleIconFile</key>
<string>AppIcon</string>"
else
echo " • no app icon found (./app-logo-$CHANNEL.icon, Resources/AppIcon-$CHANNEL.icon, or Resources/AppIcon.icon) — using system default"
fi
# Sparkle auto-update keys. Embedded only for the distribution channels (beta/rc/stable) and
# only when a public EdDSA key is available — a dev/local build ships no feed, so its updater
# stays dormant and the "Check for Updates…" menu item hides itself. The per-channel feed lives
# under NUCLEIC_FEED_BASE (the R2 bucket behind updates.nucleic.blakeslee.xyz that hosts the
# appcasts + DMGs; the GitHub release is a documented fallback origin); the public key is
# the counterpart of the private signing key generated once by Sparkle's `generate_keys` (the
# public half is safe to commit at signing/sparkle_public_ed_key). Both are overridable by env.
FEED_BASE="${NUCLEIC_FEED_BASE:-https://updates.nucleic.blakeslee.xyz}"
SPARKLE_PUB="${NUCLEIC_SPARKLE_PUB:-}"
[ -z "$SPARKLE_PUB" ] && [ -f "$ROOT/signing/sparkle_public_ed_key" ] && \
SPARKLE_PUB="$(tr -d '[:space:]' < "$ROOT/signing/sparkle_public_ed_key")"
SPARKLE_PLIST=""
if [ "$CHANNEL" != "dev" ] && [ -n "$SPARKLE_PUB" ]; then
# Background poll cadence. Sparkle defaults to 86400s (once a day) when SUScheduledCheckInterval
# is absent, which is what every channel but canary uses. Canary ships fast-moving builds, so it
# checks every hour (3600s) to pull fixes sooner; only canary overrides the default.
INTERVAL_PLIST=""
if [ "$CHANNEL" = "canary" ]; then
INTERVAL_PLIST="
<key>SUScheduledCheckInterval</key>
<integer>3600</integer>"
fi
SPARKLE_PLIST="
<key>SUFeedURL</key>
<string>$FEED_BASE/appcast-$CHANNEL.xml</string>
<key>SUPublicEDKey</key>
<string>$SPARKLE_PUB</string>
<key>SUEnableAutomaticChecks</key>
<true/>$INTERVAL_PLIST"
echo " • Sparkle feed: $FEED_BASE/appcast-$CHANNEL.xml"
[ "$CHANNEL" = "canary" ] && echo " • Sparkle check interval: 3600s (1h, canary)"
else
echo " • Sparkle feed: not embedded for this build (updater dormant)"
fi
cat > "$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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$PRODUCT</string>
<key>CFBundleIdentifier</key>
<string>$BUNDLE_ID</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$APP_NAME</string>
<key>CFBundleDisplayName</key>
<string>$APP_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$MARKETING_VERSION</string>
<key>CFBundleVersion</key>
<string>$BUILD_NUMBER</string>$ICON_PLIST$SPARKLE_PLIST
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSSupportsAutomaticTermination</key>
<true/>
<key>NSSupportsSuddenTermination</key>
<true/>
</dict>
</plist>
PLIST
printf 'APPL????' > "$CONTENTS/PkgInfo"
# Strip extended attributes (resource forks / Finder info) that iCloud and Finder
# attach to copied files — codesign rejects them with "resource fork, Finder
# information, or similar detritus not allowed". SwiftPM ships some resource files
# read-only, so make the tree writable first or xattr -c gets EACCES.
chmod -R u+w "$APP"
xattr -cr "$APP"
# ── Code signing ────────────────────────────────────────────────────────────────
# Distribution builds are signed with a "Developer ID Application" identity, the
# hardened runtime (--options runtime, required by notarization), and a SECURE
# TIMESTAMP (also required by notarization). An ad-hoc/local build ("-") can't get a
# secure timestamp, so it's only requested for a real identity.
#
# Hardened runtime is ALSO conditional, and this is load-bearing: the hardened runtime
# forces library validation (CS_REQUIRE_LV), which demands every loaded Mach-O share the
# main executable's Team ID. For a real identity that's fine — the inner Sparkle helpers
# are re-signed with the same team below, so they match. But an AD-HOC binary has no team
# identity, and the app and Sparkle.framework are signed in separate ad-hoc operations, so
# the loader rejects Sparkle at launch with "mapping process and mapped file have different
# Team IDs" (EXC_CRASH / DYLD, even though `codesign --verify` — a static check — passes).
# Ad-hoc local builds don't need (and can't notarize with) the hardened runtime, so we omit
# it for them; that drops the library-validation requirement and the embedded Sparkle loads.
#
# The virtualization entitlement is applied to the main executable so the app can
# boot Linux VMs in-process (ContainerEngine). `com.apple.security.virtualization`
# is NOT a restricted/managed entitlement: it needs no special Apple approval and no
# provisioning profile — a Developer ID cert + notarization is enough. (The
# restricted entitlement we deliberately avoid is com.apple.vm.networking; see BUILD.md.)
# ENTITLEMENTS also depends on the identity. A distribution signature claims Nucleic's shared
# Keychain access group and MUST embed a provisioning profile authorizing it; AMFI validates this
# at exec time even though `codesign --verify` does not. Ad-hoc builds cannot claim the group, so
# they get virtualization only and KeychainOwnedAccess uses its stable 0600 dev store.
if [ "$SIGN_ID" = "-" ]; then
TS_FLAG=(--timestamp=none) # ad-hoc: no secure timestamp available
RT_FLAG=() # ad-hoc: NO hardened runtime (would force library validation
# and reject the separately-ad-hoc-signed Sparkle)
echo "▸ Code-signing ad-hoc (local only — not distributable, not notarizable; no hardened runtime)"
ENTITLEMENTS="$ROOT/dist/.nucleic-adhoc.entitlements"
cat > "$ENTITLEMENTS" <<'ENT'
<?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>com.apple.security.virtualization</key>
<true/>
</dict>
</plist>
ENT
else
TS_FLAG=(--timestamp) # Developer ID: secure timestamp for notarization
RT_FLAG=(--options runtime) # Developer ID: hardened runtime, required by notarization
echo "▸ Code-signing for distribution (identity: $SIGN_ID)"
# Start from the reviewed entitlement template, then add the per-channel identity claims Xcode
# would normally synthesize. The profile validator checks that its wildcard/exact App ID covers
# this bundle before codesign sees the file.
ENTITLEMENTS="$ROOT/dist/.nucleic-distribution.entitlements"
cp "$ROOT/signing/nucleic.entitlements" "$ENTITLEMENTS"
/usr/libexec/PlistBuddy -c \
"Add :com.apple.application-identifier string L7UDTQ6F5W.$BUNDLE_ID" "$ENTITLEMENTS"
/usr/libexec/PlistBuddy -c \
"Add :com.apple.developer.team-identifier string L7UDTQ6F5W" "$ENTITLEMENTS"
cp "$PROFILE" "$CONTENTS/embedded.provisionprofile"
xattr -c "$CONTENTS/embedded.provisionprofile"
echo "▸ Embedded Keychain-authorizing profile: $PROFILE"
fi
# Sign inside-out: every nested Mach-O MUST be signed BEFORE the enclosing bundle, or (for a
# real identity) notarization rejects the unsigned inner code. Sparkle.framework carries its own
# helper executables (the Autoupdate tool, the Updater app, and the Installer/Downloader XPC
# services) which codesign does NOT sign transitively when it seals the framework, so each is
# signed explicitly here, deepest-first, exactly as Sparkle's "sign outside Xcode" guidance says.
# (RT_FLAG carries the hardened runtime only for a real identity — see the note above.)
# (${RT_FLAG[@]+...} guard: RT_FLAG is empty for ad-hoc, and bash 3.2 — macOS's /bin/bash —
# errors on "${empty[@]}" under `set -u`; the guard expands to nothing when unset/empty.)
sign_one() { [ -e "$1" ] && codesign --force ${RT_FLAG[@]+"${RT_FLAG[@]}"} "${TS_FLAG[@]}" --sign "$SIGN_ID" "$1"; }
SPK="$CONTENTS/Frameworks/Sparkle.framework"
if [ -d "$SPK" ]; then
V="$SPK/Versions/B"
sign_one "$V/XPCServices/Installer.xpc"
sign_one "$V/XPCServices/Downloader.xpc"
sign_one "$V/Autoupdate"
sign_one "$V/Updater.app"
sign_one "$SPK"
fi
# Any other embedded frameworks (none today) — sign the bundle.
shopt -s nullglob
for nested in "$CONTENTS/Frameworks"/*.framework; do
[ "$nested" = "$SPK" ] && continue
codesign --force ${RT_FLAG[@]+"${RT_FLAG[@]}"} "${TS_FLAG[@]}" --sign "$SIGN_ID" "$nested"
done
shopt -u nullglob
# The root helper is nested code and must be signed before the enclosing app. Its embedded
# __info_plist carries SMAuthorizedClients, which launchd enforces before accepting privileged XPC.
sign_one "$POWER_HELPER"
# The embedded in-guest agent is nested code — sign it inside-out (like Sparkle) so the app's final
# --deep --strict verify passes and, for a real identity, its designated requirement is STABLE across
# builds (the guest's pre-granted TCC rows key on it; an ad-hoc DR changes every build — see
# scripts/build-vm-agent.sh). sign_one is a no-op when the agent wasn't built into the bundle.
sign_one "$CONTENTS/Resources/macvm/NucleicVMAgent.app"
# Finally the app bundle itself — signs the main executable with the virtualization
# entitlement and seals the bundle.
codesign --force ${RT_FLAG[@]+"${RT_FLAG[@]}"} "${TS_FLAG[@]}" \
--entitlements "$ENTITLEMENTS" \
--sign "$SIGN_ID" "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
echo "✓ $APP"
echo " bundle id $BUNDLE_ID"
echo " version $MARKETING_VERSION (build $BUILD_NUMBER, commit $COMMIT)"
echo " open with: open \"$APP\""