我的 Telegram for macOS 设置页空白了好几个月。点进去就是一片白,General、Notifications、Privacy 这些菜单项统统不见。重装、清缓存、降级、重启、升级 macOS,全都试过,全都没用。
后来我把这个问题丢给了 Claude Opus 4.6,用 opencode 让它自己去查。接下来的几个小时里,调试走向了完全没预料到的方向:往正在运行的 Telegram 里注入代码、在几十万个内存对象里翻找线索、劫持系统 API 来观察它的行为。最后发现,问题根本不在 Telegram 里。
症状
Telegram for macOS(TelegramSwift,原生 Swift 客户端)v12.4.2,跑在 macOS 26 Tahoe 上,M3 Max 芯片。点齿轮图标进设置,白屏。顶部的搜索栏倒是能用,输入「通知」能搜到对应选项。但主列表就是不渲染。
我的 SIP 是关的(做底层开发需要),人在国内走代理,外接 4K 240Hz 显示器。这些因素理论上都可能相关。实际上都不是,除了 SIP 算半个。后面会讲到。
第一个弯路
Opus 的第一步跟任何工程师一样:读源码。TelegramSwift 是开源的,它 clone 了仓库,顺着数据管道往下追。
设置列表的构建逻辑在 AccountViewController.swift 里,通过一个 13 路 combineLatest 来驱动:
let apply = combineLatest(queue: prepareQueue,
context.account.viewTracker.peerView(...), // 0:用户信息
context.sharedContext.activeAccountsWithInfo, // 1:账号列表
appearanceSignal, // 2:主题
settings.get(), // 3:隐私设置
appUpdateState, // 4:更新状态
hasFilters.get(), // 5:聊天过滤器
sessionsCount, // 6:活跃会话数
UNUserNotifications.recurrentAuthorizationStatus(context), // 7:通知权限
twoStep, // 8:两步验证
storyStats, // 9:故事动态
acceptBots.get(), // 10:附件菜单机器人
context.starsContext.state, // 11:Stars 余额
context.tonContext.state // 12:TON 余额
)
如果你不熟悉响应式编程:combineLatest 会等所有输入信号都至少响应一次才开始工作。13 个信号里只要有 1 个永远不响应,整条管道就是死的。没有报错,没有警告,负责渲染设置列表的代码永远不会执行。
Opus 逐个分析了这些信号的定义,发现两个可疑的:
acceptBots:一个没有初始值的ValuePromise,依赖attachMenuBots()这个 API 调用,在代理后面可能会卡住storyStats:裸 API 调用,没有.single()前缀提供默认值
假设:这两个 API 调用之一卡在了代理后面,阻塞了整个管道。
这个假设是错的。 但为了证明它是错的,得先把代码注入到运行中的进程里才行。
这时候我提了一个问题,改变了调查方向:「为什么社区里其他人没报这个问题?如果这是 Telegram 的代码 bug,应该有很多用户受影响才对。会不会是我本机环境有什么特殊的?」问题就从「找 Telegram 的 bug」变成了「找这台机器上有什么不一样」。后来证明,这个方向是对的。
「别编译了,直接注入」
Opus 的本能反应是从源码编译 TelegramSwift,把修复打上去。它开始 clone 子模块、装 brew 依赖、跑框架构建脚本。但从源码编译需要先构建十个 C/C++ 框架(OpenH264、OpenSSL、libvpx、ffmpeg、webrtc……),这个过程非常漫长。
我叫停了它:「能不能先试试动态注入?不要怕困难。」
这个决定最终成了破案的关键。从源码编译只能验证一个修复是否有效,但注入让我们可以诊断:观察活进程的实时状态,找到真正卡住的信号,而不是凭猜测去修。
对一个编译好的、去掉了所有调试符号的 Swift 程序做运行时注入,不是随便能搞的事。你需要知道:
- Swift 对象的内存布局(isa 指针、引用计数、存储属性,每个都在精确的字节偏移处)
- SwiftSignalKit 里
ValuePromise、Signal、combineLatest的内部实现,不是 API 层面,是底层实现 - ObjC 运行时的方法解析、swizzling、类内省 API
malloc_size()、vm_read_overwrite()和堆区枚举的工作方式- 哪些东西对 ObjC 运行时可见(NSObject 子类),哪些是纯 Swift(对 ObjC 完全不可见)
不读 SwiftSignalKit 的源码,你不会知道 ValuePromise 把值存在偏移 16 的位置;不会知道 combineLatest 用一个在偏移 80 处的 Atomic<SignalCombineState> 字典来跟踪已发射的信号。不理解 Swift ABI,你不会知道 _swiftEmptyArrayStorage 是一个代表空数组 [] 的全局单例。有了这种跨层的理解,注入式调试才行得通。没有的话,基本做不到。
第一个尝试是 DYLD_INSERT_LIBRARIES,经典的 macOS 动态库注入方式。失败了,进程直接静默退出。即使 SIP 是关的,即使用 ad-hoc 重签了二进制并加上了正确的 entitlement,macOS 26 Tahoe 也会直接杀掉进程。AMFI 在内核层面的强制执行把这条路堵死了。
但通过 lldb 用 dlopen() 还是可以的:
lldb -p <PID> --batch \
-o 'expr -l objc -- (void*)dlopen("/tmp/my_fix.dylib", 2)'
动态库的 constructor 会立即在主线程执行,NSLog 输出出现在统一日志里。Opus 找到了入口。
整个过程中,Opus 写了、编译了、注入了大约 15 个不同的 C/ObjC 动态库,每个针对进程状态的不同层面。有些让进程直接崩了(对无效的 metadata 指针调用 swift_getTypeName)。有些没产生有用输出(NSLog 被 macOS 的隐私系统过滤了)。每次失败都在缩小搜索范围。
heap 工具:在 256,000 个对象里找一根针
进程有 256,314 个堆分配对象。问题变成了:在这 25 万多个对象里,怎么找到那个没有响应的信号?
Opus 用了 /usr/bin/heap,一个大多数开发者都没听说过的 macOS 自带工具。它能枚举进程堆上的所有对象,而且能识别 Swift 泛型:
$ heap <PID> -addresses 'SwiftSignalKit.ValuePromise<Swift.Array<TelegramCore.AttachMenuBot>>'
0x7ab5d1880: SwiftSignalKit.ValuePromise<Swift.Array<TelegramCore.AttachMenuBot>> (112 bytes)
一个实例,112 字节。这就是 acceptBots 信号。Opus 在 lldb 里读了偏移 16 的内存(ValuePromise 存储 value: T? 字段的位置):
(lldb) mem read 0x7ab5d1890
0x00000007ab1ddb80
不是 null。 API 已经返回了数据。acceptBots 没问题。
最初的假设宣告死亡。
十三分之十二
排除了嫌疑人,Opus 需要换个思路:不去逐个检查信号,而是直接看 combineLatest 自己的记录。SwiftSignalKit 的 combineLatest 内部维护着一个字典,记录哪些信号已经响应过。字典凑齐 13 条记录时,设置页才会渲染。
heap 工具找到了 96 个这样的对象。Opus 写了一个 C 动态库来扫描每一个,读取字典的 _count 字段:
void *dictPtr = *(void **)(ptr + 80);
int64_t count = *(int64_t *)((uint8_t *)dictPtr + 16);
通过 dlopen 注入后,输出是:
State 0x7a5f1b410: values=12 cap=12
★★★ STUCK at 0x7a5f1b410: 12/13 ★★★
12 个信号已经发射了。少了 1 个。Opus 解码了字典里的键,看看到底是哪个信号缺席:
0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12
缺失:索引 7。 UNUserNotifications.recurrentAuthorizationStatus。
不是代理的问题。不是机器人 API 的问题。是通知权限检查。
永远不回调的 API
找到了缺席的信号,接下来要弄清楚它为什么不响应。通知信号的实现是这样的:
static func recurrentAuthorizationStatus(_ context: AccountContext)
-> Signal<AuthorizationStatus, NoError> {
return context.window.keyWindowUpdater |> mapToSignal { _ in
return (authorizationStatus |> then(.complete()
|> suspendAwareDelay(1 * 60.0, queue: .concurrentDefaultQueue())))
|> restart
}
}
它最终会调用:
UNUserNotificationCenter.current().getNotificationSettings { settings in
if let value = AuthorizationStatus(rawValue: settings.authorizationStatus.rawValue) {
subscriber.putNext(value)
subscriber.putCompletion()
}
}
Opus 用运行时方法替换(swizzle)拦截了 UNUserNotificationCenter 上每一次通知权限查询:
getNotificationSettingsWithCompletionHandler: INTERCEPTED
Original dispatched. Waiting...
记录了 10 次调用。收到了 0 次回调。苹果的通知 API 接受了请求,然后就没有然后了。
挂掉的守护进程
苹果的 API 不可能无缘无故吞掉请求。Opus 去查了负责处理通知的系统守护进程:
$ launchctl list com.apple.usernotificationsd
"LastExitStatus" = -9;
没有 PID。退出码 -9 是 SIGKILL。通知守护进程 usernotificationsd 没有在运行,launchd 已经放弃重启它了。
系统日志说明了原因:
usernotificationsd: (AppleKeyStore) failed to open connection to AppleKeyStore
usernotificationsd: aks connection failed
每隔约 10 秒循环一次:启动、连不上 AppleKeyStore、退出、被杀。再启动。最终 launchd 永久限流了。
我去检查的时候发现系统设置里的「通知」点了也没反应,印证了通知子系统确实挂了。只是我从来没注意到,因为通知不是你会主动去检查的东西。这个状态持续了多久不好说。我的设置页坏了好几个月,但守护进程的崩溃可能只是这段时间内的部分原因,也可能是全部原因。
修复
Opus 手动启动了 usernotificationsd。它活了,带着 AppleKeyStore 的警告,但能正常工作。然后它:
- 删掉了
~/Library/Preferences/com.apple.ncprefs.plist(通知中心偏好设置文件,里面有一条来自之前安装的 Qt 版 Telegram Desktop 的过期记录,指向同一路径) - 重启了 Telegram
- 注入了一个动态库,调用了应用窗口的
windowDidBecomeKey,触发了通知权限请求链 - macOS 弹出了「允许通知?」对话框
- 我点了允许
设置页面瞬间填充出来了。
为了验证,Opus 还直接注入了一个模拟的通知回调:
UNNotificationSettings *mock = [[UNNotificationSettings alloc] performSelector:@selector(init)];
[mock setValue:@2 forKey:@"authorizationStatus"];
handler(mock);
设置页面立刻出现。缺席的第 13 个信号终于补上,combineLatest 凑齐了所有输入,表格渲染了。
重启后一切正常。AppleKeyStore 的错误仍然出现在日志里,但守护进程不再崩溃了。之前的崩溃循环是内部状态损坏导致的,不是因为关了 SIP(虽然关 SIP 可能间接造成了最初的状态损坏)。
Telegram 的设计缺陷
系统层面的修复是一回事。但 Telegram 自身有一个设计问题:一个非核心的通知权限检查,不应该有能力阻塞整个设置页面的 UI。修复只需要一行代码:
// 修改前:
return context.window.keyWindowUpdater |> mapToSignal { ... }
// 修改后:
return .single(.authorized) |> then(context.window.keyWindowUpdater |> mapToSignal { ... })
先立即发射一个合理的默认值,之后再用真实的状态更新。搜索功能就是这么做的:SearchSettingsController 用的是自己独立的 6 路管道,不依赖通知授权状态,所以搜索一直是正常的。
这个修复已经作为 pull request 提交给了 TelegramSwift。
经验总结
combineLatest 是一个隐形杀手。 N 个信号里只要有一个不发射,整条管道就无声无息地死掉。永远提供默认值:.single(default) |> then(realSignal)。
macOS 的 heap 工具值得更多人知道。 它能枚举堆上每一个 Swift 泛型特化实例,带精确的类型参数和内存地址。调试 stripped 的 Swift 二进制文件时,没有什么比它更好用。
系统守护进程可以悄无声息地出问题。 usernotificationsd 在我检查的时候已经不在运行了。唯一可见的症状是系统设置里「通知」点了没反应,但我从来没有去检查过这个,因为没有理由。
两个 bug,不是一个。 一个出了问题的 macOS 守护进程,加上一条脆弱的响应式管道。单独出现哪个都不会导致设置页空白。两个撞在一起,才造出了这个问题,而且扛住了数月的重启、系统升级和应用重装,始终不会自愈。
是什么让这次调试成为可能
一年前,这种级别的调查需要一个既精通 macOS 底层、又熟悉 Swift、响应式编程、ObjC 运行时和二进制级调试的专家。能同时覆盖这些的人很少。
这次调试里,Opus 把这些全做了。它通读并交叉比对了多个仓库里数千行不熟悉的 Swift 源码;即时编写和编译了约 15 个 C/ObjC 动态库,反复迭代,处理编译错误和崩溃;在特定字节偏移处解读原始内存数据;在长达数小时的调查中保持完整的上下文。它不是给我列建议让我去试,而是自己从头到尾执行了整个过程,从 git clone 到写 PR。
但这并不是 AI 的独角戏。关键的转折来自人的判断:是我问了「为什么只有我有这个问题?」,让调查转向了环境因素。是我说了「别编译了,直接注入」,打开了那条最终破案的诊断路径。AI 有能力沿着任何方向深入下去,而人知道该往哪个方向看。
我觉得这就是新的协作范式。不是「AI 取代工程师」,也不是「AI 辅助工程师」,而是更像一种搭档关系:一方能把整个代码库装进脑子里、几秒钟写出一个一次性的动态库,另一方知道该问什么问题。瓶颈不再是「我们能不能搞明白」,而是「我们有没有问对问题」。