凌晨两点。你的应用编译通过。签名完成。打包成DMG。执行notarytool submit。苹果说"In Progress"。你等了5分钟。10分钟。20分钟。一个小时。两个小时。提交仍然是"In Progress"。你睡觉去了。第二天早上:Invalid

除了"The signature of the binary is invalid"之外没有更多解释。对两种架构都是如此。谢谢苹果。非常有用。

公证是那种完美运行的流程…直到它不工作为止。当它失败时,会给你留下一个Gatekeeper不会打开的.dmg文件和一个什么都不告诉你的错误。在为Tokamak(我的用于监控Claude配额的菜单栏应用)与此斗争几天后,我决定记录所学到的一切并编写一个检查器以避免再次经历这种痛苦。

什么是公证(简单来说)

想象Mac App Store是一个有保安的购物中心。但你不想在购物中心销售——你想直接分发你的应用,用你自己的DMG。就像街边摊位。

苹果说:“好的,你可以。但首先要通过门卫。”

那个门卫就是公证。这是苹果的一个自动化服务,扫描你的已签名应用,验证它不包含已知的恶意软件,如果一切正常,就给你一个票据。你把这个票据钉在你的DMG上(stapler staple),从那时起,当用户下载并尝试打开它时,Gatekeeper看到票据就说"请进"。

没有那个票据,用户会看到这个:

“Tokamak.app"无法打开,因为苹果无法检查它是否不含恶意软件。

你的应用就被丢在路边了。

苹果为什么这样做

有两个原因。一个是合理的。另一个…嗯。

合理的原因:保护用户。在公证之前(2019年在macOS 10.14.5中引入),任何人都可以用Developer ID分发已签名的.app,macOS会毫无怨言地打开它。代码签名验证开发者的身份,但不扫描内容。如果你的已签名应用包含键盘记录器,签名完整的情况下也会执行。

公证增加了一层:苹果在到达用户之前扫描二进制文件寻找已知的恶意软件和可疑行为。这不是像App Store那样的人工审查——这是一个自动化系统。但至少是个保障。

另一个原因:控制。苹果希望你通过App Store Connect处理所有事情。使用Developer ID的直接分发一直是二等公民。公证是在"你可以在App Store之外分发,但我们会让你感到不便"这条道路上的又一步。

话虽如此,从macOS 10.15开始,对于所有在App Store之外分发的应用,公证是强制性的。这不是可选的。你的应用要么经过公证,要么不会打开。没有商量余地。

会让你浪费数小时的7个错误

在收集了自己的错误和开发者论坛的错误后,这些是最痛苦的:

1. 缺少--timestamp

这是经典错误。你的codesign在本地完美工作,Gatekeeper不抱怨,但公证返回"The signature of the binary is invalid.”

1
2
3
4
5
# 错误 — 本地签名有效,苹果拒绝
codesign --force --options runtime --sign "Developer ID Application: ..." MiApp.app

# 正确 — 使用苹果服务器的时间戳
codesign --force --options runtime --timestamp --sign "Developer ID Application: ..." MiApp.app

安全时间戳证明签名是在证书有效期内完成的。没有它,苹果不会信任。就像签署没有日期的合同——技术上有效,但没人会接受。

2. 证书错误

有三个听起来相似但作用不同的证书:

证书用途
Apple Development调试构建(本地开发)
Apple DistributionApp Store和TestFlight
Developer ID Application直接分发 + 公证

如果你用"Apple Development"签名并尝试公证,苹果会拒绝你。你需要Developer ID Application。看起来很明显,但当你已经尝试了三个小时时,就不那么明显了。

3. 强化运行时被禁用

公证要求你的应用使用强化运行时。这是一种保护,防止你的应用做一些事情,比如向其他进程注入代码或禁用内存保护。

1
2
# --options runtime标志激活强化运行时
codesign --force --options runtime --timestamp --sign "Developer ID Application: ..." MiApp.app

没有--options runtime,签名是有效的但苹果会拒绝。就像带着完美简历去面试但没穿裤子。

4. xcodebuild -exportArchive卡住

如果你使用带有method: developer-idxcodebuild -exportArchive,准备好:它会卡住。无限期地。没有输出。没有错误。只是…沉默。

问题是exportArchive需要Apple ID凭据来联系分发服务器,如果它没有缓存的凭据(或者Keychain中有损坏的旧账户),它会一直等待永远不会到来的交互式认证。

解决方案:跳过exportArchive并手动签名。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 1. 从archive复制.app
cp -R build/Tokamak.xcarchive/Products/Applications/Tokamak.app build/export/

# 2. 用codesign签名
codesign --force --options runtime --timestamp \
  --sign "Developer ID Application: 你的名字 (TEAM_ID)" \
  build/export/Tokamak.app

# 3. 创建DMG
hdiutil create -volname "MiApp" -srcfolder build/export/MiApp.app -format UDZO MiApp.dmg

是个变通方法,没错。但它有效。而且不会卡住。

5. codesign要求访问钥匙串…默默地

第一次用新证书签名时,codesign需要访问存储在钥匙串中的私钥。macOS显示一个弹窗请求权限。

问题:如果你从IDE、脚本或像Claude Code这样的工具运行codesign,弹窗可能不会出现。或者出现在所有窗口后面。codesign就会卡在那里等待你对看不见的弹窗的回应。

解决方案:第一次,直接从终端执行codesign。在弹窗中点击"始终允许"。从那以后,脚本就会无询问地工作。

6. 公证耗时…耗时…耗时

苹果说"大多数上传在几分钟内处理完成"。现实是:

  • 正常情况:2-15分钟
  • 使用新Developer ID的首次:长达1小时
  • 当苹果有问题时:数小时。或数天。没有通知。

在2026年2月,苹果论坛上有报告称提交在"In Progress"状态下卡了超过16小时。服务在技术上是"正常运行的",但许多提交就是不被处理。

除了等待和重试,你什么都做不了。欢迎来到苹果的开发者体验。

7. 破坏签名的扩展属性

macOS为下载的文件添加扩展属性(著名的com.apple.quarantine)。如果你的构建流水线复制具有这些属性的文件,签名会失效。

1
2
# 签名前清理
xattr -cr MiApp.app

这是一个没人告诉你的细节,直到它咬你一口。

检查器:25项检查让你不会毫无防备

在积累了所有这些错误后,我写了一个脚本来验证25个公证条件——既有静态的(你的代码和配置)也有动态的(构建产物)。

这样运行:

1
bash scripts/lint-notarize.sh

产生这样的输出:

── CRITICAL ──
✓ PASS  C1: export-direct传递--entitlements给codesign
✗ FAIL  C2: Release CODE_SIGN_IDENTITY不是Developer ID
✓ PASS  C3: Release中ENABLE_HARDENED_RUNTIME = YES

── HIGH ──
✗ FAIL  H1: export-direct没有执行xattr -cr

── MEDIUM ──
⚠ WARN  M1: DMG未签名
✓ PASS  M2: project.yml中声明了LSUIElement
⚠ WARN  M4: 只对DMG做staple,不对.app

── 证书和钥匙串 ──
✓ PASS  P1: 钥匙串中有Developer ID Application证书
✓ PASS  P2: 配置了notarytool配置文件'tokamak-notary'

── 产物 ──
✓ PASS  P13: 通用二进制(x86_64 + arm64)
✓ PASS  C2-V: Export用Developer ID Application签名
✓ PASS  C3-V: Export签名中激活了强化运行时

── 摘要 ──
  总计: 25项检查
  ✓ 18项通过
  ✗ 2项失败
  ⚠ 3项警告
  ⊘ 2项跳过(构建产物缺失)

  ✗ 不要公证 — 有2个失败需要先修复。

检查分为四个级别:

CRITICAL — 如果任何一个失败,公证肯定会失败:

  • codesign中的--entitlements(如果不传递,会失去沙盒和网络)
  • Release中CODE_SIGN_IDENTITY = Developer ID
  • 强化运行时激活

HIGH — 可能会失败或造成问题:

  • 签名前执行xattr -cr
  • Release中没有调试dylibs

MEDIUM — 可能有效,但有风险:

  • DMG签名(非强制性,但推荐)
  • App Store的LSApplicationCategoryType
  • AppInfoproject.yml之间的版本一致性

产物 — 验证生成的构建:

  • 使用Developer ID的有效签名
  • 签名中激活的强化运行时
  • 通用二进制(x86_64 + arm64)
  • bundle中没有.DS_Store或符号链接
  • 正确的结构(Contents/{MacOS,Resources,Info.plist}

脚本对配置检查是静态的(不需要构建),对产物是动态的(需要执行过make archive && make export-direct)。所以你可以在任何时候将其作为预检查执行。

完整流程

为了让你了解从头到尾的过程:

flowchart TD
    subgraph build["   🔨 构建   "]
        direction TB
        A["xcodegen generate"] --> B["xcodebuild archive<br/>(Release, 已签名)"]
    end

    subgraph sign["&nbsp;&nbsp;&nbsp;✍️ 签名&nbsp;&nbsp;&nbsp;"]
        direction TB
        C["从archive复制.app"] --> D["xattr -cr<br/>(清理属性)"]
        D --> E["codesign --force<br/>--options runtime<br/>--timestamp<br/>--sign Developer ID"]
    end

    subgraph package["&nbsp;&nbsp;&nbsp;📦 打包&nbsp;&nbsp;&nbsp;"]
        direction TB
        F["hdiutil create<br/>(DMG格式UDZO)"]
    end

    subgraph notarize["&nbsp;&nbsp;&nbsp;🍎 公证&nbsp;&nbsp;&nbsp;"]
        direction TB
        G["notarytool submit<br/>--wait"] --> H{已接受?}
        H -->|是| I["stapler staple<br/>(钉票据)"]
        H -->|否| J["notarytool log<br/>(查看错误)"]
        J --> K["修复并<br/>重新签名"]
    end

    subgraph lint["&nbsp;&nbsp;&nbsp;🔍 预检&nbsp;&nbsp;&nbsp;"]
        direction TB
        L["lint-notarize.sh<br/>(25项检查)"]
    end

    build --> sign
    sign --> lint
    lint -->|✓ 一切正常| package
    lint -->|✗ 失败| K
    package --> notarize
    K --> sign
    I --> M["✅ DMG已准备<br/>分发"]

用命令表示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 1. 构建 + archive
make archive

# 2. 导出 + 签名(不用exportArchive,因为它会卡住)
make export-direct

# 3. 检查(预检)
bash scripts/lint-notarize.sh

# 4. DMG
make dmg

# 5. 公证
make notarize
# → xcrun notarytool submit ... --wait
# → xcrun stapler staple ...

# 6. 分发
# DMG已经钉上了票据。准备上传。

凌晨3点学到的教训

  1. 总是使用--timestamp。总是。没有借口。
  2. 第一次签名,在终端中。这样钥匙串弹窗会出现。
  3. 不要对Developer ID使用exportArchive。手动复制 + codesign
  4. 对苹果要有耐心。公证可能需要几分钟或几小时。没有SLA。
  5. 检查器比一百次尝试更有价值。脚本运行的30秒为你节省数小时的失败提交。

公证就像驾照考试:不舒服、官僚主义,可能是必要的。但一旦你掌握了过程,它就成为流水线中的又一步。一个不再让你在凌晨3点醒来疑惑苹果为什么说"Invalid"而不屑于解释原因的步骤。

完整脚本在Tokamak的代码库中。