diff --git a/macos/Info.plist b/macos/Info.plist
index 4b4c816..37b4a7e 100644
--- a/macos/Info.plist
+++ b/macos/Info.plist
@@ -5,17 +5,21 @@
CFBundleExecutable
yr_crystals
CFBundleIdentifier
- org.else-if.yrcrystals
+ org.else-if.yrxtals
CFBundleName
- Yr Xtals
+ YrXtals
CFBundleDisplayName
Yr Xtals
CFBundlePackageType
APPL
+ CFBundleSupportedPlatforms
+
+ MacOSX
+
CFBundleVersion
- 0.1.0
+ 1.0.0
CFBundleShortVersionString
- 0.1.0
+ 1.0.0
LSMinimumSystemVersion
14.0
LSApplicationCategoryType
@@ -28,6 +32,8 @@
NSSupportsSuddenTermination
+ ITSAppUsesNonExemptEncryption
+
NSMicrophoneUsageDescription
Yr Xtals does not record audio. This entry is here only because the audio framework asks for it on some macOS versions.
diff --git a/scripts/macos/generate-icons.sh b/scripts/macos/generate-icons.sh
new file mode 100755
index 0000000..680ee49
--- /dev/null
+++ b/scripts/macos/generate-icons.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+SVG="$ROOT/assets/Icon.svg"
+OUT="${1:-$ROOT/build/AppIcon.icns}"
+
+if [ ! -f "$SVG" ]; then
+ echo "ERROR: $SVG not found" >&2
+ exit 1
+fi
+
+if ! command -v rsvg-convert >/dev/null 2>&1; then
+ echo "ERROR: rsvg-convert not on PATH (brew install librsvg)" >&2
+ exit 1
+fi
+
+if ! command -v iconutil >/dev/null 2>&1; then
+ echo "ERROR: iconutil not on PATH (macOS only)" >&2
+ exit 1
+fi
+
+WORK_SVG="$(mktemp -t icon-svg.XXXXXX)"
+ICONSET="$(mktemp -d -t iconset.XXXXXX)/AppIcon.iconset"
+mkdir -p "$ICONSET"
+trap 'rm -rf "$WORK_SVG" "$(dirname "$ICONSET")"' EXIT
+
+# pulls the gradient labeled #bg-color and paints it across the whole viewBox under the existing artwork.
+BG_FLAG=()
+if grep -q 'id="bg-color"' "$SVG"; then
+ VIEWBOX="$(grep -oE 'viewBox="[^"]+"' "$SVG" | head -1 | sed -E 's/viewBox="//;s/"$//')"
+ read -r VBX VBY VBW VBH <<<"$VIEWBOX"
+ INJECT=""
+ awk -v inj="$INJECT" '/<\/defs>/ {print; print inj; next} {print}' "$SVG" > "$WORK_SVG"
+ echo "Injected #bg-color full-canvas fill"
+else
+ cp "$SVG" "$WORK_SVG"
+ BG_FLAG=(--background-color="${YRXTALS_ICON_BG:-black}")
+ echo "No #bg-color id; falling back to solid ${YRXTALS_ICON_BG:-black}"
+fi
+
+ICNS_SIZES=(
+ "16x16 16"
+ "16x16@2x 32"
+ "32x32 32"
+ "32x32@2x 64"
+ "128x128 128"
+ "128x128@2x 256"
+ "256x256 256"
+ "256x256@2x 512"
+ "512x512 512"
+ "512x512@2x 1024"
+)
+
+for entry in "${ICNS_SIZES[@]}"; do
+ name="${entry%% *}"
+ size="${entry##* }"
+ rsvg-convert "${BG_FLAG[@]}" --width="$size" --height="$size" "$WORK_SVG" -o "$ICONSET/icon_${name}.png"
+done
+
+mkdir -p "$(dirname "$OUT")"
+iconutil -c icns "$ICONSET" -o "$OUT"
+
+echo "Wrote $OUT"
diff --git a/scripts/macos/release.sh b/scripts/macos/release.sh
new file mode 100755
index 0000000..f1032b7
--- /dev/null
+++ b/scripts/macos/release.sh
@@ -0,0 +1,206 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+case "$(uname -s)" in
+ Darwin) ;;
+ *) echo "wrong platform: $(uname -s), macOS release requires macOS" >&2; exit 1;;
+esac
+
+CARGO_PROFILE="release"
+
+PROFILE="${YRXTALS_MACOS_APPSTORE_PROFILE:-/Volumes/External/prvProfiles/Yr_Xtals_MacOS.provisionprofile}"
+if [ ! -f "$PROFILE" ]; then
+ echo "ERROR: Mac App Store provisioning profile not found at $PROFILE" >&2
+ echo " set YRXTALS_MACOS_APPSTORE_PROFILE to override" >&2
+ exit 1
+fi
+
+# pins an exact VER=x.y.z when set; otherwise the post-build BUMP step handles the next.
+if [ -n "${VER:-}" ]; then
+ if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "ERROR: VER must be x.y.z (got '$VER')" >&2
+ exit 1
+ fi
+ /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VER" "$ROOT/macos/Info.plist"
+ /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VER" "$ROOT/macos/Info.plist"
+ echo "Pinned macos/Info.plist version to $VER"
+fi
+
+BUILD="$ROOT/build"
+APP="$BUILD/macos-release/Yr Xtals.app"
+CONTENTS="$APP/Contents"
+MACOS_DIR="$CONTENTS/MacOS"
+RESOURCES="$CONTENTS/Resources"
+PKG="$BUILD/macos-release/YrXtals.pkg"
+
+export MACOSX_DEPLOYMENT_TARGET=14.0
+
+echo "Building Rust binary (profile=$CARGO_PROFILE)..."
+cargo build --profile "$CARGO_PROFILE" --bin yr_crystals
+
+BIN="/tmp/yr_crystals-target/$CARGO_PROFILE/yr_crystals"
+if [ ! -f "$BIN" ]; then
+ echo "ERROR: yr_crystals binary not found at $BIN" >&2
+ exit 1
+fi
+
+rm -rf "$BUILD/macos-release"
+mkdir -p "$MACOS_DIR" "$RESOURCES"
+
+bash "$ROOT/scripts/macos/generate-icons.sh" "$BUILD/macos-release/AppIcon.icns"
+cp "$BUILD/macos-release/AppIcon.icns" "$RESOURCES/AppIcon.icns"
+
+cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist"
+cp "$BIN" "$MACOS_DIR/yr_crystals"
+chmod +x "$MACOS_DIR/yr_crystals"
+
+plutil -replace CFBundleSupportedPlatforms -json '["MacOSX"]' "$CONTENTS/Info.plist"
+
+# injects the DT* and BuildMachineOSBuild keys App Store validation demands.
+SDK_VER="$(xcrun --sdk macosx --show-sdk-version)"
+SDK_BUILD="$(xcrun --sdk macosx --show-sdk-build-version)"
+XCODE_VER="$(xcodebuild -version | head -1 | awk '{print $2}')"
+XCODE_BUILD="$(xcodebuild -version | grep 'Build version' | awk '{print $3}')"
+IFS='.' read -r XV_MAJ XV_MIN XV_PAT <<< "$XCODE_VER"
+XV_MAJ="${XV_MAJ:-0}"; XV_MIN="${XV_MIN:-0}"; XV_PAT="${XV_PAT:-0}"
+DT_XCODE="$((XV_MAJ * 100 + XV_MIN * 10 + XV_PAT))"
+OS_BUILD="$(sw_vers -buildVersion)"
+
+set_or_add() {
+ local key="$1" val="$2"
+ /usr/libexec/PlistBuddy -c "Set :$key $val" "$CONTENTS/Info.plist" 2>/dev/null \
+ || /usr/libexec/PlistBuddy -c "Add :$key string $val" "$CONTENTS/Info.plist"
+}
+set_or_add BuildMachineOSBuild "$OS_BUILD"
+set_or_add DTCompiler com.apple.compilers.llvm.clang.1_0
+set_or_add DTPlatformBuild "$SDK_BUILD"
+set_or_add DTPlatformName macosx
+set_or_add DTPlatformVersion "$SDK_VER"
+set_or_add DTSDKBuild "$SDK_BUILD"
+set_or_add DTSDKName "macosx${SDK_VER}"
+set_or_add DTXcode "$DT_XCODE"
+set_or_add DTXcodeBuild "$XCODE_BUILD"
+
+cp "$PROFILE" "$CONTENTS/embedded.provisionprofile"
+
+ENT="$BUILD/macos-release/entitlements.plist"
+security cms -D -i "$PROFILE" 2>/dev/null \
+ | plutil -extract Entitlements xml1 -o "$ENT" - \
+ || { echo "ERROR: could not extract entitlements from profile" >&2; exit 1; }
+
+# pins wildcard application-identifier to the concrete TEAM.bundle.id form.
+BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$CONTENTS/Info.plist")"
+TEAM_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.developer.team-identifier" "$ENT" 2>/dev/null \
+ || /usr/libexec/PlistBuddy -c "Print :application-identifier" "$ENT" | cut -d. -f1)"
+case "$(/usr/libexec/PlistBuddy -c "Print :application-identifier" "$ENT")" in
+ *\*) /usr/libexec/PlistBuddy -c "Set :application-identifier ${TEAM_ID}.${BUNDLE_ID}" "$ENT" ;;
+esac
+
+# Mac App Store mandates the app-sandbox entitlement.
+/usr/libexec/PlistBuddy -c "Set :com.apple.security.app-sandbox true" "$ENT" 2>/dev/null \
+ || /usr/libexec/PlistBuddy -c "Add :com.apple.security.app-sandbox bool true" "$ENT"
+/usr/libexec/PlistBuddy -c "Set :com.apple.security.files.user-selected.read-only true" "$ENT" 2>/dev/null \
+ || /usr/libexec/PlistBuddy -c "Add :com.apple.security.files.user-selected.read-only bool true" "$ENT"
+/usr/libexec/PlistBuddy -c "Set :get-task-allow false" "$ENT" 2>/dev/null \
+ || /usr/libexec/PlistBuddy -c "Add :get-task-allow bool false" "$ENT"
+
+# locates an Apple Distribution / Mac App Distribution identity by SHA-matching the profile's certs.
+TMPDIR_PROF="$(mktemp -d)"
+PROFILE_PLIST="$TMPDIR_PROF/profile.plist"
+security cms -D -i "$PROFILE" > "$PROFILE_PLIST" 2>/dev/null
+
+PROFILE_SHAS=""
+for i in 0 1 2 3 4 5 6 7 8 9; do
+ if ! plutil -extract "DeveloperCertificates.$i" raw -o "$TMPDIR_PROF/c$i.b64" "$PROFILE_PLIST" >/dev/null 2>&1; then
+ break
+ fi
+ base64 -D -i "$TMPDIR_PROF/c$i.b64" -o "$TMPDIR_PROF/c$i.cer"
+ sha=$(openssl x509 -inform der -in "$TMPDIR_PROF/c$i.cer" -fingerprint -noout 2>/dev/null \
+ | sed 's/.*=//;s/://g')
+ PROFILE_SHAS="$PROFILE_SHAS $sha"
+done
+
+KEYCHAIN_SHAS=$(security find-identity -v -p codesigning 2>/dev/null \
+ | awk '/[0-9A-F]{40}/ {gsub(/[^0-9A-F]/, "", $2); print $2}')
+
+APP_IDENTITY=""
+for s in $PROFILE_SHAS; do
+ if echo "$KEYCHAIN_SHAS" | grep -qi "^$s$"; then
+ APP_IDENTITY="$s"
+ break
+ fi
+done
+rm -rf "$TMPDIR_PROF"
+
+if [ -z "$APP_IDENTITY" ]; then
+ echo "ERROR: no Apple/Mac App Distribution identity in keychain matches the profile's certs" >&2
+ echo " profile certs:$PROFILE_SHAS" >&2
+ exit 1
+fi
+
+INSTALLER_IDENTITY="$(security find-identity -v 2>/dev/null \
+ | grep -E 'Mac Installer Distribution|3rd Party Mac Developer Installer' \
+ | head -1 | awk '{print $2}')"
+if [ -z "$INSTALLER_IDENTITY" ]; then
+ echo "ERROR: no 'Mac Installer Distribution' identity in keychain — required to sign the .pkg" >&2
+ exit 1
+fi
+
+echo "Signing app with $APP_IDENTITY..."
+codesign --force --deep \
+ --sign "$APP_IDENTITY" \
+ --entitlements "$ENT" \
+ --options runtime \
+ --timestamp \
+ "$APP"
+
+codesign --verify --deep --strict --verbose=2 "$APP"
+
+echo "Building .pkg with $INSTALLER_IDENTITY..."
+productbuild --component "$APP" /Applications --sign "$INSTALLER_IDENTITY" "$PKG"
+
+SHIPPED_VER="$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$CONTENTS/Info.plist")"
+
+# resolves BUMP from --bump flag, BUMP env var, or fallback default.
+EXPLICIT_BUMP=""
+for arg in "$@"; do
+ case "$arg" in
+ --bump) EXPLICIT_BUMP=patch ;;
+ --bump=*) EXPLICIT_BUMP="${arg#--bump=}" ;;
+ esac
+done
+if [ -n "$EXPLICIT_BUMP" ]; then
+ BUMP="$EXPLICIT_BUMP"
+elif [ -z "${BUMP:-}" ]; then
+ if [ -n "${VER:-}" ]; then
+ BUMP=none
+ else
+ BUMP=patch
+ fi
+fi
+case "$BUMP" in
+ none)
+ echo "Skipping version bump (BUMP=none)"
+ ;;
+ major|minor|patch)
+ IFS='.' read -r CUR_MAJ CUR_MIN CUR_PATCH <<< "$SHIPPED_VER"
+ CUR_MAJ="${CUR_MAJ:-0}"; CUR_MIN="${CUR_MIN:-0}"; CUR_PATCH="${CUR_PATCH:-0}"
+ case "$BUMP" in
+ major) NEW_VER="$((CUR_MAJ + 1)).0.0" ;;
+ minor) NEW_VER="$CUR_MAJ.$((CUR_MIN + 1)).0" ;;
+ patch) NEW_VER="$CUR_MAJ.$CUR_MIN.$((CUR_PATCH + 1))" ;;
+ esac
+ /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $NEW_VER" "$ROOT/macos/Info.plist"
+ /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $NEW_VER" "$ROOT/macos/Info.plist"
+ echo "Bumped macos/Info.plist: $SHIPPED_VER -> $NEW_VER ($BUMP) for next release"
+ ;;
+ *)
+ echo "ERROR: BUMP must be patch|minor|major|none, got '$BUMP'" >&2
+ exit 1
+ ;;
+esac
+
+echo "Built: $PKG ($BUNDLE_ID @ $SHIPPED_VER)"
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index 28ce2be..6e9bb36 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -118,6 +118,7 @@ fn print_help() {
eprintln!(" generate-icons-android rasterize assets/Icon.svg into android mipmap buckets");
eprintln!(" xcodeproj-ios generate ios/YrXtals.xcodeproj via xcodegen");
eprintln!(" release-ios build an App Store-signed .ipa for Transporter");
+ eprintln!(" release-macos build a Mac App Store-signed .pkg for Transporter");
eprintln!(" clean wipe build/, target/, /tmp/yr_crystals-target");
eprintln!(" clean-ios wipe iOS build dirs + generated assets (--cargo also runs cargo clean)");
eprintln!(" clean-android wipe android build dirs + generated assets (--cargo also runs cargo clean)");