From 3208fe0afcb6eed92f50ce74327e86f9cfc3efa8 Mon Sep 17 00:00:00 2001 From: Roberto Nibali Date: Sun, 15 Feb 2026 12:59:10 +0100 Subject: [PATCH 1/5] Add `build_dmg.sh` script for macOS DMG creation, signing, and notarization, with accompanying `BUILD.md` documentation --- BUILD.md | 160 +++++++++++++++++++++++ scripts/build_dmg.sh | 298 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 BUILD.md create mode 100755 scripts/build_dmg.sh diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..fcb0098 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,160 @@ +# Build and Release (Signed + Notarized DMG) + +This project includes `scripts/build_dmg.sh` to produce a distributable macOS DMG: + +- Output: `dist/Aether.dmg` +- Includes a universal binary (`arm64` + `x86_64`) +- Signs the `.app` and `.dmg` +- Notarizes and staples both (unless `--skip-notarization`) + +## What "can run on any computer" means on macOS + +For normal Gatekeeper-friendly distribution to other Macs, you need: + +1. A `Developer ID Application` signing certificate +2. Apple notarization +3. Stapled notarization ticket + +Without notarization/signing, users can still sometimes run the app by bypassing security prompts, but it is not a clean "works anywhere" install experience. + +## What notarization is + +Notarization is Apple scanning your signed app/archive for malicious content. If accepted, Apple issues a ticket. When you staple that ticket to your app/DMG, macOS can validate it offline and Gatekeeper is much less likely to block first launch. + +## Prerequisites + +1. macOS with Xcode Command Line Tools +2. Apple Developer Program membership (paid) +3. Installed `Developer ID Application` certificate in your keychain +4. `xcrun notarytool` credentials stored in keychain (profile name) + +## Apple ID, Team ID, and certificate details + +### Apple ID: can this be any email? + +No. It must be the Apple Account that has access to your Apple Developer team. In practice: + +- It can be any email address format only if that email is the sign-in for an Apple Account in the developer team +- For Apple ID auth with notarytool, you also need an app-specific password +- If you are in multiple teams with the same Apple ID, `--team-id` selects which team notarytool uses + +### Team ID: what is it? + +`TEAMID` is your Apple Developer Team identifier (usually 10 uppercase alphanumeric characters), for example `ABCDE12345`. + +Use the team that owns the `Developer ID Application` certificate used to sign the app. If team/certificate/auth do not match, notarization will fail. + +You can find Team ID in: + +- App Store Connect -> Users and Access -> Membership +- developer.apple.com -> Account -> Membership + +If you are in multiple teams, use the team that issued the `Developer ID Application` certificate you pass in `APP_SIGN_IDENTITY`. + +### Signing identity used by this script + +Set `APP_SIGN_IDENTITY` to your certificate common name, for example: + +```bash +APP_SIGN_IDENTITY="Developer ID Application: Your Name (ABCDE12345)" +``` + +You can inspect available code-sign identities with: + +```bash +security find-identity -v -p codesigning +``` + +## Configure notarytool credentials locally + +Create or update a local keychain profile (example profile name: `AETHER_NOTARY`): + +```bash +xcrun notarytool store-credentials AETHER_NOTARY \ + --apple-id "you@example.com" \ + --team-id "ABCDE12345" \ + --password "" \ + --validate +``` + +Notes: + +- `--password` is an Apple app-specific password, not your Apple Account login password +- If you omit `--password`, `notarytool` prompts securely in terminal +- Running `store-credentials` again with the same profile name updates/replaces the saved credentials + +## How to update stored credentials later + +Common cases: + +1. Password rotated/revoked: rerun `store-credentials` with the same profile name +2. Switched Apple ID or Team: rerun with new values under same or new profile name +3. Multiple environments: use separate profile names (for example `AETHER_NOTARY_DEV`, `AETHER_NOTARY_CI`) + +A quick validity check is to submit a build; `store-credentials --validate` also performs a credential validation request. + +## Build command + +From repo root: + +```bash +APP_SIGN_IDENTITY="Developer ID Application: Your Name (ABCDE12345)" \ +NOTARY_PROFILE="AETHER_NOTARY" \ +scripts/build_dmg.sh --bundle-id "com.yourcompany.aether" --version "1.2.1" +``` + +If `--version` is omitted, the script uses the latest git tag (without leading `v`) or falls back to `0.0.0`. + +## Useful script options + +```bash +scripts/build_dmg.sh --help +``` + +- `--bundle-id `: CFBundleIdentifier in `Info.plist` +- `--version `: app short/build version +- `--skip-notarization`: sign only, skip notary submission/stapling + +## Resource handling in the DMG build + +`scripts/build_dmg.sh` includes resources from both: + +- SwiftPM-generated resource bundle(s) (for `Bundle.module`, e.g. `Aether_Aether.bundle`) +- Source resources under `Sources/Aether/Resources` (copied to `Contents/Resources/AetherResources`) + +It also generates `Contents/Resources/AppIcon.icns` from: + +- `Sources/Aether/Resources/Assets.xcassets/AppIcon.appiconset` + +and sets `CFBundleIconFile=AppIcon` in the app `Info.plist` so Finder/Dock can use that icon. + +You do not need to move `Sources/Aether/Resources` for this packaging flow. + +## Local dry run (no Apple account required) + +For packaging flow validation only: + +```bash +APP_SIGN_IDENTITY="-" scripts/build_dmg.sh --skip-notarization +``` + +This uses ad-hoc signing and is not suitable for public distribution. + +## Verify output + +After build: + +```bash +ls -lh dist/Aether.dmg +spctl -a -vvv -t open dist/Aether.dmg +``` + +You can also mount and inspect: + +```bash +hdiutil attach dist/Aether.dmg +``` + +## CI note + +Apple ID + app-specific password works, but App Store Connect API key auth is often preferred for CI (`notarytool store-credentials --key ... --key-id ... --issuer ...`). diff --git a/scripts/build_dmg.sh b/scripts/build_dmg.sh new file mode 100755 index 0000000..707aa9b --- /dev/null +++ b/scripts/build_dmg.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="Aether" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DIST_DIR="${ROOT_DIR}/dist" +WORK_DIR="${ROOT_DIR}/.build/release-dmg" +DMG_STAGE_DIR="${WORK_DIR}/dmg-stage" +PROJECT_RESOURCES_DIR="${ROOT_DIR}/Sources/Aether/Resources" + +APP_SIGN_IDENTITY="${APP_SIGN_IDENTITY:-}" +NOTARY_PROFILE="${NOTARY_PROFILE:-}" +SKIP_NOTARIZATION="${SKIP_NOTARIZATION:-0}" +BUNDLE_ID="${BUNDLE_ID:-com.aether.app}" +APP_VERSION="${APP_VERSION:-}" +MIN_MACOS_VERSION="${MIN_MACOS_VERSION:-14.0}" +DMG_VOLUME_NAME="${DMG_VOLUME_NAME:-Aether}" + +usage() { + cat < App version (default: latest git tag or 0.0.0) + --bundle-id Bundle identifier (default: com.aether.app) + --skip-notarization Skip notarization/stapling + -h, --help Show this help + +Required env vars: + APP_SIGN_IDENTITY Developer ID Application identity for codesign + +Required unless --skip-notarization is used: + NOTARY_PROFILE Keychain profile configured via xcrun notarytool store-credentials + +Optional env vars: + APP_VERSION Same as --version + BUNDLE_ID Same as --bundle-id + MIN_MACOS_VERSION Defaults to 14.0 + DMG_VOLUME_NAME Defaults to Aether +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + APP_VERSION="$2" + shift 2 + ;; + --bundle-id) + BUNDLE_ID="$2" + shift 2 + ;; + --skip-notarization) + SKIP_NOTARIZATION=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing required command: $1" >&2 + exit 1 + } +} + +generate_app_icon_icns() { + local app_iconset_src="$1" + local app_resources_dir="$2" + local iconset_dir="${WORK_DIR}/AppIcon.iconset" + local base_png="${app_iconset_src}/icon_1024.png" + + if [[ ! -d "${app_iconset_src}" ]]; then + echo "Warning: App iconset not found at ${app_iconset_src}" >&2 + return 0 + fi + + if [[ ! -f "${base_png}" ]]; then + base_png="$(find "${app_iconset_src}" -maxdepth 1 -type f -name '*.png' -print | head -n 1 || true)" + fi + + if [[ -z "${base_png}" || ! -f "${base_png}" ]]; then + echo "Warning: no PNG files found in ${app_iconset_src}" >&2 + return 0 + fi + + rm -rf "${iconset_dir}" + mkdir -p "${iconset_dir}" + + sips -z 16 16 "${base_png}" --out "${iconset_dir}/icon_16x16.png" >/dev/null + sips -z 32 32 "${base_png}" --out "${iconset_dir}/icon_16x16@2x.png" >/dev/null + sips -z 32 32 "${base_png}" --out "${iconset_dir}/icon_32x32.png" >/dev/null + sips -z 64 64 "${base_png}" --out "${iconset_dir}/icon_32x32@2x.png" >/dev/null + sips -z 128 128 "${base_png}" --out "${iconset_dir}/icon_128x128.png" >/dev/null + sips -z 256 256 "${base_png}" --out "${iconset_dir}/icon_128x128@2x.png" >/dev/null + sips -z 256 256 "${base_png}" --out "${iconset_dir}/icon_256x256.png" >/dev/null + sips -z 512 512 "${base_png}" --out "${iconset_dir}/icon_256x256@2x.png" >/dev/null + sips -z 512 512 "${base_png}" --out "${iconset_dir}/icon_512x512.png" >/dev/null + cp "${base_png}" "${iconset_dir}/icon_512x512@2x.png" + + iconutil -c icns "${iconset_dir}" -o "${app_resources_dir}/AppIcon.icns" +} + +log() { + printf '\n[%s] %s\n' "$(date +'%H:%M:%S')" "$*" +} + +resolve_release_dir() { + local arch="$1" + local path + + path="$(find "${ROOT_DIR}/.build" -type d -path "*/${arch}-apple-macosx*/release" -print | head -n 1 || true)" + if [[ -z "${path}" ]]; then + echo "Could not locate release directory for architecture: ${arch}" >&2 + return 1 + fi + + printf '%s\n' "${path}" +} + +if [[ -z "${APP_SIGN_IDENTITY}" ]]; then + echo "APP_SIGN_IDENTITY is required." >&2 + exit 1 +fi + +if [[ "${SKIP_NOTARIZATION}" != "1" && -z "${NOTARY_PROFILE}" ]]; then + echo "NOTARY_PROFILE is required unless --skip-notarization is used." >&2 + exit 1 +fi + +if [[ -z "${APP_VERSION}" ]]; then + APP_VERSION="$(git -C "${ROOT_DIR}" describe --tags --abbrev=0 2>/dev/null || true)" + APP_VERSION="${APP_VERSION#v}" +fi + +if [[ -z "${APP_VERSION}" ]]; then + APP_VERSION="0.0.0" +fi + +require_cmd swift +require_cmd lipo +require_cmd codesign +require_cmd hdiutil +require_cmd ditto +require_cmd xcrun +require_cmd sips +require_cmd iconutil + +log "Cleaning previous artifacts" +rm -rf "${WORK_DIR}" "${DIST_DIR}" +mkdir -p "${WORK_DIR}" "${DIST_DIR}" + +log "Building release binaries (arm64 + x86_64)" +swift build --package-path "${ROOT_DIR}" -c release --arch arm64 +swift build --package-path "${ROOT_DIR}" -c release --arch x86_64 + +ARM_RELEASE_DIR="$(resolve_release_dir arm64)" +X86_RELEASE_DIR="$(resolve_release_dir x86_64)" +ARM_BINARY="${ARM_RELEASE_DIR}/${APP_NAME}" +X86_BINARY="${X86_RELEASE_DIR}/${APP_NAME}" + +if [[ ! -f "${ARM_BINARY}" ]]; then + echo "Missing arm64 binary: ${ARM_BINARY}" >&2 + exit 1 +fi + +if [[ ! -f "${X86_BINARY}" ]]; then + echo "Missing x86_64 binary: ${X86_BINARY}" >&2 + exit 1 +fi + +APP_DIR="${WORK_DIR}/${APP_NAME}.app" +mkdir -p "${APP_DIR}/Contents/MacOS" "${APP_DIR}/Contents/Resources" + +log "Creating universal executable" +lipo -create "${ARM_BINARY}" "${X86_BINARY}" -output "${APP_DIR}/Contents/MacOS/${APP_NAME}" +chmod 755 "${APP_DIR}/Contents/MacOS/${APP_NAME}" + +log "Copying SwiftPM resource bundles" +found_bundle=0 +while IFS= read -r -d '' bundle_path; do + cp -R "${bundle_path}" "${APP_DIR}/Contents/Resources/" + found_bundle=1 +done < <(find "${ARM_RELEASE_DIR}" -maxdepth 1 -type d -name "*.bundle" -print0) + +if [[ "${found_bundle}" -eq 0 ]]; then + echo "Warning: no .bundle resources found in ${ARM_RELEASE_DIR}" >&2 +fi + +if [[ -d "${PROJECT_RESOURCES_DIR}" ]]; then + log "Copying source resources from ${PROJECT_RESOURCES_DIR}" + mkdir -p "${APP_DIR}/Contents/Resources/AetherResources" + cp -R "${PROJECT_RESOURCES_DIR}/." "${APP_DIR}/Contents/Resources/AetherResources/" +fi + +log "Generating AppIcon.icns from source iconset (if available)" +generate_app_icon_icns \ + "${PROJECT_RESOURCES_DIR}/Assets.xcassets/AppIcon.appiconset" \ + "${APP_DIR}/Contents/Resources" + +log "Writing Info.plist" +cat > "${APP_DIR}/Contents/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ${APP_NAME} + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${APP_NAME} + CFBundlePackageType + APPL + CFBundleIconFile + AppIcon + CFBundleShortVersionString + ${APP_VERSION} + CFBundleVersion + ${APP_VERSION} + LSMinimumSystemVersion + ${MIN_MACOS_VERSION} + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +log "Signing app bundle" +codesign --force --timestamp --options runtime --sign "${APP_SIGN_IDENTITY}" "${APP_DIR}" +codesign --verify --deep --strict --verbose=2 "${APP_DIR}" + +if [[ "${SKIP_NOTARIZATION}" != "1" ]]; then + APP_ZIP="${WORK_DIR}/${APP_NAME}.zip" + log "Creating zip for app notarization" + ditto -c -k --keepParent "${APP_DIR}" "${APP_ZIP}" + + log "Submitting app for notarization" + xcrun notarytool submit "${APP_ZIP}" --keychain-profile "${NOTARY_PROFILE}" --wait + + log "Stapling notarization ticket to app" + xcrun stapler staple "${APP_DIR}" + xcrun stapler validate "${APP_DIR}" +fi + +log "Preparing DMG staging folder" +rm -rf "${DMG_STAGE_DIR}" +mkdir -p "${DMG_STAGE_DIR}" +cp -R "${APP_DIR}" "${DMG_STAGE_DIR}/" +ln -s /Applications "${DMG_STAGE_DIR}/Applications" + +UNSIGNED_DMG="${WORK_DIR}/${APP_NAME}.dmg" +FINAL_DMG="${DIST_DIR}/${APP_NAME}.dmg" + +log "Building DMG" +hdiutil create \ + -volname "${DMG_VOLUME_NAME}" \ + -srcfolder "${DMG_STAGE_DIR}" \ + -format UDZO \ + -fs HFS+ \ + "${UNSIGNED_DMG}" + +log "Signing DMG" +codesign --force --timestamp --sign "${APP_SIGN_IDENTITY}" "${UNSIGNED_DMG}" +codesign --verify --verbose=2 "${UNSIGNED_DMG}" + +if [[ "${SKIP_NOTARIZATION}" != "1" ]]; then + log "Submitting DMG for notarization" + xcrun notarytool submit "${UNSIGNED_DMG}" --keychain-profile "${NOTARY_PROFILE}" --wait + + log "Stapling notarization ticket to DMG" + xcrun stapler staple "${UNSIGNED_DMG}" + xcrun stapler validate "${UNSIGNED_DMG}" +fi + +mv -f "${UNSIGNED_DMG}" "${FINAL_DMG}" + +log "Done" +echo "DMG: ${FINAL_DMG}" +shasum -a 256 "${FINAL_DMG}" From e55c4e53ee50ce2e6d350f84c91888bad16cafae Mon Sep 17 00:00:00 2001 From: Roberto Nibali Date: Sun, 15 Feb 2026 13:07:23 +0100 Subject: [PATCH 2/5] Add architecture-specific builds to `build_dmg.sh` with `--arch` option Enhance `build_dmg.sh` to support building architecture-specific binaries (`universal`, `arm64`, `x86_64`) using the new `--arch` option. Update `BUILD.md` to reflect changes and introduce `APP_BUILD` for `CFBundleVersion`. --- BUILD.md | 25 +++++++++--- scripts/build_dmg.sh | 90 ++++++++++++++++++++++++++++++++------------ 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/BUILD.md b/BUILD.md index fcb0098..6eff234 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,8 +2,8 @@ This project includes `scripts/build_dmg.sh` to produce a distributable macOS DMG: -- Output: `dist/Aether.dmg` -- Includes a universal binary (`arm64` + `x86_64`) +- Output: `dist/Aether-.dmg` (`Aether-universal.dmg`, `Aether-arm64.dmg`, or `Aether-x86_64.dmg`) +- Default build target: universal (`arm64` + `x86_64`) - Signs the `.app` and `.dmg` - Notarizes and staples both (unless `--skip-notarization`) @@ -100,11 +100,18 @@ From repo root: ```bash APP_SIGN_IDENTITY="Developer ID Application: Your Name (ABCDE12345)" \ NOTARY_PROFILE="AETHER_NOTARY" \ -scripts/build_dmg.sh --bundle-id "com.yourcompany.aether" --version "1.2.1" +scripts/build_dmg.sh --bundle-id "com.yourcompany.aether" --version "1.2.1" --arch universal ``` If `--version` is omitted, the script uses the latest git tag (without leading `v`) or falls back to `0.0.0`. +By default, the script sets: + +- `CFBundleShortVersionString` from `APP_VERSION` (what macOS shows as `Version X.Y.Z`) +- `CFBundleVersion` from `APP_BUILD` (defaults to git short hash) + +So About shows `Version X.Y.Z ()` instead of duplicating the same value twice. + ## Useful script options ```bash @@ -113,8 +120,14 @@ scripts/build_dmg.sh --help - `--bundle-id `: CFBundleIdentifier in `Info.plist` - `--version `: app short/build version +- `--arch `: `universal` (default), `arm64`, or `x86_64` - `--skip-notarization`: sign only, skip notary submission/stapling +Additional env var: + +- `APP_BUILD`: explicit build info for `CFBundleVersion` (for example `a1b2c3d4` or CI build number) +- `TARGET_ARCH`: same as `--arch` + ## Resource handling in the DMG build `scripts/build_dmg.sh` includes resources from both: @@ -145,14 +158,14 @@ This uses ad-hoc signing and is not suitable for public distribution. After build: ```bash -ls -lh dist/Aether.dmg -spctl -a -vvv -t open dist/Aether.dmg +ls -lh dist/Aether-*.dmg +spctl -a -vvv -t open dist/Aether-universal.dmg ``` You can also mount and inspect: ```bash -hdiutil attach dist/Aether.dmg +hdiutil attach dist/Aether-universal.dmg ``` ## CI note diff --git a/scripts/build_dmg.sh b/scripts/build_dmg.sh index 707aa9b..9302b16 100755 --- a/scripts/build_dmg.sh +++ b/scripts/build_dmg.sh @@ -12,8 +12,10 @@ PROJECT_RESOURCES_DIR="${ROOT_DIR}/Sources/Aether/Resources" APP_SIGN_IDENTITY="${APP_SIGN_IDENTITY:-}" NOTARY_PROFILE="${NOTARY_PROFILE:-}" SKIP_NOTARIZATION="${SKIP_NOTARIZATION:-0}" +TARGET_ARCH="${TARGET_ARCH:-universal}" BUNDLE_ID="${BUNDLE_ID:-com.aether.app}" APP_VERSION="${APP_VERSION:-}" +APP_BUILD="${APP_BUILD:-}" MIN_MACOS_VERSION="${MIN_MACOS_VERSION:-14.0}" DMG_VOLUME_NAME="${DMG_VOLUME_NAME:-Aether}" @@ -21,10 +23,11 @@ usage() { cat <.dmg. Options: --version App version (default: latest git tag or 0.0.0) + --arch universal (default), arm64, x86_64 --bundle-id Bundle identifier (default: com.aether.app) --skip-notarization Skip notarization/stapling -h, --help Show this help @@ -37,6 +40,8 @@ Required unless --skip-notarization is used: Optional env vars: APP_VERSION Same as --version + APP_BUILD CFBundleVersion (default: git short hash, fallback APP_VERSION) + TARGET_ARCH Same as --arch BUNDLE_ID Same as --bundle-id MIN_MACOS_VERSION Defaults to 14.0 DMG_VOLUME_NAME Defaults to Aether @@ -49,6 +54,10 @@ while [[ $# -gt 0 ]]; do APP_VERSION="$2" shift 2 ;; + --arch) + TARGET_ARCH="$2" + shift 2 + ;; --bundle-id) BUNDLE_ID="$2" shift 2 @@ -135,6 +144,11 @@ if [[ -z "${APP_SIGN_IDENTITY}" ]]; then exit 1 fi +if [[ "${TARGET_ARCH}" != "universal" && "${TARGET_ARCH}" != "arm64" && "${TARGET_ARCH}" != "x86_64" ]]; then + echo "Invalid --arch value: ${TARGET_ARCH}. Expected one of: universal, arm64, x86_64" >&2 + exit 1 +fi + if [[ "${SKIP_NOTARIZATION}" != "1" && -z "${NOTARY_PROFILE}" ]]; then echo "NOTARY_PROFILE is required unless --skip-notarization is used." >&2 exit 1 @@ -149,6 +163,14 @@ if [[ -z "${APP_VERSION}" ]]; then APP_VERSION="0.0.0" fi +if [[ -z "${APP_BUILD}" ]]; then + APP_BUILD="$(git -C "${ROOT_DIR}" rev-parse --short=8 HEAD 2>/dev/null || true)" +fi + +if [[ -z "${APP_BUILD}" ]]; then + APP_BUILD="${APP_VERSION}" +fi + require_cmd swift require_cmd lipo require_cmd codesign @@ -162,30 +184,50 @@ log "Cleaning previous artifacts" rm -rf "${WORK_DIR}" "${DIST_DIR}" mkdir -p "${WORK_DIR}" "${DIST_DIR}" -log "Building release binaries (arm64 + x86_64)" -swift build --package-path "${ROOT_DIR}" -c release --arch arm64 -swift build --package-path "${ROOT_DIR}" -c release --arch x86_64 +log "Build metadata: version=${APP_VERSION}, build=${APP_BUILD}, arch=${TARGET_ARCH}" -ARM_RELEASE_DIR="$(resolve_release_dir arm64)" -X86_RELEASE_DIR="$(resolve_release_dir x86_64)" -ARM_BINARY="${ARM_RELEASE_DIR}/${APP_NAME}" -X86_BINARY="${X86_RELEASE_DIR}/${APP_NAME}" +APP_DIR="${WORK_DIR}/${APP_NAME}.app" +mkdir -p "${APP_DIR}/Contents/MacOS" "${APP_DIR}/Contents/Resources" -if [[ ! -f "${ARM_BINARY}" ]]; then - echo "Missing arm64 binary: ${ARM_BINARY}" >&2 - exit 1 -fi +RESOURCE_RELEASE_DIR="" +if [[ "${TARGET_ARCH}" == "universal" ]]; then + log "Building release binaries (arm64 + x86_64)" + swift build --package-path "${ROOT_DIR}" -c release --arch arm64 + swift build --package-path "${ROOT_DIR}" -c release --arch x86_64 -if [[ ! -f "${X86_BINARY}" ]]; then - echo "Missing x86_64 binary: ${X86_BINARY}" >&2 - exit 1 -fi + ARM_RELEASE_DIR="$(resolve_release_dir arm64)" + X86_RELEASE_DIR="$(resolve_release_dir x86_64)" + ARM_BINARY="${ARM_RELEASE_DIR}/${APP_NAME}" + X86_BINARY="${X86_RELEASE_DIR}/${APP_NAME}" -APP_DIR="${WORK_DIR}/${APP_NAME}.app" -mkdir -p "${APP_DIR}/Contents/MacOS" "${APP_DIR}/Contents/Resources" + if [[ ! -f "${ARM_BINARY}" ]]; then + echo "Missing arm64 binary: ${ARM_BINARY}" >&2 + exit 1 + fi + + if [[ ! -f "${X86_BINARY}" ]]; then + echo "Missing x86_64 binary: ${X86_BINARY}" >&2 + exit 1 + fi + + log "Creating universal executable" + lipo -create "${ARM_BINARY}" "${X86_BINARY}" -output "${APP_DIR}/Contents/MacOS/${APP_NAME}" + RESOURCE_RELEASE_DIR="${ARM_RELEASE_DIR}" +else + log "Building release binary (${TARGET_ARCH})" + swift build --package-path "${ROOT_DIR}" -c release --arch "${TARGET_ARCH}" + RELEASE_DIR="$(resolve_release_dir "${TARGET_ARCH}")" + ARCH_BINARY="${RELEASE_DIR}/${APP_NAME}" + + if [[ ! -f "${ARCH_BINARY}" ]]; then + echo "Missing ${TARGET_ARCH} binary: ${ARCH_BINARY}" >&2 + exit 1 + fi + + cp "${ARCH_BINARY}" "${APP_DIR}/Contents/MacOS/${APP_NAME}" + RESOURCE_RELEASE_DIR="${RELEASE_DIR}" +fi -log "Creating universal executable" -lipo -create "${ARM_BINARY}" "${X86_BINARY}" -output "${APP_DIR}/Contents/MacOS/${APP_NAME}" chmod 755 "${APP_DIR}/Contents/MacOS/${APP_NAME}" log "Copying SwiftPM resource bundles" @@ -193,10 +235,10 @@ found_bundle=0 while IFS= read -r -d '' bundle_path; do cp -R "${bundle_path}" "${APP_DIR}/Contents/Resources/" found_bundle=1 -done < <(find "${ARM_RELEASE_DIR}" -maxdepth 1 -type d -name "*.bundle" -print0) +done < <(find "${RESOURCE_RELEASE_DIR}" -maxdepth 1 -type d -name "*.bundle" -print0) if [[ "${found_bundle}" -eq 0 ]]; then - echo "Warning: no .bundle resources found in ${ARM_RELEASE_DIR}" >&2 + echo "Warning: no .bundle resources found in ${RESOURCE_RELEASE_DIR}" >&2 fi if [[ -d "${PROJECT_RESOURCES_DIR}" ]]; then @@ -233,7 +275,7 @@ cat > "${APP_DIR}/Contents/Info.plist" <CFBundleShortVersionString ${APP_VERSION} CFBundleVersion - ${APP_VERSION} + ${APP_BUILD} LSMinimumSystemVersion ${MIN_MACOS_VERSION} NSHighResolutionCapable @@ -268,7 +310,7 @@ cp -R "${APP_DIR}" "${DMG_STAGE_DIR}/" ln -s /Applications "${DMG_STAGE_DIR}/Applications" UNSIGNED_DMG="${WORK_DIR}/${APP_NAME}.dmg" -FINAL_DMG="${DIST_DIR}/${APP_NAME}.dmg" +FINAL_DMG="${DIST_DIR}/${APP_NAME}-${TARGET_ARCH}.dmg" log "Building DMG" hdiutil create \ From 9492400566de08a4f25d15b69051371e216d08ff Mon Sep 17 00:00:00 2001 From: Roberto Nibali Date: Sun, 15 Feb 2026 14:04:51 +0100 Subject: [PATCH 3/5] Update `.gitignore` to exclude `dist` directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1c2162e..c81a492 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ Build/ DerivedData/ *.app +dist # Swift Package Manager .swiftpm/ From 67630705f8c2af259af2f923d744d3301b3af314 Mon Sep 17 00:00:00 2001 From: Roberto Nibali Date: Sun, 15 Feb 2026 14:05:16 +0100 Subject: [PATCH 4/5] Expand `build_dmg.sh` with `--app-only`, `--open-app`, enhanced metadata, and build manifests --- scripts/build_dmg.sh | 145 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 10 deletions(-) diff --git a/scripts/build_dmg.sh b/scripts/build_dmg.sh index 9302b16..8f5b52e 100755 --- a/scripts/build_dmg.sh +++ b/scripts/build_dmg.sh @@ -16,23 +16,30 @@ TARGET_ARCH="${TARGET_ARCH:-universal}" BUNDLE_ID="${BUNDLE_ID:-com.aether.app}" APP_VERSION="${APP_VERSION:-}" APP_BUILD="${APP_BUILD:-}" +BUILD_TIMESTAMP="${BUILD_TIMESTAMP:-}" +BUILD_COMMIT="${BUILD_COMMIT:-}" +LICENSE_NAME="${LICENSE_NAME:-MIT License}" MIN_MACOS_VERSION="${MIN_MACOS_VERSION:-14.0}" DMG_VOLUME_NAME="${DMG_VOLUME_NAME:-Aether}" +APP_ONLY="${APP_ONLY:-0}" +OPEN_APP="${OPEN_APP:-0}" usage() { cat <.dmg. +Builds Aether.app and by default also signs/notarizes/outputs dist/Aether-.dmg. Options: --version App version (default: latest git tag or 0.0.0) --arch universal (default), arm64, x86_64 --bundle-id Bundle identifier (default: com.aether.app) + --app-only Build/sign app bundle only (no DMG/notarization) + --open-app Open resulting app bundle when done --skip-notarization Skip notarization/stapling -h, --help Show this help -Required env vars: +Required env vars (unless --app-only is used): APP_SIGN_IDENTITY Developer ID Application identity for codesign Required unless --skip-notarization is used: @@ -41,6 +48,9 @@ Required unless --skip-notarization is used: Optional env vars: APP_VERSION Same as --version APP_BUILD CFBundleVersion (default: git short hash, fallback APP_VERSION) + BUILD_TIMESTAMP ISO8601 UTC timestamp (default: current UTC time) + BUILD_COMMIT Source revision marker (default: git short hash) + LICENSE_NAME License label shown in About window (default: MIT License) TARGET_ARCH Same as --arch BUNDLE_ID Same as --bundle-id MIN_MACOS_VERSION Defaults to 14.0 @@ -62,6 +72,14 @@ while [[ $# -gt 0 ]]; do BUNDLE_ID="$2" shift 2 ;; + --app-only) + APP_ONLY=1 + shift + ;; + --open-app) + OPEN_APP=1 + shift + ;; --skip-notarization) SKIP_NOTARIZATION=1 shift @@ -139,9 +157,17 @@ resolve_release_dir() { printf '%s\n' "${path}" } +if [[ "${APP_ONLY}" == "1" ]]; then + SKIP_NOTARIZATION=1 +fi + if [[ -z "${APP_SIGN_IDENTITY}" ]]; then - echo "APP_SIGN_IDENTITY is required." >&2 - exit 1 + if [[ "${APP_ONLY}" == "1" ]]; then + APP_SIGN_IDENTITY="-" + else + echo "APP_SIGN_IDENTITY is required." >&2 + exit 1 + fi fi if [[ "${TARGET_ARCH}" != "universal" && "${TARGET_ARCH}" != "arm64" && "${TARGET_ARCH}" != "x86_64" ]]; then @@ -171,20 +197,39 @@ if [[ -z "${APP_BUILD}" ]]; then APP_BUILD="${APP_VERSION}" fi +if [[ -z "${BUILD_TIMESTAMP}" ]]; then + BUILD_TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +fi + +if [[ -z "${BUILD_COMMIT}" ]]; then + BUILD_COMMIT="$(git -C "${ROOT_DIR}" rev-parse --short=12 HEAD 2>/dev/null || true)" +fi + +if [[ -z "${BUILD_COMMIT}" ]]; then + BUILD_COMMIT="${APP_BUILD}" +fi + require_cmd swift require_cmd lipo require_cmd codesign -require_cmd hdiutil -require_cmd ditto -require_cmd xcrun require_cmd sips require_cmd iconutil +if [[ "${APP_ONLY}" != "1" ]]; then + require_cmd hdiutil +fi +if [[ "${SKIP_NOTARIZATION}" != "1" ]]; then + require_cmd ditto + require_cmd xcrun +fi +if [[ "${OPEN_APP}" == "1" ]]; then + require_cmd open +fi log "Cleaning previous artifacts" rm -rf "${WORK_DIR}" "${DIST_DIR}" mkdir -p "${WORK_DIR}" "${DIST_DIR}" -log "Build metadata: version=${APP_VERSION}, build=${APP_BUILD}, arch=${TARGET_ARCH}" +log "Build metadata: version=${APP_VERSION}, build=${APP_BUILD}, commit=${BUILD_COMMIT}, arch=${TARGET_ARCH}" APP_DIR="${WORK_DIR}/${APP_NAME}.app" mkdir -p "${APP_DIR}/Contents/MacOS" "${APP_DIR}/Contents/Resources" @@ -247,6 +292,10 @@ if [[ -d "${PROJECT_RESOURCES_DIR}" ]]; then cp -R "${PROJECT_RESOURCES_DIR}/." "${APP_DIR}/Contents/Resources/AetherResources/" fi +if [[ -f "${ROOT_DIR}/LICENSE" ]]; then + cp "${ROOT_DIR}/LICENSE" "${APP_DIR}/Contents/Resources/LICENSE.txt" +fi + log "Generating AppIcon.icns from source iconset (if available)" generate_app_icon_icns \ "${PROJECT_RESOURCES_DIR}/Assets.xcassets/AppIcon.appiconset" \ @@ -276,6 +325,14 @@ cat > "${APP_DIR}/Contents/Info.plist" <${APP_VERSION} CFBundleVersion ${APP_BUILD} + AetherBuildTimestamp + ${BUILD_TIMESTAMP} + AetherBuildTargetArch + ${TARGET_ARCH} + AetherBuildCommit + ${BUILD_COMMIT} + AetherLicense + ${LICENSE_NAME} LSMinimumSystemVersion ${MIN_MACOS_VERSION} NSHighResolutionCapable @@ -287,7 +344,12 @@ cat > "${APP_DIR}/Contents/Info.plist" < "${APP_SHA_FILE}" + +if [[ "${APP_ONLY}" == "1" ]]; then + MANIFEST_FILE="${DIST_DIR}/${APP_NAME}-${TARGET_ARCH}.build-manifest.json" + cat > "${MANIFEST_FILE}" < "${APP_SHA_FILE}" +printf '%s %s\n' "${DMG_SHA256}" "$(basename "${FINAL_DMG}")" > "${DMG_SHA_FILE}" + +cat > "${MANIFEST_FILE}" < Date: Sun, 15 Feb 2026 14:05:26 +0100 Subject: [PATCH 5/5] Add `run_app_bundle.sh` for local `.app` launching and improve build metadata/docs Introduce a helper script (`run_app_bundle.sh`) to build and run `.app` bundles locally, supporting architecture-specific builds and customizable signing options. Update `BUILD.md` to document the script, expanded metadata fields, integrity artifacts, and recommended publishing steps. --- BUILD.md | 72 +++++++++++++++++++++++++++++++++++++ scripts/run_app_bundle.sh | 74 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100755 scripts/run_app_bundle.sh diff --git a/BUILD.md b/BUILD.md index 6eff234..81c7505 100644 --- a/BUILD.md +++ b/BUILD.md @@ -6,6 +6,7 @@ This project includes `scripts/build_dmg.sh` to produce a distributable macOS DM - Default build target: universal (`arm64` + `x86_64`) - Signs the `.app` and `.dmg` - Notarizes and staples both (unless `--skip-notarization`) +- Generates checksum/manifest artifacts for verification publishing ## What "can run on any computer" means on macOS @@ -109,6 +110,10 @@ By default, the script sets: - `CFBundleShortVersionString` from `APP_VERSION` (what macOS shows as `Version X.Y.Z`) - `CFBundleVersion` from `APP_BUILD` (defaults to git short hash) +- `AetherBuildTimestamp` from current UTC time +- `AetherBuildCommit` from git short hash +- `AetherBuildTargetArch` from `--arch` +- `AetherLicense` from `LICENSE_NAME` (default `MIT License`) So About shows `Version X.Y.Z ()` instead of duplicating the same value twice. @@ -121,12 +126,32 @@ scripts/build_dmg.sh --help - `--bundle-id `: CFBundleIdentifier in `Info.plist` - `--version `: app short/build version - `--arch `: `universal` (default), `arm64`, or `x86_64` +- `--app-only`: build/sign only `dist/Aether-.app` (no DMG/notarization) +- `--open-app`: open the resulting app bundle after build - `--skip-notarization`: sign only, skip notary submission/stapling Additional env var: - `APP_BUILD`: explicit build info for `CFBundleVersion` (for example `a1b2c3d4` or CI build number) - `TARGET_ARCH`: same as `--arch` +- `BUILD_TIMESTAMP`: explicit UTC timestamp in ISO8601 format +- `BUILD_COMMIT`: explicit source marker (commit, tag, or CI revision) +- `LICENSE_NAME`: license label shown in About + +## Integrity artifacts generated + +For each build, the script writes: + +- `dist/Aether-.dmg` +- `dist/Aether-.dmg.sha256` +- `dist/Aether-.app-executable.sha256` +- `dist/Aether-.build-manifest.json` + +Recommended release publishing: + +1. Publish the DMG +2. Publish the `.dmg.sha256` and `.build-manifest.json` +3. Optionally publish the executable checksum file for in-app SHA comparison ## Resource handling in the DMG build @@ -153,12 +178,59 @@ APP_SIGN_IDENTITY="-" scripts/build_dmg.sh --skip-notarization This uses ad-hoc signing and is not suitable for public distribution. +## Run from IntelliJ as a real `.app` bundle + +`SwiftRunPackage` runs the executable directly, not from an `.app` bundle. +That is why App-menu behavior (including About integration) can differ from installed/package builds. + +Use the helper script to run the app as a bundle locally: + +```bash +scripts/run_app_bundle.sh +``` + +This defaults to your host architecture (`arm64` on Apple Silicon, `x86_64` on Intel), builds `dist/Aether-.app`, and launches it. + +To force universal: + +```bash +scripts/run_app_bundle.sh --arch universal +``` + +IntelliJ Run Configuration (recommended): + +1. Run | Edit Configurations... +2. Add New Configuration | Shell Script +3. Name: `Aether (Run App Bundle)` +4. Script path: `$PROJECT_DIR$/scripts/run_app_bundle.sh` +5. Working directory: `$PROJECT_DIR$` +6. Run + +Optional env vars in that config: + +- `APP_VERSION=1.2.1` (or your target version) + +By default, `scripts/run_app_bundle.sh` always uses ad-hoc signing (`-`) to avoid keychain/timestamp prompts. +If you need certificate signing for local runs, pass it explicitly: + +```bash +scripts/run_app_bundle.sh --sign-identity "Developer ID Application: Your Name (ABCDE12345)" +``` + +or set: + +```bash +RUN_APP_SIGN_IDENTITY="Developer ID Application: Your Name (ABCDE12345)" scripts/run_app_bundle.sh +``` + ## Verify output After build: ```bash ls -lh dist/Aether-*.dmg +cat dist/Aether-universal.dmg.sha256 +cat dist/Aether-universal.build-manifest.json spctl -a -vvv -t open dist/Aether-universal.dmg ``` diff --git a/scripts/run_app_bundle.sh b/scripts/run_app_bundle.sh new file mode 100755 index 0000000..b0d1be0 --- /dev/null +++ b/scripts/run_app_bundle.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +TARGET_ARCH="" +SIGN_IDENTITY="${RUN_APP_SIGN_IDENTITY:--}" +EXTRA_ARGS=() + +usage() { + cat <] + +Builds Aether.app as a bundle (no DMG), then launches it. + +Options: + --arch arm64, x86_64, or universal + --sign-identity Override signing identity for local run (default: -) + -h, --help Show this help + +Examples: + scripts/run_app_bundle.sh + scripts/run_app_bundle.sh --arch universal + scripts/run_app_bundle.sh --sign-identity "Developer ID Application: Your Name (TEAMID)" + scripts/run_app_bundle.sh -- --version 1.2.1 +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --arch) + TARGET_ARCH="$2" + shift 2 + ;; + --sign-identity) + SIGN_IDENTITY="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + EXTRA_ARGS+=("$@") + break + ;; + *) + EXTRA_ARGS+=("$1") + shift + ;; + esac +done + +if [[ -z "${TARGET_ARCH}" ]]; then + host_arch="$(uname -m)" + case "${host_arch}" in + arm64|x86_64) + TARGET_ARCH="${host_arch}" + ;; + *) + TARGET_ARCH="universal" + ;; + esac +fi + +cd "${ROOT_DIR}" +APP_SIGN_IDENTITY="${SIGN_IDENTITY}" \ + "${SCRIPT_DIR}/build_dmg.sh" \ + --app-only \ + --open-app \ + --arch "${TARGET_ARCH}" \ + "${EXTRA_ARGS[@]}"