#!/usr/bin/env bash # # build-macappstore-pkg-macos.sh - Build a Mac App Store upload package. # # This script uses the App Store sandbox build path, signs the app with an # Apple-issued distribution identity, embeds the Mac App Store provisioning # profile, then packages the app as a signed .pkg for App Store Connect upload. # # Required environment: # PRIVATEVOICE_APPSTORE_APP_IDENTITY # Example: "Apple Distribution: Example Team (TEAMID)" # Legacy: "3rd Party Mac Developer Application: Example Team (TEAMID)" # # PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY # Example: "3rd Party Mac Developer Installer: Example Team (TEAMID)" # # PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE # Path to the Mac App Store provisioning profile for com.shanghai3168.privatevoicedictation. # # Optional environment: # PRIVATEVOICE_SIGNING_KEYCHAIN # PRIVATEVOICE_APPSTORE_MIN_SYSTEM_VERSION (default: 13.4) # PRIVATEVOICE_MACAPPSTORE_BUILD_ROOT (default: build/macappstore) # PRIVATEVOICE_APP_VERSION (default: build/darwin/Info.plist CFBundleShortVersionString) # PRIVATEVOICE_APP_BUILD (default: build/darwin/Info.plist CFBundleVersion) # # Usage: # cd privatevoice.src # PRIVATEVOICE_APPSTORE_APP_IDENTITY="Apple Distribution: ..." \ # PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: ..." \ # PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE="/path/to/profile.provisionprofile" \ # ./scripts/build-macappstore-pkg-macos.sh arm64 # set -euo pipefail APP_NAME="PrivateVoice Dictation" APPSTORE_APP_IDENTITY="${PRIVATEVOICE_APPSTORE_APP_IDENTITY:-}" APPSTORE_INSTALLER_IDENTITY="${PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY:-}" APPSTORE_PROVISIONING_PROFILE="${PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE:-}" SIGNING_KEYCHAIN="${PRIVATEVOICE_SIGNING_KEYCHAIN:-}" APPSTORE_MIN_SYSTEM_VERSION="${PRIVATEVOICE_APPSTORE_MIN_SYSTEM_VERSION:-13.4}" APPSTORE_APP_SIGN_IDENTITY="" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" MACAPPSTORE_BUILD_ROOT="${PRIVATEVOICE_MACAPPSTORE_BUILD_ROOT:-$PROJECT_DIR/build/macappstore}" INFO_PLIST="$PROJECT_DIR/build/darwin/Info.plist" BASE_ENTITLEMENTS="$PROJECT_DIR/build/darwin/entitlements-appstore.plist" PRIVACY_MANIFEST="$PROJECT_DIR/build/darwin/PrivacyInfo.xcprivacy" GOMODCACHE="$(go env GOMODCACHE)" SHERPA_MACOS_MODULE_VERSION="$(cd "$PROJECT_DIR" && go list -m -f '{{.Version}}' github.com/k2-fsa/sherpa-onnx-go-macos)" VERSION="${PRIVATEVOICE_APP_VERSION:-$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$INFO_PLIST" 2>/dev/null || echo "2.1.0")}" BUILD_ID="${PRIVATEVOICE_APP_BUILD:-$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$INFO_PLIST" 2>/dev/null || date +%Y%m%d.%H%M)}" BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$INFO_PLIST" 2>/dev/null || echo "")" step() { echo "" echo "==> $1" } fail() { echo "ERROR: $1" >&2 exit 1 } security_find_code_identities() { if [[ -n "$SIGNING_KEYCHAIN" ]]; then security find-identity -v -p codesigning "$SIGNING_KEYCHAIN" else security find-identity -v -p codesigning fi } security_find_installer_identities() { # Installer identities are not codesigning identities; do not filter with -p. if [[ -n "$SIGNING_KEYCHAIN" ]]; then security find-identity -v "$SIGNING_KEYCHAIN" else security find-identity -v fi } normalize_sha1() { tr '[:lower:]' '[:upper:]' | tr -d ':[:space:]' } is_sha1_fingerprint() { [[ "$1" =~ ^[0-9A-Fa-f]{40}$ ]] } resolve_codesigning_identity_sha1() { local requested="$1" local requested_sha1="" local matches=() local line hash name if is_sha1_fingerprint "$requested"; then requested_sha1="$(printf "%s" "$requested" | normalize_sha1)" fi while IFS= read -r line; do hash="$(awk '{print $2}' <<<"$line")" [[ "$hash" =~ ^[0-9A-Fa-f]{40}$ ]] || continue name="${line#*\"}" name="${name%\"*}" if [[ -n "$requested_sha1" ]]; then [[ "$(printf "%s" "$hash" | normalize_sha1)" == "$requested_sha1" ]] && matches+=("$(printf "%s" "$hash" | normalize_sha1)") elif [[ "$name" == "$requested" ]]; then matches+=("$(printf "%s" "$hash" | normalize_sha1)") fi done < <(security_find_code_identities) case "${#matches[@]}" in 0) fail "App Store app signing identity not found: $requested" ;; 1) printf "%s\n" "${matches[0]}" ;; *) fail "App Store app signing identity is ambiguous: $requested. Use the 40-character SHA1 fingerprint." ;; esac } profile_developer_certificate_sha1s() { local profile_plist="$1" local index=0 local cert_der cert_sha1 local found=0 while :; do cert_der="$(mktemp)" if ! /usr/libexec/PlistBuddy -c "Print :DeveloperCertificates:$index" "$profile_plist" >"$cert_der" 2>/dev/null; then rm -f "$cert_der" break fi cert_sha1="$(openssl x509 -inform der -in "$cert_der" -noout -fingerprint -sha1 | sed 's/^.*=//' | normalize_sha1)" rm -f "$cert_der" [[ -n "$cert_sha1" ]] || fail "Unable to read DeveloperCertificates:$index SHA1 from provisioning profile" printf "%s\n" "$cert_sha1" found=1 index=$((index + 1)) done [[ "$found" -eq 1 ]] || fail "Provisioning profile has no DeveloperCertificates" } resolve_arch() { local target="$1" case "$target" in arm64) GOARCH="arm64" SHERPA_ARCH="aarch64-apple-darwin" OUT_ARCH="arm64" ;; x86_64|amd64) GOARCH="amd64" SHERPA_ARCH="x86_64-apple-darwin" OUT_ARCH="x86_64" ;; *) fail "Unknown architecture: $target (use arm64 or x86_64)" ;; esac BUILD_DIR="$MACAPPSTORE_BUILD_ROOT/$OUT_ARCH" APP_BUNDLE="$BUILD_DIR/$APP_NAME.app" PKG_PATH="$BUILD_DIR/PrivateVoice-Dictation-$VERSION-build$BUILD_ID-$OUT_ARCH-macappstore.pkg" SHERPA_LIB_DIR="$GOMODCACHE/github.com/k2-fsa/sherpa-onnx-go-macos@$SHERPA_MACOS_MODULE_VERSION/lib/$SHERPA_ARCH" ONNXRUNTIME_DYLIB="" ONNXRUNTIME_DYLIB_NAME="" } sign_plain() { local item="$1" codesign --force \ --options runtime \ --timestamp \ --sign "$APPSTORE_APP_SIGN_IDENTITY" \ "$item" } sign_sandboxed() { local item="$1" codesign --force \ --options runtime \ --timestamp \ --entitlements "$SIGNING_ENTITLEMENTS" \ --sign "$APPSTORE_APP_SIGN_IDENTITY" \ "$item" } fix_binary_rpaths() { local binary="$1" # The sherpa-onnx macOS module injects its cache lib directory as an LC_RPATH # ahead of bundle Frameworks. MAS candidates must load bundled dylibs. install_name_tool -delete_rpath "$SHERPA_LIB_DIR" "$binary" 2>/dev/null || true install_name_tool -add_rpath @executable_path/../Frameworks "$binary" 2>/dev/null || true } plist_set_string() { local plist="$1" local key="$2" local value="$3" /usr/libexec/PlistBuddy -c "Delete :$key" "$plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :$key string $value" "$plist" } strip_extended_attributes() { local item="$1" if command -v xattr >/dev/null; then chmod -R u+rwX "$item" xattr -cr "$item" fi } assert_no_quarantine_attributes() { local item="$1" if command -v xattr >/dev/null; then local found found="$(xattr -lr "$item" 2>/dev/null | grep -F "com.apple.quarantine" | head -n 20 || true)" [[ -z "$found" ]] || fail "com.apple.quarantine attributes remain under $item: $found" fi } remove_appledouble_files() { local item="$1" find "$item" \( -name '._*' -o -name '.DS_Store' \) -delete } assert_no_appledouble_files() { local item="$1" local found found="$(find "$item" \( -name '._*' -o -name '.DS_Store' \) -print | head -n 20 || true)" [[ -z "$found" ]] || fail "AppleDouble or Finder metadata files remain under $item: $found" } assert_pkg_no_quarantine_attributes() { local pkg="$1" local expanded local found expanded="$(mktemp -d)" pkgutil --expand-full "$pkg" "$expanded/pkg" >/dev/null found="$(xattr -lr "$expanded/pkg" 2>/dev/null | grep -F "com.apple.quarantine" | head -n 20 || true)" rm -rf "$expanded" [[ -z "$found" ]] || fail "com.apple.quarantine attributes remain in package: $found" } TARGET_ARCH="${1:-$(uname -m)}" resolve_arch "$TARGET_ARCH" step "Preflight checks" [[ -n "$APPSTORE_APP_IDENTITY" ]] || fail "PRIVATEVOICE_APPSTORE_APP_IDENTITY is required" [[ -n "$APPSTORE_INSTALLER_IDENTITY" ]] || fail "PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY is required" [[ -n "$APPSTORE_PROVISIONING_PROFILE" ]] || fail "PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE is required" [[ -f "$APPSTORE_PROVISIONING_PROFILE" ]] || fail "Provisioning profile not found: $APPSTORE_PROVISIONING_PROFILE" [[ -f "$INFO_PLIST" ]] || fail "Info.plist not found at $INFO_PLIST" [[ -n "$BUNDLE_ID" ]] || fail "CFBundleIdentifier is missing from $INFO_PLIST" [[ -f "$BASE_ENTITLEMENTS" ]] || fail "App Store entitlements not found at $BASE_ENTITLEMENTS" [[ -f "$PRIVACY_MANIFEST" ]] || fail "PrivacyInfo.xcprivacy not found at $PRIVACY_MANIFEST" [[ -f "$PROJECT_DIR/build/darwin/icon.icns" ]] || fail "icon.icns not found" [[ -d "$SHERPA_LIB_DIR" ]] || fail "sherpa-onnx dylibs not found at $SHERPA_LIB_DIR" ONNXRUNTIME_DYLIB="$(find "$SHERPA_LIB_DIR" -maxdepth 1 -type f -name 'libonnxruntime.*.dylib' | sort | tail -n 1)" [[ -f "$ONNXRUNTIME_DYLIB" ]] || fail "onnxruntime dylib not found in $SHERPA_LIB_DIR" ONNXRUNTIME_DYLIB_NAME="$(basename "$ONNXRUNTIME_DYLIB")" APPSTORE_APP_SIGN_IDENTITY="$(resolve_codesigning_identity_sha1 "$APPSTORE_APP_IDENTITY")" security_find_installer_identities | grep -Fq "$APPSTORE_INSTALLER_IDENTITY" || fail "App Store installer signing identity not found: $APPSTORE_INSTALLER_IDENTITY" command -v productbuild >/dev/null || fail "productbuild not found" command -v pkgutil >/dev/null || fail "pkgutil not found" PROFILE_PLIST="$(mktemp)" SIGNING_ENTITLEMENTS="$(mktemp)" trap 'rm -f "$PROFILE_PLIST" "$SIGNING_ENTITLEMENTS"' EXIT security cms -D -i "$APPSTORE_PROVISIONING_PROFILE" >"$PROFILE_PLIST" || fail "Unable to decode provisioning profile: $APPSTORE_PROVISIONING_PROFILE" PROFILE_APP_IDENTIFIER="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.application-identifier" "$PROFILE_PLIST" 2>/dev/null || true)" PROFILE_TEAM_IDENTIFIER="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.team-identifier" "$PROFILE_PLIST" 2>/dev/null || true)" PROFILE_KEYCHAIN_GROUP="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:keychain-access-groups:0" "$PROFILE_PLIST" 2>/dev/null || true)" PROFILE_DEVELOPER_CERT_SHA1S="$(profile_developer_certificate_sha1s "$PROFILE_PLIST")" PROFILE_BUNDLE_ID="${PROFILE_APP_IDENTIFIER#*.}" [[ "$PROFILE_BUNDLE_ID" == "$BUNDLE_ID" ]] || fail "Provisioning profile bundle ID mismatch: profile has $PROFILE_BUNDLE_ID, app has $BUNDLE_ID" [[ -n "$PROFILE_TEAM_IDENTIFIER" ]] || fail "Provisioning profile missing com.apple.developer.team-identifier entitlement" if ! grep -Fxq "$APPSTORE_APP_SIGN_IDENTITY" <<<"$PROFILE_DEVELOPER_CERT_SHA1S"; then fail "App Store app signing identity certificate mismatch: identity SHA1 $APPSTORE_APP_SIGN_IDENTITY is not present in provisioning profile DeveloperCertificates: $(tr '\n' ',' <<<"$PROFILE_DEVELOPER_CERT_SHA1S" | sed 's/,$//')" fi cp "$BASE_ENTITLEMENTS" "$SIGNING_ENTITLEMENTS" plist_set_string "$SIGNING_ENTITLEMENTS" "com.apple.application-identifier" "$PROFILE_APP_IDENTIFIER" plist_set_string "$SIGNING_ENTITLEMENTS" "com.apple.developer.team-identifier" "$PROFILE_TEAM_IDENTIFIER" if [[ -n "$PROFILE_KEYCHAIN_GROUP" ]]; then /usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$SIGNING_ENTITLEMENTS" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :keychain-access-groups array" "$SIGNING_ENTITLEMENTS" /usr/libexec/PlistBuddy -c "Add :keychain-access-groups:0 string $PROFILE_KEYCHAIN_GROUP" "$SIGNING_ENTITLEMENTS" fi step "Cleaning old Mac App Store build" rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" step "Building frontend" cd "$PROJECT_DIR/frontend" if [[ ! -d node_modules ]]; then npm ci --prefer-offline fi npm run build step "Building Go binary ($OUT_ARCH, appstore tag)" cd "$PROJECT_DIR" CGO_ENABLED=1 GOOS=darwin GOARCH="$GOARCH" \ MACOSX_DEPLOYMENT_TARGET="$APPSTORE_MIN_SYSTEM_VERSION" \ CGO_CFLAGS="${CGO_CFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \ CGO_LDFLAGS="${CGO_LDFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \ go build -tags appstore -buildvcs=false -gcflags=all="-l" \ -ldflags="-s -w -X main.appVersion=$VERSION -X main.appBuild=$BUILD_ID" \ -o "$BUILD_DIR/$APP_NAME" step "Assembling $APP_NAME.app" mkdir -p "$APP_BUNDLE/Contents/MacOS" mkdir -p "$APP_BUNDLE/Contents/Frameworks" mkdir -p "$APP_BUNDLE/Contents/Resources" cp "$INFO_PLIST" "$APP_BUNDLE/Contents/Info.plist" plist_set_string "$APP_BUNDLE/Contents/Info.plist" "CFBundleShortVersionString" "$VERSION" plist_set_string "$APP_BUNDLE/Contents/Info.plist" "CFBundleVersion" "$BUILD_ID" /usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $APPSTORE_MIN_SYSTEM_VERSION" \ "$APP_BUNDLE/Contents/Info.plist" cp "$APPSTORE_PROVISIONING_PROFILE" "$APP_BUNDLE/Contents/embedded.provisionprofile" cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME" cp "$PROJECT_DIR/build/darwin/icon.icns" "$APP_BUNDLE/Contents/Resources/icon.icns" cp "$PRIVACY_MANIFEST" "$APP_BUNDLE/Contents/Resources/PrivacyInfo.xcprivacy" find "$PROJECT_DIR/build/darwin" -maxdepth 1 -type d -name "*.lproj" \ -exec cp -R {} "$APP_BUNDLE/Contents/Resources/" \; cp "$SHERPA_LIB_DIR/libsherpa-onnx-c-api.dylib" "$APP_BUNDLE/Contents/Frameworks/" cp "$ONNXRUNTIME_DYLIB" "$APP_BUNDLE/Contents/Frameworks/" step "Removing extended attributes from app bundle" strip_extended_attributes "$APP_BUNDLE" remove_appledouble_files "$APP_BUNDLE" assert_no_quarantine_attributes "$APP_BUNDLE" assert_no_appledouble_files "$APP_BUNDLE" step "Fixing rpaths" install_name_tool -add_rpath @executable_path/../Frameworks \ "$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true fix_binary_rpaths "$APP_BUNDLE/Contents/MacOS/$APP_NAME" install_name_tool -id @rpath/libsherpa-onnx-c-api.dylib \ "$APP_BUNDLE/Contents/Frameworks/libsherpa-onnx-c-api.dylib" install_name_tool -id "@rpath/$ONNXRUNTIME_DYLIB_NAME" \ "$APP_BUNDLE/Contents/Frameworks/$ONNXRUNTIME_DYLIB_NAME" step "Code signing for Mac App Store" sign_plain "$APP_BUNDLE/Contents/Frameworks/$ONNXRUNTIME_DYLIB_NAME" sign_plain "$APP_BUNDLE/Contents/Frameworks/libsherpa-onnx-c-api.dylib" sign_sandboxed "$APP_BUNDLE/Contents/MacOS/$APP_NAME" sign_sandboxed "$APP_BUNDLE" step "Removing extended attributes after signing" strip_extended_attributes "$APP_BUNDLE" remove_appledouble_files "$APP_BUNDLE" assert_no_quarantine_attributes "$APP_BUNDLE" assert_no_appledouble_files "$APP_BUNDLE" step "Verifying app signature and entitlements" codesign -vvv --deep --strict "$APP_BUNDLE" codesign -d --entitlements :- "$APP_BUNDLE" SIGNED_ENTITLEMENTS="$(mktemp)" codesign -d --entitlements :- "$APP_BUNDLE" >"$SIGNED_ENTITLEMENTS" 2>/dev/null SIGNED_APP_IDENTIFIER="$(/usr/libexec/PlistBuddy -c "Print :com.apple.application-identifier" "$SIGNED_ENTITLEMENTS" 2>/dev/null || true)" rm -f "$SIGNED_ENTITLEMENTS" [[ "$SIGNED_APP_IDENTIFIER" == "$PROFILE_APP_IDENTIFIER" ]] || fail "Signed app application-identifier mismatch: signed has $SIGNED_APP_IDENTIFIER, profile has $PROFILE_APP_IDENTIFIER" step "Creating signed installer package" COPYFILE_DISABLE=1 productbuild \ --sign "$APPSTORE_INSTALLER_IDENTITY" \ --component "$APP_BUNDLE" /Applications \ "$PKG_PATH" step "Verifying installer package signature" pkgutil --check-signature "$PKG_PATH" assert_pkg_no_quarantine_attributes "$PKG_PATH" step "Mac App Store package complete" echo " App: $APP_BUNDLE" echo " PKG: $PKG_PATH" echo " App signing identity: $APPSTORE_APP_IDENTITY ($APPSTORE_APP_SIGN_IDENTITY)" echo " Installer signing identity: $APPSTORE_INSTALLER_IDENTITY"