Ariver
2026-05-18 441f7731af05f2d5c092bca0d537a318bac7b458
build: use stable local macOS signing
3 files modified
1 files added
264 ■■■■■ changed files
TODO.md 9 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/app.go 2 ●●● patch | view | raw | blame | history
VoiceSnapGo/build/darwin/Info.plist 2 ●●● patch | view | raw | blame | history
VoiceSnapGo/scripts/build-local-macos.sh 251 ●●●●● patch | view | raw | blame | history
TODO.md
@@ -17,6 +17,15 @@
## Done
- [2026-05-18] 将本地 macOS 打包从 ad-hoc 签名改为固定本地 Code Signing 身份,减少辅助功能权限失效。
  - 新增 `VoiceSnapGo/scripts/build-local-macos.sh`,用于本地构建、稳定签名、生成 DMG,可选安装到 `/Applications`。
  - 自动创建/复用钥匙串身份 `VoiceSnap Local Code Signing`,签名 `.app`、内部 dylib 和 `.dmg`。
  - 当前安装包签名已验证: `Authority=VoiceSnap Local Code Signing`。
  - build: `20260518.0211`
  - 输出: `VoiceSnapGo/build/local/arm64/VoiceSnap.app`
  - 输出: `VoiceSnapGo/build/local/arm64/VoiceSnap-2.1.1-build20260518.0211-arm64-local.dmg`
  - 兼容输出: `VoiceSnapGo/build/local/arm64/VoiceSnap-2.1.1-arm64-local.dmg` 已替换为同一份新包。
  - 已安装到 `/Applications/VoiceSnap.app`;本次签名身份变化后需要在系统设置里重新添加一次辅助功能权限。
- [2026-05-18] 修复停止录音时 `ma_device_stop` 与音频回调互等导致 App 假死的问题。
  - 根因: `Recorder.StopAndGetSamples()` 持有录音互斥锁调用 `device.Stop()`;底层 `ma_device_stop` 等待音频回调结束,而音频回调同时等待同一把锁,形成互等。
  - 修复: 停止录音时先在锁内摘除设备并标记非录音,释放锁后再调用 `Stop/Uninit`,避免底层停止设备时阻塞音频回调。
VoiceSnapGo/app.go
@@ -27,7 +27,7 @@
const (
    appVersion        = "2.1.1"
    appBuild          = "20260518.0125"
    appBuild          = "20260518.0211"
    appDisplayVersion = appVersion + " (build " + appBuild + ")"
    appName           = "VoiceSnap"
VoiceSnapGo/build/darwin/Info.plist
@@ -17,7 +17,7 @@
    <key>CFBundleShortVersionString</key>
    <string>2.1.1</string>
    <key>CFBundleVersion</key>
    <string>20260518.0125</string>
    <string>20260518.0211</string>
    <key>LSMinimumSystemVersion</key>
    <string>11.0</string>
    <key>NSMicrophoneUsageDescription</key>
VoiceSnapGo/scripts/build-local-macos.sh
New file
@@ -0,0 +1,251 @@
#!/usr/bin/env bash
#
# build-local-macos.sh - Build a locally signed macOS app and DMG for VoiceSnap.
#
# 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="VoiceSnap"
LOCAL_IDENTITY="${VOICESNAP_LOCAL_SIGN_IDENTITY:-VoiceSnap Local Code Signing}"
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)"
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@v1.12.24/lib/$SHERPA_ARCH"
ensure_local_identity() {
  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" <<EOF
[req]
distinguished_name = dn
prompt = no
[dn]
CN = $LOCAL_IDENTITY
O = VoiceSnap Local
[v3_codesign]
basicConstraints = critical, CA:true
keyUsage = critical, digitalSignature, keyCertSign
extendedKeyUsage = codeSigning
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
EOF
  openssl req -new -newkey rsa:2048 -nodes \
    -keyout "$key" \
    -out "$csr" \
    -config "$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"
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 "$SHERPA_LIB_DIR/libonnxruntime.1.23.2.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/libonnxruntime.1.23.2.dylib \
  "$APP_BUNDLE/Contents/Frameworks/libonnxruntime.1.23.2.dylib"
step "Code signing with stable local identity"
sign_item "$APP_BUNDLE/Contents/Frameworks/libonnxruntime.1.23.2.dylib"
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"