#!/usr/bin/env bash # # build-local-macos.sh - Build a locally signed macOS app and DMG for PrivateVoice Dictation. # # This script intentionally does not notarize. It creates or reuses a stable # local Code Signing identity so macOS TCC permissions, such as Accessibility, # do not change on every local rebuild the way they do with ad-hoc signing. # # Usage: # cd VoiceSnapGo # ./scripts/build-local-macos.sh [arm64|x86_64] [--install] # set -euo pipefail APP_NAME="PrivateVoice Dictation" DEFAULT_LOCAL_IDENTITY="PrivateVoice Local Code Signing" LEGACY_LOCAL_IDENTITY="VoiceSnap Local Code Signing" LOCAL_IDENTITY="${PRIVATEVOICE_LOCAL_SIGN_IDENTITY:-${VOICESNAP_LOCAL_SIGN_IDENTITY:-}}" KEYCHAIN="${PRIVATEVOICE_LOCAL_KEYCHAIN:-${VOICESNAP_LOCAL_KEYCHAIN:-$HOME/Library/Keychains/login.keychain-db}}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" INFO_PLIST="$PROJECT_DIR/build/darwin/Info.plist" ENTITLEMENTS="$PROJECT_DIR/build/darwin/entitlements.plist" 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="$(defaults read "$INFO_PLIST" CFBundleShortVersionString 2>/dev/null || echo "2.1.1")" BUILD_ID="$(defaults read "$INFO_PLIST" CFBundleVersion 2>/dev/null || date +%Y%m%d.%H%M)" TARGET_ARCH="" INSTALL_AFTER_BUILD=0 step() { echo "" echo "==> $1" } fail() { echo "ERROR: $1" >&2 exit 1 } for arg in "$@"; do case "$arg" in arm64|x86_64|amd64) TARGET_ARCH="$arg" ;; --install) INSTALL_AFTER_BUILD=1 ;; *) fail "Unknown argument: $arg" ;; esac done TARGET_ARCH="${TARGET_ARCH:-$(uname -m)}" case "$TARGET_ARCH" in arm64) GOARCH="arm64" SHERPA_ARCH="aarch64-apple-darwin" DMG_ARCH="arm64" ;; x86_64|amd64) GOARCH="amd64" SHERPA_ARCH="x86_64-apple-darwin" DMG_ARCH="x86_64" ;; *) fail "Unknown architecture: $TARGET_ARCH (use arm64 or x86_64)" ;; esac BUILD_DIR="$PROJECT_DIR/build/local/$DMG_ARCH" APP_BUNDLE="$BUILD_DIR/$APP_NAME.app" DMG_PATH="$BUILD_DIR/$APP_NAME-$VERSION-build$BUILD_ID-$DMG_ARCH-local.dmg" COMPAT_DMG_PATH="$BUILD_DIR/$APP_NAME-$VERSION-$DMG_ARCH-local.dmg" SHERPA_LIB_DIR="$GOMODCACHE/github.com/k2-fsa/sherpa-onnx-go-macos@$SHERPA_MACOS_MODULE_VERSION/lib/$SHERPA_ARCH" ONNXRUNTIME_DYLIB="" ONNXRUNTIME_DYLIB_NAME="" ensure_local_identity() { if [[ -z "$LOCAL_IDENTITY" ]]; then if security find-identity -v -p codesigning "$KEYCHAIN" | grep -Fq "$DEFAULT_LOCAL_IDENTITY"; then LOCAL_IDENTITY="$DEFAULT_LOCAL_IDENTITY" elif security find-identity -v -p codesigning "$KEYCHAIN" | grep -Fq "$LEGACY_LOCAL_IDENTITY"; then LOCAL_IDENTITY="$LEGACY_LOCAL_IDENTITY" else LOCAL_IDENTITY="$DEFAULT_LOCAL_IDENTITY" fi fi if security find-identity -v -p codesigning "$KEYCHAIN" | grep -Fq "$LOCAL_IDENTITY"; then echo "Using existing local signing identity: $LOCAL_IDENTITY" return fi step "Creating local signing identity: $LOCAL_IDENTITY" local tmpdir tmpdir="$(mktemp -d)" trap 'rm -rf "${tmpdir:-}"' RETURN local conf="$tmpdir/codesign.cnf" local key="$tmpdir/codesign.key" local csr="$tmpdir/codesign.csr" local cert="$tmpdir/codesign.cer" local p12="$tmpdir/codesign.p12" local pass pass="$(uuidgen)" cat > "$conf" </dev/null 2>&1 openssl x509 -req -days 3650 \ -in "$csr" \ -signkey "$key" \ -out "$cert" \ -extfile "$conf" \ -extensions v3_codesign >/dev/null 2>&1 openssl pkcs12 -export \ -legacy \ -inkey "$key" \ -in "$cert" \ -out "$p12" \ -name "$LOCAL_IDENTITY" \ -passout "pass:$pass" >/dev/null 2>&1 security import "$p12" \ -k "$KEYCHAIN" \ -P "$pass" \ -A \ -T /usr/bin/codesign \ -T /usr/bin/security >/dev/null security add-trusted-cert \ -d \ -r trustRoot \ -p codeSign \ -k "$KEYCHAIN" \ "$cert" >/dev/null 2>&1 || true if ! security find-identity -v -p codesigning "$KEYCHAIN" | grep -Fq "$LOCAL_IDENTITY"; then fail "Local signing identity was created but is not available to codesign" fi trap - RETURN rm -rf "$tmpdir" } sign_item() { local item="$1" codesign --force \ --options runtime \ --timestamp=none \ --entitlements "$ENTITLEMENTS" \ --sign "$LOCAL_IDENTITY" \ "$item" } step "Preflight checks" [[ -f "$INFO_PLIST" ]] || fail "Info.plist not found at $INFO_PLIST" [[ -f "$ENTITLEMENTS" ]] || fail "entitlements.plist not found at $ENTITLEMENTS" [[ -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")" ensure_local_identity step "Cleaning old local build" rm -rf "$APP_BUNDLE" "$DMG_PATH" 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 ($DMG_ARCH)" cd "$PROJECT_DIR" CGO_ENABLED=1 GOOS=darwin GOARCH="$GOARCH" \ go build -buildvcs=false -gcflags=all="-l" -ldflags="-s -w" \ -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" 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 "$SHERPA_LIB_DIR/libsherpa-onnx-c-api.dylib" "$APP_BUNDLE/Contents/Frameworks/" cp "$ONNXRUNTIME_DYLIB" "$APP_BUNDLE/Contents/Frameworks/" step "Fixing rpaths" install_name_tool -add_rpath @executable_path/../Frameworks \ "$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true 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 with stable local identity" sign_item "$APP_BUNDLE/Contents/Frameworks/$ONNXRUNTIME_DYLIB_NAME" sign_item "$APP_BUNDLE/Contents/Frameworks/libsherpa-onnx-c-api.dylib" sign_item "$APP_BUNDLE/Contents/MacOS/$APP_NAME" sign_item "$APP_BUNDLE" step "Verifying app signature" codesign -vvv --deep --strict "$APP_BUNDLE" codesign -dv --verbose=2 "$APP_BUNDLE" 2>&1 | sed -n '1,24p' step "Creating DMG" DMG_TEMP="$BUILD_DIR/dmg-staging" rm -rf "$DMG_TEMP" mkdir -p "$DMG_TEMP" cp -R "$APP_BUNDLE" "$DMG_TEMP/" ln -s /Applications "$DMG_TEMP/Applications" hdiutil create -volname "$APP_NAME" \ -srcfolder "$DMG_TEMP" \ -ov -format UDZO \ "$DMG_PATH" rm -rf "$DMG_TEMP" step "Signing DMG" codesign --force --timestamp=none --sign "$LOCAL_IDENTITY" "$DMG_PATH" hdiutil verify "$DMG_PATH" cp "$DMG_PATH" "$COMPAT_DMG_PATH" if [[ "$INSTALL_AFTER_BUILD" == "1" ]]; then step "Installing to /Applications" osascript -e "tell application \"$APP_NAME\" to quit" 2>/dev/null || true sleep 1 pkill -x "$APP_NAME" 2>/dev/null || true rm -rf "/Applications/$APP_NAME.app" ditto "$APP_BUNDLE" "/Applications/$APP_NAME.app" codesign -vvv --deep --strict "/Applications/$APP_NAME.app" fi step "Local build complete" echo " App: $APP_BUNDLE" echo " DMG: $DMG_PATH" echo " Compatibility DMG: $COMPAT_DMG_PATH" echo " Signing identity: $LOCAL_IDENTITY"