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,往往指向同一类问题:运行环境的资源布局,和你开发时不一样。
一句话总结
SwiftPM 会为带资源的 target 自动合成一个 Bundle.module 访问器,它在找不到资源包时不是返回 nil,而是直接 fatalError 崩掉进程。纯 SPM 的 app 手动组装成 .app 后,资源包位置和 .build/ 目录里不一样,Bundle.module 的默认定位逻辑就失效了。开发时它能在可执行文件旁边找到资源包所以不崩,用户首装时找不到就必崩。
崩溃现场:栈顶是一句 fatalError
两份 crash report 关键部分完全一致。信号是 EXC_BREAKPOINT (SIGTRAP)——这不是野指针越界那种 SIGSEGV,而是代码里主动触发的断言/fatalError:
1 | Process: Owlet [21723] |
这条栈从下往上读,故事非常清楚:
- frame 6-5:SwiftUI 要渲染
RankIconView,读它的body,进而调用TokenRank.iconImage这个 getter——也就是「加载排名图标」。 - frame 4-2:
iconImage里访问了Bundle.module。Bundle.module是个static let,第一次访问会触发_dispatch_once一次性初始化(one-time initialization function for module)。 - 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 | // SwiftPM 自动生成,简化版 |
注意最后一行:找不到资源包,它不给你 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 的默认路径一找一个准。
我早就知道这个坑——但漏了一处
最扎心的是:这个坑我早就踩过并且修过。项目里专门有一个封装,就是为了绕开 Bundle.module 在 .app 里定位失败的问题:
1 | enum AppResourceBundle { |
它的核心就是:先用 fileExists + Bundle(url:) 按 .app 的真实布局显式查找,命中就返回;只有全都落空时,才把烫手山芋丢回给 Bundle.module。这样在 .app 里能稳定命中第 1 步,根本走不到会 fatalError 的那一行。
全项目一共 8 处加载资源,其中 7 处都规规矩矩用了 AppResourceBundle.module:hook 脚本、app 图标、菜单栏猫头鹰、Dashboard 图片、本地化 bundle……唯独排名图标这一处漏了网:
1 | // TokenRankIcon.swift —— 出事的那一行 |
修复只有一行:
1 | - guard let url = Bundle.module.url( |
为什么这种「只漏一处」的 bug 最难防
这行代码有几个特别阴的地方,值得单独拎出来说:
第一,guard let ... else { return nil } 给了你虚假的安全感。 这行写得很「防御」——找不到资源就返回 nil,调用方还有 SF Symbol 兜底。看起来无懈可击。但崩溃发生在 Bundle.module 这个表达式被求值的瞬间,压根轮不到 .url(...) 返回 nil。你的 else 分支再周全也拦不住,因为进程在进入 guard 之前就已经 fatalError 了。
第二,它是 lazy 的定时炸弹。 Bundle.module 是 static let + dispatch_once,只在第一次被访问时初始化。只要 app 有任何一条路径会渲染排名图标(menu bar 状态图标、灵动卡、排名弹窗都会),首启就必然触发。而恰恰这个 app 的核心功能就是「显示排名」,等于把炸弹放在了必经之路上。
第三,编译器完全帮不上忙。 Bundle.module 和 AppResourceBundle.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 的技术根因五分钟就能讲清,但它能上线,靠的是「开发机永远复现不出」+「编译器不报错」+「八处只漏一处」三重巧合。防它的关键不在于更聪明地写这一行,而在于让这一行根本没机会写错。












