返回博客

Tauri 2 macOS 签名公证三层坑:dmg 无票、.node 未签、密码被 shell 吃掉

花了两天打通 Tauri 2 的 macOS 签名流程,本地 spctl 验证全绿,发给用户却反复报「开发者无法验证」。打开 xcrun notarytool log 才发现,公证这件事远比官方文档说的多三道工序。

现象

  1. 本地 Mac 双击 .dmg 一切正常,用户(全新系统)下载后看到 Gatekeeper 弹窗「已损坏,无法打开」。
  2. CI 里 tauri build 显示公证成功,但 xcrun stapler validate your.dmgThe staple and validate action failed
  3. 公证日志里出现一堆 "The executable does not have the hardened runtime enabled." — 但明明已经开了 Hardened Runtime。
  4. CI 有时报 MAC verification failed,密码明明没错。

根因

Tauri 2 的内置签名流程实际上只做了半条路:对 .app 签名 + 把 .app.zip 提交 notarytool 等待,然后把结果塞回 .app。但它漏掉了三件事:

坑一:.dmg 本身从未提交公证

Tauri 把 stapled 的 .app 打进 .dmg 就收工了,.dmg 自身没有 staple ticket。用户从浏览器下载 .dmg 时,macOS 会校验这个 .dmg 的 ticket——找不到,直接 Gatekeeper 拒绝。.app 内部再干净也没用,用户根本打不开外层包装。

坑二:sidecar 里的 *.node 原生模块没有递归签名

如果你的 sidecar(externalBin)带了自己的 node_modules/(比如 better-sqlite3sharpplaywright),这些 .node 文件是原生二进制。Tauri 的 bundler 只签名外层 .app 壳,不会递归进 Resources/ 里的子目录。Apple 的公证服务会遍历 bundle 里每一个二进制——遇到第一个未签名的 .node 就整体拒绝。

坑三:CI 密码被 shell 转义吃掉

APPLE_CERTIFICATE_PASSWORD 如果包含 !(bash history expansion)、$(变量展开)、'"、空格,shell 会在赋值或传参时偷偷改掉内容。症状是 codesignMAC verification failed——看起来像密码错,实际上是 shell 把密码改了。

坑:Tauri 公证成功 ≠ dmg 可以分发

tauri build 成功、notarytool 返回 Accepted,只代表内层 .app.zip 通过了公证。.dmg 本身没有 staple ticket,用户下载后 Gatekeeper 会拒绝整个文件。这个失败是延迟失败——你本地测试全绿,因为你的 Mac 已经把公证结果缓存在 Keychain 里了。

正解

把 Tauri 的内置流程视为 三步里的第二步,在它之前和之后各补一道工序。

第零步:先对 sidecar 里的 .node 预签名

tauri build 之前,递归签名所有原生模块:

find sidecar/node_modules -type f -name "*.node" | while read -r f; do
  codesign --force --timestamp --options runtime \
    --sign "$APPLE_SIGNING_IDENTITY" "$f"
done

三个 flag 缺一不可

  • --force:幂等,覆盖已有签名不报错
  • --timestamp:嵌入 Apple RFC 3161 时间戳,公证必须
  • --options runtime:开启 Hardened Runtime,公证必须

第一步:用 API Key 代替 Apple ID + 应用专用密码

APPLE_ID + APPLE_PASSWORD(应用专用密码)路径容易 401、会过期、CI 里不稳定。改用 App Store Connect API Key(.p8 文件):

unset APPLE_ID APPLE_PASSWORD
export APPLE_API_KEY="<Key ID,10 字符>"
export APPLE_API_ISSUER="<Issuer UUID>"
export APPLE_API_KEY_PATH="/path/to/AuthKey_XXX.p8"

.p8 文件只能下载一次,妥善保管。Key ID 和 Issuer ID 是公开值,只有 .p8 内容是密钥。

密码含特殊字符时,用 base64 存储再解码使用,或改用 URL-safe 字符集的密码,彻底绕开 shell 转义问题。

第二步:跑 tauri build(处理 .app 签名 + .app.zip 公证)

npm run tauri -- build --target "$TARGET"

Tauri 此时:签名 .app → 打包为 .app.zip → 提交 notarytool → 等待结果 → staple 到 .app → 打包进 .dmg

第三步:单独对 .dmg 提交公证 + staple

DMG_PATH=$(find "src-tauri/target/$TARGET/release/bundle/dmg" -name "*.dmg" | head -1)
 
xcrun notarytool submit "$DMG_PATH" \
  --key "$APPLE_API_KEY_PATH" \
  --key-id "$APPLE_API_KEY" \
  --issuer "$APPLE_API_ISSUER" \
  --wait
 
xcrun stapler staple "$DMG_PATH"

最后验证两层都通过:

spctl --assess --type execute --verbose=2 YourApp.app
# 期望:accepted source=Notarized Developer ID
 
spctl --assess --type install --verbose=2 YourApp.dmg
# 期望:accepted source=Notarized Developer ID
正解:Tauri 公证是三步,不是一步

预签名 .nodetauri build(公证 .app)→ 单独公证并 staple .dmg。缺任何一步,要么公证被拒(.node 未签),要么用户收到无效的 .dmg(dmg 无票)。

本地 Mac 与 CI 的身份源互斥

如果 APPLE_CERTIFICATE.p12 base64)和本地 Keychain 里的证书同时存在,Tauri 会尝试两条路并可能选错。本地开发时应 unset APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD,让 Tauri 走 Keychain;CI 环境里没有 Keychain,才用 .p12 base64 注入。

一句话外卖

Tauri 2 的签名文档覆盖的是「公证 .app」,但分发给用户的是「.dmg」——两件事,两套 xcrun notarytool submit + xcrun stapler staple,一步都不能省。