mirror of
https://github.com/Dimillian/Skills.git
synced 2026-03-25 08:55:54 +00:00
Add macOS SwiftPM app packaging templates and docs
Introduce a set of templates, scripts, and documentation for scaffolding, building, packaging, signing, and notarizing SwiftPM-based macOS apps without Xcode. Includes bootstrap project skeleton, build and packaging scripts, signing/notarization helpers, and reference guides for setup, packaging, and release workflows.
This commit is contained in:
parent
b6d69af073
commit
f07d884c74
16 changed files with 712 additions and 0 deletions
38
macos-spm-app-packaging/SKILL.md
Normal file
38
macos-spm-app-packaging/SKILL.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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"),
|
||||||
|
])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MyApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
Text("Hello from MyApp")
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
MARKETING_VERSION=0.1.0
|
||||||
|
BUILD_NUMBER=1
|
||||||
49
macos-spm-app-packaging/assets/templates/build_icon.sh
Normal file
49
macos-spm-app-packaging/assets/templates/build_icon.sh
Normal file
|
|
@ -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"
|
||||||
63
macos-spm-app-packaging/assets/templates/compile_and_run.sh
Normal file
63
macos-spm-app-packaging/assets/templates/compile_and_run.sh
Normal file
|
|
@ -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)."
|
||||||
28
macos-spm-app-packaging/assets/templates/launch.sh
Normal file
28
macos-spm-app-packaging/assets/templates/launch.sh
Normal file
|
|
@ -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
|
||||||
82
macos-spm-app-packaging/assets/templates/make_appcast.sh
Normal file
82
macos-spm-app-packaging/assets/templates/make_appcast.sh
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
ZIP=${1:?
|
||||||
|
"Usage: $0 MyApp-<ver>.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" <<HTML
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${ZIP_BASE}</title>
|
||||||
|
<body>
|
||||||
|
<h2>${ZIP_BASE}</h2>
|
||||||
|
<p>Release notes not provided.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
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"
|
||||||
206
macos-spm-app-packaging/assets/templates/package_app.sh
Normal file
206
macos-spm-app-packaging/assets/templates/package_app.sh
Normal file
|
|
@ -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" <<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>CFBundleName</key><string>${APP_NAME}</string>
|
||||||
|
<key>CFBundleDisplayName</key><string>${APP_NAME}</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
|
||||||
|
<key>CFBundleExecutable</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>
|
||||||
|
<key>LSMinimumSystemVersion</key><string>${MACOS_MIN_VERSION}</string>
|
||||||
|
<key>LSUIElement</key><${LSUI_VALUE}/>
|
||||||
|
<key>CFBundleIconFile</key><string>Icon</string>
|
||||||
|
<key>BuildTimestamp</key><string>${BUILD_TIMESTAMP}</string>
|
||||||
|
<key>GitCommit</key><string>${GIT_COMMIT}</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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
|
||||||
|
<?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>
|
||||||
|
<!-- Add entitlements here if needed. -->
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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"
|
||||||
|
|
@ -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" <<EOFCONF
|
||||||
|
[ req ]
|
||||||
|
distinguished_name = req_distinguished_name
|
||||||
|
x509_extensions = v3_req
|
||||||
|
prompt = no
|
||||||
|
|
||||||
|
[ req_distinguished_name ]
|
||||||
|
CN = $CERT_NAME
|
||||||
|
O = ${APP_NAME} Development
|
||||||
|
C = US
|
||||||
|
|
||||||
|
[ v3_req ]
|
||||||
|
keyUsage = critical,digitalSignature
|
||||||
|
extendedKeyUsage = codeSigning
|
||||||
|
EOFCONF
|
||||||
|
|
||||||
|
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \
|
||||||
|
-nodes -keyout /tmp/dev.key -out /tmp/dev.crt \
|
||||||
|
-config "$TEMP_CONFIG" 2>/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'"
|
||||||
|
|
@ -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"
|
||||||
2
macos-spm-app-packaging/assets/templates/version.env
Normal file
2
macos-spm-app-packaging/assets/templates/version.env
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
MARKETING_VERSION=0.1.0
|
||||||
|
BUILD_NUMBER=1
|
||||||
17
macos-spm-app-packaging/references/packaging.md
Normal file
17
macos-spm-app-packaging/references/packaging.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Packaging notes
|
||||||
|
|
||||||
|
## Build output paths
|
||||||
|
SwiftPM places binaries under:
|
||||||
|
- `.build/<arch>-apple-macosx/<config>/<AppName>` for arch-specific builds
|
||||||
|
- `.build/<config>/<AppName>` 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.
|
||||||
14
macos-spm-app-packaging/references/release.md
Normal file
14
macos-spm-app-packaging/references/release.md
Normal file
|
|
@ -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`.
|
||||||
79
macos-spm-app-packaging/references/scaffold.md
Normal file
79
macos-spm-app-packaging/references/scaffold.md
Normal file
|
|
@ -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()
|
||||||
|
```
|
||||||
Loading…
Reference in a new issue