Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 20 additions & 13 deletions Sources/IMsgHelper/IMsgInjected.m
Original file line number Diff line number Diff line change
Expand Up @@ -5775,19 +5775,20 @@ 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;
// 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 {
// 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");
// macOS 26 renamed this selector; retain the older spelling as fallback.
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];
Expand Down Expand Up @@ -5823,9 +5824,15 @@ 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");
// macOS 26 renamed this selector; retain the older spelling as fallback.
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];
Expand Down
52 changes: 52 additions & 0 deletions Tests/imsgTests/BridgeParticipantMutationTests.swift
Original file line number Diff line number Diff line change
@@ -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..<source.endIndex) {
let suffix = source[nameRange.upperBound...]
guard let openBrace = suffix.firstIndex(of: "{") else { return nil }
if let semicolon = suffix.firstIndex(of: ";"), semicolon < openBrace {
searchStart = nameRange.upperBound
continue
}
var depth = 0
var index = openBrace
while index < source.endIndex {
if source[index] == "{" { depth += 1 }
if source[index] == "}" {
depth -= 1
if depth == 0 { return String(source[openBrace...index]) }
}
index = source.index(after: index)
}
return nil
}
return nil
}