From c7d269fbb0cb5285d0f6781faa27feb0ed85fd49 Mon Sep 17 00:00:00 2001 From: test Date: Sat, 18 Jul 2026 23:08:06 -0400 Subject: [PATCH 1/2] fix(bridge): repair group add/remove-member on macOS 26 (two stacked bugs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat-add-member failed with "Could not vend handle" for every address — including confirmed-reachable iMessage numbers — while chat-create worked. Two independent bugs stacked in the add path: 1. Handle vend used a one-shot IMHandleWithID: and bailed on nil. That selector returns nil for many reachable handles; getIMHandlesForID: still resolves them. create-chat already routes through vendIMHandle() (which tries both + service match + fallback); add-member took the shortcut. Route add-member through the same vendIMHandle() helper. 2. The participant-invite selector was renamed on macOS 26 (Tahoe): inviteParticipantsToiMessageChat:reason: -> inviteParticipants:reason: (verified via IMChat runtime introspection). respondsToSelector returned NO, so add failed with "selector not available" once the vend was fixed. Probe for the current selector, fall back to the old name for older macOS. chat-remove-member had the identical macOS 26 selector rename (removeParticipantsFromiMessageChat:reason: -> removeParticipants:reason:) — fixed the same way so remove doesn't regress the moment add works. Verified live on macOS 26.0 / imsg 0.13.1 (SIP off, bridge v2): add-member now returns {added:true} and the participant lands in chat.db; create/rename/status unaffected. Co-Authored-By: Claude Opus 4.8 --- Sources/IMsgHelper/IMsgInjected.m | 44 ++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 5728db9b..b177e0e5 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -5775,19 +5775,29 @@ static void cleanupPreparedStickerPaths(NSString *snapshotPath, Class hrClass = NSClassFromString(@"IMHandleRegistrar"); id hr = hrClass ? [hrClass performSelector:@selector(sharedInstance)] : nil; - id handle = (hr && [hr respondsToSelector:@selector(IMHandleWithID:)]) - ? [hr performSelector:@selector(IMHandleWithID:) withObject:address] - : nil; + // Use the same fallback-capable vend path create-chat uses. The old code + // called IMHandleWithID: directly and bailed on nil — but for many reachable + // handles that selector returns nil while getIMHandlesForID: still resolves + // the handle. That one-sided shortcut is why add-member failed with + // "Could not vend handle" even for confirmed-iMessage numbers, while + // create-chat (which routes through vendIMHandle) worked. Prefer the iMessage + // service, fall back to any resolvable handle. + id handle = vendIMHandle(hr, address, @"iMessage", YES); if (!handle) return errorResponse(requestId, @"Could not vend handle"); @try { - // BB-verified macOS 11+ selector: `inviteParticipantsToiMessageChat:reason:`. - // `addParticipantsToiMessageChat:reason:` (what we used before) is not - // declared on IMChat; respondsToSelector returned NO and the call - // failed with "selector not available". - SEL sel = @selector(inviteParticipantsToiMessageChat:reason:); - if (![chat respondsToSelector:sel]) { - return errorResponse(requestId, @"inviteParticipantsToiMessageChat:reason: not available"); + // IMChat's participant-invite selector has drifted across macOS versions. + // macOS 26 (Tahoe) exposes `inviteParticipants:reason:`; older builds used + // `inviteParticipantsToiMessageChat:reason:`. Both take (NSArray*, NSInteger). + // Probe the live IMChat and use whichever it actually responds to. + SEL sel = 0; + for (NSString *name in @[@"inviteParticipants:reason:", + @"inviteParticipantsToiMessageChat:reason:"]) { + SEL cand = NSSelectorFromString(name); + if ([chat respondsToSelector:cand]) { sel = cand; break; } + } + if (!sel) { + return errorResponse(requestId, @"no participant-invite selector available on this IMChat"); } NSMethodSignature *sig = [chat methodSignatureForSelector:sel]; NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; @@ -5823,9 +5833,17 @@ static void cleanupPreparedStickerPaths(NSString *snapshotPath, if (!targetHandle) return errorResponse(requestId, @"Participant not found on chat"); @try { - SEL sel = @selector(removeParticipantsFromiMessageChat:reason:); - if (![chat respondsToSelector:sel]) { - return errorResponse(requestId, @"removeParticipantsFromiMessageChat:reason: not available"); + // Same macOS selector drift as the invite path: macOS 26 exposes + // `removeParticipants:reason:`, older builds `removeParticipantsFromiMessageChat:reason:`. + // Both take (NSArray*, NSInteger). Probe for whichever the IMChat responds to. + SEL sel = 0; + for (NSString *name in @[@"removeParticipants:reason:", + @"removeParticipantsFromiMessageChat:reason:"]) { + SEL cand = NSSelectorFromString(name); + if ([chat respondsToSelector:cand]) { sel = cand; break; } + } + if (!sel) { + return errorResponse(requestId, @"no participant-remove selector available on this IMChat"); } NSMethodSignature *sig = [chat methodSignatureForSelector:sel]; NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; From 3252cc6cb1379baa4d6b1a40d7095adc2b04dccc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 06:53:17 -0700 Subject: [PATCH 2/2] fix: harden participant mutation compatibility Co-authored-by: Akshay <186180952+oficiallyAkshay@users.noreply.github.com> --- CHANGELOG.md | 3 ++ Sources/IMsgHelper/IMsgInjected.m | 17 ++---- .../BridgeParticipantMutationTests.swift | 52 +++++++++++++++++++ 3 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 Tests/imsgTests/BridgeParticipantMutationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 38611973..a112e81a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.13.2 - Unreleased +### Advanced IMCore +- fix: restore group participant add/remove on macOS 26 by using fallback-capable handle lookup and probing both current and legacy IMChat selectors (#185, thanks @oficiallyAkshay). + ## 0.13.1 - 2026-07-17 ### Highlights diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index b177e0e5..e863ca33 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -5775,21 +5775,12 @@ static void cleanupPreparedStickerPaths(NSString *snapshotPath, Class hrClass = NSClassFromString(@"IMHandleRegistrar"); id hr = hrClass ? [hrClass performSelector:@selector(sharedInstance)] : nil; - // Use the same fallback-capable vend path create-chat uses. The old code - // called IMHandleWithID: directly and bailed on nil — but for many reachable - // handles that selector returns nil while getIMHandlesForID: still resolves - // the handle. That one-sided shortcut is why add-member failed with - // "Could not vend handle" even for confirmed-iMessage numbers, while - // create-chat (which routes through vendIMHandle) worked. Prefer the iMessage - // service, fall back to any resolvable handle. + // Match chat-create's fallback-capable iMessage handle lookup. id handle = vendIMHandle(hr, address, @"iMessage", YES); if (!handle) return errorResponse(requestId, @"Could not vend handle"); @try { - // IMChat's participant-invite selector has drifted across macOS versions. - // macOS 26 (Tahoe) exposes `inviteParticipants:reason:`; older builds used - // `inviteParticipantsToiMessageChat:reason:`. Both take (NSArray*, NSInteger). - // Probe the live IMChat and use whichever it actually responds to. + // macOS 26 renamed this selector; retain the older spelling as fallback. SEL sel = 0; for (NSString *name in @[@"inviteParticipants:reason:", @"inviteParticipantsToiMessageChat:reason:"]) { @@ -5833,9 +5824,7 @@ static void cleanupPreparedStickerPaths(NSString *snapshotPath, if (!targetHandle) return errorResponse(requestId, @"Participant not found on chat"); @try { - // Same macOS selector drift as the invite path: macOS 26 exposes - // `removeParticipants:reason:`, older builds `removeParticipantsFromiMessageChat:reason:`. - // Both take (NSArray*, NSInteger). Probe for whichever the IMChat responds to. + // macOS 26 renamed this selector; retain the older spelling as fallback. SEL sel = 0; for (NSString *name in @[@"removeParticipants:reason:", @"removeParticipantsFromiMessageChat:reason:"]) { diff --git a/Tests/imsgTests/BridgeParticipantMutationTests.swift b/Tests/imsgTests/BridgeParticipantMutationTests.swift new file mode 100644 index 00000000..41a9b60a --- /dev/null +++ b/Tests/imsgTests/BridgeParticipantMutationTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing + +@Test +func participantMutationsUseCurrentAndLegacySelectors() throws { + let source = try participantMutationBridgeSource() + let addBody = try #require( + participantMutationFunctionBody(named: "handleAddParticipant", in: source)) + let removeBody = try #require( + participantMutationFunctionBody(named: "handleRemoveParticipant", in: source)) + + #expect(addBody.contains(#"vendIMHandle(hr, address, @"iMessage", YES)"#)) + #expect(addBody.contains(#"@"inviteParticipants:reason:""#)) + #expect(addBody.contains(#"@"inviteParticipantsToiMessageChat:reason:""#)) + #expect(removeBody.contains(#"@"removeParticipants:reason:""#)) + #expect(removeBody.contains(#"@"removeParticipantsFromiMessageChat:reason:""#)) +} + +private func participantMutationBridgeSource() throws -> String { + let testFile = URL(fileURLWithPath: #filePath) + let repoRoot = + testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let helper = repoRoot.appendingPathComponent("Sources/IMsgHelper/IMsgInjected.m") + return try String(contentsOf: helper, encoding: .utf8) +} + +private func participantMutationFunctionBody(named name: String, in source: String) -> String? { + var searchStart = source.startIndex + while let nameRange = source.range(of: name, range: searchStart..