Owlet 是我用 Claude Code + Codex vibe 出来的常驻 menu-bar app,统计每天 Claude Code 和 Codex 的 Token 用量、排名、实时会话活动。它是个纯 SPM 项目——没有 .xcodeproj,靠脚本 swift build 出可执行文件,再手动组装成 Owlet.app。这个「纯 SPM + 手动打包」的选择很轻量,但也埋了一个坑:这次我收到两份用户 crash report,症状是最吓人的那种——第一次安装,一打开就崩,连界面都没露脸

更迷惑的是:我自己机器上从来没崩过。开发、构建、swift run,一路顺畅。但用户装上就崩,而且两台完全不同的机器(macOS 26.5 和 26.3.1、不同硬件、不同用户)崩得一模一样。这种「开发机好好的,用户机必崩」的 bug,往往指向同一类问题:运行环境的资源布局,和你开发时不一样。

打包后 Bundle.module 直接崩

一句话总结

SwiftPM 会为带资源的 target 自动合成一个 Bundle.module 访问器,它在找不到资源包时不是返回 nil,而是直接 fatalError 崩掉进程。纯 SPM 的 app 手动组装成 .app 后,资源包位置和 .build/ 目录里不一样,Bundle.module 的默认定位逻辑就失效了。开发时它能在可执行文件旁边找到资源包所以不崩,用户首装时找不到就必崩。

崩溃现场:栈顶是一句 fatalError

两份 crash report 关键部分完全一致。信号是 EXC_BREAKPOINT (SIGTRAP)——这不是野指针越界那种 SIGSEGV,而是代码里主动触发的断言/fatalError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Process:             Owlet [21723]
Version: 0.4.2 (28)
OS Version: macOS 26.5 (25F71)

Exception Type: EXC_BREAKPOINT (SIGTRAP)
Termination Reason: Namespace SIGNAL, Code 5, Trace/BPT trap: 5

Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
0 libswiftCore.dylib _assertionFailure(_:_:file:line:flags:) + 172
1 Owlet closure #1 in variable initialization expression of static NSBundle.module + 584
2 Owlet one-time initialization function for module + 12
3 libdispatch.dylib _dispatch_client_callout + 16
4 libdispatch.dylib _dispatch_once_callout + 32
5 Owlet TokenRank.iconImage.getter + 500
6 Owlet RankIconView.body.getter + 304
7 SwiftUICore ViewBodyAccessor.updateBody(of:changed:) ...

这条栈从下往上读,故事非常清楚:

  1. frame 6-5:SwiftUI 要渲染 RankIconView,读它的 body,进而调用 TokenRank.iconImage 这个 getter——也就是「加载排名图标」。
  2. frame 4-2iconImage 里访问了 Bundle.moduleBundle.module 是个 static let,第一次访问会触发 _dispatch_once 一次性初始化(one-time initialization function for module)。
  3. frame 1-0:这个初始化闭包里执行了 _assertionFailure——直接崩

栈顶的 _assertionFailure + 中间的 static NSBundle.module 初始化,是这类 bug 的经典指纹。它对应的其实就是 SwiftPM 生成代码里那句:

1
Fatal error: unable to find bundle named Owlet_Owlet

为什么 Bundle.module 会 fatalError

如果你的 SPM target 声明了 resources,Swift Package Manager 会在编译期自动合成一段 Bundle.module 的代码,逻辑大致是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SwiftPM 自动生成,简化版
extension Foundation.Bundle {
static let module: Bundle = {
let bundleName = "Owlet_Owlet"
// 依次在几个候选路径里找 Owlet_Owlet.bundle
let candidates = [
Bundle.main.resourceURL,
Bundle.main.bundleURL,
// .build 相关路径……
]
for candidate in candidates {
if let path = candidate?.appendingPathComponent(bundleName + ".bundle"),
let bundle = Bundle(url: path) {
return bundle
}
}
// 找不到就直接崩
fatalError("unable to find bundle named \(bundleName)")
}()
}

注意最后一行:找不到资源包,它不给你 fallback,直接 fatalError 这是 SwiftPM 的刻意设计——在它的世界观里,资源包应该总是跟着可执行文件走,找不到就是构建/打包出错,属于「不该发生」的致命错误。

问题在于,它内置的候选路径是为 swift build/swift test 那套 .build/ 目录布局准备的。当你手动把可执行文件塞进 Owlet.app/Contents/MacOS/、把资源包塞进 Contents/Resources/ 时,布局对不上,Bundle(url:) 在它预设的候选路径里全部返回 nil,于是走到最后一行崩掉。

这就解释了那个最迷惑的现象:

场景可执行文件位置资源包位置Bundle.module 结果
开发机 swift run.build/debug/Owlet.build/debug/Owlet_Owlet.bundle(就在旁边)✅ 找到,不崩
用户首装 .app.../MacOS/Owlet.../Resources/Owlet_Owlet.bundle(布局不同)❌ 找不到 → fatalError

开发时永远复现不出来,因为开发时资源包就躺在可执行文件旁边,SwiftPM 的默认路径一找一个准。

同一份代码,开发机布局和 .app 布局不同:一个能找到资源包,一个找不到

我早就知道这个坑——但漏了一处

最扎心的是:这个坑我早就踩过并且修过。项目里专门有一个封装,就是为了绕开 Bundle.module.app 里定位失败的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum AppResourceBundle {
static let module: Bundle = {
let fm = FileManager.default
// 1. 按 .app 的真实布局找:Contents/Resources/Owlet_Owlet.bundle
if let resourcesURL = Bundle.main.resourceURL {
let url = resourcesURL.appendingPathComponent("Owlet_Owlet.bundle")
if fm.fileExists(atPath: url.path), let b = Bundle(url: url) { return b }
}
// 2. 兼容另一种布局
let legacy = Bundle.main.bundleURL.appendingPathComponent("Owlet_Owlet.bundle")
if fm.fileExists(atPath: legacy.path), let b = Bundle(url: legacy) { return b }
// 3. 实在找不到才回落到 SwiftPM 的 Bundle.module
return Bundle.module
}()
}

它的核心就是:先用 fileExists + Bundle(url:).app 的真实布局显式查找,命中就返回;只有全都落空时,才把烫手山芋丢回给 Bundle.module。这样在 .app 里能稳定命中第 1 步,根本走不到会 fatalError 的那一行。

八处资源加载里七处都有护栏,唯独一处的护盾缺失,漏出红色告警

全项目一共 8 处加载资源,其中 7 处都规规矩矩用了 AppResourceBundle.module:hook 脚本、app 图标、菜单栏猫头鹰、Dashboard 图片、本地化 bundle……唯独排名图标这一处漏了网:

1
2
3
4
5
6
7
8
9
10
// TokenRankIcon.swift —— 出事的那一行
extension TokenRank {
var iconImage: NSImage? {
let name = "rank-\(persistenceValue)"
guard let url = Bundle.module.url( // ❌ 直接用了 Bundle.module
forResource: name, withExtension: "png", subdirectory: "RankIcons"
) else { return nil }
return NSImage(contentsOf: url)
}
}

修复只有一行:

1
2
3
4
-        guard let url = Bundle.module.url(
+ guard let url = AppResourceBundle.module.url(
forResource: name, withExtension: "png", subdirectory: "RankIcons"
) else { return nil }

为什么这种「只漏一处」的 bug 最难防

这行代码有几个特别阴的地方,值得单独拎出来说:

第一,guard let ... else { return nil } 给了你虚假的安全感。 这行写得很「防御」——找不到资源就返回 nil,调用方还有 SF Symbol 兜底。看起来无懈可击。但崩溃发生在 Bundle.module 这个表达式被求值的瞬间,压根轮不到 .url(...) 返回 nil。你的 else 分支再周全也拦不住,因为进程在进入 guard 之前就已经 fatalError 了。

Bundle.module 是一枚 lazy 定时炸弹,menu bar、灵动卡、排名弹窗三条路都会引爆它

第二,它是 lazy 的定时炸弹。 Bundle.modulestatic let + dispatch_once,只在第一次被访问时初始化。只要 app 有任何一条路径会渲染排名图标(menu bar 状态图标、灵动卡、排名弹窗都会),首启就必然触发。而恰恰这个 app 的核心功能就是「显示排名」,等于把炸弹放在了必经之路上。

第三,编译器完全帮不上忙。 Bundle.moduleAppResourceBundle.module 类型一样、API 一样、调用方式一样,编译期没有任何区别。少写四个字符 AppResource,代码照样编译通过、开发机照样跑得欢,只在用户机上炸。这种「语义上错、语法上对、且只有一处」的 bug,靠 code review 用肉眼扫也极容易滑过去。

真正能防住它的其实只有一招:从源头上让「危险的那个 API」不可直接访问。 如果我能把 Bundle.module 的可见性收窄、逼所有资源加载都必须走 AppResourceBundle,这个 bug 从一开始就写不出来。可惜 SwiftPM 合成的 Bundle.module 是 internal 可见的,没法轻易禁掉——退而求其次,至少可以加一条 lint / grep 规则,在 CI 里扫「除了 AppResourceBundle.swift 之外任何文件出现裸的 Bundle.module」直接 fail。

要不要用 / 我的判断框架

这次踩坑给「纯 SPM 打 macOS app」这条路线补了几条经验,供同样在 vibe macOS app 的人参考:

  • 值得上:纯 SPM + 脚本打包对小工具型 app 依然是划算的,省掉整个 Xcode 工程的心智负担。但你必须清楚地知道——一旦你手动组装 .app,SwiftPM 那套「资源就在可执行文件旁边」的假设就不成立了,所有 Bundle.module 都得换成按 .app 真实布局查找的封装。这不是可选优化,是必修课。

  • 重点关注:凡是「开发机好好的、用户机崩」的 bug,第一反应就去查运行环境差异——资源布局、签名方式、路径假设、权限。这次是资源包路径,上次是 codesign identity 漂移。它们的共同点是:你的开发环境「太顺了」,顺到掩盖了打包产物里的问题。能在装好的 .app 上做一次冷启动冒烟测试,胜过在 .build/ 里跑一百次。

  • 可以再等 / 顺手做:给危险 API 加护栏这件事,值得排进 backlog。fatalError 类的「找不到就崩」API(Bundle.module、强解包、try!、数组越界下标)在常驻 app 里都是首启崩溃的高发点。与其靠自觉每次都记得用封装,不如让「裸用」在 CI 里直接编不过或过不了 lint——把「记得」变成「想漏都漏不掉」。

一句话收尾:这个 bug 的技术根因五分钟就能讲清,但它能上线,靠的是「开发机永远复现不出」+「编译器不报错」+「八处只漏一处」三重巧合。防它的关键不在于更聪明地写这一行,而在于让这一行根本没机会写错。