Why my macOS Messages badge lied to me (and the one-line fix)

I will be the first to admit that I'm sometimes bad at responding to texts 1 Instead of 'sometimes' my friends might say 'often'. But some of them have thousands of unread messages so I'm gonna use that strawman to ignore them all. — but I'm not 113-unread-messages bad, as the notification badge on my Mac makes me out to be.

It sits there mocking me, over a hundred messages that despite my best efforts I can never clear. No matter how many times I right-click to try and find them they remain tucked away somewhere, inactionable and yet unread, invisible yet demanding to be seen. "Look at me!" they shout. "You're missing out on the opportunity of a lifetime!" 2 Flexible ways to earn up to $500/day working from home! The ability to shape a political campaign with a timely $5 donation! Opportunity!

What it looks like with clearable messages What it looks like when I've lost hope

I've ignored this in the past and tabled it for later: it must stand the test of time. But that time is now. 3 Never miss an opportunity to quote my favorite movie opening monologue, Tom Wilkinson weaving wonder in Michael Clayton. Just go watch it (and then watch the full movie, Tilda Swinton slays). Here's how I used lldb to reverse engineer Messages, found a bug in Apple's code, and fixed my badge.

What the badge thinks is unread

We'll start with the best angle for any investigation—sheer dumb luck. 4 5 points to Gryffindor! If you search "badge" in the Mac's Console logs you'll find that it seems to be controlled by imagent and usernoted (if you wanted to be a little more principled you could probably trace this, or look up how Apple does badging and set breakpoints in usernoted, but luck 5 There's a proper Unicode character for a three-leafed clover, ☘︎, but to do four you have to bastardize the emoji: 🍀︎. As a fun fact the best estimate for four-leafed clover rarity comes from a Swiss couple who couldn't find a good source for commonly-cited numbers, and instead chose to analyze ~7 million clovers over several years, pegging it at approximately 1 in 5,040. It’s good to have hobbies. is more fun).

We can attach a debugger to it with lldb -n imagent 6 This is only possible because I have System Integrity Protection or SIP turned off (mostly for Spaces Renamer but more and more for stuff like this). and search loaded functions that have "unread" in the name with image lookup -rn "unread". The most promising is IMDaemonChatRequestHandler and its unreadCount methods, so we can huck the imagent binary into Hopper to disassemble it and take a look. 7 This process is much like smooshing a cake apart with your hands to figure out how many eggs the chef used: messy. It's backed by [[IMDMessageStore sharedInstance] unreadMessagesCount], which certainly sounds right.

Calling po [[IMDMessageStore sharedInstance] unreadMessagesCount] in lldb attached to imagent gives us a matching number, which is a great sign.

p/d is shorthand for "print decimal", showing that 0x71 in hex is 113 in decimal

We want to know where this logic lives. Normally the compiler creates a symbol table when building code that maps between the name of the function and the chunk of ones and zeroes that it corresponds to, and you can read from it with image lookup -n "+[IMDMessageStore sharedInstance]". Unfortunately macOS strips the symbol tables for private frameworks like imagent so that won't work. We can work around this by finding where the sharedInstance method pointer is stored in memory, and then looking up that memory block: it's in IMDaemonCore. 8 Daemon is the name for a computer background process, stemming from the glorious time in the 60s where you could coin a term in passing and have it survive decades. Personally it always makes me think of the dæmons from His Dark Materials, though I'll admit typing the æ(ash) character is a little trickier (godspeed to Elon and Grimes' son).

po class_getMethodImplementation((Class)objc_getMetaClass("IMDMessageStore"), (SEL)NSSelectorFromString(@"sharedInstance"))
# 0x63540001ba3443e0
image lookup -a 0x63540001ba3443e0
#  Address: IMDaemonCore[0x00000001b69443e0] (IMDaemonCore.__TEXT.__text + 1027656)
#  Summary: IMDaemonCore`___lldb_unnamed_symbol6499

image list IMDaemonCore
# [  0] 700E2D71-CAE8-3628-B422-5638E20E5A22 0x00000001ba246000 /System/Library/PrivateFrameworks/IMDaemonCore.framework/Versions/A/IMDaemonCore 

Disassembling this is slightly tricky because IMDaemonCore.framework/Versions/A/IMDaemonCore doesn't actually exist as a file: macOS (and iOS) merge their private frameworks into a large DYLD shared cache to help reduce startup time. 9 You can read more here if you're curious. Nerd. Instead to see a specific library like IMDaemonCore we have to drag the combined file from /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e into Hopper and choose it from the list.

This is another redirection, because unreadMessagesCount in IMDaemonCore just calls IMDMessageRecordCountAllUnreadMessages(), which bounces us to another framework, IMDPersistence. But if we load it in the same way as IMDaemonCore we can finally find the raw SQL (Structured Query Language) that IMDPersistence uses to query the Messages' internal database, ~/Library/Messages/chat.db. 10 This is what I used to back Markov Messages, my app that used Markov chains to create (ostensibly) plausible texts based on your chat history. Now that it's a decade later and WASM can read SQLite in-browser maybe it's worth an LLM-backed refresh...

We can jump to the full string allocation and tada! Here's the full query that you can run:

SELECT COUNT(1) FROM (
  SELECT
    m.rowid, cm.message_id, cm.chat_id
  FROM message m
  INNER JOIN chat_message_join cm
    ON cm.message_id = m.rowid
  INNER JOIN chat c
    ON c.rowid = cm.chat_id
  WHERE m.is_read == 0
    AND m.is_finished == 1
    AND m.is_from_me == 0
    AND m.item_type == 0
    AND m.is_system_message == 0
    AND c.is_archived == 0
    AND c.is_filtered != 2
)

What messages are missing?

We can get a sense of what kind of messages these are by grouping by service and account (I'm running commands in a SQLite browser app, but you can wrap queries with sqlite3 ~/Library/Messages/chat.db << 'SQL' and SQL to run it directly in Terminal). The majority are SMS to my work phone number, which I could understand causing some syncing issues, but there are still normal iMessage messages that should be working.

We can also check what content the messages have, though most of these don't have the text field populated and are storing the message content in attributedBody. Extracting this without directly parsing it is tricky, but you can get a rough feel with some hacky substrings by just running this in Terminal:

sqlite3 ~/Library/Messages/chat.db <<'SQL'
.mode column
.headers on
.width 60 20 20
SELECT
  COALESCE(text, printf('%s', CAST(
    SUBSTR(
      m.attributedBody,
      INSTR(m.attributedBody, CAST(x'2B' AS BLOB)) + 2,  -- '+' separator, then skip length byte
      200
    ) AS TEXT
  ))) AS message_text,
  COALESCE(NULLIF(c.display_name, ''), c.chat_identifier) AS chat,
  DATETIME(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') AS sent
FROM message m
INNER JOIN chat_message_join cm ON cm.message_id = m.rowid
INNER JOIN chat c ON c.rowid = cm.chat_id
WHERE m.is_read=0 AND m.is_finished=1 AND m.is_from_me=0
  AND m.item_type=0 AND m.is_system_message=0
  AND c.is_archived=0 AND c.is_filtered!=2
 ORDER BY m.date DESC;
SQL

Here's a sample of the very important messages being lost:

message_text                                                  sent                  chat
------------------------------------------------------------  --------------------  --------------
Cоіnbаѕе: Your withdrаwаl cоdе is 4**-***. If this wаѕn't y   2025-11-21 11:40:42   +1216*******
ou, call us at +1 888 *** *****.??
iI

????
NSDictionary

?                                                             2025-11-20 20:31:22   +1650*******
Hello, you have a visit with Life@ Wellness Center on Nov 21
at 10:00am PT. Be sure to review your confirmation email for
 visit details and check in for your visit ahead of

?                                                             2025-11-18 12:31:13   +1323*******

?                                                             2025-11-18 12:23:11   96***

?                                                             2025-10-26 11:20:13   +1650*******

7                                                             2025-10-23 17:04:07   +1916*******
May God help us if we lose - Gavin Newsom

Hi there. It's Gavin Newsom. I'm reaching out to respectfull
y ask you to make a contribution to help me pass the Electio
n Rigging Response Act in Califo

Now that we know specifically what it's querying we can modify chat.db to adjust is_read for these messages—but this can cause problems if there are other fields we don't know we need to update. Why doesn't the dock popup show these unread messages so we can clear them normally through the UI?

What the hell is going on with the dock popup?

We can look up dock popups (or Dock menus as Apple calls them) and see that they're defined with applicationDockMenu.

We can attach to Messages with lldb -n Messages and set a breakpoint for where this is called with breakpoint set --func-regex ".*applicationDockMenu.*". The second breakpoint is UIKitMacHelper`-[UINSApplicationDelegate applicationDockMenu:], and if we look at the code with disassemble --frame --count 50 we can see that it calls sharedShortcutManager and combines staticShortcutItems and dynamicShortcutItems. 11 Static here refers to those defined as constants in Info.plist file (in this case just 'New Message') vs. dynamic for the code-backed ones.

I can use a breakpoint on setDynamicShortcutItems to trace this to _unreadCountDidChange, which sets the dynamic shortcut items to any chat from [[IMChatRegistry sharedRegistry] cachedChats] with nonzero unreadMessageCount. 12 Interestingly it also calls CKISRunningInMacCatalyst(), a wrapper to enable custom functionality within iOS/iPad apps running on macOS. I'm not sure when Messages swapped over to being primarily a mobile app (I'm currently on Sequoia 15.6), but I think it's been recent.

We can replicate this in lldb, iterating over the chats and printing them out if they have unread messages:

expr -l objc -O --
NSMutableArray *r = [NSMutableArray array];
for (id c in (id)[[IMChatRegistry sharedRegistry] cachedChats]) {
  unsigned long n = (unsigned long)[c unreadMessageCount];
  if (n > 0) [r addObject:@{@"guid": (id)[c guid], @"number": (id)[(id)[c contextInfo] groupID], @"participants": (id)[c participants], @"unread": @(n)}];
}
r
# <__NSArrayM 0xbddaf8810>(
# {
#   guid = "SMS;-;*****
# ...

This has substantially fewer chats than the badge count, and searching the list for a phone number that's not showing up doesn't find the relevant IMChat—it's not even there to check the unreadMessageCount.

expr -l objc -O --
NSMutableArray *r = [NSMutableArray array];
for (id c in (id)[[IMChatRegistry sharedRegistry] cachedChats]) {
  id gid = (id)[(id)[c contextInfo] groupID];
  if ([(NSString *)gid isEqualToString:@"+14958675309"]) {
    [r addObject:c];
  }
}
r
# <__NSArrayM 0xbddafafa0>(
# 
# )

The question is therefore what populates IMChatRegistry and cachedChats?

What populates IMChatRegistry

We can decompile -[IMChatRegistry cachedChats] to see that it just reads it from a property, _cachedChatsInThreadNameMap, at an offset of 0x98.

po ivar_getOffset((void *)class_getInstanceVariable((Class)objc_getClass("IMChatRegistry"), "_cachedChatsInThreadNameMap"))
# 0x98

We can then look through the references to #0x98 in IMCore under IMChatRegistry to find all of the functions that read or write to it: 13 The script I used is detailed here but it basically involves cross-referencing loads and writes to 0x98 with functions in IMChatRegistry.

The last two seem the most useful: _registerChatDictionary because it appends to the cache, and _resetChatRegistry because it allows us to clear the cache for debugging. Let's set a breakpoint for _registerChatDictionary::

# Figure out where the _registerChatDictionary... method lives
# (objc_getClass for instance methods, objc_getMetaClass for class methods)
p/x (void *)class_getMethodImplementation((Class)objc_getClass("IMChatRegistry"), (SEL)NSSelectorFromString(@"_registerChatDictionary:forChat:isIncoming:newGUID:shouldPostNotification:"))
# p/x because it's interpreted as a date, but we know it's an address
(__NSTaggedDate *) 0x8f200001cedd0d80 2001-01-01 00:00:42 UTC
# Set a breakpoint
b 0x8f200001cedd0d80
# Clear cache
po [[IMChatRegistry sharedRegistry] _resetChatRegistry]

With this set up we can open Messages and click into a chat, triggering this trace that calls out over XPC, Apple's Cross-Process Communications framework:

* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
  * frame #0: 0x00000001cedd0d80 IMCore`___lldb_unnamed_symbol7909
...
    frame #4: 0x0000000183f3f1a4 CoreFoundation`-[NSInvocation invoke] + 424
    frame #5: 0x00000001854f2644 Foundation`__NSXPCCONNECTION_IS_CALLING_OUT_TO_REPLY_BLOCK__ + 16
    frame #6: 0x00000001854f0e40 Foundation`-[NSXPCConnection _decodeAndInvokeReplyBlockWithEvent:sequence:replyInfo:] + 532
    frame #7: 0x00000001854f0798 Foundation`__88-[NSXPCConnection _sendInvocation:orArguments:count:methodSignature:selector:withProxy:]_block_invoke_3 + 188
    frame #8: 0x00000001854ee728 Foundation`-[NSXPCConnection _sendInvocation:orArguments:count:methodSignature:selector:withProxy:] + 2172
    frame #9: 0x00000001854f48b8 Foundation`-[NSXPCConnection _sendSelector:withProxy:arg1:arg2:] + 128
    frame #10: 0x00000001854f47e0 Foundation`_NSXPCDistantObjectSimpleMessageSend2 + 68
...
    frame #26: 0x00000001d31fb254 ChatKit`-[CKConversationList conversationForExistingChatWithGUID:] + 76
...
    frame #31: 0x00000001d353c404 ChatKit`__86-[CKConversationListCollectionViewController collectionView:didSelectItemAtIndexPath:]_block_invoke + 288

Jumping frames and printing variables shows it calling out to imagent for loadChatWithGUID:reply: with an explicit chat GUID:

# Print the connection details (ps -p 66515 confirms that this is imagent)
po $x0
<NSXPCConnection: 0x768129860> connection to service with pid 66515 named
com.apple.imagent.desktop.auth

# Print the selector
p (char *)$x6
loadChatWithGUID:reply:

# Print the argument count
p/d $x4
2

# Print the arguments (we know there are 2)
po ((id *)$x3)[0]
iMessage;+;chat440831645673026476
po ((id *)$x3)[1]
 <__NSMallocBlock__: 0x76ad14570>
  signature: "v16@?0@"NSArray"8"
  invoke   : 0xee498001ced44920 (/System/iOSSupport/System/Library/PrivateFrameworks/IMCore.framework/Versions/A/IMCore`IMCoreSimulatedEnvironmentEnabled)

The stacktrace also shows us a way to load an arbitrary missing chat, with [[CKConversationList sharedConversationList] conversationForExistingChatWithGUID:@"SMS;-;+14958675309"]. 14 I found sharedConversationList by looking at all of the CKConversationList functions with po [@"" __methodDescriptionForClass:[CKConversationList class]] Unfortunately while this does work it has an unread count of 0.

po [[IMChatRegistry sharedRegistry] _resetChatRegistry]
expr -i 0 -- (void)[[CKConversationList sharedConversationList] conversationForExistingChatWithGUID:@"SMS;-;+14958675309"]
po [[IMChatRegistry sharedRegistry] cachedChats]
# Unread Count: 0
<__NSSingleObjectArrayI 0x6ff885580>(
  <IMChat 0x6fe9d8c00> [Identifier: +14958675309   GUID: SMS;-;+14958675309
  Persistent ID: +14958675309   Account: D7F3CF71-****-****-****-************
  Style: -   State: 3  Participants: 1  Room Name: (null)  Display Name: (null)
  Unregistered: NO Last Addressed Handle: +1650******* 
  Last Addressed SIMID: 4273B56F-****-****-****-************
  Group ID: 1957F2C2-****-****-****-************  Unread Count: 0 
  Failure Count: 0, isFiltered: 1, hasHadSuccessfulQuery: NO, bizIntent: (null)
  personCentricID: +14958675309 mergedPinningIdentifiers: (null), isRecovered: NO
  isDeletingIncomingMessages: NO]
)

Where this IMChat comes from

To figure out why the unread count is 0 we need to know the imagent logic. We can set a breakpoint on br s -n '-[NSInvocation invokeWithTarget:]' to get the other side of the coin, 15 Why do coins have two different sides? This stemmed from early coinage where they were roughly hammered into a mold, leaving the other side rough. Over time this evolved into raw punches: which then became ornate: and finally into more opportunities for what really matters: depictions of gods and leaders. -[IMDaemonChatRequestHandler loadChatWithGUID:reply:], and decompile it to see the two things it's doing:

po [[IMDChatRegistry sharedInstance] existingChatWithGUID:@"SMS;-;+14958675309"]
# self is IMDaemonChatRequestHandler, chat is the return of the previous function.
# (All _loadChat does is enrich the result from existingChatWithGUID
# with metadata from Contacts)
po [self _loadChat:chat completionHandler:reply]

existingChatWithGUID on the other hand just reads from two caches, notably returning nothing if neither has it.

# First we check the cache
po [[IMDChatRegistry sharedInstance] _cachedChatWithGUID:@"SMS;-;+14958675309"]
# Which reads from the _chats variable
po [[IMDChatRegistry sharedInstance] valueForKey:@"chats"]
# If that's not set we check the chatStore
po [[[IMDChatRegistry sharedInstance] chatStore] chatWithGUID:@"SMS;-;+14958675309"]
# Also accessible with a shared instance
po [[IMDChatStore sharedInstance] chatWithGUID:@"SMS;-;+14958675309"]

So where are these caches are set?

Figuring out where the cache is set

Let's take an individual that does get a nonzero unread count and figure out where that's coming from. 16 All debugging needs a healthy repro and an unhealthy repro, or you end up chasing all sorts of rabbit holes that the healthy one also goes down. imagent caches all of its chat objects, so if we want to know how they're initialized we'll need to beat it to the punch. We can easily do this 17 Much easier than beating people to physical punches, given that I'd bet my firstborn on Kalshi that I'd get laid out before I landed one in a fight. by attaching with --wait-for and restarting it to attached right when it starts, before it builds its cache. We can then immediately break on -[IMDChat _setUnreadCount:] and figure out where it's called. 18 Sometimes dynamic libraries aren't loaded when --wait-for triggers. In that case we can run settings set target.process.stop-on-sharedlibrary-events true to break every time a shared library gets loaded, and step through until it works.

lldb --wait-for -n imagent
# In another window run `killall -9 imagent`
p/x (void *)class_getMethodImplementation((Class)objc_getClass("IMDChat"), (SEL)NSSelectorFromString(@"_setUnreadCount:"))
# 0xf4360001ba35b764
b -a 0xf4360001ba35b764

and then attached to Messages:

po [[IMChatRegistry sharedRegistry] _resetChatRegistry]
# Healthy, pick someone that has unread
expr -i 0 -- (void)[[CKConversationList sharedConversationList] conversationForExistingChatWithGUID:@"iMessage;+;chat440831645673026476"]
# Broken
expr -i 0 -- (void)[[CKConversationList sharedConversationList] conversationForExistingChatWithGUID:@"SMS;-;+14958675309"]

This gives us a call chain from _loadChatsIncludingAllChats where it ends up setting unread counts.

0x00000001ba35b764: -[IMDChat _setUnreadCount:]
0x00000001ba2caa08: sub_1b68ca608 // (reads the unread count)
0x00000001ba390458: sub_1b699043c // handler block which processes the chat with sub_1b68ca608
0x00000001ba3901ac: -[IMDChatStore _loadChatsIncludingAllChats:]
0x00000001ba37830c: -[IMDChatRegistry loadChatsWithCompletionBlock:]

Looking through the decompiled code for sub_1b68ca608 shows that we're setting the unread count from IMDChatRecord with IMDChatRecordCachedUnreadCount(). That function is relatively simple, just reading a field (offset 0x28) from the IMDChatRecord.

The IMDChatRecord comes from _loadChatsIncludingAllChats and more specifically IMDChatRecordCopyAllUnreadChatsAndRecentChatsWithLimit, which is invoked with two limit arguments: one for "known senders", which defaults to 200 and is overrideable with the default RecentChatsToLoad, and one for "other filters, which defaults to 64 and is overrideable with RecentFilteredChatsToLoad.

With a lot of disassembling and breakpoints we can break it down into three functions and SQL runs. 19 Most of this is just the grindy work of setting breakpoints, cross-referencing with decompilers, turning into pseudocode, repeat ad nauesum, ad finem. For readability this is left as an exercise for the reader.

The first is +[IMDChatQueryStrings copyRecentChatsQueryWithFilterCount:] and it's the primary source of IMDChatRecords. It concatenates 5 versions of the same SQL query, just tweaking the is_filtered status: 200 results (or RecentChatsToLoad) for is_filtered = 0 and 64 results (or RecentFilteredChatsToLoad) for the other filter values, prioritizing the chats with the most recent messages.

SELECT *
FROM (SELECT ROWID, guid, style, state, account_id, properties, chat_identifier, service_name, room_name, account_login, is_archived, last_addressed_handle, display_name, group_id, is_filtered, successful_query, engram_id, server_change_token, ck_sync_state, original_group_id, last_read_message_timestamp, cloudkit_record_id, last_addressed_sim_id, is_blackholed, syndication_date, syndication_type, is_recovered, is_deleting_incoming_messages
FROM chat
JOIN chat_message_join ON chat_message_join.chat_id == chat.rowid
WHERE chat.is_archived = 0 AND chat.is_filtered = 0
GROUP BY chat.rowid
ORDER BY MAX(chat_message_join.message_date) DESC
LIMIT 200)

UNION ALL

SELECT *
FROM (SELECT ROWID, guid, style, state, account_id, properties, chat_identifier, service_name, room_name, account_login, is_archived, last_addressed_handle, display_name, group_id, is_filtered, successful_query, engram_id, server_change_token, ck_sync_state, original_group_id, last_read_message_timestamp, cloudkit_record_id, last_addressed_sim_id, is_blackholed, syndication_date, syndication_type, is_recovered, is_deleting_incoming_messages
FROM chat
JOIN chat_message_join ON chat_message_join.chat_id == chat.rowid
WHERE chat.is_archived = 0 AND chat.is_filtered = 1
GROUP BY chat.rowid
ORDER BY MAX(chat_message_join.message_date) DESC
LIMIT 64)

UNION ALL

SELECT *
FROM (SELECT ROWID, guid, style, state, account_id, properties, chat_identifier, service_name, room_name, account_login, is_archived, last_addressed_handle, display_name, group_id, is_filtered, successful_query, engram_id, server_change_token, ck_sync_state, original_group_id, last_read_message_timestamp, cloudkit_record_id, last_addressed_sim_id, is_blackholed, syndication_date, syndication_type, is_recovered, is_deleting_incoming_messages
FROM chat
JOIN chat_message_join ON chat_message_join.chat_id == chat.rowid
WHERE chat.is_archived = 0 AND chat.is_filtered = 2
GROUP BY chat.rowid
ORDER BY MAX(chat_message_join.message_date) DESC
LIMIT 64)

UNION ALL

SELECT *
FROM (SELECT ROWID, guid, style, state, account_id, properties, chat_identifier, service_name, room_name, account_login, is_archived, last_addressed_handle, display_name, group_id, is_filtered, successful_query, engram_id, server_change_token, ck_sync_state, original_group_id, last_read_message_timestamp, cloudkit_record_id, last_addressed_sim_id, is_blackholed, syndication_date, syndication_type, is_recovered, is_deleting_incoming_messages
FROM chat
JOIN chat_message_join ON chat_message_join.chat_id == chat.rowid
WHERE chat.is_archived = 0 AND chat.is_filtered = 3
GROUP BY chat.rowid
ORDER BY MAX(chat_message_join.message_date) DESC
LIMIT 64)

UNION ALL

SELECT *
FROM (SELECT ROWID, guid, style, state, account_id, properties, chat_identifier, service_name, room_name, account_login, is_archived, last_addressed_handle, display_name, group_id, is_filtered, successful_query, engram_id, server_change_token, ck_sync_state, original_group_id, last_read_message_timestamp, cloudkit_record_id, last_addressed_sim_id, is_blackholed, syndication_date, syndication_type, is_recovered, is_deleting_incoming_messages
FROM chat
JOIN chat_message_join ON chat_message_join.chat_id == chat.rowid
WHERE chat.is_archived = 0 AND chat.is_filtered = 4
GROUP BY chat.rowid
ORDER BY MAX(chat_message_join.message_date) DESC
LIMIT 64)

The second query bolsters these results with +[IMDChatQueryStrings copyChatGUIDsWithUnreadMessagesQuery], selecting all unread messages. 20 Up to a max of 2,000 minus the number returned from the first query. I think this is just an arbitrary bound to avoid choking on large backlogs. These are notably less detailed, missing many of the fields from copyRecentChatsQueryWithFilterCount.

SELECT
  cm.chat_id,
  c.guid,
  COUNT(1)
FROM
  message m
INNER JOIN chat_message_join cm
  ON m.ROWiD = cm.message_id
INNER JOIN chat c
  ON c.ROWID = cm.chat_id
WHERE
  m.item_type == 0
  AND m.is_read == 0
  AND m.is_from_me == 0
GROUP BY
  +cm.chat_id
LIMIT 1736

And finally, there's _IMDMessageRecordCountAllUnreadMessagesForAllUnreadChats. This gets the unread count for every chat, quite similar to the second SQL query but without the limit. However where the second query backs which chats are in the list, this third query ends up backing unread_count_cache and the "Unread Count" we later read.

SELECT
  cm.chat_id,
  COUNT(1)
FROM message m
INNER JOIN chat_message_join cm
  ON m.ROWiD = cm.message_id
WHERE
  m.item_type == 0
  AND m.is_read == 0
  AND m.is_from_me == 0
GROUP BY
  cm.chat_id

"But wait," you interrupt. 21 Hypothetically, in a world where your eyes haven't glazed over. "This has the unread count for every chat. Shouldn't it be nonzero?" How right you are: it actually is nonzero, and we can prove it with some frame jumping.

# Do the same `imagent` waitfor command, and break on _setUnreadCount
frame select 3
# This has (for me) 348 chats: 200 + 64 + 84 unread chats
p/d [(NSArray *)$x23 count]
# Filter down to the one that matches +14958675309
expr -l objc -O --
NSMutableArray *r = [NSMutableArray array];
NSArray *a = (id)$x23;
for (unsigned long i = 0; i < [a count]; i++) {
  id rec = [a objectAtIndex:i];
  NSString *guid = [rec guid];
  if ([(NSString *)guid isEqualToString:@"SMS;-;+14958675309"]) {
    [r addObject:@{@"index": @(i), @"record": rec, @"guid":guid}];
  }
}
r
# <__NSArrayM 0x6c59b1a10>(
# {
#     guid = "SMS;-;+14958675309";
#     index = 275;
#     record = "<IMDChatRecord: 0x6c4092700>";
# }
# )
p/d (int)IMDChatRecordCachedUnreadCount(0x6c4092700)
# 1 - It finds the unread count!!

But if the unread count is correct why were we getting 0 earlier?

The bug

IMDChatRecordCachedUnreadCount gets the right value, but it calls _setUnreadCount with it on the result of -[IMDChat initWithAccountID:service:guid:groupID:chatIdentifier:...]...and unfortunately this returns nil if the chatIdentifier is nil.

For chats surfaced from the first query the chat identifier is set, but chats surfaced from the second query are missing it. In these cases the unread count is ignored entirely. We can validate this by setting a breakpoint in imagent to confirm that the IMDChat is nil:

# Set a breakpoint for right before IMDChatRecordCachedUnreadCount (IMDaemonCore)
b 0x1ba2ca9e8
breakpoint modify -c '(BOOL)[(id)[(id)$x19 guid] containsString:@"+14958675309"]' 1
# And then when it fires
po $x21
# nil

The fix

Luckily we don't have to wait on Apple to fix this. 22 Though if you're an employee, rdar://FB22665132. Chats resulting from the second query are broken, but if they show up in the first query they work fine. The first query truncates according to limits, but we can change those limits with defaults. 23 I promised 'one line' in the title, so technically I only needed to run the second. Simply set them both to large values, restart Messages and...

defaults write com.apple.messages RecentChatsToLoad -int 1000
defaults write com.apple.messages RecentFilteredChatsToLoad -int 1000
pkill -9 imagent IMDPersistenceAgent Messages

...it works!

Working on this meant attaching lldb to Messages, which broke text and FaceTime notifications. If debugging this took any longer I think my girlfriend might have legal grounds to kill me.
Thank god  24

After you've gone through the chats I recommend reverting this to avoid any potential latency regressions. After all, as long as you stay on top of your chats 25 Alright, alright, I'll take responsibility. it should be okay. You wouldn't fall behind again, would you? Would you?

defaults delete com.apple.messages RecentChatsToLoad
defaults delete com.apple.messages RecentFilteredChatsToLoad
pkill -9 imagent IMDPersistenceAgent Messages

  1. Instead of 'sometimes' my friends might say 'often'. But some of them have thousands of unread messages so I'm gonna use that strawman to ignore them all. ↩︎

  2. Flexible ways to earn up to $500/day working from home! The ability to shape a political campaign with a timely $5 donation! Opportunity! ↩︎

  3. Never miss an opportunity to quote my favorite movie opening monologue, Tom Wilkinson weaving wonder in Michael Clayton. Just go watch it (and then watch the full movie, Tilda Swinton slays). ↩︎

  4. 5 points to Gryffindor! ↩︎

  5. There's a proper Unicode character for a three-leafed clover, ☘︎, but to do four you have to bastardize the emoji: 🍀︎. As a fun fact the best estimate for four-leafed clover rarity comes from a Swiss couple who couldn't find a good source for commonly-cited numbers, and instead chose to analyze ~7 million clovers over several years, pegging it at approximately 1 in 5,040. It’s good to have hobbies. ↩︎

  6. This is only possible because I have System Integrity Protection or SIP turned off (mostly for Spaces Renamer but more and more for stuff like this). ↩︎

  7. This process is much like smooshing a cake apart with your hands to figure out how many eggs the chef used: messy. ↩︎

  8. Daemon is the name for a computer background process, stemming from the glorious time in the 60s where you could coin a term in passing and have it survive decades. Personally it always makes me think of the dæmons from His Dark Materials, though I'll admit typing the æ(ash) character is a little trickier (godspeed to Elon and Grimes' son). ↩︎

  9. You can read more here if you're curious. Nerd. ↩︎

  10. This is what I used to back Markov Messages, my app that used Markov chains to create (ostensibly) plausible texts based on your chat history. Now that it's a decade later and WASM can read SQLite in-browser maybe it's worth an LLM-backed refresh... ↩︎

  11. Static here refers to those defined as constants in Info.plist file (in this case just 'New Message') vs. dynamic for the code-backed ones. ↩︎

  12. Interestingly it also calls CKISRunningInMacCatalyst(), a wrapper to enable custom functionality within iOS/iPad apps running on macOS. I'm not sure when Messages swapped over to being primarily a mobile app (I'm currently on Sequoia 15.6), but I think it's been recent. ↩︎

  13. The script I used is detailed here but it basically involves cross-referencing loads and writes to 0x98 with functions in IMChatRegistry↩︎

  14. I found sharedConversationList by looking at all of the CKConversationList functions with po [@"" __methodDescriptionForClass:[CKConversationList class]] ↩︎

  15. Why do coins have two different sides? This stemmed from early coinage where they were roughly hammered into a mold, leaving the other side rough. Over time this evolved into raw punches: which then became ornate: and finally into more opportunities for what really matters: depictions of gods and leaders.  ↩︎

  16. All debugging needs a healthy repro and an unhealthy repro, or you end up chasing all sorts of rabbit holes that the healthy one also goes down. ↩︎

  17. Much easier than beating people to physical punches, given that I'd bet my firstborn on Kalshi that I'd get laid out before I landed one in a fight. ↩︎

  18. Sometimes dynamic libraries aren't loaded when --wait-for triggers. In that case we can run settings set target.process.stop-on-sharedlibrary-events true to break every time a shared library gets loaded, and step through until it works. ↩︎

  19. Most of this is just the grindy work of setting breakpoints, cross-referencing with decompilers, turning into pseudocode, repeat ad nauesum, ad finem. For readability this is left as an exercise for the reader. ↩︎

  20. Up to a max of 2,000 minus the number returned from the first query. I think this is just an arbitrary bound to avoid choking on large backlogs. ↩︎

  21. Hypothetically, in a world where your eyes haven't glazed over. ↩︎

  22. Though if you're an employee, rdar://FB22665132↩︎

  23. I promised 'one line' in the title, so technically I only needed to run the second. ↩︎

  24. Working on this meant attaching lldb to Messages, which broke text and FaceTime notifications. If debugging this took any longer I think my girlfriend might have legal grounds to kill me.  ↩︎

  25. Alright, alright, I'll take responsibility. ↩︎