今天排查 Stats.app 不能打开时,遇到一个很有意思、也很容易被忽略的 macOS 打包坑:不是签名证书坏了,也不是 Gatekeeper 误杀,而是更新器复制 App 的方式改变了 bundle 内部结构。
现象是 Stats v2.12.14 自更新后,/Applications/Stats.app 无法再启动。手动检查签名时,spctl 给出的错误是:
/Applications/Stats.app: rejected (bundle format is ambiguous (could be app or framework))
继续看 App 包内部,发现 Sensors.framework 顶层本应是 framework 标准结构里的符号链接:
Sensors.framework/Sensors -> Versions/Current/Sensors
Sensors.framework/Resources -> Versions/Current/Resources
但更新器安装后的副本里,这两个入口被复制成了真实文件和真实目录。也就是说,App 不是下载坏了,而是安装时被复制坏了。
根因在更新脚本里:
cp -rf "$APP_SRC" "$APP_DST"
在这个场景下,cp -rf 会把 DMG 里的 framework symlink 展开,破坏 .framework 的 bundle 结构。然后 Gatekeeper 再看这个 App 时,就会认为某个 bundle 形态是歧义的,直接拒绝执行。
我用同一个官方 v2.12.14 DMG 做了一个最小复现:
hdiutil attach -nobrowse -readonly -mountpoint /tmp/stats-mount Stats.dmg
cp -rf /tmp/stats-mount/Stats.app /tmp/stats-cp/Stats.app
spctl --assess --type execute --verbose=4 /tmp/stats-cp/Stats.app
结果:
/tmp/stats-cp/Stats.app: rejected (bundle format is ambiguous (could be app or framework))
换成 ditto 复制同一个 App:
ditto /tmp/stats-mount/Stats.app /tmp/stats-ditto/Stats.app
spctl --assess --type execute --verbose=4 /tmp/stats-ditto/Stats.app
结果就正常了:
/tmp/stats-ditto/Stats.app: accepted
source=Notarized Developer ID
建议很简单:macOS App bundle,尤其包含 .framework、.appex、登录项、签名资源的 bundle,不要在更新器里用普通 cp -rf 当安装逻辑;优先用 /usr/bin/ditto。 它更符合 macOS 打包/分发场景,会保留 symlink、资源 fork、扩展属性等结构信息。
我已经给上游发了 issue 和 PR:
- Issue: Updater can corrupt framework symlinks and make Stats.app fail Gatekeeper · Issue #3218 · exelban/stats · GitHub
- PR: Fix updater app copy preserving framework symlinks by constansino · Pull Request #3219 · exelban/stats · GitHub
这个案例的有趣点在于:表面报错像“签名/公证问题”,但真正的问题在签名验证之前就埋下了。复制工具改变了 bundle 结构,最后由 Gatekeeper 暴露出来。以后写 macOS 自更新器时,建议至少加两类验证:
- 安装后的 App 再跑一次
spctl --assess --type execute --verbose=4。 - 对
.framework这种 bundle 检查顶层Binary、Resources、Versions/Current是否仍是 symlink。
这能避免“下载包是好的,但用户机器上的安装结果坏了”的隐蔽问题。