Tauri 2 macOS 签名公证三层坑:dmg 无票、.node 未签、密码被 shell 吃掉
花了两天打通 Tauri 2 的 macOS 签名流程,本地 spctl 验证全绿,发给用户却反复报「开发者无法验证」。打开 xcrun notarytool log 才发现,公证这件事远比官方文档说的多三道工序。
现象
- 本地 Mac 双击
.dmg一切正常,用户(全新系统)下载后看到 Gatekeeper 弹窗「已损坏,无法打开」。 - CI 里
tauri build显示公证成功,但xcrun stapler validate your.dmg报The staple and validate action failed。 - 公证日志里出现一堆
"The executable does not have the hardened runtime enabled."— 但明明已经开了 Hardened Runtime。 - 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-sqlite3、sharp、playwright),这些 .node 文件是原生二进制。Tauri 的 bundler 只签名外层 .app 壳,不会递归进 Resources/ 里的子目录。Apple 的公证服务会遍历 bundle 里每一个二进制——遇到第一个未签名的 .node 就整体拒绝。
坑三:CI 密码被 shell 转义吃掉
APPLE_CERTIFICATE_PASSWORD 如果包含 !(bash history expansion)、$(变量展开)、'、"、空格,shell 会在赋值或传参时偷偷改掉内容。症状是 codesign 报 MAC verification failed——看起来像密码错,实际上是 shell 把密码改了。
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预签名 .node → tauri build(公证 .app)→ 单独公证并 staple .dmg。缺任何一步,要么公证被拒(.node 未签),要么用户收到无效的 .dmg(dmg 无票)。
如果 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,一步都不能省。