#!/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"
|