diff --git a/build_tools/macos/README_MAS.md b/build_tools/macos/README_MAS.md index 79c4302cf..0cb7b2962 100644 --- a/build_tools/macos/README_MAS.md +++ b/build_tools/macos/README_MAS.md @@ -212,6 +212,51 @@ Outputs: --- +## Architecture note — arm64 vs universal (arm64+x86_64) + +For Mac App Store uploads, your `.pkg` can contain either: + +- **arm64-only** app (Apple Silicon only), or +- **universal** app (arm64 + x86_64), or +- **x86_64-only** app (runs on Apple Silicon under Rosetta 2, but native Intel only otherwise) + +Apple does **not** require universal binaries for review. **arm64-only is allowed**, but: + +- Intel Macs **cannot** run an arm64-only app. +- If you ship arm64-only, App Store Connect will effectively make the app available only to Apple Silicon Macs (and your listing will reflect that). + +### Recommendation + +- If you want the broadest compatibility, aim for a **universal build**. +- If you’re okay supporting only Apple Silicon Macs, arm64-only is the simplest path. + +### Can I upload two different `.pkg`s (one arm64, one x86_64)? + +Not in the way you want. + +- In App Store Connect you can upload multiple builds over time, but for any given version/submission you ultimately pick **one build** to submit. +- Apple will not “merge” two separate uploads (arm64-only + x86_64-only) into one app for customers. + +If you want both Apple Silicon and Intel supported **natively**, you need to produce a **single universal** app bundle and package that into **one** `.pkg`. + +If you don’t want to deal with universal yet, your practical choices are: + +- **arm64-only**: Apple Silicon only. +- **x86_64-only**: runs on Intel natively, and on Apple Silicon under **Rosetta 2** (slower, but widely compatible). + +### Practical reality for this repo + +This project depends on large native dependency stacks (Qt, GStreamer, plugins). If you build those via Homebrew, you typically end up with **single-architecture** libraries (arm64 under `/opt/homebrew`, x86_64 under `/usr/local`). + +A true universal app requires **all bundled native code** (your executable *and* all `.dylib`/plugins/frameworks you ship) to be universal as well. + +If you decide you want universal: + +- You’ll need a universal build of **Qt** and **GStreamer** (and all bundled plugins), or +- Build arm64 and x86_64 bundles separately and combine *matching* binaries where possible (advanced; easy to break signing / plugin loading). + +--- + ## Troubleshooting — `productbuild` fails with CSSM `-60008` (authorization) If you see something like: @@ -303,3 +348,74 @@ xcrun iTMSTransporter -help CLI upload requires additional credentials (App Store Connect API key or Apple ID auth) and is easier to get wrong than the Transporter GUI. For most folks, **Transporter.app is the recommended path**. +--- + +## Creating a universal Mac App Store upload using two Macs (arm64 + x86_64) + +If you have both an Apple Silicon Mac and an Intel Mac, the most reliable way to ship a universal app for this repo is: + +1. Build + deploy the **unsigned** MAS app bundle on each machine (arm64 and x86_64). +2. Copy both `.app` bundles to the machine that has your signing keys. +3. Merge them with `lipo` and then **sign + package** once, producing a single universal `.pkg`. + +### Step A — Build + deploy (arm64 machine) + +On your Apple Silicon Mac: + +```bash +./build_tools/macos/build_app.sh --release --clean --mas --deploy --build-dir ./cmake-build-macos-release-mas-arm64 +``` + +This produces (unsigned): + +- `cmake-build-macos-release-mas-arm64/strawberry.app` + +### Step B — Build + deploy (x86_64 machine) + +On your Intel Mac: + +```bash +./build_tools/macos/build_app.sh --release --clean --mas --deploy --build-dir ./cmake-build-macos-release-mas-x86_64 +``` + +This produces (unsigned): + +- `cmake-build-macos-release-mas-x86_64/strawberry.app` + +### Step C — Copy both app bundles to one “packaging” machine + +Pick the Mac that has your **Apple Distribution** and **Installer** identities (private keys) installed. +Copy both `.app` bundles onto that Mac, for example: + +- `/path/to/inputs/strawberry-arm64.app` +- `/path/to/inputs/strawberry-x86_64.app` + +Tip: `rsync` works well for app bundles: + +```bash +rsync -a "/path/to/arm64/strawberry.app" "/path/to/inputs/strawberry-arm64.app" +rsync -a "/path/to/x86_64/strawberry.app" "/path/to/inputs/strawberry-x86_64.app" +``` + +### Step D — Merge + sign + build the universal `.pkg` + +On the packaging machine: + +```bash +./build_tools/macos/build_mas_universal_pkg.sh --run \ + --arm-app "/path/to/inputs/strawberry-arm64.app" \ + --x86-app "/path/to/inputs/strawberry-x86_64.app" \ + --codesign-identity "Apple Distribution: Your Name (TEAMID)" \ + --installer-identity "3rd Party Mac Developer Installer: Your Name (TEAMID)" \ + --provisionprofile "/path/to/profile.provisionprofile" +``` + +Outputs: + +- `cmake-build-macos-release-mas-universal/strawberry.app` (universal) +- `cmake-build-macos-release-mas-universal/strawberry-mas-universal.pkg` + +### Important constraints (don’t skip) + +- The two input apps must be built from the **same commit** with the **same enabled features** so the app bundle layouts match. +- Do **not** sign the per-arch apps first; `lipo` invalidates signatures. Sign **only after** merging. diff --git a/build_tools/macos/build_mas_universal_pkg.sh b/build_tools/macos/build_mas_universal_pkg.sh new file mode 100644 index 000000000..ee9042a86 --- /dev/null +++ b/build_tools/macos/build_mas_universal_pkg.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build a universal (arm64+x86_64) Mac App Store upload package by: +# - merging two already-deployed Strawberry.app bundles (arm64 + x86_64) using lipo +# - embedding a Mac App Store provisioning profile +# - codesigning with Apple Distribution (+ entitlements) +# - producing a signed .pkg with productbuild (Installer identity) +# +# Intended workflow with two Macs: +# 1) On Apple Silicon Mac: build+deploy MAS app bundle (unsigned) → copy strawberry.app somewhere +# 2) On Intel Mac: build+deploy MAS app bundle (unsigned) → copy strawberry.app somewhere +# 3) On the Mac that holds your signing keys (either one): run THIS script to merge+sign+package + +ts() { date +"%H:%M:%S"; } + +if [[ -z "${BASH_VERSION:-}" ]]; then + echo "Error: this script must be run with bash." >&2 + exit 2 +fi +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: This script is for macOS only." >&2 + exit 1 +fi + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "${script_dir}/../.." && pwd)" + +ensure_keychain_search_list() { + local login_kc="$HOME/Library/Keychains/login.keychain-db" + local system_kc="/Library/Keychains/System.keychain" + local roots_kc="/System/Library/Keychains/SystemRootCertificates.keychain" + + local current + current="$(security list-keychains -d user 2>/dev/null | tr -d '"' | tr -d ' ' || true)" + if echo "$current" | grep -Fq "$system_kc"; then + return 0 + fi + echo "==> [$(ts)] Note: adding System keychains to the user keychain search list" + security list-keychains -d user -s "$login_kc" "$system_kc" "$roots_kc" >/dev/null 2>&1 || true +} + +prepare_login_keychain_for_signing() { + local keychain_path="$HOME/Library/Keychains/login.keychain-db" + local pw="${1:-}" + if [[ -z "$pw" ]]; then + return 0 + fi + echo "==> [$(ts)] Preparing login keychain for signing (unlock + key partition list)" + security unlock-keychain -p "$pw" "$keychain_path" >/dev/null 2>&1 || true + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$pw" "$keychain_path" >/dev/null 2>&1 || true +} + +preflight_identity() { + local what="$1" + local policy="$2" + local identity="$3" + if ! security find-identity -p "$policy" -v 2>/dev/null | grep -Fq "$identity"; then + echo "Error: ${what} identity not found/usable in Keychain: $identity" >&2 + exit 2 + fi +} + +usage() { + cat <<'EOF' +Usage: + ./build_tools/macos/build_mas_universal_pkg.sh --run [options] + +Required: + --run + --arm-app Path to arm64 Strawberry.app (already built+deployed, unsigned) + --x86-app Path to x86_64 Strawberry.app (already built+deployed, unsigned) + --codesign-identity "" Apple Distribution: ... + --installer-identity "" 3rd Party Mac Developer Installer: ... + --provisionprofile Mac App Store provisioning profile (*.provisionprofile) + +Optional: + --out-dir Output directory (default: cmake-build-macos-release-mas-universal) + --entitlements Codesign entitlements (default: dist/macos/entitlements.mas.plist) + --pkg-out Output .pkg path (default: /strawberry-mas-universal.pkg) + --bundle-id For display/logging only (does not rewrite Info.plist) + --keychain-password Or set env var STRAWBERRY_KEYCHAIN_PASSWORD (quote it!) + +Notes: + - This script does NOT build Strawberry. It merges two pre-built app bundles. + - After lipo-merging, the app must be re-signed (this script does that). +EOF +} + +do_run=0 +arm_app="" +x86_app="" +out_dir="" +codesign_identity="" +installer_identity="" +provisionprofile="" +entitlements="" +pkg_out="" +bundle_id="" +keychain_password="${STRAWBERRY_KEYCHAIN_PASSWORD:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --run) do_run=1; shift ;; + --arm-app) arm_app="${2:-}"; shift 2 ;; + --x86-app) x86_app="${2:-}"; shift 2 ;; + --out-dir) out_dir="${2:-}"; shift 2 ;; + --codesign-identity) codesign_identity="${2:-}"; shift 2 ;; + --installer-identity) installer_identity="${2:-}"; shift 2 ;; + --provisionprofile) provisionprofile="${2:-}"; shift 2 ;; + --entitlements) entitlements="${2:-}"; shift 2 ;; + --pkg-out) pkg_out="${2:-}"; shift 2 ;; + --bundle-id) bundle_id="${2:-}"; shift 2 ;; + --keychain-password) keychain_password="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown arg: $1" >&2; usage; exit 2 ;; + esac +done + +if [[ "$do_run" -eq 0 ]]; then + usage + exit 0 +fi + +if [[ -z "$arm_app" || ! -d "$arm_app" ]]; then + echo "Error: missing/invalid --arm-app: $arm_app" >&2 + exit 2 +fi +if [[ -z "$x86_app" || ! -d "$x86_app" ]]; then + echo "Error: missing/invalid --x86-app: $x86_app" >&2 + exit 2 +fi +if [[ -z "$codesign_identity" ]]; then + echo "Error: missing --codesign-identity" >&2 + exit 2 +fi +if [[ -z "$installer_identity" ]]; then + echo "Error: missing --installer-identity" >&2 + exit 2 +fi +if [[ -z "$provisionprofile" || ! -f "$provisionprofile" ]]; then + echo "Error: missing/invalid --provisionprofile: $provisionprofile" >&2 + exit 2 +fi + +if [[ -z "$entitlements" ]]; then + entitlements="${repo_root}/dist/macos/entitlements.mas.plist" +fi +if [[ ! -f "$entitlements" ]]; then + echo "Error: entitlements file not found: $entitlements" >&2 + exit 2 +fi + +if [[ -z "$out_dir" ]]; then + out_dir="${repo_root}/cmake-build-macos-release-mas-universal" +fi +mkdir -p "$out_dir" + +universal_app="${out_dir}/strawberry.app" +if [[ -e "$universal_app" ]]; then + rm -rf "$universal_app" +fi + +echo "==> [$(ts)] Repo: $repo_root" +echo "==> [$(ts)] arm app: $arm_app" +echo "==> [$(ts)] x86 app: $x86_app" +echo "==> [$(ts)] out dir: $out_dir" +if [[ -n "$bundle_id" ]]; then + echo "==> [$(ts)] bundle id (expected): $bundle_id" +fi + +echo "==> [$(ts)] Creating universal app bundle (lipo merge)" +"${repo_root}/build_tools/macos/make_universal_app.sh" \ + --arm-app "$arm_app" \ + --x86-app "$x86_app" \ + --out-app "$universal_app" \ + --clean + +echo "==> [$(ts)] Embedding provisioning profile" +cp -f "$provisionprofile" "${universal_app}/Contents/embedded.provisionprofile" + +ensure_keychain_search_list +prepare_login_keychain_for_signing "$keychain_password" +preflight_identity "codesign" "codesigning" "$codesign_identity" +preflight_identity "installer" "basic" "$installer_identity" + +echo "==> [$(ts)] Codesigning universal app (Mac App Store)" +codesign_args=( --force --timestamp --options runtime --sign "$codesign_identity" --entitlements "$entitlements" ) + +# Clean up any leftover codesign temp files and xattrs. +find "$universal_app" -name "*.cstemp" -print0 2>/dev/null | while IFS= read -r -d '' f; do rm -f "$f" || true; done +xattr -dr com.apple.provenance "$universal_app" >/dev/null 2>&1 || true +xattr -dr com.apple.quarantine "$universal_app" >/dev/null 2>&1 || true + +# Sign nested code first, then frameworks, then the main app bundle. +find "$universal_app" -type f \( -name "*.dylib" -o -name "*.so" -o -perm -111 \) \ + ! -name "*.cstemp" \ + ! -path "*/Contents/Frameworks/*.framework/*" \ + ! -path "*/Contents/Frameworks/*.app/*" \ + ! -path "*/Contents/Frameworks/*.xpc/*" \ + ! -path "*/Contents/PlugIns/*.framework/*" \ + ! -path "*/Contents/PlugIns/*.app/*" \ + ! -path "*/Contents/PlugIns/*.xpc/*" \ + -print0 | while IFS= read -r -d '' f; do + if /usr/bin/file -b "$f" | grep -q "Mach-O"; then + codesign "${codesign_args[@]}" "$f" >/dev/null + fi + done + +find "$universal_app" -type d \( -name "*.xpc" -o -name "*.app" \) -print0 2>/dev/null | while IFS= read -r -d '' b; do + codesign "${codesign_args[@]}" "$b" >/dev/null +done + +find "$universal_app/Contents/Frameworks" "$universal_app/Contents/PlugIns" -type d -name "*.framework" -print0 2>/dev/null | while IFS= read -r -d '' fw; do + codesign "${codesign_args[@]}" "$fw" >/dev/null +done + +codesign "${codesign_args[@]}" "$universal_app" >/dev/null + +echo "==> [$(ts)] Verifying codesign" +codesign --verify --deep --strict --verbose=2 "$universal_app" + +if [[ -z "$pkg_out" ]]; then + pkg_out="${out_dir}/strawberry-mas-universal.pkg" +fi +rm -f "$pkg_out" >/dev/null 2>&1 || true + +echo "==> [$(ts)] Building signed .pkg for App Store upload" +productbuild \ + --component "$universal_app" /Applications \ + --sign "$installer_identity" \ + "$pkg_out" + +echo "==> [$(ts)] Verifying pkg signature" +pkgutil --check-signature "$pkg_out" || true + +echo +echo "Done." +echo "Universal app: $universal_app" +echo "PKG: $pkg_out" + diff --git a/build_tools/macos/make_universal_app.sh b/build_tools/macos/make_universal_app.sh new file mode 100644 index 000000000..3098b663b --- /dev/null +++ b/build_tools/macos/make_universal_app.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Create a universal (arm64+x86_64) .app by merging two already-deployed app bundles +# that have identical layouts, one built on Apple Silicon and one built on Intel. +# +# Usage: +# ./build_tools/macos/make_universal_app.sh \ +# --arm-app /path/to/arm64/strawberry.app \ +# --x86-app /path/to/x86_64/strawberry.app \ +# --out-app /path/to/output/strawberry.app \ +# [--clean] +# +# Notes: +# - Do NOT sign the per-arch apps first; signatures will be invalidated by lipo anyway. +# - Both inputs must be the same app version/config with the same enabled features, +# so the file lists match. + +ts() { date +"%H:%M:%S"; } + +usage() { + cat <<'EOF' +Usage: + ./build_tools/macos/make_universal_app.sh --arm-app --x86-app --out-app [--clean] + +What it does: + - Copies the arm64 app to --out-app + - For every Mach-O file in the copied app, finds the corresponding file in the x86_64 app + - Uses lipo to combine the two slices into a universal binary at the same relative path + +Required: + --arm-app Path to arm64 Strawberry.app (built+deployed on Apple Silicon) + --x86-app Path to x86_64 Strawberry.app (built+deployed on Intel) + --out-app Output path for universal Strawberry.app + +Optional: + --clean Delete --out-app if it already exists +EOF +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: This script is for macOS only." >&2 + exit 1 +fi + +arm_app="" +x86_app="" +out_app="" +do_clean=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --arm-app) arm_app="${2:-}"; shift 2 ;; + --x86-app) x86_app="${2:-}"; shift 2 ;; + --out-app) out_app="${2:-}"; shift 2 ;; + --clean) do_clean=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown arg: $1" >&2; usage; exit 2 ;; + esac +done + +if [[ -z "$arm_app" || -z "$x86_app" || -z "$out_app" ]]; then + echo "Error: missing required args." >&2 + usage + exit 2 +fi +if [[ ! -d "$arm_app" ]]; then + echo "Error: --arm-app not found: $arm_app" >&2 + exit 2 +fi +if [[ ! -d "$x86_app" ]]; then + echo "Error: --x86-app not found: $x86_app" >&2 + exit 2 +fi + +for cmd in /usr/bin/file /usr/bin/lipo /usr/bin/ditto; do + if [[ ! -x "$cmd" ]]; then + echo "Error: required tool not found: $cmd" >&2 + exit 1 + fi +done + +out_parent="$(cd -- "$(dirname -- "$out_app")" && pwd)" +out_name="$(basename -- "$out_app")" +out_app="${out_parent}/${out_name}" + +if [[ -e "$out_app" && "$do_clean" -eq 1 ]]; then + echo "==> [$(ts)] Removing existing output app: $out_app" + rm -rf "$out_app" +fi +if [[ -e "$out_app" ]]; then + echo "Error: output already exists: $out_app (use --clean to overwrite)" >&2 + exit 2 +fi + +echo "==> [$(ts)] Copying arm64 app to output" +/usr/bin/ditto "$arm_app" "$out_app" + +# Remove any existing signatures in the copied app; we'll re-sign after creating universal binaries. +echo "==> [$(ts)] Removing existing code signature metadata (will be re-signed later)" +find "$out_app" -type d -name "_CodeSignature" -print0 2>/dev/null | while IFS= read -r -d '' d; do + rm -rf "$d" || true +done + +echo "==> [$(ts)] Merging Mach-O files with lipo" + +merged=0 +skipped=0 + +# Traverse output app and lipo-merge any Mach-O file with its counterpart in the x86 app. +while IFS= read -r -d '' f; do + # Only operate on regular files. + if [[ ! -f "$f" ]]; then + continue + fi + + ft="$(/usr/bin/file -b "$f" 2>/dev/null || true)" + if [[ "$ft" != *"Mach-O"* ]]; then + skipped=$((skipped + 1)) + continue + fi + + rel="${f#"$out_app"/}" + other="${x86_app}/${rel}" + if [[ ! -f "$other" ]]; then + echo "Error: missing matching file in x86 app for:" >&2 + echo " $rel" >&2 + echo "Expected at:" >&2 + echo " $other" >&2 + exit 1 + fi + + other_ft="$(/usr/bin/file -b "$other" 2>/dev/null || true)" + if [[ "$other_ft" != *"Mach-O"* ]]; then + echo "Error: file is Mach-O in arm app but not Mach-O in x86 app:" >&2 + echo " $rel" >&2 + echo "arm64: $ft" >&2 + echo "x86_64: $other_ft" >&2 + exit 1 + fi + + # Validate architectures. + arm_archs="$(/usr/bin/lipo -archs "$f" 2>/dev/null || true)" + x86_archs="$(/usr/bin/lipo -archs "$other" 2>/dev/null || true)" + if [[ "$arm_archs" != *"arm64"* ]]; then + echo "Error: expected arm64 slice in arm app file:" >&2 + echo " $rel" >&2 + echo " archs: $arm_archs" >&2 + exit 1 + fi + if [[ "$x86_archs" != *"x86_64"* ]]; then + echo "Error: expected x86_64 slice in x86 app file:" >&2 + echo " $rel" >&2 + echo " archs: $x86_archs" >&2 + exit 1 + fi + + tmp="$(mktemp -t strawberry-universal.XXXXXX)" + /usr/bin/lipo -create "$f" "$other" -output "$tmp" + chmod --reference="$f" "$tmp" 2>/dev/null || true + mv -f "$tmp" "$f" + merged=$((merged + 1)) +done < <(find "$out_app" -type f -print0 2>/dev/null) + +echo "==> [$(ts)] Done" +echo "Merged Mach-O files: $merged" +echo "Non-Mach-O files skipped: $skipped" +echo "Output app:" +echo " $out_app" +