diff --git a/macos-spm-app-packaging/SKILL.md b/macos-spm-app-packaging/SKILL.md new file mode 100644 index 0000000..be3c0f1 --- /dev/null +++ b/macos-spm-app-packaging/SKILL.md @@ -0,0 +1,38 @@ +--- +name: macos-spm-app-packaging +description: Scaffold, build, and package SwiftPM-based macOS apps without an Xcode project. Use when you need a from-scratch macOS app layout, SwiftPM targets/resources, a custom .app bundle assembly script, or signing/notarization/appcast steps outside Xcode. +--- + +# macOS SwiftPM App Packaging (No Xcode) + +## Overview +Bootstrap a complete SwiftPM macOS app folder, then build, package, and run it without Xcode. Use `assets/templates/bootstrap/` for the starter layout and `references/packaging.md` + `references/release.md` for packaging and release details. + +## Two-Step Workflow +1) Bootstrap the project folder + - Copy `assets/templates/bootstrap/` into a new repo. + - Rename `MyApp` in `Package.swift`, `Sources/MyApp/`, and `version.env`. + - Customize `APP_NAME`, `BUNDLE_ID`, and versions. + +2) Build, package, and run the bootstrapped app + - Copy scripts from `assets/templates/` into your repo (for example, `Scripts/`). + - Build/tests: `swift build` and `swift test`. + - Package: `Scripts/package_app.sh`. + - Run: `Scripts/compile_and_run.sh` (preferred) or `Scripts/launch.sh`. + - Release (optional): `Scripts/sign-and-notarize.sh` and `Scripts/make_appcast.sh`. + +## Templates +- `assets/templates/package_app.sh`: Build binaries, create the .app bundle, copy resources, sign. +- `assets/templates/compile_and_run.sh`: Dev loop to kill running app, package, launch. +- `assets/templates/build_icon.sh`: Generate .icns from an Icon Composer file (requires Xcode install). +- `assets/templates/sign-and-notarize.sh`: Notarize, staple, and zip a release build. +- `assets/templates/make_appcast.sh`: Generate Sparkle appcast entries for updates. +- `assets/templates/setup_dev_signing.sh`: Create a stable dev code-signing identity. +- `assets/templates/launch.sh`: Simple launcher for a packaged .app. +- `assets/templates/version.env`: Example version file consumed by packaging scripts. +- `assets/templates/bootstrap/`: Minimal SwiftPM macOS app skeleton (Package.swift, Sources/, version.env). + +## Notes +- Keep entitlements and signing configuration explicit; edit the template scripts instead of reimplementing. +- Remove Sparkle steps if you do not use Sparkle for updates. +- For menu bar apps, set `MENU_BAR_APP=1` when packaging to emit `LSUIElement` in Info.plist. diff --git a/macos-spm-app-packaging/assets/templates/bootstrap/Package.swift b/macos-spm-app-packaging/assets/templates/bootstrap/Package.swift new file mode 100644 index 0000000..3207b8e --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/bootstrap/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "MyApp", + platforms: [ + .macOS(.v14), + ], + targets: [ + .executableTarget( + name: "MyApp", + path: "Sources/MyApp", + resources: [ + .process("Resources"), + ]) + ] +) diff --git a/macos-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/Resources/.keep b/macos-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/Resources/.keep new file mode 100644 index 0000000..e69de29 diff --git a/macos-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/main.swift b/macos-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/main.swift new file mode 100644 index 0000000..e317016 --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/main.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + Text("Hello from MyApp") + .padding() + } + } +} diff --git a/macos-spm-app-packaging/assets/templates/bootstrap/version.env b/macos-spm-app-packaging/assets/templates/bootstrap/version.env new file mode 100644 index 0000000..6c5435e --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/bootstrap/version.env @@ -0,0 +1,2 @@ +MARKETING_VERSION=0.1.0 +BUILD_NUMBER=1 diff --git a/macos-spm-app-packaging/assets/templates/build_icon.sh b/macos-spm-app-packaging/assets/templates/build_icon.sh new file mode 100644 index 0000000..464c485 --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/build_icon.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ICON_FILE=${1:-Icon.icon} +BASENAME=${2:-Icon} +OUT_ROOT=${3:-build/icon} +XCODE_APP=${XCODE_APP:-/Applications/Xcode.app} + +ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/ictool" +if [[ ! -x "$ICTOOL" ]]; then + ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/icontool" +fi +if [[ ! -x "$ICTOOL" ]]; then + echo "ictool/icontool not found. Set XCODE_APP if Xcode is elsewhere." >&2 + exit 1 +fi + +ICONSET_DIR="$OUT_ROOT/${BASENAME}.iconset" +TMP_DIR="$OUT_ROOT/tmp" +mkdir -p "$ICONSET_DIR" "$TMP_DIR" + +MASTER_ART="$TMP_DIR/icon_art_824.png" +MASTER_1024="$TMP_DIR/icon_1024.png" + +# Render inner art (no margin) with macOS Default appearance. +"$ICTOOL" "$ICON_FILE" \ + --export-preview macOS Default 824 824 1 -45 "$MASTER_ART" + +# Pad to 1024x1024 with transparent border. +sips --padToHeightWidth 1024 1024 "$MASTER_ART" --out "$MASTER_1024" >/dev/null + +# Generate required sizes. +sizes=(16 32 64 128 256 512 1024) +for sz in "${sizes[@]}"; do + out="$ICONSET_DIR/icon_${sz}x${sz}.png" + sips -z "$sz" "$sz" "$MASTER_1024" --out "$out" >/dev/null + if [[ "$sz" -ne 1024 ]]; then + dbl=$((sz*2)) + out2="$ICONSET_DIR/icon_${sz}x${sz}@2x.png" + sips -z "$dbl" "$dbl" "$MASTER_1024" --out "$out2" >/dev/null + fi +done + +# 512x512@2x already covered by 1024; ensure it exists. +cp "$MASTER_1024" "$ICONSET_DIR/icon_512x512@2x.png" + +iconutil -c icns "$ICONSET_DIR" -o Icon.icns + +echo "Icon.icns generated at $(pwd)/Icon.icns" diff --git a/macos-spm-app-packaging/assets/templates/compile_and_run.sh b/macos-spm-app-packaging/assets/templates/compile_and_run.sh new file mode 100644 index 0000000..8bec4bc --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/compile_and_run.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Kill running instances, package, relaunch, verify. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_NAME=${APP_NAME:-MyApp} +APP_BUNDLE="${ROOT_DIR}/${APP_NAME}.app" +APP_PROCESS_PATTERN="${APP_NAME}.app/Contents/MacOS/${APP_NAME}" +DEBUG_PROCESS_PATTERN="${ROOT_DIR}/.build/debug/${APP_NAME}" +RELEASE_PROCESS_PATTERN="${ROOT_DIR}/.build/release/${APP_NAME}" +RUN_TESTS=0 +RELEASE_ARCHES="" + +log() { printf '%s\n' "$*"; } +fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +for arg in "$@"; do + case "${arg}" in + --test|-t) RUN_TESTS=1 ;; + --release-universal) RELEASE_ARCHES="arm64 x86_64" ;; + --release-arches=*) RELEASE_ARCHES="${arg#*=}" ;; + --help|-h) + log "Usage: $(basename "$0") [--test] [--release-universal] [--release-arches=\"arm64 x86_64\"]" + exit 0 + ;; + esac +done + +log "==> Killing existing ${APP_NAME} instances" +pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true +pkill -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true +pkill -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true +pkill -x "${APP_NAME}" 2>/dev/null || true + +if [[ "${RUN_TESTS}" == "1" ]]; then + log "==> swift test" + swift test -q +fi + +HOST_ARCH="$(uname -m)" +ARCHES_VALUE="${HOST_ARCH}" +if [[ -n "${RELEASE_ARCHES}" ]]; then + ARCHES_VALUE="${RELEASE_ARCHES}" +fi + +log "==> package app" +SIGNING_MODE=adhoc ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" release + +log "==> launch app" +if ! open "${APP_BUNDLE}"; then + log "WARN: open failed; launching binary directly." + "${APP_BUNDLE}/Contents/MacOS/${APP_NAME}" >/dev/null 2>&1 & + disown +fi + +for _ in {1..10}; do + if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then + log "OK: ${APP_NAME} is running." + exit 0 + fi + sleep 0.4 +done +fail "App exited immediately. Check crash logs in Console.app (User Reports)." diff --git a/macos-spm-app-packaging/assets/templates/launch.sh b/macos-spm-app-packaging/assets/templates/launch.sh new file mode 100644 index 0000000..92af4e1 --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/launch.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_NAME=${APP_NAME:-MyApp} +APP_PATH="$PROJECT_ROOT/${APP_NAME}.app" + +echo "==> Killing existing ${APP_NAME} instances" +pkill -x "$APP_NAME" || pkill -f "${APP_NAME}.app" || true +sleep 0.5 + +if [[ ! -d "$APP_PATH" ]]; then + echo "ERROR: ${APP_NAME}.app not found at $APP_PATH" + echo "Run ./Scripts/package_app.sh first to build the app" + exit 1 +fi + +echo "==> Launching ${APP_NAME} from $APP_PATH" +open -n "$APP_PATH" + +sleep 1 +if pgrep -x "$APP_NAME" > /dev/null; then + echo "OK: ${APP_NAME} is running." +else + echo "ERROR: App exited immediately. Check crash logs in Console.app (User Reports)." + exit 1 +fi diff --git a/macos-spm-app-packaging/assets/templates/make_appcast.sh b/macos-spm-app-packaging/assets/templates/make_appcast.sh new file mode 100644 index 0000000..1dce6d9 --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/make_appcast.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +ZIP=${1:? +"Usage: $0 MyApp-.zip"} +FEED_URL=${2:-"https://example.com/appcast.xml"} +PRIVATE_KEY_FILE=${SPARKLE_PRIVATE_KEY_FILE:-} +if [[ -z "$PRIVATE_KEY_FILE" ]]; then + echo "Set SPARKLE_PRIVATE_KEY_FILE to your ed25519 private key (Sparkle)." >&2 + exit 1 +fi +if [[ ! -f "$ZIP" ]]; then + echo "Zip not found: $ZIP" >&2 + exit 1 +fi + +ZIP_DIR=$(cd "$(dirname "$ZIP")" && pwd) +ZIP_NAME=$(basename "$ZIP") +ZIP_BASE="${ZIP_NAME%.zip}" +VERSION=${SPARKLE_RELEASE_VERSION:-} +if [[ -z "$VERSION" ]]; then + if [[ "$ZIP_NAME" =~ ^[^-]+-([0-9]+(\.[0-9]+){1,2}([-.][^.]*)?)\.zip$ ]]; then + VERSION="${BASH_REMATCH[1]}" + else + echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2 + exit 1 + fi +fi + +NOTES_HTML="${ZIP_DIR}/${ZIP_BASE}.html" +KEEP_NOTES=${KEEP_SPARKLE_NOTES:-0} +if [[ -x "$ROOT/Scripts/changelog-to-html.sh" ]]; then + "$ROOT/Scripts/changelog-to-html.sh" "$VERSION" >"$NOTES_HTML" +else + cat >"$NOTES_HTML" < + + +${ZIP_BASE} + +

${ZIP_BASE}

+

Release notes not provided.

+ + +HTML +fi +cleanup() { + if [[ -n "${WORK_DIR:-}" ]]; then + rm -rf "$WORK_DIR" + fi + if [[ "$KEEP_NOTES" != "1" ]]; then + rm -f "$NOTES_HTML" + fi +} +trap cleanup EXIT + +DOWNLOAD_URL_PREFIX=${SPARKLE_DOWNLOAD_URL_PREFIX:-"https://example.com/downloads/v${VERSION}/"} + +if ! command -v generate_appcast >/dev/null; then + echo "generate_appcast not found in PATH. Install Sparkle tools." >&2 + exit 1 +fi + +WORK_DIR=$(mktemp -d /tmp/appcast.XXXXXX) + +cp "$ROOT/appcast.xml" "$WORK_DIR/appcast.xml" +cp "$ZIP" "$WORK_DIR/$ZIP_NAME" +cp "$NOTES_HTML" "$WORK_DIR/$ZIP_BASE.html" + +pushd "$WORK_DIR" >/dev/null +generate_appcast \ + --ed-key-file "$PRIVATE_KEY_FILE" \ + --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ + --embed-release-notes \ + --link "$FEED_URL" \ + "$WORK_DIR" +popd >/dev/null + +cp "$WORK_DIR/appcast.xml" "$ROOT/appcast.xml" + +echo "Appcast generated (appcast.xml). Upload alongside $ZIP at $FEED_URL" diff --git a/macos-spm-app-packaging/assets/templates/package_app.sh b/macos-spm-app-packaging/assets/templates/package_app.sh new file mode 100644 index 0000000..1175cbc --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/package_app.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONF=${1:-release} +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +APP_NAME=${APP_NAME:-MyApp} +BUNDLE_ID=${BUNDLE_ID:-com.example.myapp} +MACOS_MIN_VERSION=${MACOS_MIN_VERSION:-14.0} +MENU_BAR_APP=${MENU_BAR_APP:-0} +SIGNING_MODE=${SIGNING_MODE:-} +APP_IDENTITY=${APP_IDENTITY:-} + +if [[ -f "$ROOT/version.env" ]]; then + source "$ROOT/version.env" +else + MARKETING_VERSION=${MARKETING_VERSION:-0.1.0} + BUILD_NUMBER=${BUILD_NUMBER:-1} +fi + +ARCH_LIST=( ${ARCHES:-} ) +if [[ ${#ARCH_LIST[@]} -eq 0 ]]; then + HOST_ARCH=$(uname -m) + ARCH_LIST=("$HOST_ARCH") +fi + +for ARCH in "${ARCH_LIST[@]}"; do + swift build -c "$CONF" --arch "$ARCH" +done + +APP="$ROOT/${APP_NAME}.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks" + +# Convert Icon.icon to Icon.icns if present (requires iconutil). +ICON_SOURCE="$ROOT/Icon.icon" +ICON_TARGET="$ROOT/Icon.icns" +if [[ -f "$ICON_SOURCE" ]]; then + iconutil --convert icns --output "$ICON_TARGET" "$ICON_SOURCE" +fi + +LSUI_VALUE="false" +if [[ "$MENU_BAR_APP" == "1" ]]; then + LSUI_VALUE="true" +fi + +BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName${APP_NAME} + CFBundleDisplayName${APP_NAME} + CFBundleIdentifier${BUNDLE_ID} + CFBundleExecutable${APP_NAME} + CFBundlePackageTypeAPPL + CFBundleShortVersionString${MARKETING_VERSION} + CFBundleVersion${BUILD_NUMBER} + LSMinimumSystemVersion${MACOS_MIN_VERSION} + LSUIElement<${LSUI_VALUE}/> + CFBundleIconFileIcon + BuildTimestamp${BUILD_TIMESTAMP} + GitCommit${GIT_COMMIT} + + +PLIST + +build_product_path() { + local name="$1" + local arch="$2" + case "$arch" in + arm64|x86_64) echo ".build/${arch}-apple-macosx/$CONF/$name" ;; + *) echo ".build/$CONF/$name" ;; + esac +} + +verify_binary_arches() { + local binary="$1"; shift + local expected=("$@") + local actual + actual=$(lipo -archs "$binary") + local actual_count expected_count + actual_count=$(wc -w <<<"$actual" | tr -d ' ') + expected_count=${#expected[@]} + if [[ "$actual_count" -ne "$expected_count" ]]; then + echo "ERROR: $binary arch mismatch (expected: ${expected[*]}, actual: ${actual})" >&2 + exit 1 + fi + for arch in "${expected[@]}"; do + if [[ "$actual" != *"$arch"* ]]; then + echo "ERROR: $binary missing arch $arch (have: ${actual})" >&2 + exit 1 + fi + done +} + +install_binary() { + local name="$1" + local dest="$2" + local binaries=() + for arch in "${ARCH_LIST[@]}"; do + local src + src=$(build_product_path "$name" "$arch") + if [[ ! -f "$src" ]]; then + echo "ERROR: Missing ${name} build for ${arch} at ${src}" >&2 + exit 1 + fi + binaries+=("$src") + done + if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then + lipo -create "${binaries[@]}" -output "$dest" + else + cp "${binaries[0]}" "$dest" + fi + chmod +x "$dest" + verify_binary_arches "$dest" "${ARCH_LIST[@]}" +} + +install_binary "$APP_NAME" "$APP/Contents/MacOS/$APP_NAME" + +# Bundle app resources (if any). +APP_RESOURCES_DIR="$ROOT/Sources/$APP_NAME/Resources" +if [[ -d "$APP_RESOURCES_DIR" ]]; then + cp -R "$APP_RESOURCES_DIR/." "$APP/Contents/Resources/" +fi + +# SwiftPM resource bundles are emitted next to the built binary. +PREFERRED_BUILD_DIR="$(dirname "$(build_product_path "$APP_NAME" "${ARCH_LIST[0]}")")" +shopt -s nullglob +SWIFTPM_BUNDLES=("${PREFERRED_BUILD_DIR}/"*.bundle) +shopt -u nullglob +if [[ ${#SWIFTPM_BUNDLES[@]} -gt 0 ]]; then + for bundle in "${SWIFTPM_BUNDLES[@]}"; do + cp -R "$bundle" "$APP/Contents/Resources/" + done +fi + +# Embed frameworks if any exist in the build folder. +FRAMEWORK_DIRS=(".build/$CONF" ".build/${ARCH_LIST[0]}-apple-macosx/$CONF") +for dir in "${FRAMEWORK_DIRS[@]}"; do + if compgen -G "${dir}/*.framework" >/dev/null; then + cp -R "${dir}/"*.framework "$APP/Contents/Frameworks/" + chmod -R a+rX "$APP/Contents/Frameworks" + install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/$APP_NAME" + break + fi +done + +if [[ -f "$ICON_TARGET" ]]; then + cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns" +fi + +# Ensure contents are writable before stripping attributes and signing. +chmod -R u+w "$APP" + +# Strip extended attributes to prevent AppleDouble files that break code sealing. +xattr -cr "$APP" +find "$APP" -name '._*' -delete + +ENTITLEMENTS_DIR="$ROOT/.build/entitlements" +DEFAULT_ENTITLEMENTS="$ENTITLEMENTS_DIR/${APP_NAME}.entitlements" +mkdir -p "$ENTITLEMENTS_DIR" + +APP_ENTITLEMENTS=${APP_ENTITLEMENTS:-$DEFAULT_ENTITLEMENTS} +if [[ ! -f "$APP_ENTITLEMENTS" ]]; then + cat > "$APP_ENTITLEMENTS" < + + + + + + +PLIST +fi + +if [[ "$SIGNING_MODE" == "adhoc" || -z "$APP_IDENTITY" ]]; then + CODESIGN_ARGS=(--force --sign "-") +else + CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$APP_IDENTITY") +fi + +# Sign embedded frameworks and their nested binaries before the app bundle. +sign_frameworks() { + local fw + for fw in "$APP/Contents/Frameworks/"*.framework; do + if [[ ! -d "$fw" ]]; then + continue + fi + while IFS= read -r -d '' bin; do + codesign "${CODESIGN_ARGS[@]}" "$bin" + done < <(find "$fw" -type f -perm -111 -print0) + codesign "${CODESIGN_ARGS[@]}" "$fw" + done +} +sign_frameworks + +codesign "${CODESIGN_ARGS[@]}" \ + --entitlements "$APP_ENTITLEMENTS" \ + "$APP" + +echo "Created $APP" diff --git a/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh b/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh new file mode 100644 index 0000000..42014d1 --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Setup stable development code signing to reduce keychain prompts. +set -euo pipefail + +APP_NAME=${APP_NAME:-MyApp} +CERT_NAME="${APP_NAME} Development" + +if security find-certificate -c "$CERT_NAME" >/dev/null 2>&1; then + echo "Certificate '$CERT_NAME' already exists." + echo "Export this in your shell profile:" + echo " export APP_IDENTITY='$CERT_NAME'" + exit 0 +fi + +echo "Creating self-signed certificate '$CERT_NAME'..." + +TEMP_CONFIG=$(mktemp) +trap "rm -f $TEMP_CONFIG" EXIT + +cat > "$TEMP_CONFIG" </dev/null + +openssl pkcs12 -export -out /tmp/dev.p12 \ + -inkey /tmp/dev.key -in /tmp/dev.crt \ + -passout pass: 2>/dev/null + +security import /tmp/dev.p12 -k ~/Library/Keychains/login.keychain-db \ + -T /usr/bin/codesign -T /usr/bin/security + +rm -f /tmp/dev.{key,crt,p12} + +echo "" +echo "Trust this certificate for code signing in Keychain Access." +echo "Then export in your shell profile:" +echo " export APP_IDENTITY='$CERT_NAME'" diff --git a/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh b/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh new file mode 100644 index 0000000..2e74bbe --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME=${APP_NAME:-MyApp} +APP_IDENTITY=${APP_IDENTITY:-"Developer ID Application: Example (TEAMID)"} +APP_BUNDLE="${APP_NAME}.app" +ROOT=$(cd "$(dirname "$0")/.." && pwd) +source "$ROOT/version.env" +ZIP_NAME="${APP_NAME}-${MARKETING_VERSION}.zip" + +if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-}" || -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then + echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2 + exit 1 +fi + +echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/app-store-connect-key.p8 +trap 'rm -f /tmp/app-store-connect-key.p8 /tmp/${APP_NAME}Notarize.zip' EXIT + +ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} +ARCH_LIST=( ${ARCHES_VALUE} ) +for ARCH in "${ARCH_LIST[@]}"; do + swift build -c release --arch "$ARCH" +done +ARCHES="${ARCHES_VALUE}" "$ROOT/Scripts/package_app.sh" release + +ENTITLEMENTS_DIR="$ROOT/.build/entitlements" +APP_ENTITLEMENTS="${APP_ENTITLEMENTS:-${ENTITLEMENTS_DIR}/${APP_NAME}.entitlements}" + +codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \ + --entitlements "$APP_ENTITLEMENTS" \ + "$APP_BUNDLE" + +DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto} +"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "/tmp/${APP_NAME}Notarize.zip" + +xcrun notarytool submit "/tmp/${APP_NAME}Notarize.zip" \ + --key /tmp/app-store-connect-key.p8 \ + --key-id "$APP_STORE_CONNECT_KEY_ID" \ + --issuer "$APP_STORE_CONNECT_ISSUER_ID" \ + --wait + +xcrun stapler staple "$APP_BUNDLE" + +xattr -cr "$APP_BUNDLE" +find "$APP_BUNDLE" -name '._*' -delete + +"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$ZIP_NAME" + +spctl -a -t exec -vv "$APP_BUNDLE" +stapler validate "$APP_BUNDLE" + +echo "Done: $ZIP_NAME" diff --git a/macos-spm-app-packaging/assets/templates/version.env b/macos-spm-app-packaging/assets/templates/version.env new file mode 100644 index 0000000..6c5435e --- /dev/null +++ b/macos-spm-app-packaging/assets/templates/version.env @@ -0,0 +1,2 @@ +MARKETING_VERSION=0.1.0 +BUILD_NUMBER=1 diff --git a/macos-spm-app-packaging/references/packaging.md b/macos-spm-app-packaging/references/packaging.md new file mode 100644 index 0000000..acaf317 --- /dev/null +++ b/macos-spm-app-packaging/references/packaging.md @@ -0,0 +1,17 @@ +# Packaging notes + +## Build output paths +SwiftPM places binaries under: +- `.build/-apple-macosx//` for arch-specific builds +- `.build//` for some products (frameworks/tools) + +Use `ARCHES="arm64 x86_64"` with `swift build` to produce universal binaries. + +## Common environment variables (used by templates) +- `APP_NAME`: App/binary name (for example, `MyApp`). +- `BUNDLE_ID`: Bundle identifier (for example, `com.example.myapp`). +- `ARCHES`: Space-separated architectures (default: host arch). +- `SIGNING_MODE`: `adhoc` to avoid keychain prompts in dev. +- `APP_IDENTITY`: Codesigning identity name for release builds. +- `MACOS_MIN_VERSION`: Minimum macOS version for Info.plist. +- `MENU_BAR_APP`: Set to `1` to add `LSUIElement` to Info.plist. diff --git a/macos-spm-app-packaging/references/release.md b/macos-spm-app-packaging/references/release.md new file mode 100644 index 0000000..cbcd127 --- /dev/null +++ b/macos-spm-app-packaging/references/release.md @@ -0,0 +1,14 @@ +# Release and notarization notes + +## Notarization requirements +- Install Xcode Command Line Tools (for `xcrun` and `notarytool`). +- Provide App Store Connect API credentials: + - `APP_STORE_CONNECT_API_KEY_P8` + - `APP_STORE_CONNECT_KEY_ID` + - `APP_STORE_CONNECT_ISSUER_ID` +- Provide a Developer ID Application identity in `APP_IDENTITY`. + +## Sparkle appcast (optional) +- Install Sparkle tools so `generate_appcast` is on PATH. +- Provide `SPARKLE_PRIVATE_KEY_FILE` (ed25519 key). +- The appcast script uses your zip artifact to create an updated `appcast.xml`. diff --git a/macos-spm-app-packaging/references/scaffold.md b/macos-spm-app-packaging/references/scaffold.md new file mode 100644 index 0000000..1eee900 --- /dev/null +++ b/macos-spm-app-packaging/references/scaffold.md @@ -0,0 +1,79 @@ +# Scaffold a SwiftPM macOS app (no Xcode) + +## Steps +1) Create a repo and initialize SwiftPM: +``` +mkdir MyApp +cd MyApp +swift package init --type executable +``` + +2) Update `Package.swift` to target macOS and define an executable target for the app. + +3) Create the app entry point under `Sources/MyApp/`. +- Use SwiftUI if you want a windowed app with minimal AppKit glue. +- Use AppKit if you want a menu bar or accessory-style app. + +4) If you need app resources, add: +``` +resources: [.process("Resources")] +``` +and create `Sources/MyApp/Resources/`. + +5) Add a `version.env` file (used by packaging templates): +``` +MARKETING_VERSION=0.1.0 +BUILD_NUMBER=1 +``` + +6) Copy script templates from `assets/templates/` into your repo (for example, `Scripts/`). + +## Minimal Package.swift (example) +``` +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "MyApp", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget( + name: "MyApp", + path: "Sources/MyApp", + resources: [ + .process("Resources") + ]) + ] +) +``` + +## Minimal SwiftUI entry point (example) +``` +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + Text("Hello") + } + } +} +``` + +## Minimal AppKit entry point (example) +``` +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + // Initialize app state here. + } +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.run() +```