From f8f0c5d7121ec0d711fcc377046cf42cfea113ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 08:51:46 +0100 Subject: [PATCH] fix: normalize typing chat lookup --- CHANGELOG.md | 5 +- README.md | 3 + Sources/IMsgCore/TypingIndicator.swift | 62 +++++++++++++++++-- Sources/IMsgHelper/IMsgInjected.m | 44 ++++++++++--- .../IMsgCoreTests/TypingIndicatorTests.swift | 37 +++++++++++ 5 files changed, 132 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6402c57..33dad01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 77ff9e7..ba5c360 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/Sources/IMsgCore/TypingIndicator.swift b/Sources/IMsgCore/TypingIndicator.swift index 836c669..edd7120 100644 --- a/Sources/IMsgCore/TypingIndicator.swift +++ b/Sources/IMsgCore/TypingIndicator.swift @@ -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() + 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 { diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 447fb40..004acc1 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -75,6 +75,19 @@ static NSDictionary* errorResponse(NSInteger requestId, NSString *error) { #pragma mark - Chat Resolution +static NSArray* 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; } diff --git a/Tests/IMsgCoreTests/TypingIndicatorTests.swift b/Tests/IMsgCoreTests/TypingIndicatorTests.swift index e3ed939..1b559b4 100644 --- a/Tests/IMsgCoreTests/TypingIndicatorTests.swift +++ b/Tests/IMsgCoreTests/TypingIndicatorTests.swift @@ -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) +}