fix: normalize typing chat lookup

This commit is contained in:
Peter Steinberger 2026-05-04 08:51:46 +01:00
parent 6253bdd135
commit f8f0c5d712
No known key found for this signature in database
5 changed files with 132 additions and 19 deletions

View File

@ -19,9 +19,8 @@
- fix: confirm standard tapback reaction selection in Messages automation (#53, thanks @PeterRosdahl)
- fix: gate RPC watch reaction metadata on `include_reactions`, not `attachments` (#82)
- fix: dedupe URL balloon preview duplicates in watch stream without cross-chat/schema regressions (#64, thanks @lesaai)
- fix: remove non-functional `typing` command and related RPC methods
- fix: remove unsupported standalone IMCore typing path and stale error branch
- test: drop typing-specific unit/integration tests with command/RPC surface removal
- fix: normalize IMCore typing chat lookup across `iMessage`, `SMS`, and `any` prefixes (#51, #54, #56, #58)
- docs: document macOS 26 advanced IMCore injection limits (#60)
## 0.5.0 - 2026-02-16

View File

@ -132,6 +132,9 @@ Important:
- This is opt-in only. Default send/history/watch flows do not need injection.
- `imsg launch` refuses to inject when SIP is enabled.
- `imsg status` is read-only and does not auto-launch or auto-inject.
- macOS 26 can also block Messages.app dylib injection with library validation.
In that case `imsg status` reports advanced features unavailable even with SIP
disabled; normal send/history/watch commands still work.
Setup:
1) Disable SIP from Recovery mode: `csrutil disable`

View File

@ -197,19 +197,27 @@ public struct TypingIndicator: Sendable {
throw IMsgError.typingIndicatorFailed("Failed to get IMChatRegistry shared instance")
}
let candidates = chatLookupCandidates(for: identifier)
let guidSel = sel_registerName("existingChatWithGUID:")
if registry.responds(to: guidSel) {
if let chat = registry.perform(guidSel, with: identifier)?.takeUnretainedValue() as? NSObject
{
return chat
for candidate in candidates {
if let chat = registry.perform(guidSel, with: candidate)?.takeUnretainedValue()
as? NSObject
{
return chat
}
}
}
let identSel = sel_registerName("existingChatWithChatIdentifier:")
if registry.responds(to: identSel) {
if let chat = registry.perform(identSel, with: identifier)?.takeUnretainedValue() as? NSObject
{
return chat
for candidate in candidates {
if let chat = registry.perform(identSel, with: candidate)?.takeUnretainedValue()
as? NSObject
{
return chat
}
}
}
@ -217,6 +225,48 @@ public struct TypingIndicator: Sendable {
"Chat not found for identifier: \(identifier). "
+ "Make sure Messages.app has an active conversation with this contact.")
}
static func chatLookupCandidates(for identifier: String) -> [String] {
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
let bareIdentifier = stripKnownChatPrefix(trimmed) ?? trimmed
var candidates = [trimmed]
if bareIdentifier != trimmed {
candidates.append(bareIdentifier)
}
for prefix in chatIdentifierPrefixes {
candidates.append(prefix + bareIdentifier)
}
return dedupe(candidates)
}
private static let chatIdentifierPrefixes = [
"iMessage;-;",
"iMessage;+;",
"SMS;-;",
"SMS;+;",
"any;-;",
"any;+;",
]
private static func stripKnownChatPrefix(_ value: String) -> String? {
for prefix in chatIdentifierPrefixes where value.hasPrefix(prefix) {
return String(value.dropFirst(prefix.count))
}
return nil
}
private static func dedupe(_ values: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for value in values where !value.isEmpty {
if seen.insert(value).inserted {
result.append(value)
}
}
return result
}
}
private final class DaemonConnectionTracker: @unchecked Sendable {

View File

@ -75,6 +75,19 @@ static NSDictionary* errorResponse(NSInteger requestId, NSString *error) {
#pragma mark - Chat Resolution
static NSArray<NSString *>* chatIdentifierPrefixes(void) {
return @[@"iMessage;-;", @"iMessage;+;", @"SMS;-;", @"SMS;+;", @"any;-;", @"any;+;"];
}
static NSString* stripKnownChatPrefix(NSString *value) {
for (NSString *prefix in chatIdentifierPrefixes()) {
if ([value hasPrefix:prefix]) {
return [value substringFromIndex:prefix.length];
}
}
return nil;
}
/// Try multiple methods to find a chat, including GUID lookup, chat identifier,
/// and participant matching with phone number normalization.
static id findChat(NSString *identifier) {
@ -91,6 +104,7 @@ static id findChat(NSString *identifier) {
}
id chat = nil;
NSString *bareIdentifier = stripKnownChatPrefix(identifier) ?: identifier;
// Method 1: Try existingChatWithGUID: with the identifier as-is (if it looks like a GUID)
SEL guidSel = @selector(existingChatWithGUID:);
@ -104,9 +118,8 @@ static id findChat(NSString *identifier) {
}
// Try constructing GUIDs with common prefixes (iMessage, SMS, any)
NSArray *prefixes = @[@"iMessage;-;", @"iMessage;+;", @"SMS;-;", @"SMS;+;", @"any;-;", @"any;+;"];
for (NSString *prefix in prefixes) {
NSString *fullGUID = [prefix stringByAppendingString:identifier];
for (NSString *prefix in chatIdentifierPrefixes()) {
NSString *fullGUID = [prefix stringByAppendingString:bareIdentifier];
chat = [registry performSelector:guidSel withObject:fullGUID];
if (chat) {
NSLog(@"[imsg-bridge] Found chat via existingChatWithGUID: %@", fullGUID);
@ -123,6 +136,13 @@ static id findChat(NSString *identifier) {
NSLog(@"[imsg-bridge] Found chat via existingChatWithChatIdentifier: %@", identifier);
return chat;
}
if (![bareIdentifier isEqualToString:identifier]) {
chat = [registry performSelector:identSel withObject:bareIdentifier];
if (chat) {
NSLog(@"[imsg-bridge] Found chat via existingChatWithChatIdentifier: %@", bareIdentifier);
return chat;
}
}
}
// Method 3: Iterate all chats and match by participant
@ -138,12 +158,13 @@ static id findChat(NSString *identifier) {
// Normalize the search identifier for phone number matching
NSString *normalizedIdentifier = nil;
if ([identifier hasPrefix:@"+"] || [identifier hasPrefix:@"1"] ||
if (bareIdentifier.length > 0 &&
([bareIdentifier hasPrefix:@"+"] || [bareIdentifier hasPrefix:@"1"] ||
[[NSCharacterSet decimalDigitCharacterSet]
characterIsMember:[identifier characterAtIndex:0]]) {
characterIsMember:[bareIdentifier characterAtIndex:0]])) {
NSMutableString *digits = [NSMutableString string];
for (NSUInteger i = 0; i < identifier.length; i++) {
unichar c = [identifier characterAtIndex:i];
for (NSUInteger i = 0; i < bareIdentifier.length; i++) {
unichar c = [bareIdentifier characterAtIndex:i];
if ([[NSCharacterSet decimalDigitCharacterSet] characterIsMember:c]) {
[digits appendFormat:@"%C", c];
}
@ -155,7 +176,8 @@ static id findChat(NSString *identifier) {
// Check GUID
if ([aChat respondsToSelector:@selector(guid)]) {
NSString *chatGUID = [aChat performSelector:@selector(guid)];
if ([chatGUID isEqualToString:identifier]) {
if ([chatGUID isEqualToString:identifier] ||
[chatGUID isEqualToString:bareIdentifier]) {
NSLog(@"[imsg-bridge] Found chat by GUID exact match: %@", chatGUID);
return aChat;
}
@ -164,7 +186,8 @@ static id findChat(NSString *identifier) {
// Check chatIdentifier
if ([aChat respondsToSelector:@selector(chatIdentifier)]) {
NSString *chatId = [aChat performSelector:@selector(chatIdentifier)];
if ([chatId isEqualToString:identifier]) {
if ([chatId isEqualToString:identifier] ||
[chatId isEqualToString:bareIdentifier]) {
NSLog(@"[imsg-bridge] Found chat by chatIdentifier exact match: %@", chatId);
return aChat;
}
@ -177,7 +200,8 @@ static id findChat(NSString *identifier) {
for (id handle in participants) {
if ([handle respondsToSelector:@selector(ID)]) {
NSString *handleID = [handle performSelector:@selector(ID)];
if ([handleID isEqualToString:identifier]) {
if ([handleID isEqualToString:identifier] ||
[handleID isEqualToString:bareIdentifier]) {
NSLog(@"[imsg-bridge] Found chat by participant exact match: %@", handleID);
return aChat;
}

View File

@ -41,3 +41,40 @@ func typingIndicatorStopsAfterNormalDuration() async throws {
#expect(didSleep == true)
#expect(events == ["start", "stop"])
}
@Test
func typingLookupCandidatesExpandAnyPrefixToServiceVariants() {
let candidates = TypingIndicator.chatLookupCandidates(for: "any;-;+15551234567")
#expect(
candidates == [
"any;-;+15551234567",
"+15551234567",
"iMessage;-;+15551234567",
"iMessage;+;+15551234567",
"SMS;-;+15551234567",
"SMS;+;+15551234567",
"any;+;+15551234567",
])
}
@Test
func typingLookupCandidatesAvoidDoublePrefixingDirectIdentifiers() {
let candidates = TypingIndicator.chatLookupCandidates(for: " iMessage;-;user@example.com ")
#expect(
candidates == [
"iMessage;-;user@example.com",
"user@example.com",
"iMessage;+;user@example.com",
"SMS;-;user@example.com",
"SMS;+;user@example.com",
"any;-;user@example.com",
"any;+;user@example.com",
])
}
@Test
func typingLookupCandidatesRejectBlankIdentifier() {
#expect(TypingIndicator.chatLookupCandidates(for: " ").isEmpty)
}