352 lines
18 KiB
Bash
Executable File
352 lines
18 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).
|
|
#
|
|
# 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"
|
|
|
|
# ── 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
|
|
|
|
echo "▸ Building $PRODUCT (channel=$CHANNEL, config=$CONFIG)"
|
|
NUCLEIC_CHANNEL="$CHANNEL" swift build -c "$CONFIG" --product "$PRODUCT"
|
|
BIN_DIR="$(NUCLEIC_CHANNEL="$CHANNEL" swift build -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"
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# 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 3 hours (10800s) to pull fixes sooner; only canary overrides the default.
|
|
INTERVAL_PLIST=""
|
|
if [ "$CHANNEL" = "canary" ]; then
|
|
INTERVAL_PLIST="
|
|
<key>SUScheduledCheckInterval</key>
|
|
<integer>10800</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: 10800s (3h, 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.)
|
|
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)"
|
|
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)"
|
|
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
|
|
|
|
# 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 "$ROOT/signing/nucleic.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\""
|