一个有趣的 macOS 更新器坑:cp -rf 会把公证 App 复制坏

今天排查 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:

这个案例的有趣点在于:表面报错像“签名/公证问题”,但真正的问题在签名验证之前就埋下了。复制工具改变了 bundle 结构,最后由 Gatekeeper 暴露出来。以后写 macOS 自更新器时,建议至少加两类验证:

  1. 安装后的 App 再跑一次 spctl --assess --type execute --verbose=4
  2. .framework 这种 bundle 检查顶层 BinaryResourcesVersions/Current 是否仍是 symlink。

这能避免“下载包是好的,但用户机器上的安装结果坏了”的隐蔽问题。