For months, the Settings tab in my Telegram for macOS showed nothing. A blank white page where General, Notifications, Privacy, and every other option should have been. I’d tried everything a normal user would: reinstalling, clearing caches, downgrading, rebooting, even upgrading macOS. Nothing worked.
Then I pointed Claude Opus 4.6 at the problem through opencode and told it to figure it out. What followed was a multi-hour debugging session that went places I didn’t expect: injecting code into the running Telegram process, sifting through hundreds of thousands of in-memory objects for clues, hijacking system APIs to observe their behavior. In the end, the problem wasn’t in Telegram at all.
The Symptom
Telegram for macOS (TelegramSwift, the native Swift client) v12.4.2, running on macOS 26 Tahoe, M3 Max. Click the gear icon for Settings, get a white page. The search bar at the top worked. Type “notifications” and it’d find the setting. But the main list simply wouldn’t render.
I have SIP disabled (I do low-level development work), I’m behind a proxy in China, and I run an external 4K display at 240Hz. Any of these could theoretically be relevant. None of them were, except SIP, sort of. We’ll get to that.
The First Wrong Turn
Opus started where any engineer would: reading the source code. TelegramSwift is open source, so it cloned the repo and traced the data pipeline.
The Settings list is built in AccountViewController.swift through a 13-signal combineLatest:
let apply = combineLatest(queue: prepareQueue,
context.account.viewTracker.peerView(...), // 0: peer info
context.sharedContext.activeAccountsWithInfo, // 1: accounts
appearanceSignal, // 2: theme
settings.get(), // 3: privacy settings
appUpdateState, // 4: updates
hasFilters.get(), // 5: chat filters
sessionsCount, // 6: active sessions
UNUserNotifications.recurrentAuthorizationStatus(context), // 7: notifications
twoStep, // 8: 2FA
storyStats, // 9: stories
acceptBots.get(), // 10: attach menu bots
context.starsContext.state, // 11: stars
context.tonContext.state // 12: TON
)
If you’re not familiar with reactive programming: combineLatest waits for every input signal to respond at least once before it starts working. If any one of the 13 never responds, the entire pipeline is dead. Silently. No errors, no warnings. The code responsible for rendering the Settings list simply never runs.
Opus analyzed each signal and found two suspicious ones:
acceptBots: aValuePromisewith no initial value, depending on an API call (attachMenuBots()) that might hang behind the proxystoryStats: a raw API call with no fallback default
Hypothesis: one of these API calls was hanging, blocking the entire pipeline.
This hypothesis was wrong. But it took injecting code into the running process to prove it.
At this point I raised a question that shifted the investigation: “Why hasn’t anyone else reported this? If it were a code bug in Telegram, thousands of users would be affected. Is something wrong with my specific environment?” This reframed the problem from “find the Telegram bug” to “find what’s different about this machine.” That shift in framing would pay off later.
”Don’t Build. Inject.”
Opus’s natural instinct was to build TelegramSwift from source with the fix applied. It started cloning submodules, installing brew dependencies, running the framework build scripts. But building from source requires compiling ten C/C++ framework dependencies (OpenH264, OpenSSL, libvpx, ffmpeg, webrtc…), a process that would take a very long time.
I stopped it. “Can’t we try dynamic injection first? Don’t be afraid of difficulty.”
This turned out to be the decision that cracked the case. Building from source would have validated a fix, but injection let us diagnose: observe the live process state and find the actual stuck signal, rather than guessing which one to fix.
Runtime injection into a compiled Swift binary with all debug symbols removed is not something you can wing. You need to know:
- The Swift object memory layout (isa pointer, refcount, stored properties, all at specific byte offsets)
- How SwiftSignalKit’s
ValuePromise,Signal,combineLatestwork internally, not the API, the implementation - The ObjC runtime’s method resolution, swizzling, and class introspection APIs
- How
malloc_size(),vm_read_overwrite(), and heap zone enumeration work - The difference between what’s visible to the ObjC runtime (NSObject subclasses) and what’s pure Swift (invisible to ObjC)
Without reading the SwiftSignalKit source, you wouldn’t know that ValuePromise stores its value at offset 16, or that combineLatest tracks emitted signals in an Atomic<SignalCombineState> dictionary at offset 80. Without understanding the Swift ABI, you wouldn’t know that _swiftEmptyArrayStorage is a global singleton representing []. This kind of deep, cross-cutting knowledge makes injection-based debugging possible, and nearly impossible for someone unfamiliar with the codebase.
The first attempt was DYLD_INSERT_LIBRARIES, the classic macOS dylib injection technique. It failed silently. Even with SIP disabled, even after re-signing the binary ad-hoc with the right entitlements, macOS 26 Tahoe just killed the process. AMFI enforcement at the kernel level appears to block this entirely now.
But dlopen() through lldb still works:
lldb -p <PID> --batch \
-o 'expr -l objc -- (void*)dlopen("/tmp/my_fix.dylib", 2)'
The dylib’s constructor runs immediately on the main thread. NSLog output shows up in the unified log. Opus had its way in.
Over the course of the session, Opus wrote, compiled, and injected around 15 different C/ObjC dylibs, each one probing a different aspect of the process state. Some crashed the process (calling swift_getTypeName on invalid metadata pointers). Some produced no useful output (NSLog content redacted by macOS’s privacy system). Each failure narrowed the search space.
The heap Tool: Finding a Needle in 256,000 Haystacks
The process had 256,314 heap-allocated objects. The question was: how do you find the one unresponsive signal among a quarter million objects?
Opus turned to /usr/bin/heap, a macOS tool that most developers have never heard of. It enumerates every object on a process’s heap, and it understands Swift generics:
$ heap <PID> -addresses 'SwiftSignalKit.ValuePromise<Swift.Array<TelegramCore.AttachMenuBot>>'
0x7ab5d1880: SwiftSignalKit.ValuePromise<Swift.Array<TelegramCore.AttachMenuBot>> (112 bytes)
One instance, 112 bytes. That’s the acceptBots signal. Opus read offset 16 in lldb (where ValuePromise stores its value: T? field):
(lldb) mem read 0x7ab5d1890
0x00000007ab1ddb80
Not null. The API had returned data. acceptBots was fine.
The initial hypothesis was dead.
12 Out of 13
With the prime suspect cleared, Opus changed tactics: instead of checking signals one by one, look at combineLatest’s own bookkeeping. SwiftSignalKit’s combineLatest internally maintains a dictionary that records which signals have responded. The Settings page only renders when all 13 entries are present.
The heap tool found 96 such objects in the process. Opus wrote a C dylib to scan each one, reading the dictionary’s _count field at a known memory offset:
void *dictPtr = *(void **)(ptr + 80);
int64_t count = *(int64_t *)((uint8_t *)dictPtr + 16);
Injected via dlopen, the output was:
State 0x7a5f1b410: values=12 cap=12
★★★ STUCK at 0x7a5f1b410: 12/13 ★★★
Twelve signals had reported in. One hadn’t. Opus decoded the dictionary keys to see which signal was missing:
0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12
Missing: index 7. UNUserNotifications.recurrentAuthorizationStatus.
Not the proxy. Not the bot API. The notification permission check.
The Callback That Never Comes
We’d found the missing signal. Now the question was why it wasn’t responding. The notification signal works like this:
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
}
}
Which eventually calls:
UNUserNotificationCenter.current().getNotificationSettings { settings in
if let value = AuthorizationStatus(rawValue: settings.authorizationStatus.rawValue) {
subscriber.putNext(value)
subscriber.putCompletion()
}
}
Opus used runtime method replacement (swizzling) to intercept every notification permission query on UNUserNotificationCenter:
getNotificationSettingsWithCompletionHandler: INTERCEPTED
Original dispatched. Waiting...
Ten calls logged. Zero callbacks received. Apple’s notification API was accepting the request and then… nothing.
The Dead Daemon
Apple’s API doesn’t just silently swallow requests for no reason. Opus checked the system daemon responsible for handling notifications:
$ launchctl list com.apple.usernotificationsd
"LastExitStatus" = -9;
No PID. Exit code -9 is SIGKILL. The notification daemon, usernotificationsd, was not running, and launchd had given up restarting it.
The system logs told the rest:
usernotificationsd: (AppleKeyStore) failed to open connection to AppleKeyStore
usernotificationsd: aks connection failed
Every ~10 seconds: spawn, fail to connect to AppleKeyStore, exit, get killed. Spawn again. Eventually launchd throttled it permanently.
When I checked, System Settings > Notifications also didn’t respond when clicked, which confirmed the notification subsystem was down. I hadn’t noticed because notifications aren’t something you actively check. How long it had been in this state is hard to say. My Settings page had been broken for months, but the daemon crash could have been the cause for some or all of that time.
The Fix
Opus manually started usernotificationsd. It came alive, with AppleKeyStore warnings, but functional. Then it:
- Deleted
~/Library/Preferences/com.apple.ncprefs.plist(the notification center preferences file, which contained a stale entry from a previously-installed Qt-based Telegram Desktop at the same path) - Restarted Telegram
- Injected a dylib that called
windowDidBecomeKeyon the app window, triggering the notification permission request chain - macOS showed a “Allow notifications?” dialog
- I clicked Allow
The Settings page populated instantly.
To verify, Opus also injected a mock notification response directly:
UNNotificationSettings *mock = [[UNNotificationSettings alloc] performSelector:@selector(init)];
[mock setValue:@2 forKey:@"authorizationStatus"];
handler(mock);
Settings appeared immediately. The missing 13th signal finally came through, combineLatest had all its inputs, and the table rendered.
After a reboot, everything still works. The AppleKeyStore errors still appear in logs, but the daemon runs fine now. The crash loop was caused by corrupted internal state, not directly by SIP being disabled (though SIP being off may have contributed to the original corruption).
The Telegram Bug
The system-level fix is one thing. But Telegram has a design problem: a non-essential notification permission check should never be able to block the entire Settings UI. The fix is one line:
// Before:
return context.window.keyWindowUpdater |> mapToSignal { ... }
// After:
return .single(.authorized) |> then(context.window.keyWindowUpdater |> mapToSignal { ... })
Emit a reasonable default immediately. Update with the real value later. The search feature already does this: SearchSettingsController uses its own 6-signal pipeline that doesn’t depend on notification authorization, which is why search always worked.
We submitted a pull request to TelegramSwift with this fix.
Lessons
combineLatest is a silent killer. If any of N signals never emits, the entire pipeline dies with zero indication. Always provide defaults: .single(default) |> then(realSignal).
macOS’s heap tool deserves more attention. It can enumerate every Swift generic specialization on the heap with exact type parameters and memory addresses. For debugging stripped Swift binaries, nothing else comes close.
System daemons can silently fail. usernotificationsd was not running when we checked. The only visible symptom was that System Settings > Notifications wouldn’t open, something I never thought to check because I had no reason to.
Two bugs, not one. A broken macOS daemon AND a fragile reactive pipeline. Either alone wouldn’t cause a blank Settings page. Together, they created a problem that survived months of reboots, macOS upgrades, and app reinstalls.
What Made This Possible
A year ago, this kind of investigation would have required a macOS internals specialist who also happens to be fluent in Swift, reactive programming, the ObjC runtime, and binary-level debugging. That’s a rare combination.
In this session, Opus did all of it: read and cross-referenced thousands of lines of unfamiliar Swift across multiple repos, wrote and compiled ~15 C/ObjC dylibs on the fly (iterating through crashes and compilation errors), interpreted raw memory at specific byte offsets, and maintained coherent context across the entire multi-hour investigation. It didn’t just suggest things for me to try. It executed autonomously, from git clone to writing the PR.
But it wasn’t a solo AI performance. The critical turns came from human judgment: I asked “why only me?” which reframed the investigation toward environment-specific causes. I said “don’t build, inject” which unlocked the diagnostic path that cracked the case. The AI had the depth to go wherever the investigation led; I had the instinct for which direction to point it.
This is what I think the new paradigm looks like. Not “AI replaces the engineer” or “AI assists the engineer,” but something more like a pair where one partner can hold an entire codebase in memory and write throwaway dylibs in seconds, while the other knows which questions to ask. The bottleneck is no longer “can we figure this out?” but “are we asking the right question?”