3389 lines
149 KiB
Objective-C
3389 lines
149 KiB
Objective-C
//
|
||
// IMsgInjected.m
|
||
// IMsgHelper - Injectable dylib for Messages.app
|
||
//
|
||
// This dylib is injected into Messages.app via DYLD_INSERT_LIBRARIES
|
||
// to gain access to IMCore's chat registry and messaging functions.
|
||
// It provides file-based IPC for the CLI to send commands.
|
||
//
|
||
// Requires SIP disabled for DYLD_INSERT_LIBRARIES to work on system apps.
|
||
//
|
||
|
||
#import <Foundation/Foundation.h>
|
||
#import <objc/runtime.h>
|
||
#import <objc/message.h>
|
||
#import <os/lock.h>
|
||
#import <unistd.h>
|
||
#import <stdio.h>
|
||
#import <sys/stat.h>
|
||
#import <dlfcn.h>
|
||
|
||
// IMCore C function. The symbol lives in the dyld shared cache on macOS 26
|
||
// and isn't picked up by the static linker, so resolve dynamically. Given a
|
||
// parent message's first IMMessagePartChatItem, returns the thread
|
||
// identifier string ("0:0:<parent-len>:<parent-guid>") to set on the reply.
|
||
typedef NSString *(*IMCreateThreadIdentifierForMessagePartChatItemFn)(id);
|
||
|
||
static IMCreateThreadIdentifierForMessagePartChatItemFn
|
||
imCreateThreadIdentifierFn(void) {
|
||
static IMCreateThreadIdentifierForMessagePartChatItemFn fn = NULL;
|
||
static dispatch_once_t once;
|
||
dispatch_once(&once, ^{
|
||
fn = (IMCreateThreadIdentifierForMessagePartChatItemFn)
|
||
dlsym(RTLD_DEFAULT,
|
||
"IMCreateThreadIdentifierForMessagePartChatItem");
|
||
});
|
||
return fn;
|
||
}
|
||
|
||
#pragma mark - Constants
|
||
|
||
// v1 (legacy) single-file IPC paths.
|
||
static NSString *kCommandFile = nil;
|
||
static NSString *kResponseFile = nil;
|
||
static NSString *kLockFile = nil;
|
||
|
||
// v2 queue-directory IPC paths.
|
||
static NSString *kRpcDir = nil; // .imsg-rpc/
|
||
static NSString *kRpcInDir = nil; // .imsg-rpc/in/
|
||
static NSString *kRpcOutDir = nil; // .imsg-rpc/out/
|
||
static NSString *kEventsFile = nil; // .imsg-events.jsonl
|
||
static NSString *kEventsRotated = nil;// .imsg-events.jsonl.1
|
||
|
||
// Diagnostic file logger. Unified logging redacts NSLog output from inside
|
||
// system app processes on macOS 26, which makes diagnosing handler behavior
|
||
// from outside the dylib painful. Append-only file in the sandbox container
|
||
// gives us a stable channel that's readable from outside.
|
||
static NSString *kDebugLogFile = nil; // .imsg-bridge.log
|
||
|
||
static NSTimer *fileWatchTimer = nil;
|
||
static NSTimer *rpcInboxTimer = nil;
|
||
static NSMutableSet *processedRpcIds = nil;
|
||
static os_unfair_lock eventsLock = OS_UNFAIR_LOCK_INIT;
|
||
static int lockFd = -1;
|
||
|
||
static const NSUInteger kEventsRotateBytes = 1 * 1024 * 1024;
|
||
|
||
static void initFilePaths(void) {
|
||
if (kCommandFile == nil) {
|
||
// Messages.app runs in a container; NSHomeDirectory() resolves to
|
||
// ~/Library/Containers/com.apple.MobileSMS/Data inside the sandbox.
|
||
NSString *containerPath = NSHomeDirectory();
|
||
kCommandFile = [containerPath stringByAppendingPathComponent:@".imsg-command.json"];
|
||
kResponseFile = [containerPath stringByAppendingPathComponent:@".imsg-response.json"];
|
||
kLockFile = [containerPath stringByAppendingPathComponent:@".imsg-bridge-ready"];
|
||
kRpcDir = [containerPath stringByAppendingPathComponent:@".imsg-rpc"];
|
||
kRpcInDir = [kRpcDir stringByAppendingPathComponent:@"in"];
|
||
kRpcOutDir = [kRpcDir stringByAppendingPathComponent:@"out"];
|
||
kEventsFile = [containerPath stringByAppendingPathComponent:@".imsg-events.jsonl"];
|
||
kEventsRotated = [containerPath stringByAppendingPathComponent:@".imsg-events.jsonl.1"];
|
||
kDebugLogFile = [containerPath stringByAppendingPathComponent:@".imsg-bridge.log"];
|
||
}
|
||
if (processedRpcIds == nil) {
|
||
processedRpcIds = [NSMutableSet set];
|
||
}
|
||
}
|
||
|
||
/// Append a line to `.imsg-bridge.log` inside the Messages container. NSLog
|
||
/// output is redacted by unified logging when emitted from system apps on
|
||
/// macOS 26, so this is the only reliable diagnostic channel for behavior
|
||
/// inside the injected dylib.
|
||
__attribute__((format(NSString, 1, 2)))
|
||
static void debugLog(NSString *fmt, ...) {
|
||
if (!kDebugLogFile) return;
|
||
va_list args;
|
||
va_start(args, fmt);
|
||
NSString *msg = [[NSString alloc] initWithFormat:fmt arguments:args];
|
||
va_end(args);
|
||
static NSISO8601DateFormatter *fmtr;
|
||
static dispatch_once_t once;
|
||
dispatch_once(&once, ^{ fmtr = [NSISO8601DateFormatter new]; });
|
||
NSString *line = [NSString stringWithFormat:@"%@ %@\n",
|
||
[fmtr stringFromDate:[NSDate date]], msg];
|
||
FILE *fp = fopen(kDebugLogFile.UTF8String, "a");
|
||
if (fp) { fputs(line.UTF8String, fp); fclose(fp); }
|
||
}
|
||
|
||
#pragma mark - Path Hardening
|
||
|
||
// Returns YES if any component of `path` (after tilde expansion and CWD
|
||
// resolution for relative paths) is a symbolic link, including the final
|
||
// component. Mirrors `SecurePath.hasSymlinkComponent` in IMsgCore: realpath()
|
||
// alone isn't enough because macOS rewrites `/tmp` -> `/private/tmp`, breaking
|
||
// any "resolved == lexical" check. Walking each component with lstat() and
|
||
// rejecting on S_IFLNK is the robust answer.
|
||
//
|
||
// Used to refuse RPC queue dirs and attachment paths that traverse a symlink
|
||
// at any level, closing the same-UID-attacker exfiltration path where someone
|
||
// drops a symlink to ~/.ssh/id_rsa or a password-manager DB and has Messages
|
||
// send it as an attachment to an attacker-controlled handle.
|
||
static NSString *normalizeTrustedSystemAliasPrefix(NSString *path) {
|
||
NSDictionary<NSString *, NSString *> *aliases = @{
|
||
@"/tmp": @"/private/tmp",
|
||
@"/var": @"/private/var",
|
||
@"/etc": @"/private/etc",
|
||
};
|
||
for (NSString *alias in aliases) {
|
||
if ([path isEqualToString:alias]) {
|
||
return aliases[alias];
|
||
}
|
||
NSString *prefix = [alias stringByAppendingString:@"/"];
|
||
if ([path hasPrefix:prefix]) {
|
||
return [aliases[alias] stringByAppendingString:
|
||
[path substringFromIndex:alias.length]];
|
||
}
|
||
}
|
||
return path;
|
||
}
|
||
|
||
static BOOL pathHasSymlinkComponent(NSString *path) {
|
||
NSString *lexicalPath = [path stringByExpandingTildeInPath];
|
||
if (!lexicalPath.isAbsolutePath) {
|
||
lexicalPath = [[[NSFileManager defaultManager] currentDirectoryPath]
|
||
stringByAppendingPathComponent:lexicalPath];
|
||
}
|
||
lexicalPath = normalizeTrustedSystemAliasPrefix(lexicalPath);
|
||
|
||
NSArray *components = [lexicalPath pathComponents];
|
||
if (components.count == 0) return NO;
|
||
|
||
NSString *cursor = [components.firstObject isEqualToString:@"/"] ? @"/" : @"";
|
||
for (NSString *component in components) {
|
||
if ([component isEqualToString:@"/"] || component.length == 0) continue;
|
||
cursor = [cursor stringByAppendingPathComponent:component];
|
||
|
||
struct stat st;
|
||
if (lstat([cursor fileSystemRepresentation], &st) != 0) {
|
||
continue;
|
||
}
|
||
if (S_ISLNK(st.st_mode)) {
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
static BOOL ensureSecureDirectory(NSString *path, NSError **error) {
|
||
if (pathHasSymlinkComponent(path)) {
|
||
if (error) {
|
||
*error = [NSError errorWithDomain:@"imsg.bridge"
|
||
code:1
|
||
userInfo:@{
|
||
NSLocalizedDescriptionKey: @"RPC queue path traverses a symlink"
|
||
}];
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
NSDictionary *secureMode = @{ NSFilePosixPermissions: @(0700) };
|
||
BOOL ok = [[NSFileManager defaultManager]
|
||
createDirectoryAtPath:path
|
||
withIntermediateDirectories:YES
|
||
attributes:secureMode
|
||
error:error];
|
||
if (!ok) return NO;
|
||
if (pathHasSymlinkComponent(path)) {
|
||
if (error) {
|
||
*error = [NSError errorWithDomain:@"imsg.bridge"
|
||
code:2
|
||
userInfo:@{
|
||
NSLocalizedDescriptionKey: @"RPC queue path traverses a symlink (post-mkdir)"
|
||
}];
|
||
}
|
||
return NO;
|
||
}
|
||
chmod([path fileSystemRepresentation], 0700);
|
||
return YES;
|
||
}
|
||
|
||
#pragma mark - Selector Probes
|
||
|
||
// Populated at startup by probeSelectors(). Surfaced via the `status` action so
|
||
// the CLI can report which IMCore selectors are present on the running macOS
|
||
// (edit/unsend names changed across 13/14/15).
|
||
static BOOL gHasEditMessageItem = NO; // editMessageItem:atPartIndex:withNewPartText:backwardCompatabilityText:
|
||
static BOOL gHasEditMessage = NO; // editMessage:atPartIndex:withNewPartText:backwardCompatabilityText:
|
||
static BOOL gHasRetractMessagePart = NO; // retractMessagePart:
|
||
static BOOL gHasSendMessageReason = NO; // sendMessage:reason:
|
||
|
||
static void probeSelectors(void) {
|
||
Class chatClass = NSClassFromString(@"IMChat");
|
||
if (!chatClass) return;
|
||
gHasEditMessageItem = [chatClass instancesRespondToSelector:
|
||
@selector(editMessageItem:atPartIndex:withNewPartText:backwardCompatabilityText:)];
|
||
gHasEditMessage = [chatClass instancesRespondToSelector:
|
||
@selector(editMessage:atPartIndex:withNewPartText:backwardCompatabilityText:)];
|
||
gHasRetractMessagePart = [chatClass instancesRespondToSelector:
|
||
@selector(retractMessagePart:)];
|
||
gHasSendMessageReason = [chatClass instancesRespondToSelector:
|
||
@selector(sendMessage:reason:)];
|
||
NSLog(@"[imsg-bridge] Selector probes: editItem=%d editLegacy=%d retract=%d sendReason=%d",
|
||
gHasEditMessageItem, gHasEditMessage, gHasRetractMessagePart, gHasSendMessageReason);
|
||
}
|
||
|
||
#pragma mark - Forward Declarations for IMCore Classes
|
||
|
||
@interface IMHandle : NSObject
|
||
- (NSString *)ID;
|
||
- (NSString *)serviceName;
|
||
@end
|
||
|
||
@interface IMAccount : NSObject
|
||
- (NSArray *)vettedAliases;
|
||
- (id)loginIMHandle;
|
||
- (NSString *)serviceName;
|
||
- (BOOL)isActive;
|
||
@end
|
||
|
||
@interface IMAccountController : NSObject
|
||
+ (instancetype)sharedInstance;
|
||
- (IMAccount *)activeIMessageAccount;
|
||
- (NSArray *)activeAccounts;
|
||
@end
|
||
|
||
@interface IMHandleRegistrar : NSObject
|
||
+ (instancetype)sharedInstance;
|
||
- (id)IMHandleWithID:(NSString *)handleID;
|
||
@end
|
||
|
||
@interface IMChatRegistry : NSObject
|
||
+ (instancetype)sharedInstance;
|
||
- (id)existingChatWithGUID:(NSString *)guid;
|
||
- (id)existingChatWithChatIdentifier:(NSString *)identifier;
|
||
- (NSArray *)allExistingChats;
|
||
- (id)chatForIMHandle:(id)handle;
|
||
- (id)chatForIMHandles:(NSArray *)handles;
|
||
@end
|
||
|
||
@interface IMChat : NSObject
|
||
- (void)setLocalUserIsTyping:(BOOL)typing;
|
||
- (void)markAllMessagesAsRead;
|
||
- (NSArray *)participants;
|
||
- (NSString *)guid;
|
||
- (NSString *)chatIdentifier;
|
||
- (NSString *)displayName;
|
||
- (id)lastMessage;
|
||
- (id)lastSentMessage;
|
||
- (id)account;
|
||
- (NSString *)displayNameForChat;
|
||
- (void)sendMessage:(id)message;
|
||
- (void)_sendMessage:(id)message adjustingSender:(BOOL)adjust shouldQueue:(BOOL)queue;
|
||
- (void)leaveChat;
|
||
- (void)_setDisplayName:(NSString *)name;
|
||
- (BOOL)hasUnreadMessages;
|
||
- (NSArray *)chatItems;
|
||
- (void)inviteParticipantsToiMessageChat:(NSArray *)participants reason:(NSInteger)reason;
|
||
- (void)markLastMessageAsUnread;
|
||
- (void)markChatItemAsNotifyRecipient:(id)chatItem;
|
||
- (void)sendGroupPhotoUpdate:(NSString *)transferGUID;
|
||
@end
|
||
|
||
@interface IMMessage : NSObject
|
||
- (NSString *)guid;
|
||
- (id)sender;
|
||
- (NSDate *)time;
|
||
- (NSAttributedString *)text;
|
||
- (NSAttributedString *)subject;
|
||
- (NSArray *)fileTransferGUIDs;
|
||
- (id)_imMessageItem;
|
||
- (void)_updateText:(NSAttributedString *)attributedText;
|
||
- (void)setThreadIdentifier:(NSString *)threadIdentifier;
|
||
- (void)setThreadOriginator:(id)originator;
|
||
+ (id)messageFromIMMessageItem:(id)item sender:(id)sender subject:(id)subject;
|
||
@end
|
||
|
||
@interface IMMessageItem : NSObject
|
||
- (NSString *)guid;
|
||
- (NSArray *)_newChatItems;
|
||
- (id)message;
|
||
- (NSData *)bodyData;
|
||
- (id)body;
|
||
- (void)setBodyData:(NSData *)data;
|
||
- (void)_regenerateBodyData;
|
||
- (id)initWithSender:(id)sender
|
||
time:(NSDate *)time
|
||
body:(NSAttributedString *)body
|
||
attributes:(NSDictionary *)attributes
|
||
fileTransferGUIDs:(NSArray *)fileTransferGUIDs
|
||
flags:(unsigned long long)flags
|
||
error:(NSError *)error
|
||
guid:(NSString *)guid
|
||
threadIdentifier:(NSString *)threadIdentifier;
|
||
- (void)setExpressiveSendStyleID:(NSString *)styleID;
|
||
- (void)setSubject:(NSString *)subject;
|
||
- (void)setMessageSubject:(NSAttributedString *)subject;
|
||
- (void)setAssociatedMessageGUID:(NSString *)guid;
|
||
- (void)setAssociatedMessageType:(long long)type;
|
||
- (void)setAssociatedMessageRange:(NSRange)range;
|
||
- (void)setMessageSummaryInfo:(NSDictionary *)info;
|
||
@end
|
||
|
||
@interface IMMessagePartChatItem : NSObject
|
||
- (NSInteger)index;
|
||
- (NSAttributedString *)text;
|
||
- (NSRange)messagePartRange;
|
||
@end
|
||
|
||
@interface IMAggregateAttachmentMessagePartChatItem : NSObject
|
||
- (NSArray *)aggregateAttachmentParts;
|
||
@end
|
||
|
||
@interface IMFileTransfer : NSObject
|
||
- (NSString *)guid;
|
||
- (NSString *)localPath;
|
||
- (NSString *)transferState;
|
||
- (NSURL *)localURL;
|
||
- (void)setLocalURL:(NSURL *)url;
|
||
@end
|
||
|
||
@interface IMFileTransferCenter : NSObject
|
||
+ (instancetype)sharedInstance;
|
||
- (NSString *)guidForNewOutgoingTransferWithLocalURL:(NSURL *)url;
|
||
- (IMFileTransfer *)transferForGUID:(NSString *)guid;
|
||
- (void)retargetTransfer:(NSString *)guid toPath:(NSString *)path;
|
||
- (void)registerTransferWithDaemon:(NSString *)guid;
|
||
@end
|
||
|
||
@interface IMDPersistentAttachmentController : NSObject
|
||
+ (instancetype)sharedInstance;
|
||
- (NSString *)_persistentPathForTransfer:(IMFileTransfer *)transfer
|
||
filename:(NSString *)filename
|
||
highQuality:(BOOL)highQuality
|
||
chatGUID:(NSString *)chatGUID
|
||
storeAtExternalPath:(BOOL)external;
|
||
@end
|
||
|
||
@interface IMChatHistoryController : NSObject
|
||
+ (instancetype)sharedInstance;
|
||
- (void)loadedChatItemsForChat:(IMChat *)chat
|
||
beforeDate:(NSDate *)date
|
||
limit:(NSUInteger)limit
|
||
loadIfNeeded:(BOOL)load;
|
||
- (void)loadMessageWithGUID:(NSString *)guid
|
||
completionBlock:(void (^)(id message))completion;
|
||
@end
|
||
|
||
@interface IMNicknameController : NSObject
|
||
+ (instancetype)sharedController;
|
||
- (id)nicknameForHandle:(NSString *)handle;
|
||
@end
|
||
|
||
@interface IDSIDQueryController : NSObject
|
||
+ (instancetype)sharedController;
|
||
- (id)currentIDStatusForDestination:(NSString *)destination service:(id)service;
|
||
@end
|
||
|
||
#pragma mark - JSON Response Helpers
|
||
|
||
static NSDictionary* successResponse(NSInteger requestId, NSDictionary *data) {
|
||
NSMutableDictionary *response = [NSMutableDictionary dictionaryWithDictionary:data ?: @{}];
|
||
response[@"id"] = @(requestId);
|
||
response[@"success"] = @YES;
|
||
response[@"timestamp"] = [[NSISO8601DateFormatter new] stringFromDate:[NSDate date]];
|
||
return response;
|
||
}
|
||
|
||
static NSDictionary* errorResponse(NSInteger requestId, NSString *error) {
|
||
return @{
|
||
@"id": @(requestId),
|
||
@"success": @NO,
|
||
@"error": error ?: @"Unknown error",
|
||
@"timestamp": [[NSISO8601DateFormatter new] stringFromDate:[NSDate date]]
|
||
};
|
||
}
|
||
|
||
#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) {
|
||
Class registryClass = NSClassFromString(@"IMChatRegistry");
|
||
if (!registryClass) {
|
||
NSLog(@"[imsg-bridge] IMChatRegistry class not found");
|
||
return nil;
|
||
}
|
||
|
||
id registry = [registryClass performSelector:@selector(sharedInstance)];
|
||
if (!registry) {
|
||
NSLog(@"[imsg-bridge] Could not get IMChatRegistry instance");
|
||
return nil;
|
||
}
|
||
|
||
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:);
|
||
if ([registry respondsToSelector:guidSel]) {
|
||
if ([identifier containsString:@";"]) {
|
||
chat = [registry performSelector:guidSel withObject:identifier];
|
||
if (chat) {
|
||
NSLog(@"[imsg-bridge] Found chat via existingChatWithGUID: %@", identifier);
|
||
return chat;
|
||
}
|
||
}
|
||
|
||
// Try constructing GUIDs with common prefixes (iMessage, SMS, any)
|
||
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);
|
||
return chat;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Method 2: Try existingChatWithChatIdentifier:
|
||
SEL identSel = @selector(existingChatWithChatIdentifier:);
|
||
if ([registry respondsToSelector:identSel]) {
|
||
chat = [registry performSelector:identSel withObject:identifier];
|
||
if (chat) {
|
||
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
|
||
SEL allChatsSel = @selector(allExistingChats);
|
||
if ([registry respondsToSelector:allChatsSel]) {
|
||
NSArray *allChats = [registry performSelector:allChatsSel];
|
||
if (!allChats) {
|
||
NSLog(@"[imsg-bridge] allExistingChats returned nil");
|
||
return nil;
|
||
}
|
||
NSLog(@"[imsg-bridge] Searching %lu chats for identifier: %@",
|
||
(unsigned long)allChats.count, identifier);
|
||
|
||
// Normalize the search identifier for phone number matching
|
||
NSString *normalizedIdentifier = nil;
|
||
if (bareIdentifier.length > 0 &&
|
||
([bareIdentifier hasPrefix:@"+"] || [bareIdentifier hasPrefix:@"1"] ||
|
||
[[NSCharacterSet decimalDigitCharacterSet]
|
||
characterIsMember:[bareIdentifier characterAtIndex:0]])) {
|
||
NSMutableString *digits = [NSMutableString string];
|
||
for (NSUInteger i = 0; i < bareIdentifier.length; i++) {
|
||
unichar c = [bareIdentifier characterAtIndex:i];
|
||
if ([[NSCharacterSet decimalDigitCharacterSet] characterIsMember:c]) {
|
||
[digits appendFormat:@"%C", c];
|
||
}
|
||
}
|
||
normalizedIdentifier = [digits copy];
|
||
}
|
||
|
||
for (id aChat in allChats) {
|
||
// Check GUID
|
||
if ([aChat respondsToSelector:@selector(guid)]) {
|
||
NSString *chatGUID = [aChat performSelector:@selector(guid)];
|
||
if ([chatGUID isEqualToString:identifier] ||
|
||
[chatGUID isEqualToString:bareIdentifier]) {
|
||
NSLog(@"[imsg-bridge] Found chat by GUID exact match: %@", chatGUID);
|
||
return aChat;
|
||
}
|
||
}
|
||
|
||
// Check chatIdentifier
|
||
if ([aChat respondsToSelector:@selector(chatIdentifier)]) {
|
||
NSString *chatId = [aChat performSelector:@selector(chatIdentifier)];
|
||
if ([chatId isEqualToString:identifier] ||
|
||
[chatId isEqualToString:bareIdentifier]) {
|
||
NSLog(@"[imsg-bridge] Found chat by chatIdentifier exact match: %@", chatId);
|
||
return aChat;
|
||
}
|
||
}
|
||
|
||
// Check participants
|
||
if ([aChat respondsToSelector:@selector(participants)]) {
|
||
NSArray *participants = [aChat performSelector:@selector(participants)];
|
||
if (!participants) continue;
|
||
for (id handle in participants) {
|
||
if ([handle respondsToSelector:@selector(ID)]) {
|
||
NSString *handleID = [handle performSelector:@selector(ID)];
|
||
if ([handleID isEqualToString:identifier] ||
|
||
[handleID isEqualToString:bareIdentifier]) {
|
||
NSLog(@"[imsg-bridge] Found chat by participant exact match: %@", handleID);
|
||
return aChat;
|
||
}
|
||
// Normalized phone number match
|
||
if (normalizedIdentifier && normalizedIdentifier.length >= 10) {
|
||
NSMutableString *handleDigits = [NSMutableString string];
|
||
for (NSUInteger i = 0; i < handleID.length; i++) {
|
||
unichar c = [handleID characterAtIndex:i];
|
||
if ([[NSCharacterSet decimalDigitCharacterSet] characterIsMember:c]) {
|
||
[handleDigits appendFormat:@"%C", c];
|
||
}
|
||
}
|
||
if (handleDigits.length >= 10 &&
|
||
([handleDigits hasSuffix:normalizedIdentifier] ||
|
||
[normalizedIdentifier hasSuffix:handleDigits])) {
|
||
NSLog(@"[imsg-bridge] Found chat by normalized phone match: %@ ~ %@",
|
||
handleID, identifier);
|
||
return aChat;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
NSLog(@"[imsg-bridge] Chat not found for identifier: %@", identifier);
|
||
return nil;
|
||
}
|
||
|
||
#pragma mark - Command Handlers
|
||
|
||
static NSDictionary* handleTyping(NSInteger requestId, NSDictionary *params) {
|
||
NSString *handle = params[@"handle"];
|
||
NSNumber *state = params[@"typing"] ?: params[@"state"];
|
||
debugLog(@"handleTyping: enter handle=%@ state=%@ params=%@", handle, state, params);
|
||
|
||
if (!handle) {
|
||
return errorResponse(requestId, @"Missing required parameter: handle");
|
||
}
|
||
|
||
BOOL typing = [state boolValue];
|
||
id chat = findChat(handle);
|
||
|
||
if (!chat) {
|
||
debugLog(@"handleTyping: chat not found for %@", handle);
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", handle]);
|
||
}
|
||
|
||
@try {
|
||
// Gather diagnostic info
|
||
NSString *chatGUID = @"unknown";
|
||
NSString *chatIdent = @"unknown";
|
||
NSString *chatClass = NSStringFromClass([chat class]);
|
||
BOOL supportsTyping = YES;
|
||
|
||
if ([chat respondsToSelector:@selector(guid)]) {
|
||
chatGUID = [chat performSelector:@selector(guid)] ?: @"nil";
|
||
}
|
||
if ([chat respondsToSelector:@selector(chatIdentifier)]) {
|
||
chatIdent = [chat performSelector:@selector(chatIdentifier)] ?: @"nil";
|
||
}
|
||
|
||
SEL supportsSel = @selector(supportsSendingTypingIndicators);
|
||
if ([chat respondsToSelector:supportsSel]) {
|
||
supportsTyping = ((BOOL (*)(id, SEL))objc_msgSend)(chat, supportsSel);
|
||
}
|
||
|
||
BOOL isCurrentlyTyping = NO;
|
||
if ([chat respondsToSelector:@selector(isCurrentlyTyping)]) {
|
||
isCurrentlyTyping = ((BOOL (*)(id, SEL))objc_msgSend)(chat, @selector(isCurrentlyTyping));
|
||
}
|
||
|
||
id account = nil;
|
||
NSString *acctService = @"nil";
|
||
BOOL acctActive = NO;
|
||
BOOL acctLoggedIn = NO;
|
||
if ([chat respondsToSelector:@selector(account)]) {
|
||
account = [chat performSelector:@selector(account)];
|
||
if ([account respondsToSelector:@selector(serviceName)]) {
|
||
acctService = [account performSelector:@selector(serviceName)] ?: @"nil";
|
||
}
|
||
if ([account respondsToSelector:@selector(isActive)]) {
|
||
acctActive = ((BOOL (*)(id, SEL))objc_msgSend)(account, @selector(isActive));
|
||
}
|
||
if ([account respondsToSelector:@selector(loggedIn)]) {
|
||
acctLoggedIn = ((BOOL (*)(id, SEL))objc_msgSend)(account, @selector(loggedIn));
|
||
}
|
||
}
|
||
|
||
debugLog(@"handleTyping: chat class=%@ guid=%@ ident=%@ supportsTyping=%d alreadyTyping=%d "
|
||
@"acctService=%@ acctActive=%d acctLoggedIn=%d target=%d",
|
||
chatClass, chatGUID, chatIdent, supportsTyping, isCurrentlyTyping,
|
||
acctService, acctActive, acctLoggedIn, typing);
|
||
|
||
NSLog(@"[imsg-bridge] Chat found: class=%@, guid=%@, identifier=%@, supportsTyping=%@",
|
||
chatClass, chatGUID, chatIdent, supportsTyping ? @"YES" : @"NO");
|
||
|
||
SEL typingSel = @selector(setLocalUserIsTyping:);
|
||
if ([chat respondsToSelector:typingSel]) {
|
||
NSMethodSignature *sig = [chat methodSignatureForSelector:typingSel];
|
||
if (!sig) {
|
||
return errorResponse(requestId,
|
||
@"Could not get method signature for setLocalUserIsTyping:");
|
||
}
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:typingSel];
|
||
[inv setTarget:chat];
|
||
[inv setArgument:&typing atIndex:2];
|
||
[inv invoke];
|
||
|
||
BOOL afterTyping = NO;
|
||
if ([chat respondsToSelector:@selector(isCurrentlyTyping)]) {
|
||
afterTyping = ((BOOL (*)(id, SEL))objc_msgSend)(chat, @selector(isCurrentlyTyping));
|
||
}
|
||
debugLog(@"handleTyping: setLocalUserIsTyping:%d returned, isCurrentlyTyping after=%d",
|
||
typing, afterTyping);
|
||
|
||
NSLog(@"[imsg-bridge] Called setLocalUserIsTyping:%@ for %@",
|
||
typing ? @"YES" : @"NO", handle);
|
||
return successResponse(requestId, @{
|
||
@"handle": handle,
|
||
@"typing": @(typing)
|
||
});
|
||
}
|
||
|
||
debugLog(@"handleTyping: setLocalUserIsTyping: not available on chat class=%@", chatClass);
|
||
return errorResponse(requestId, @"setLocalUserIsTyping: method not available");
|
||
} @catch (NSException *exception) {
|
||
debugLog(@"handleTyping: exception=%@", exception.reason);
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Failed to set typing: %@", exception.reason]);
|
||
}
|
||
}
|
||
|
||
static NSDictionary* handleRead(NSInteger requestId, NSDictionary *params) {
|
||
NSString *handle = params[@"handle"];
|
||
debugLog(@"handleRead: enter handle=%@ params=%@", handle, params);
|
||
|
||
if (!handle) {
|
||
return errorResponse(requestId, @"Missing required parameter: handle");
|
||
}
|
||
|
||
id chat = findChat(handle);
|
||
|
||
if (!chat) {
|
||
debugLog(@"handleRead: chat not found for %@", handle);
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", handle]);
|
||
}
|
||
|
||
NSString *chatClass = NSStringFromClass([chat class]);
|
||
NSUInteger unreadBefore = 0;
|
||
BOOL hadUnread = NO;
|
||
if ([chat respondsToSelector:@selector(unreadMessageCount)]) {
|
||
unreadBefore = ((NSUInteger (*)(id, SEL))objc_msgSend)(chat, @selector(unreadMessageCount));
|
||
}
|
||
if ([chat respondsToSelector:@selector(hasUnreadMessages)]) {
|
||
hadUnread = ((BOOL (*)(id, SEL))objc_msgSend)(chat, @selector(hasUnreadMessages));
|
||
}
|
||
|
||
@try {
|
||
SEL readSel = @selector(markAllMessagesAsRead);
|
||
debugLog(@"handleRead: chat class=%@ unreadBefore=%lu hasUnread=%d responds=%d",
|
||
chatClass, (unsigned long)unreadBefore, hadUnread,
|
||
[chat respondsToSelector:readSel]);
|
||
if ([chat respondsToSelector:readSel]) {
|
||
[chat performSelector:readSel];
|
||
NSUInteger unreadAfter = 0;
|
||
BOOL hasUnreadAfter = NO;
|
||
if ([chat respondsToSelector:@selector(unreadMessageCount)]) {
|
||
unreadAfter = ((NSUInteger (*)(id, SEL))objc_msgSend)(chat, @selector(unreadMessageCount));
|
||
}
|
||
if ([chat respondsToSelector:@selector(hasUnreadMessages)]) {
|
||
hasUnreadAfter = ((BOOL (*)(id, SEL))objc_msgSend)(chat, @selector(hasUnreadMessages));
|
||
}
|
||
debugLog(@"handleRead: markAllMessagesAsRead returned, unreadAfter=%lu hasUnreadAfter=%d",
|
||
(unsigned long)unreadAfter, hasUnreadAfter);
|
||
NSLog(@"[imsg-bridge] Marked all messages as read for %@", handle);
|
||
return successResponse(requestId, @{
|
||
@"handle": handle,
|
||
@"marked_as_read": @YES
|
||
});
|
||
} else {
|
||
return errorResponse(requestId, @"markAllMessagesAsRead method not available");
|
||
}
|
||
} @catch (NSException *exception) {
|
||
debugLog(@"handleRead: exception=%@", exception.reason);
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Failed to mark as read: %@", exception.reason]);
|
||
}
|
||
}
|
||
|
||
static NSDictionary* handleStatus(NSInteger requestId, NSDictionary *params) {
|
||
Class registryClass = NSClassFromString(@"IMChatRegistry");
|
||
BOOL hasRegistry = (registryClass != nil);
|
||
NSUInteger chatCount = 0;
|
||
|
||
if (hasRegistry) {
|
||
id registry = [registryClass performSelector:@selector(sharedInstance)];
|
||
if ([registry respondsToSelector:@selector(allExistingChats)]) {
|
||
NSArray *chats = [registry performSelector:@selector(allExistingChats)];
|
||
chatCount = chats.count;
|
||
}
|
||
}
|
||
|
||
NSDictionary *selectors = @{
|
||
@"editMessageItem": @(gHasEditMessageItem),
|
||
@"editMessage": @(gHasEditMessage),
|
||
@"retractMessagePart": @(gHasRetractMessagePart),
|
||
@"sendMessageReason": @(gHasSendMessageReason)
|
||
};
|
||
|
||
return successResponse(requestId, @{
|
||
@"injected": @YES,
|
||
@"registry_available": @(hasRegistry),
|
||
@"chat_count": @(chatCount),
|
||
@"typing_available": @(hasRegistry),
|
||
@"read_available": @(hasRegistry),
|
||
@"bridge_version": @2,
|
||
@"v2_ready": @(rpcInboxTimer != nil),
|
||
@"selectors": selectors
|
||
});
|
||
}
|
||
|
||
static NSDictionary* handleListChats(NSInteger requestId, NSDictionary *params) {
|
||
Class registryClass = NSClassFromString(@"IMChatRegistry");
|
||
if (!registryClass) {
|
||
return errorResponse(requestId, @"IMChatRegistry not available");
|
||
}
|
||
|
||
id registry = [registryClass performSelector:@selector(sharedInstance)];
|
||
if (!registry) {
|
||
return errorResponse(requestId, @"Could not get IMChatRegistry instance");
|
||
}
|
||
|
||
NSMutableArray *chatList = [NSMutableArray array];
|
||
|
||
if ([registry respondsToSelector:@selector(allExistingChats)]) {
|
||
NSArray *allChats = [registry performSelector:@selector(allExistingChats)];
|
||
for (id chat in allChats) {
|
||
NSMutableDictionary *chatInfo = [NSMutableDictionary dictionary];
|
||
|
||
if ([chat respondsToSelector:@selector(guid)]) {
|
||
chatInfo[@"guid"] = [chat performSelector:@selector(guid)] ?: @"";
|
||
}
|
||
if ([chat respondsToSelector:@selector(chatIdentifier)]) {
|
||
chatInfo[@"identifier"] = [chat performSelector:@selector(chatIdentifier)] ?: @"";
|
||
}
|
||
if ([chat respondsToSelector:@selector(participants)]) {
|
||
NSMutableArray *handles = [NSMutableArray array];
|
||
NSArray *participants = [chat performSelector:@selector(participants)];
|
||
for (id handle in participants) {
|
||
if ([handle respondsToSelector:@selector(ID)]) {
|
||
[handles addObject:[handle performSelector:@selector(ID)] ?: @""];
|
||
}
|
||
}
|
||
chatInfo[@"participants"] = handles;
|
||
}
|
||
|
||
[chatList addObject:chatInfo];
|
||
}
|
||
}
|
||
|
||
return successResponse(requestId, @{
|
||
@"chats": chatList,
|
||
@"count": @(chatList.count)
|
||
});
|
||
}
|
||
|
||
#pragma mark - Resolve Chat (v2)
|
||
|
||
/// Resolve an IMChat from a chatGuid string (BlueBubbles-style addressing,
|
||
/// e.g. `iMessage;-;+15551234567` or `iMessage;+;chat0000`). Falls back to
|
||
/// `chatForIMHandle:` to materialize chats that don't yet exist in the
|
||
/// registry's allExistingChats snapshot. Returns nil if no chat could be
|
||
/// resolved or created.
|
||
static IMChat *resolveChatByGuid(NSString *chatGuid) {
|
||
if (![chatGuid isKindOfClass:[NSString class]] || chatGuid.length == 0) {
|
||
return nil;
|
||
}
|
||
Class registryClass = NSClassFromString(@"IMChatRegistry");
|
||
if (!registryClass) return nil;
|
||
id registry = [registryClass performSelector:@selector(sharedInstance)];
|
||
if (!registry) return nil;
|
||
|
||
if ([registry respondsToSelector:@selector(existingChatWithGUID:)]) {
|
||
id chat = [registry performSelector:@selector(existingChatWithGUID:)
|
||
withObject:chatGuid];
|
||
if (chat) return chat;
|
||
}
|
||
|
||
// Fallback: parse trailing address out of `<service>;<+|->;<address>`
|
||
// and try to vend a handle, then materialize a chat.
|
||
NSArray *parts = [chatGuid componentsSeparatedByString:@";"];
|
||
if (parts.count == 3) {
|
||
NSString *address = parts.lastObject;
|
||
Class hrClass = NSClassFromString(@"IMHandleRegistrar");
|
||
if (hrClass) {
|
||
id hr = [hrClass performSelector:@selector(sharedInstance)];
|
||
if ([hr respondsToSelector:@selector(IMHandleWithID:)]) {
|
||
id handle = [hr performSelector:@selector(IMHandleWithID:)
|
||
withObject:address];
|
||
if (handle && [registry respondsToSelector:@selector(chatForIMHandle:)]) {
|
||
id chat = [registry performSelector:@selector(chatForIMHandle:)
|
||
withObject:handle];
|
||
if (chat) return chat;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
/// Resolve a chat by EITHER chatGuid (preferred) OR a free-form handle
|
||
/// (legacy path that walks `findChat`). Used to keep existing callers working.
|
||
static id resolveChatFlexible(NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
if ([chatGuid isKindOfClass:[NSString class]] && chatGuid.length) {
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (chat) return chat;
|
||
}
|
||
NSString *handle = params[@"handle"];
|
||
if ([handle isKindOfClass:[NSString class]] && handle.length) {
|
||
return findChat(handle);
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
#pragma mark - AttributedBody Helpers
|
||
|
||
/// Decode a base64 NSKeyedArchiver blob into an NSAttributedString. Returns
|
||
/// nil on any decoding failure.
|
||
static NSAttributedString *attributedBodyFromBase64(NSString *b64) {
|
||
if (![b64 isKindOfClass:[NSString class]] || b64.length == 0) return nil;
|
||
NSData *data = [[NSData alloc] initWithBase64EncodedString:b64
|
||
options:NSDataBase64DecodingIgnoreUnknownCharacters];
|
||
if (!data) return nil;
|
||
NSError *err = nil;
|
||
NSSet *allowed = [NSSet setWithObjects:
|
||
[NSAttributedString class], [NSDictionary class], [NSString class],
|
||
[NSArray class], [NSNumber class], [NSURL class], [NSData class], nil];
|
||
NSAttributedString *attr = [NSKeyedUnarchiver unarchivedObjectOfClasses:allowed
|
||
fromData:data
|
||
error:&err];
|
||
if (err) {
|
||
// Fall back to non-secure unarchiving for older blobs.
|
||
@try {
|
||
attr = [NSKeyedUnarchiver unarchiveObjectWithData:data];
|
||
} @catch (__unused NSException *ex) {
|
||
attr = nil;
|
||
}
|
||
}
|
||
return attr;
|
||
}
|
||
|
||
/// Build a plain NSAttributedString carrying `text` as message-part `partIndex`.
|
||
/// Applies the private `__kIMMessagePartAttributeName` attribute IMCore expects.
|
||
static NSAttributedString *buildPlainAttributed(NSString *text, NSInteger partIndex) {
|
||
if (![text isKindOfClass:[NSString class]]) text = @"";
|
||
NSDictionary *attrs = @{
|
||
@"__kIMMessagePartAttributeName": @(partIndex),
|
||
@"__kIMBaseWritingDirectionAttributeName": @"-1"
|
||
};
|
||
return [[NSAttributedString alloc] initWithString:text attributes:attrs];
|
||
}
|
||
|
||
/// Apply a JSON-shape array of text-formatting ranges to `text`. Each entry is
|
||
/// `{ "start": int, "length": int, "styles": ["bold"|"italic"|"underline"|"strikethrough", ...] }`.
|
||
/// macOS 15+ only — earlier OSes silently degrade to plain text (the private
|
||
/// IMText* attribute names don't exist before Sequoia). Attribute names and
|
||
/// range shape are based on BlueBubbles helper PR #50; implementation is local.
|
||
static NSMutableAttributedString *buildFormattedAttributed(NSString *text,
|
||
NSArray *formatting,
|
||
NSInteger partIndex) {
|
||
if (![text isKindOfClass:[NSString class]]) text = @"";
|
||
NSMutableAttributedString *attr = [[NSMutableAttributedString alloc] initWithString:text];
|
||
NSUInteger len = text.length;
|
||
|
||
// Always carry the same base IM attributes as plain sends across the
|
||
// whole string, then layer style ranges on top when supported.
|
||
if (len > 0) {
|
||
[attr addAttribute:@"__kIMMessagePartAttributeName" value:@(partIndex)
|
||
range:NSMakeRange(0, len)];
|
||
[attr addAttribute:@"__kIMBaseWritingDirectionAttributeName" value:@"-1"
|
||
range:NSMakeRange(0, len)];
|
||
}
|
||
|
||
if ([[NSProcessInfo processInfo] operatingSystemVersion].majorVersion < 15) {
|
||
return attr; // Pre-Sequoia: no IMText* attributes; ship plain.
|
||
}
|
||
if (len == 0 || ![formatting isKindOfClass:[NSArray class]] || formatting.count == 0) {
|
||
return attr;
|
||
}
|
||
|
||
for (id raw in formatting) {
|
||
if (![raw isKindOfClass:[NSDictionary class]]) continue;
|
||
NSDictionary *r = (NSDictionary *)raw;
|
||
NSNumber *startNum = r[@"start"];
|
||
NSNumber *lengthNum = r[@"length"];
|
||
NSArray *styles = r[@"styles"];
|
||
if (![startNum isKindOfClass:[NSNumber class]]) continue;
|
||
if (![lengthNum isKindOfClass:[NSNumber class]]) continue;
|
||
if (![styles isKindOfClass:[NSArray class]]) continue;
|
||
NSInteger start = startNum.integerValue;
|
||
NSInteger length = lengthNum.integerValue;
|
||
if (start < 0 || length <= 0) continue;
|
||
if ((NSUInteger)(start + length) > len) continue;
|
||
|
||
NSRange range = NSMakeRange((NSUInteger)start, (NSUInteger)length);
|
||
if ([styles containsObject:@"bold"]) {
|
||
[attr addAttribute:@"__kIMTextBoldAttributeName" value:@1 range:range];
|
||
}
|
||
if ([styles containsObject:@"italic"]) {
|
||
[attr addAttribute:@"__kIMTextItalicAttributeName" value:@1 range:range];
|
||
}
|
||
if ([styles containsObject:@"underline"]) {
|
||
[attr addAttribute:@"__kIMTextUnderlineAttributeName" value:@1 range:range];
|
||
}
|
||
if ([styles containsObject:@"strikethrough"]) {
|
||
[attr addAttribute:@"__kIMTextStrikethroughAttributeName" value:@1 range:range];
|
||
}
|
||
}
|
||
return attr;
|
||
}
|
||
|
||
#pragma mark - IMMessage Builder
|
||
|
||
/// Invoke a class method that returns an object, returning a strongly
|
||
/// retained id. NSInvocation returns object references without transferring
|
||
/// ownership, so we read into an `__unsafe_unretained` slot then assign to a
|
||
/// strong variable to balance ARC.
|
||
static id invokeReturningObject(NSInvocation *inv) {
|
||
__unsafe_unretained id raw = nil;
|
||
[inv invoke];
|
||
[inv getReturnValue:&raw];
|
||
return raw;
|
||
}
|
||
|
||
/// Apply optional metadata fields directly onto the IMMessageItem before
|
||
/// the IMMessage wrap. Setters on a wrapped IMMessage's `_imMessageItem`
|
||
/// don't persist (the wrap returns a transient item rebuilt each call), so
|
||
/// extended fields like `expressiveSendStyleID` and `associatedMessageGUID`
|
||
/// must be applied here, ahead of the wrap.
|
||
static void applyItemExtendedFields(id item,
|
||
NSAttributedString *subject,
|
||
NSString *effectId,
|
||
NSString *associatedMessageGuid,
|
||
long long associatedMessageType,
|
||
NSRange associatedMessageRange,
|
||
NSDictionary *summaryInfo) {
|
||
if (!item) return;
|
||
if (subject.length
|
||
&& [item respondsToSelector:@selector(setMessageSubject:)]) {
|
||
[item performSelector:@selector(setMessageSubject:) withObject:subject];
|
||
}
|
||
if (effectId.length
|
||
&& [item respondsToSelector:@selector(setExpressiveSendStyleID:)]) {
|
||
[item performSelector:@selector(setExpressiveSendStyleID:)
|
||
withObject:effectId];
|
||
}
|
||
if (associatedMessageGuid.length && associatedMessageType > 0) {
|
||
if ([item respondsToSelector:@selector(setAssociatedMessageGUID:)]) {
|
||
[item performSelector:@selector(setAssociatedMessageGUID:)
|
||
withObject:associatedMessageGuid];
|
||
}
|
||
if ([item respondsToSelector:@selector(setAssociatedMessageType:)]) {
|
||
NSMethodSignature *sig = [item methodSignatureForSelector:
|
||
@selector(setAssociatedMessageType:)];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:@selector(setAssociatedMessageType:)];
|
||
[inv setTarget:item];
|
||
[inv setArgument:&associatedMessageType atIndex:2];
|
||
[inv invoke];
|
||
}
|
||
if ([item respondsToSelector:@selector(setAssociatedMessageRange:)]) {
|
||
NSMethodSignature *sig = [item methodSignatureForSelector:
|
||
@selector(setAssociatedMessageRange:)];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:@selector(setAssociatedMessageRange:)];
|
||
[inv setTarget:item];
|
||
NSRange range = associatedMessageRange;
|
||
[inv setArgument:&range atIndex:2];
|
||
[inv invoke];
|
||
}
|
||
if (summaryInfo
|
||
&& [item respondsToSelector:@selector(setMessageSummaryInfo:)]) {
|
||
[item performSelector:@selector(setMessageSummaryInfo:)
|
||
withObject:summaryInfo];
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build an IMMessageItem with the body set up-front, apply any extended
|
||
/// metadata fields onto the item, then wrap with IMMessage. On macOS 26 the
|
||
/// high-level `+initIMMessageWith…` factories build a transient
|
||
/// IMMessageItem on demand whose `body` / `bodyData` don't survive
|
||
/// `[chat sendMessage:]` — imagent reads `bodyData` from the underlying
|
||
/// item, sees nothing, and silently drops the message. Building the item
|
||
/// up-front and seeding `bodyData` via NSArchiver is the only path that
|
||
/// lands on macOS 26. Returns nil if the required selectors are missing
|
||
/// (older OSes; caller should fall back).
|
||
static id constructIMMessageViaItem(NSAttributedString *attributedText,
|
||
NSAttributedString *subject,
|
||
NSString *effectId,
|
||
NSString *threadIdentifier,
|
||
NSString *associatedMessageGuid,
|
||
long long associatedMessageType,
|
||
NSRange associatedMessageRange,
|
||
NSDictionary *summaryInfo,
|
||
NSArray *fileTransferGuids,
|
||
BOOL isAudioMessage) {
|
||
Class IMMessageClass = NSClassFromString(@"IMMessage");
|
||
Class IMMessageItemClass = NSClassFromString(@"IMMessageItem");
|
||
if (!IMMessageClass || !IMMessageItemClass) return nil;
|
||
|
||
SEL itemInitSel = @selector(initWithSender:time:body:attributes:fileTransferGUIDs:flags:error:guid:threadIdentifier:);
|
||
if (![IMMessageItemClass instancesRespondToSelector:itemInitSel]) return nil;
|
||
|
||
SEL wrapSel = @selector(messageFromIMMessageItem:sender:subject:);
|
||
if (![IMMessageClass respondsToSelector:wrapSel]) return nil;
|
||
|
||
id item = [IMMessageItemClass alloc];
|
||
if (!item) return nil;
|
||
|
||
NSDate *now = [NSDate date];
|
||
NSArray *transferGuids = fileTransferGuids ?: @[];
|
||
NSError *err = nil;
|
||
NSString *guid = [[NSUUID UUID] UUIDString];
|
||
// BlueBubblesHelper-verified flag set: 0x100005 (FromMe | Finished |
|
||
// 0x100000 finalize bit) for normal text+attachment, 0x10000d when a
|
||
// subject is set, 0x300005 for audio messages. The earlier `0x5`
|
||
// variant was the cause of malformed attachments on the receiver — the
|
||
// 0x100000 bit is what tells imagent to finalize the payload.
|
||
unsigned long long flags;
|
||
if (isAudioMessage) {
|
||
flags = 0x300005ULL;
|
||
} else if (subject.length) {
|
||
flags = 0x10000dULL;
|
||
} else {
|
||
flags = 0x100005ULL;
|
||
}
|
||
id sender = nil;
|
||
NSDictionary *attributes = nil;
|
||
|
||
NSMethodSignature *isig =
|
||
[IMMessageItemClass instanceMethodSignatureForSelector:itemInitSel];
|
||
NSInvocation *iinv = [NSInvocation invocationWithMethodSignature:isig];
|
||
[iinv setSelector:itemInitSel];
|
||
[iinv setTarget:item];
|
||
[iinv setArgument:&sender atIndex:2];
|
||
[iinv setArgument:&now atIndex:3];
|
||
[iinv setArgument:&attributedText atIndex:4];
|
||
[iinv setArgument:&attributes atIndex:5];
|
||
[iinv setArgument:&transferGuids atIndex:6];
|
||
[iinv setArgument:&flags atIndex:7];
|
||
[iinv setArgument:&err atIndex:8];
|
||
[iinv setArgument:&guid atIndex:9];
|
||
[iinv setArgument:&threadIdentifier atIndex:10];
|
||
[iinv retainArguments];
|
||
item = invokeReturningObject(iinv);
|
||
if (!item) return nil;
|
||
|
||
if ([item respondsToSelector:@selector(_regenerateBodyData)]) {
|
||
[item performSelector:@selector(_regenerateBodyData)];
|
||
}
|
||
|
||
NSData *bodyData = [item respondsToSelector:@selector(bodyData)]
|
||
? [item performSelector:@selector(bodyData)] : nil;
|
||
// imagent reads bodyData (NSArchiver typedstream). On macOS 26 the
|
||
// initWithSender: path leaves bodyData empty; force-archive the
|
||
// attributed string ourselves so the daemon has a payload to ship.
|
||
if (bodyData.length == 0 && attributedText.length > 0
|
||
&& [item respondsToSelector:@selector(setBodyData:)]) {
|
||
@try {
|
||
#pragma clang diagnostic push
|
||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||
NSData *typedstream = [NSArchiver archivedDataWithRootObject:attributedText];
|
||
#pragma clang diagnostic pop
|
||
if (typedstream.length > 0) {
|
||
[item performSelector:@selector(setBodyData:) withObject:typedstream];
|
||
}
|
||
} @catch (NSException *e) {
|
||
// NSArchiver chokes on NSPresentationIntent attributes that some
|
||
// markdown initializers emit. Retry with a plain copy.
|
||
NSMutableAttributedString *plain = [[NSMutableAttributedString alloc]
|
||
initWithString:[attributedText string]];
|
||
@try {
|
||
#pragma clang diagnostic push
|
||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||
NSData *plainData = [NSArchiver archivedDataWithRootObject:plain];
|
||
#pragma clang diagnostic pop
|
||
[item performSelector:@selector(setBodyData:) withObject:plainData];
|
||
} @catch (__unused NSException *e2) {
|
||
// Give up; the wrap below may still succeed for non-empty cases.
|
||
}
|
||
}
|
||
}
|
||
|
||
// Set extended fields on the item BEFORE wrapping. The IMMessage wrap's
|
||
// `_imMessageItem` accessor returns a transient item rebuilt each call,
|
||
// so post-wrap setters don't persist (per the macOS 26 behavior 10ce6ab
|
||
// documented).
|
||
applyItemExtendedFields(item, subject, effectId,
|
||
associatedMessageGuid, associatedMessageType,
|
||
associatedMessageRange, summaryInfo);
|
||
|
||
NSMethodSignature *wsig =
|
||
[IMMessageClass methodSignatureForSelector:wrapSel];
|
||
NSInvocation *winv = [NSInvocation invocationWithMethodSignature:wsig];
|
||
[winv setSelector:wrapSel];
|
||
[winv setTarget:IMMessageClass];
|
||
id nilSender = nil;
|
||
id nilSubject = nil;
|
||
[winv setArgument:&item atIndex:2];
|
||
[winv setArgument:&nilSender atIndex:3];
|
||
[winv setArgument:&nilSubject atIndex:4];
|
||
[winv retainArguments];
|
||
return invokeReturningObject(winv);
|
||
}
|
||
|
||
/// Load the parent message for a reply via IMChatHistoryController and
|
||
/// derive the thread identifier required for proper threaded-reply
|
||
/// rendering on macOS 26 (`0:0:<parent-len>:<parent-guid>`). On earlier
|
||
/// macOS releases setting `associatedMessageGUID` + `associatedMessageType=100`
|
||
/// alone produced a quoted reply; on macOS 26 the receiver also needs the
|
||
/// thread identifier to render the in-line reply UI. Returns nil if the
|
||
/// parent can't be resolved (block-based load timed out, or the IMCore C
|
||
/// helper isn't available); caller should still send without threading
|
||
/// rather than fail the whole reply.
|
||
static NSString *deriveThreadIdentifier(NSString *parentGuid,
|
||
id *outParentMessage) {
|
||
if (outParentMessage) *outParentMessage = nil;
|
||
if (parentGuid.length == 0) return nil;
|
||
|
||
Class hcClass = NSClassFromString(@"IMChatHistoryController");
|
||
if (!hcClass) {
|
||
debugLog(@"deriveThreadIdentifier: IMChatHistoryController class missing");
|
||
return nil;
|
||
}
|
||
id hc = [hcClass performSelector:@selector(sharedInstance)];
|
||
if (!hc) {
|
||
debugLog(@"deriveThreadIdentifier: sharedInstance returned nil");
|
||
return nil;
|
||
}
|
||
SEL loadSel = @selector(loadMessageWithGUID:completionBlock:);
|
||
if (![hc respondsToSelector:loadSel]) {
|
||
debugLog(@"deriveThreadIdentifier: loadMessageWithGUID:completionBlock: missing");
|
||
return nil;
|
||
}
|
||
|
||
__block id parent = nil;
|
||
__block BOOL done = NO;
|
||
NSMethodSignature *sig = [hc methodSignatureForSelector:loadSel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:loadSel];
|
||
[inv setTarget:hc];
|
||
NSString *guid = parentGuid;
|
||
[inv setArgument:&guid atIndex:2];
|
||
void (^completion)(id) = ^(id message) {
|
||
parent = message;
|
||
done = YES;
|
||
};
|
||
[inv setArgument:&completion atIndex:3];
|
||
[inv retainArguments];
|
||
[inv invoke];
|
||
|
||
// Pump the run loop briefly so the load completion can run inline.
|
||
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:3.0];
|
||
while (!done && [deadline timeIntervalSinceNow] > 0) {
|
||
[[NSRunLoop currentRunLoop]
|
||
runMode:NSDefaultRunLoopMode
|
||
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
|
||
}
|
||
if (!parent) {
|
||
debugLog(@"deriveThreadIdentifier: parent did not load within 3s for %@",
|
||
parentGuid);
|
||
return nil;
|
||
}
|
||
if (outParentMessage) *outParentMessage = parent;
|
||
|
||
if (![parent respondsToSelector:@selector(_imMessageItem)]) {
|
||
debugLog(@"deriveThreadIdentifier: parent has no _imMessageItem");
|
||
return nil;
|
||
}
|
||
id parentItem = [parent performSelector:@selector(_imMessageItem)];
|
||
SEL chatItemsSel = NSSelectorFromString(@"_newChatItems");
|
||
if (!parentItem || ![parentItem respondsToSelector:chatItemsSel]) {
|
||
debugLog(@"deriveThreadIdentifier: parentItem missing _newChatItems");
|
||
return nil;
|
||
}
|
||
|
||
id items = [parentItem performSelector:chatItemsSel];
|
||
id chatItem = [items isKindOfClass:[NSArray class]]
|
||
? ((NSArray *)items).firstObject : items;
|
||
if (!chatItem) {
|
||
debugLog(@"deriveThreadIdentifier: parent has no chat items");
|
||
return nil;
|
||
}
|
||
|
||
IMCreateThreadIdentifierForMessagePartChatItemFn fn =
|
||
imCreateThreadIdentifierFn();
|
||
if (!fn) {
|
||
debugLog(@"deriveThreadIdentifier: IMCreateThreadIdentifier… symbol not found");
|
||
return nil;
|
||
}
|
||
NSString *result = fn(chatItem);
|
||
debugLog(@"deriveThreadIdentifier: parent=%@ result=%@",
|
||
parentGuid, result ?: @"(nil)");
|
||
return result;
|
||
}
|
||
|
||
/// Load the parent message via `IMChatHistoryController` and return its
|
||
/// first `IMMessagePartChatItem` plus the parent message itself. Used by
|
||
/// reactions to derive the canonical `associatedMessageRange` (BB-verified:
|
||
/// `[item messagePartRange]`, not a hardcoded `{0,1}`).
|
||
///
|
||
/// Block-based load semantics match `loadMessageWithGUID:completionBlock:`,
|
||
/// which `deriveThreadIdentifier` already drives. This helper duplicates
|
||
/// the load to keep the reply / reaction code paths independent (each
|
||
/// fires its own load), which is what BlueBubblesHelper does too — and
|
||
/// avoids gnarly out-parameter plumbing through deriveThreadIdentifier.
|
||
static id loadParentFirstChatItem(NSString *parentGuid, id *outParentMessage) {
|
||
if (outParentMessage) *outParentMessage = nil;
|
||
if (parentGuid.length == 0) return nil;
|
||
|
||
Class hcClass = NSClassFromString(@"IMChatHistoryController");
|
||
if (!hcClass) return nil;
|
||
id hc = [hcClass performSelector:@selector(sharedInstance)];
|
||
SEL loadSel = @selector(loadMessageWithGUID:completionBlock:);
|
||
if (!hc || ![hc respondsToSelector:loadSel]) return nil;
|
||
|
||
__block id parent = nil;
|
||
__block BOOL done = NO;
|
||
NSMethodSignature *sig = [hc methodSignatureForSelector:loadSel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:loadSel];
|
||
[inv setTarget:hc];
|
||
NSString *guid = parentGuid;
|
||
[inv setArgument:&guid atIndex:2];
|
||
void (^completion)(id) = ^(id m) { parent = m; done = YES; };
|
||
[inv setArgument:&completion atIndex:3];
|
||
[inv retainArguments];
|
||
[inv invoke];
|
||
|
||
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:3.0];
|
||
while (!done && [deadline timeIntervalSinceNow] > 0) {
|
||
[[NSRunLoop currentRunLoop]
|
||
runMode:NSDefaultRunLoopMode
|
||
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
|
||
}
|
||
if (!parent) return nil;
|
||
if (outParentMessage) *outParentMessage = parent;
|
||
|
||
if (![parent respondsToSelector:@selector(_imMessageItem)]) return nil;
|
||
id parentItem = [parent performSelector:@selector(_imMessageItem)];
|
||
SEL chatItemsSel = NSSelectorFromString(@"_newChatItems");
|
||
if (!parentItem || ![parentItem respondsToSelector:chatItemsSel]) return nil;
|
||
id items = [parentItem performSelector:chatItemsSel];
|
||
return [items isKindOfClass:[NSArray class]]
|
||
? ((NSArray *)items).firstObject : items;
|
||
}
|
||
|
||
/// Dispatch a built IMMessage into the chat. BlueBubblesHelper uses the
|
||
/// public `-[IMChat sendMessage:]` for every send (text, attachment,
|
||
/// reaction, reply) on macOS 11+ — including macOS 26. It Just Works as
|
||
/// long as the IMMessage has been built with a proper init (sender = nil
|
||
/// is fine; IMChat's sendMessage: implementation fills it from the chat's
|
||
/// account). The private `_sendMessage:adjustingSender:shouldQueue:` we
|
||
/// were preferring earlier is unnecessary and may silently drop items in
|
||
/// some macOS 26 states.
|
||
static void dispatchIMMessageInChat(IMChat *chat, id message) {
|
||
[chat performSelector:@selector(sendMessage:) withObject:message];
|
||
}
|
||
|
||
/// Build an IMMessage suitable for `[chat sendMessage:]`. Handles plain text,
|
||
/// optional subject, optional effect (`com.apple.MobileSMS.expressivesend.*`),
|
||
/// optional reply target (`selectedMessageGuid`), and ddScan flag.
|
||
///
|
||
/// On macOS 26 `+initIMMessageWith…` returns a message whose underlying
|
||
/// IMMessageItem has empty `bodyData`, which imagent silently drops. Try the
|
||
/// IMMessageItem-first path first; fall back to the legacy initializer for
|
||
/// older OSes that don't expose the modern item-construction selectors.
|
||
static id buildIMMessage(NSAttributedString *body,
|
||
NSAttributedString *subject,
|
||
NSString *effectId,
|
||
NSString *threadIdentifier,
|
||
NSString *associatedMessageGuid,
|
||
long long associatedMessageType,
|
||
NSRange associatedMessageRange,
|
||
NSDictionary *summaryInfo,
|
||
NSArray *fileTransferGuids,
|
||
BOOL isAudioMessage,
|
||
BOOL ddScan) {
|
||
// Reactions take a different code path entirely (macOS 26 init below) —
|
||
// the IMMessageItem-first construction can't carry associated-message
|
||
// fields atomically, and post-init setters don't survive the wrap.
|
||
//
|
||
// Attachments also bypass IMMessageItem-first: BB's `initWithSender:…:
|
||
// expressiveSendStyleID:` (further down) handles fileTransferGUIDs
|
||
// natively, and going through IMMessageItem-first appears to leave the
|
||
// attachment payload unfinalized even with the right flags.
|
||
BOOL isReaction = associatedMessageGuid.length && associatedMessageType > 0;
|
||
BOOL hasAttachment = fileTransferGuids.count > 0;
|
||
if (!isReaction && !hasAttachment) {
|
||
id viaItem = constructIMMessageViaItem(body, subject, effectId,
|
||
threadIdentifier,
|
||
associatedMessageGuid,
|
||
associatedMessageType,
|
||
associatedMessageRange,
|
||
summaryInfo,
|
||
fileTransferGuids,
|
||
isAudioMessage);
|
||
if (viaItem) return viaItem;
|
||
}
|
||
// Legacy fallback for older macOS that doesn't expose the
|
||
// IMMessageItem 9-arg initializer or +messageFromIMMessageItem:.
|
||
Class messageClass = NSClassFromString(@"IMMessage");
|
||
if (!messageClass) return nil;
|
||
|
||
// Reaction / reply path: associatedMessageGuid + associatedMessageType.
|
||
if (associatedMessageGuid.length && associatedMessageType > 0) {
|
||
// macOS 26 path (BlueBubblesHelper-verified, 13 args, no
|
||
// balloonBundleID/payloadData/expressiveSendStyleID). BB allocates
|
||
// and inits in two steps: `[[IMMessage alloc] init]` then call this
|
||
// longer initializer on the result.
|
||
SEL macos26Sel = @selector(initWithSender:time:text:messageSubject:fileTransferGUIDs:flags:error:guid:subject:associatedMessageGUID:associatedMessageType:associatedMessageRange:messageSummaryInfo:);
|
||
if ([messageClass instancesRespondToSelector:macos26Sel]) {
|
||
unsigned long long flags = 0x5;
|
||
id msg = [[messageClass alloc] init];
|
||
NSMethodSignature *sig =
|
||
[messageClass instanceMethodSignatureForSelector:macos26Sel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:macos26Sel];
|
||
[inv setTarget:msg];
|
||
id nilObj = nil;
|
||
NSDate *now = [NSDate date];
|
||
[inv setArgument:&nilObj atIndex:2]; // sender
|
||
[inv setArgument:&now atIndex:3]; // time
|
||
[inv setArgument:&body atIndex:4]; // text
|
||
[inv setArgument:&subject atIndex:5]; // messageSubject
|
||
[inv setArgument:&fileTransferGuids atIndex:6];
|
||
[inv setArgument:&flags atIndex:7];
|
||
[inv setArgument:&nilObj atIndex:8]; // error
|
||
[inv setArgument:&nilObj atIndex:9]; // guid
|
||
[inv setArgument:&nilObj atIndex:10]; // subject (string)
|
||
[inv setArgument:&associatedMessageGuid atIndex:11];
|
||
[inv setArgument:&associatedMessageType atIndex:12];
|
||
[inv setArgument:&associatedMessageRange atIndex:13];
|
||
[inv setArgument:&summaryInfo atIndex:14];
|
||
[inv retainArguments];
|
||
id result = invokeReturningObject(inv);
|
||
debugLog(@"buildIMMessage: reaction via macos26Sel result=%@",
|
||
result ? NSStringFromClass([result class]) : @"(nil)");
|
||
if (result) return result;
|
||
}
|
||
|
||
// Legacy 17-arg form for older macOS.
|
||
SEL sel = @selector(initIMMessageWithSender:time:text:messageSubject:fileTransferGUIDs:flags:error:guid:subject:balloonBundleID:payloadData:expressiveSendStyleID:associatedMessageGUID:associatedMessageType:associatedMessageRange:messageSummaryInfo:);
|
||
BOOL responds = [messageClass instancesRespondToSelector:sel];
|
||
debugLog(@"buildIMMessage: reaction path; long-init responds=%d type=%lld guid=%@",
|
||
responds, associatedMessageType, associatedMessageGuid);
|
||
id msg = [messageClass alloc];
|
||
if ([msg respondsToSelector:sel]) {
|
||
unsigned long long flags = 0x5;
|
||
NSMethodSignature *sig = [messageClass instanceMethodSignatureForSelector:sel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:sel];
|
||
[inv setTarget:msg];
|
||
id nilObj = nil;
|
||
NSDate *now = [NSDate date];
|
||
[inv setArgument:&nilObj atIndex:2]; // sender
|
||
[inv setArgument:&now atIndex:3]; // time
|
||
[inv setArgument:&body atIndex:4]; // text
|
||
[inv setArgument:&subject atIndex:5]; // messageSubject
|
||
[inv setArgument:&fileTransferGuids atIndex:6];
|
||
[inv setArgument:&flags atIndex:7];
|
||
[inv setArgument:&nilObj atIndex:8]; // error
|
||
[inv setArgument:&nilObj atIndex:9]; // guid
|
||
[inv setArgument:&nilObj atIndex:10]; // subject (string form)
|
||
[inv setArgument:&nilObj atIndex:11]; // balloonBundleID
|
||
[inv setArgument:&nilObj atIndex:12]; // payloadData
|
||
[inv setArgument:&effectId atIndex:13]; // expressiveSendStyleID
|
||
[inv setArgument:&associatedMessageGuid atIndex:14];
|
||
[inv setArgument:&associatedMessageType atIndex:15];
|
||
[inv setArgument:&associatedMessageRange atIndex:16];
|
||
[inv setArgument:&summaryInfo atIndex:17];
|
||
[inv invoke];
|
||
__unsafe_unretained id result = nil;
|
||
[inv getReturnValue:&result];
|
||
return result;
|
||
}
|
||
}
|
||
|
||
// Normal send / reply path. Try the BB-verified macOS 26 selector
|
||
// (`initWithSender:…:expressiveSendStyleID:`, 12 args, no `IMMessage`
|
||
// prefix) first; fall back to the legacy `initIMMessageWithSender:` for
|
||
// older releases.
|
||
SEL bbSendSel = @selector(initWithSender:time:text:messageSubject:fileTransferGUIDs:flags:error:guid:subject:balloonBundleID:payloadData:expressiveSendStyleID:);
|
||
if ([messageClass instancesRespondToSelector:bbSendSel]) {
|
||
unsigned long long flags;
|
||
if (isAudioMessage) {
|
||
flags = 0x300005ULL;
|
||
} else if (subject.length) {
|
||
flags = 0x10000dULL;
|
||
} else {
|
||
flags = 0x100005ULL;
|
||
}
|
||
id m = [[messageClass alloc] init];
|
||
NSMethodSignature *sig = [messageClass instanceMethodSignatureForSelector:bbSendSel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:bbSendSel];
|
||
[inv setTarget:m];
|
||
id nilObj = nil;
|
||
NSDate *now = [NSDate date];
|
||
[inv setArgument:&nilObj atIndex:2]; // sender
|
||
[inv setArgument:&now atIndex:3]; // time
|
||
[inv setArgument:&body atIndex:4]; // text
|
||
[inv setArgument:&subject atIndex:5]; // messageSubject
|
||
[inv setArgument:&fileTransferGuids atIndex:6];
|
||
[inv setArgument:&flags atIndex:7];
|
||
[inv setArgument:&nilObj atIndex:8]; // error
|
||
[inv setArgument:&nilObj atIndex:9]; // guid
|
||
[inv setArgument:&nilObj atIndex:10]; // subject string
|
||
[inv setArgument:&nilObj atIndex:11]; // balloonBundleID
|
||
[inv setArgument:&nilObj atIndex:12]; // payloadData
|
||
[inv setArgument:&effectId atIndex:13]; // expressiveSendStyleID
|
||
[inv retainArguments];
|
||
id result = invokeReturningObject(inv);
|
||
if (result) {
|
||
if (threadIdentifier
|
||
&& [result respondsToSelector:@selector(setThreadIdentifier:)]) {
|
||
[result performSelector:@selector(setThreadIdentifier:)
|
||
withObject:threadIdentifier];
|
||
}
|
||
return result;
|
||
}
|
||
}
|
||
|
||
SEL sel = @selector(initIMMessageWithSender:time:text:messageSubject:fileTransferGUIDs:flags:error:guid:subject:balloonBundleID:payloadData:expressiveSendStyleID:);
|
||
id msg = [messageClass alloc];
|
||
if ([msg respondsToSelector:sel]) {
|
||
unsigned long long flags;
|
||
if (isAudioMessage) {
|
||
flags = 0x300005ULL;
|
||
} else if (subject.length) {
|
||
flags = 0x10000dULL;
|
||
} else {
|
||
flags = 0x100005ULL;
|
||
}
|
||
NSMethodSignature *sig = [messageClass instanceMethodSignatureForSelector:sel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:sel];
|
||
[inv setTarget:msg];
|
||
id nilObj = nil;
|
||
NSDate *now = [NSDate date];
|
||
[inv setArgument:&nilObj atIndex:2]; // sender
|
||
[inv setArgument:&now atIndex:3]; // time
|
||
[inv setArgument:&body atIndex:4]; // text
|
||
[inv setArgument:&subject atIndex:5]; // messageSubject
|
||
[inv setArgument:&fileTransferGuids atIndex:6];
|
||
[inv setArgument:&flags atIndex:7];
|
||
[inv setArgument:&nilObj atIndex:8]; // error
|
||
[inv setArgument:&nilObj atIndex:9]; // guid
|
||
[inv setArgument:&nilObj atIndex:10]; // subject string
|
||
[inv setArgument:&nilObj atIndex:11]; // balloonBundleID
|
||
[inv setArgument:&nilObj atIndex:12]; // payloadData
|
||
[inv setArgument:&effectId atIndex:13]; // expressiveSendStyleID
|
||
[inv invoke];
|
||
__unsafe_unretained id result = nil;
|
||
[inv getReturnValue:&result];
|
||
return result;
|
||
}
|
||
|
||
// Last resort: simplest 2-arg initializer if the long form isn't available.
|
||
SEL simple = @selector(initWithText:flags:);
|
||
if ([msg respondsToSelector:simple]) {
|
||
unsigned long long flags = 0x100005ULL;
|
||
NSMethodSignature *sig2 = [messageClass instanceMethodSignatureForSelector:simple];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig2];
|
||
[inv setSelector:simple];
|
||
[inv setTarget:msg];
|
||
[inv setArgument:&body atIndex:2];
|
||
[inv setArgument:&flags atIndex:3];
|
||
[inv invoke];
|
||
__unsafe_unretained id result = nil;
|
||
[inv getReturnValue:&result];
|
||
return result;
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
/// Look up a chat item by message guid. Tries BlueBubblesHelper's
|
||
/// block-based `loadMessageWithGUID:completionBlock:` first — that path
|
||
/// works for messages older than what's currently loaded into the live
|
||
/// `chat.chatItems` window. Falls back to the older
|
||
/// `loadedChatItemsForChat:beforeDate:limit:loadIfNeeded:` + sync poll
|
||
/// for OSes that don't expose the block-based load.
|
||
static id findMessageItem(IMChat *chat, NSString *messageGuid) {
|
||
if (!chat || !messageGuid.length) {
|
||
return nil;
|
||
}
|
||
|
||
// BB-verified macOS 11+ path: block-based load via IMChatHistoryController
|
||
// (returns an IMMessage). Callers want the chat item, so navigate
|
||
// IMMessage → IMMessageItem → first IMMessagePartChatItem via the
|
||
// same accessor walk loadParentFirstChatItem performs.
|
||
id loadedChatItem = loadParentFirstChatItem(messageGuid, NULL);
|
||
if (loadedChatItem) return loadedChatItem;
|
||
|
||
Class hcClass = NSClassFromString(@"IMChatHistoryController");
|
||
id hc = hcClass ? [hcClass performSelector:@selector(sharedInstance)] : nil;
|
||
if (hc && [hc respondsToSelector:@selector(loadedChatItemsForChat:beforeDate:limit:loadIfNeeded:)]) {
|
||
NSMethodSignature *sig = [hc methodSignatureForSelector:
|
||
@selector(loadedChatItemsForChat:beforeDate:limit:loadIfNeeded:)];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:@selector(loadedChatItemsForChat:beforeDate:limit:loadIfNeeded:)];
|
||
[inv setTarget:hc];
|
||
[inv setArgument:&chat atIndex:2];
|
||
NSDate *now = [NSDate date];
|
||
[inv setArgument:&now atIndex:3];
|
||
NSUInteger limit = 100;
|
||
[inv setArgument:&limit atIndex:4];
|
||
BOOL load = YES;
|
||
[inv setArgument:&load atIndex:5];
|
||
[inv invoke];
|
||
}
|
||
|
||
// Poll chat.chatItems for the guid for up to 2s. Spinning the current
|
||
// run loop gives IMCore a chance to finish loading requested chat items.
|
||
for (NSInteger attempts = 0; attempts < 20; attempts++) {
|
||
NSArray *items = nil;
|
||
if ([chat respondsToSelector:@selector(chatItems)]) {
|
||
items = [chat performSelector:@selector(chatItems)];
|
||
}
|
||
for (id item in items) {
|
||
id message = nil;
|
||
if ([item respondsToSelector:@selector(message)]) {
|
||
message = [item performSelector:@selector(message)];
|
||
}
|
||
NSString *guid = nil;
|
||
if (message && [message respondsToSelector:@selector(guid)]) {
|
||
guid = [message performSelector:@selector(guid)];
|
||
} else if ([item respondsToSelector:@selector(guid)]) {
|
||
guid = [item performSelector:@selector(guid)];
|
||
}
|
||
if ([guid isEqualToString:messageGuid]) {
|
||
return item;
|
||
}
|
||
}
|
||
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
|
||
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
/// Best-effort messageGuid extractor for transactional sends. Returns the
|
||
/// guid of `chat.lastSentMessage` after a brief grace period for the message
|
||
/// to register, or nil if unavailable.
|
||
static NSString *lastSentMessageGuid(IMChat *chat) {
|
||
if (!chat || ![chat respondsToSelector:@selector(lastSentMessage)]) return nil;
|
||
id msg = [chat performSelector:@selector(lastSentMessage)];
|
||
if (msg && [msg respondsToSelector:@selector(guid)]) {
|
||
return [msg performSelector:@selector(guid)];
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
#pragma mark - v2 Response Helpers
|
||
|
||
/// Build a v2-shaped success envelope: { v:2, id, success:true, data:{...} }
|
||
static NSDictionary* successResponseV2(NSString *uuid, NSDictionary *data) {
|
||
return @{
|
||
@"v": @2,
|
||
@"id": uuid ?: @"",
|
||
@"success": @YES,
|
||
@"data": data ?: @{},
|
||
@"timestamp": [[NSISO8601DateFormatter new] stringFromDate:[NSDate date]]
|
||
};
|
||
}
|
||
|
||
/// Build a v2-shaped error envelope.
|
||
static NSDictionary* errorResponseV2(NSString *uuid, NSString *error) {
|
||
return @{
|
||
@"v": @2,
|
||
@"id": uuid ?: @"",
|
||
@"success": @NO,
|
||
@"error": error ?: @"Unknown error",
|
||
@"timestamp": [[NSISO8601DateFormatter new] stringFromDate:[NSDate date]]
|
||
};
|
||
}
|
||
|
||
#pragma mark - Inbound Events (v2)
|
||
|
||
/// Append a single JSON object as a line to `.imsg-events.jsonl`. Rotates the
|
||
/// file once it crosses kEventsRotateBytes by renaming to `.1` (overwriting).
|
||
/// Safe to call from any thread (guarded by an unfair lock).
|
||
__attribute__((unused))
|
||
static void appendEvent(NSDictionary *evt) {
|
||
if (![evt isKindOfClass:[NSDictionary class]]) return;
|
||
initFilePaths();
|
||
|
||
NSMutableDictionary *out = [NSMutableDictionary dictionaryWithDictionary:evt];
|
||
if (out[@"ts"] == nil) {
|
||
out[@"ts"] = [[NSISO8601DateFormatter new] stringFromDate:[NSDate date]];
|
||
}
|
||
|
||
NSError *err = nil;
|
||
NSData *body = [NSJSONSerialization dataWithJSONObject:out options:0 error:&err];
|
||
if (!body) return;
|
||
|
||
os_unfair_lock_lock(&eventsLock);
|
||
|
||
// Rotate if oversized.
|
||
struct stat st;
|
||
if (stat(kEventsFile.UTF8String, &st) == 0 && st.st_size >= (off_t)kEventsRotateBytes) {
|
||
rename(kEventsFile.UTF8String, kEventsRotated.UTF8String);
|
||
}
|
||
|
||
FILE *fp = fopen(kEventsFile.UTF8String, "a");
|
||
if (fp != NULL) {
|
||
fwrite(body.bytes, 1, body.length, fp);
|
||
fputc('\n', fp);
|
||
fclose(fp);
|
||
}
|
||
|
||
os_unfair_lock_unlock(&eventsLock);
|
||
}
|
||
|
||
#pragma mark - Send Handlers (v2)
|
||
|
||
/// Implementation core for `send-message`. Builds an IMMessage with optional
|
||
/// effect/subject/reply and dispatches via `[chat sendMessage:]`. ddScan on
|
||
/// macOS 13+ defers the send by 100ms.
|
||
static NSDictionary *handleSendMessage(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *message = params[@"message"];
|
||
NSString *effectId = params[@"effectId"];
|
||
NSString *subject = params[@"subject"];
|
||
NSString *selectedMessageGuid = params[@"selectedMessageGuid"];
|
||
NSNumber *partIndexNum = params[@"partIndex"];
|
||
NSInteger partIndex = partIndexNum ? [partIndexNum integerValue] : 0;
|
||
NSNumber *ddScanNum = params[@"ddScan"];
|
||
BOOL ddScan = [ddScanNum boolValue];
|
||
NSString *attributedBodyB64 = params[@"attributedBody"];
|
||
NSArray *textFormatting = params[@"textFormatting"];
|
||
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (!message) message = @"";
|
||
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
|
||
}
|
||
|
||
NSAttributedString *body = attributedBodyFromBase64(attributedBodyB64);
|
||
if (!body) {
|
||
if ([textFormatting isKindOfClass:[NSArray class]] && textFormatting.count > 0) {
|
||
body = buildFormattedAttributed(message, textFormatting, partIndex);
|
||
} else {
|
||
body = buildPlainAttributed(message, partIndex);
|
||
}
|
||
}
|
||
NSAttributedString *subjectAttr = subject.length
|
||
? buildPlainAttributed(subject, 0)
|
||
: nil;
|
||
|
||
NSRange zeroRange = NSMakeRange(0, body.length);
|
||
long long associatedType = selectedMessageGuid.length ? 100 : 0;
|
||
|
||
// Reply targets need a derived thread identifier on macOS 26 to render
|
||
// as a threaded in-line reply rather than a standalone message — the
|
||
// associated_message_guid alone isn't enough on the receiver. Best-effort:
|
||
// if we can't derive (parent not loadable, IMCore symbol missing) we
|
||
// still send with the associated fields and let the receiver render
|
||
// a quoted reply.
|
||
id parentMessage = nil;
|
||
NSString *threadIdentifier = nil;
|
||
if (selectedMessageGuid.length) {
|
||
threadIdentifier = deriveThreadIdentifier(selectedMessageGuid, &parentMessage);
|
||
debugLog(@"handleSendMessage: parent=%@ threadId=%@",
|
||
selectedMessageGuid, threadIdentifier ?: @"(none)");
|
||
}
|
||
|
||
@try {
|
||
id imMessage = buildIMMessage(body, subjectAttr,
|
||
effectId,
|
||
threadIdentifier,
|
||
selectedMessageGuid,
|
||
associatedType,
|
||
zeroRange,
|
||
/*summaryInfo*/ nil,
|
||
/*fileTransferGuids*/ @[],
|
||
/*isAudio*/ NO,
|
||
ddScan);
|
||
if (!imMessage) {
|
||
return errorResponse(requestId, @"Could not construct IMMessage");
|
||
}
|
||
|
||
// Set thread originator on the wrapped message too — some receivers
|
||
// expect both setThreadIdentifier on the item and setThreadOriginator
|
||
// on the IMMessage to render as a thread.
|
||
if (parentMessage
|
||
&& [imMessage respondsToSelector:@selector(setThreadOriginator:)]) {
|
||
[imMessage performSelector:@selector(setThreadOriginator:)
|
||
withObject:parentMessage];
|
||
}
|
||
if (threadIdentifier
|
||
&& [imMessage respondsToSelector:@selector(setThreadIdentifier:)]) {
|
||
[imMessage performSelector:@selector(setThreadIdentifier:)
|
||
withObject:threadIdentifier];
|
||
}
|
||
|
||
if (gHasSendMessageReason && ddScan) {
|
||
// Deferred-send path on macOS 13+: sleep 100ms, then call
|
||
// `sendMessage:reason:` so the spam filter can run on the body.
|
||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 100 * NSEC_PER_MSEC),
|
||
dispatch_get_main_queue(), ^{
|
||
NSMethodSignature *sig = [chat methodSignatureForSelector:
|
||
@selector(sendMessage:reason:)];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:@selector(sendMessage:reason:)];
|
||
[inv setTarget:chat];
|
||
__unsafe_unretained id arg = imMessage;
|
||
[inv setArgument:&arg atIndex:2];
|
||
NSInteger reason = 0;
|
||
[inv setArgument:&reason atIndex:3];
|
||
[inv invoke];
|
||
});
|
||
} else {
|
||
dispatchIMMessageInChat(chat, imMessage);
|
||
}
|
||
|
||
// Best-effort messageGuid; not always available immediately.
|
||
NSString *guid = lastSentMessageGuid(chat);
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": chatGuid,
|
||
@"messageGuid": guid ?: @"",
|
||
@"queued": @(ddScan)
|
||
});
|
||
} @catch (NSException *exception) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"send-message failed: %@", exception.reason]);
|
||
}
|
||
}
|
||
|
||
/// `send-multipart`: at minimum, sends an attributedBody composed of multiple
|
||
/// text parts. v1 supports text-only multipart; mention/file parts can land in
|
||
/// a follow-up.
|
||
static NSDictionary *handleSendMultipart(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSArray *parts = params[@"parts"];
|
||
NSString *effectId = params[@"effectId"];
|
||
NSString *subject = params[@"subject"];
|
||
NSString *selectedMessageGuid = params[@"selectedMessageGuid"];
|
||
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (![parts isKindOfClass:[NSArray class]] || parts.count == 0) {
|
||
return errorResponse(requestId, @"Missing or empty parts array");
|
||
}
|
||
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
|
||
}
|
||
|
||
NSMutableAttributedString *body = [[NSMutableAttributedString alloc] init];
|
||
NSInteger partIndex = 0;
|
||
for (NSDictionary *part in parts) {
|
||
if (![part isKindOfClass:[NSDictionary class]]) continue;
|
||
NSString *text = part[@"text"];
|
||
if (!text.length) continue;
|
||
NSArray *partFormatting = part[@"textFormatting"];
|
||
NSAttributedString *seg;
|
||
if ([partFormatting isKindOfClass:[NSArray class]] && partFormatting.count > 0) {
|
||
seg = buildFormattedAttributed(text, partFormatting, partIndex);
|
||
} else {
|
||
seg = buildPlainAttributed(text, partIndex);
|
||
}
|
||
[body appendAttributedString:seg];
|
||
partIndex++;
|
||
}
|
||
if (body.length == 0) {
|
||
return errorResponse(requestId, @"No usable parts");
|
||
}
|
||
|
||
NSAttributedString *subjectAttr = subject.length
|
||
? buildPlainAttributed(subject, 0)
|
||
: nil;
|
||
|
||
@try {
|
||
long long associatedType = selectedMessageGuid.length ? 100 : 0;
|
||
id imMessage = buildIMMessage(body, subjectAttr, effectId, nil,
|
||
selectedMessageGuid, associatedType,
|
||
NSMakeRange(0, body.length),
|
||
nil, @[], NO, NO);
|
||
if (!imMessage) {
|
||
return errorResponse(requestId, @"Could not construct multipart IMMessage");
|
||
}
|
||
dispatchIMMessageInChat(chat, imMessage);
|
||
NSString *guid = lastSentMessageGuid(chat);
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": chatGuid,
|
||
@"messageGuid": guid ?: @"",
|
||
@"parts_count": @(partIndex)
|
||
});
|
||
} @catch (NSException *exception) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"send-multipart failed: %@", exception.reason]);
|
||
}
|
||
}
|
||
|
||
/// Build an attachment-bearing attributed string. The placeholder is an OBJ
|
||
/// replacement character () tagged with the IMCore attachment attributes
|
||
/// (`__kIMFileTransferGUIDAttributeName`, `__kIMFilenameAttributeName`,
|
||
/// `__kIMMessagePartAttributeName`, `__kIMBaseWritingDirectionAttributeName`).
|
||
/// Without these attributes Messages.app sends an empty-body message and never
|
||
/// links the attachment row in chat.db.
|
||
static NSAttributedString *buildAttachmentAttributed(NSString *transferGuid,
|
||
NSString *filename,
|
||
NSInteger partIndex) {
|
||
NSDictionary *attrs = @{
|
||
@"__kIMBaseWritingDirectionAttributeName": @"-1",
|
||
@"__kIMFileTransferGUIDAttributeName": transferGuid ?: @"",
|
||
@"__kIMFilenameAttributeName": filename ?: @"",
|
||
@"__kIMMessagePartAttributeName": @(partIndex),
|
||
};
|
||
return [[NSAttributedString alloc] initWithString:@"" attributes:attrs];
|
||
}
|
||
|
||
/// Register an outgoing file transfer with IMFileTransferCenter so that
|
||
/// Messages.app/imagent persists the attachment row and links it back to the
|
||
/// outbound message. Mirrors BlueBubblesHelper's `prepareFileTransferForAttachment`:
|
||
/// 1. Allocate a guid via `guidForNewOutgoingTransferWithLocalURL:`.
|
||
/// 2. Resolve the resulting `IMFileTransfer` via `transferForGUID:`.
|
||
/// 3. Stage the source file in the IMD-managed attachments tree.
|
||
/// 4. `retargetTransfer:toPath:` + `setLocalURL:` to point the transfer at
|
||
/// the staged copy.
|
||
/// 5. `registerTransferWithDaemon:` so the daemon picks it up.
|
||
/// On failure returns `nil`; the caller emits the error.
|
||
static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer,
|
||
NSString *transferGuid, NSString *path) {
|
||
if (!path.length) return;
|
||
// Updating only `localURL` is not enough: IMFileTransferCenter keeps its
|
||
// own guid -> path map, and imagent reads that map when daemon registration
|
||
// happens.
|
||
if ([ftc respondsToSelector:@selector(retargetTransfer:toPath:)]) {
|
||
NSMethodSignature *rsig = [ftc methodSignatureForSelector:
|
||
@selector(retargetTransfer:toPath:)];
|
||
NSInvocation *rinv = [NSInvocation invocationWithMethodSignature:rsig];
|
||
[rinv setSelector:@selector(retargetTransfer:toPath:)];
|
||
[rinv setTarget:ftc];
|
||
__unsafe_unretained NSString *g = transferGuid;
|
||
__unsafe_unretained NSString *p = path;
|
||
[rinv setArgument:&g atIndex:2];
|
||
[rinv setArgument:&p atIndex:3];
|
||
[rinv invoke];
|
||
}
|
||
if ([transfer respondsToSelector:@selector(setLocalURL:)]) {
|
||
[transfer performSelector:@selector(setLocalURL:)
|
||
withObject:[NSURL fileURLWithPath:path]];
|
||
}
|
||
}
|
||
|
||
static NSString *saveAttachmentForTransfer(id pac, IMFileTransfer *transfer,
|
||
NSString *chatGuid, NSString **outErr) {
|
||
SEL saveSel = @selector(saveAttachmentsForTransfer:chatGUID:storeAtExternalLocation:completion:);
|
||
if (!pac || ![pac respondsToSelector:saveSel]) {
|
||
return nil;
|
||
}
|
||
|
||
__block BOOL done = NO;
|
||
__block NSString *savedPath = nil;
|
||
__block id saveError = nil;
|
||
// Runtime probe on macOS 26 shows the completion receives:
|
||
// primaryPath, error, externalPath
|
||
// `externalPath` is only populated when `storeAtExternalLocation:YES`.
|
||
void (^completion)(id, id, id) = ^(id primaryPath, id error, id externalPath) {
|
||
if ([primaryPath isKindOfClass:[NSString class]] && [(NSString *)primaryPath length]) {
|
||
savedPath = primaryPath;
|
||
} else if ([externalPath isKindOfClass:[NSString class]]
|
||
&& [(NSString *)externalPath length]) {
|
||
savedPath = externalPath;
|
||
}
|
||
saveError = error;
|
||
done = YES;
|
||
};
|
||
|
||
NSMethodSignature *sig = [pac methodSignatureForSelector:saveSel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:saveSel];
|
||
[inv setTarget:pac];
|
||
__unsafe_unretained IMFileTransfer *xfer = transfer;
|
||
__unsafe_unretained NSString *cg = chatGuid;
|
||
BOOL external = chatGuid.length > 0;
|
||
// Call via NSInvocation because this private selector and block signature
|
||
// are absent from public SDK headers.
|
||
[inv setArgument:&xfer atIndex:2];
|
||
[inv setArgument:&cg atIndex:3];
|
||
[inv setArgument:&external atIndex:4];
|
||
[inv setArgument:&completion atIndex:5];
|
||
[inv retainArguments];
|
||
[inv invoke];
|
||
|
||
// The completion is usually synchronous, but keep the same bounded run-loop
|
||
// pump used by other bridge helpers in case IMDPersistence hops queues.
|
||
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:3.0];
|
||
while (!done && [deadline timeIntervalSinceNow] > 0) {
|
||
[[NSRunLoop currentRunLoop]
|
||
runMode:NSDefaultRunLoopMode
|
||
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
|
||
}
|
||
|
||
debugLog(@"prepareOutgoingTransfer: saveAttachments path=%@ error=%@ done=%d",
|
||
savedPath ?: @"(nil)", saveError ?: @"(nil)", done);
|
||
if (!done) {
|
||
if (outErr) *outErr = @"Timed out staging attachment";
|
||
return nil;
|
||
}
|
||
if (saveError && outErr) {
|
||
*outErr = [NSString stringWithFormat:@"Failed to stage attachment: %@", saveError];
|
||
}
|
||
return savedPath;
|
||
}
|
||
|
||
static IMFileTransfer *prepareOutgoingTransfer(NSURL *originalURL, NSString *filename,
|
||
NSString *chatGuid, NSString **outErr) {
|
||
Class ftcClass = NSClassFromString(@"IMFileTransferCenter");
|
||
if (!ftcClass) {
|
||
if (outErr) *outErr = @"IMFileTransferCenter not available";
|
||
return nil;
|
||
}
|
||
id ftc = [ftcClass performSelector:@selector(sharedInstance)];
|
||
if (!ftc) {
|
||
if (outErr) *outErr = @"FileTransferCenter unavailable";
|
||
return nil;
|
||
}
|
||
if (![ftc respondsToSelector:@selector(guidForNewOutgoingTransferWithLocalURL:)]) {
|
||
if (outErr) *outErr = @"guidForNewOutgoingTransferWithLocalURL: unavailable";
|
||
return nil;
|
||
}
|
||
|
||
id rawGuid = [ftc performSelector:@selector(guidForNewOutgoingTransferWithLocalURL:)
|
||
withObject:originalURL];
|
||
if (![rawGuid isKindOfClass:[NSString class]] || ![(NSString *)rawGuid length]) {
|
||
if (outErr) *outErr = @"Could not allocate transfer guid";
|
||
return nil;
|
||
}
|
||
NSString *transferGuid = (NSString *)rawGuid;
|
||
|
||
IMFileTransfer *transfer = nil;
|
||
if ([ftc respondsToSelector:@selector(transferForGUID:)]) {
|
||
transfer = [ftc performSelector:@selector(transferForGUID:) withObject:transferGuid];
|
||
}
|
||
if (!transfer) {
|
||
if (outErr) *outErr = @"Could not resolve IMFileTransfer for guid";
|
||
return nil;
|
||
}
|
||
|
||
// Try to copy the source file into the IMD-managed attachments tree and
|
||
// retarget the transfer. macOS 26 returns nil here if `chatGUID` is nil;
|
||
// passing the real chat GUID is what gives IMD enough context to choose the
|
||
// per-chat attachment-store path that Messages/imagent will accept.
|
||
Class pacClass = NSClassFromString(@"IMDPersistentAttachmentController");
|
||
if (pacClass) {
|
||
id pac = [pacClass performSelector:@selector(sharedInstance)];
|
||
SEL pathSel = @selector(_persistentPathForTransfer:filename:highQuality:chatGUID:storeAtExternalPath:);
|
||
if (pac && [pac respondsToSelector:pathSel]) {
|
||
NSMethodSignature *sig = [pac methodSignatureForSelector:pathSel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:pathSel];
|
||
[inv setTarget:pac];
|
||
__unsafe_unretained IMFileTransfer *xfer = transfer;
|
||
__unsafe_unretained NSString *fn = filename ?: [originalURL lastPathComponent];
|
||
__unsafe_unretained NSString *cg = chatGuid;
|
||
BOOL hi = YES;
|
||
BOOL ext = YES;
|
||
[inv setArgument:&xfer atIndex:2];
|
||
[inv setArgument:&fn atIndex:3];
|
||
[inv setArgument:&hi atIndex:4];
|
||
[inv setArgument:&cg atIndex:5];
|
||
[inv setArgument:&ext atIndex:6];
|
||
[inv retainArguments];
|
||
[inv invoke];
|
||
__unsafe_unretained NSString *raw = nil;
|
||
[inv getReturnValue:&raw];
|
||
// Take a strong reference immediately — invocation returns an
|
||
// unretained pointer that ARC may release before the next use.
|
||
NSString *persistentPath = raw;
|
||
debugLog(@"prepareOutgoingTransfer: persistentPath=%@ filename=%@",
|
||
persistentPath ?: @"(nil)", fn);
|
||
|
||
if (persistentPath.length) {
|
||
NSURL *persistentURL = [NSURL fileURLWithPath:persistentPath];
|
||
NSURL *parent = [persistentURL URLByDeletingLastPathComponent];
|
||
NSError *folderErr = nil;
|
||
[[NSFileManager defaultManager] createDirectoryAtURL:parent
|
||
withIntermediateDirectories:YES
|
||
attributes:nil
|
||
error:&folderErr];
|
||
if (folderErr) {
|
||
if (outErr) *outErr = [NSString stringWithFormat:
|
||
@"Failed to create attachment dir: %@", folderErr.localizedDescription];
|
||
return nil;
|
||
}
|
||
// If the destination already exists (e.g., re-send of the same
|
||
// file), nuke the stale copy so copyItem doesn't fail.
|
||
if ([[NSFileManager defaultManager] fileExistsAtPath:persistentPath]) {
|
||
[[NSFileManager defaultManager] removeItemAtURL:persistentURL error:NULL];
|
||
}
|
||
NSError *copyErr = nil;
|
||
[[NSFileManager defaultManager] copyItemAtURL:originalURL
|
||
toURL:persistentURL
|
||
error:©Err];
|
||
if (copyErr) {
|
||
if (outErr) *outErr = [NSString stringWithFormat:
|
||
@"Failed to copy attachment: %@", copyErr.localizedDescription];
|
||
return nil;
|
||
}
|
||
retargetPreparedTransfer(ftc, transfer, transferGuid, persistentPath);
|
||
} else {
|
||
// Newer IMDPersistence builds also expose a block-based save
|
||
// API. Use it as a fallback when the older path helper refuses
|
||
// to return a staging path for this transfer.
|
||
NSString *savedPath = saveAttachmentForTransfer(pac, transfer, chatGuid, outErr);
|
||
if (savedPath.length) {
|
||
retargetPreparedTransfer(ftc, transfer, transferGuid, savedPath);
|
||
} else if (outErr && !*outErr) {
|
||
*outErr = @"Could not stage attachment";
|
||
return nil;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Register the transfer so imagent picks it up. BB notes this can warn
|
||
// silently on failure; we still try because skipping it leaves the
|
||
// attachment unsendable.
|
||
if ([ftc respondsToSelector:@selector(registerTransferWithDaemon:)]) {
|
||
[ftc performSelector:@selector(registerTransferWithDaemon:) withObject:transferGuid];
|
||
}
|
||
return transfer;
|
||
}
|
||
|
||
/// `send-attachment`: registers the file via IMFileTransferCenter and sends a
|
||
/// message whose attributedBody carries the OBJ placeholder tagged with the
|
||
/// transfer guid (Messages requires this attribute or the attachment row is
|
||
/// never linked to the outgoing message).
|
||
static NSDictionary *handleSendAttachment(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *filePath = params[@"filePath"];
|
||
NSNumber *audioFlag = params[@"isAudioMessage"];
|
||
BOOL isAudio = [audioFlag boolValue];
|
||
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (!filePath.length) return errorResponse(requestId, @"Missing filePath");
|
||
NSError *attrErr = nil;
|
||
NSDictionary *attrs = [[NSFileManager defaultManager]
|
||
attributesOfItemAtPath:filePath error:&attrErr];
|
||
if (!attrs) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"File not found: %@", filePath]);
|
||
}
|
||
if ([attrs[NSFileType] isEqualToString:NSFileTypeSymbolicLink]) {
|
||
return errorResponse(requestId, @"Symlinked attachment paths are not allowed");
|
||
}
|
||
if (pathHasSymlinkComponent(filePath)) {
|
||
return errorResponse(requestId, @"Attachment path traverses a symlink");
|
||
}
|
||
if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"File not found: %@", filePath]);
|
||
}
|
||
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
|
||
}
|
||
|
||
NSURL *fileURL = [NSURL fileURLWithPath:filePath];
|
||
NSString *filename = [fileURL lastPathComponent];
|
||
|
||
@try {
|
||
NSString *prepErr = nil;
|
||
IMFileTransfer *transfer = prepareOutgoingTransfer(fileURL, filename, chatGuid, &prepErr);
|
||
if (!transfer) {
|
||
return errorResponse(requestId,
|
||
prepErr.length ? prepErr : @"Could not register attachment transfer");
|
||
}
|
||
NSString *transferGuid = [transfer guid];
|
||
if (!transferGuid.length) {
|
||
return errorResponse(requestId, @"Transfer registered without guid");
|
||
}
|
||
|
||
NSAttributedString *body = buildAttachmentAttributed(transferGuid, filename, 0);
|
||
id imMessage = buildIMMessage(body, nil, nil, nil, nil, 0,
|
||
NSMakeRange(0, body.length), nil,
|
||
@[transferGuid], isAudio, NO);
|
||
if (!imMessage) {
|
||
return errorResponse(requestId, @"Could not build IMMessage with attachment");
|
||
}
|
||
dispatchIMMessageInChat(chat, imMessage);
|
||
NSString *guid = lastSentMessageGuid(chat);
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": chatGuid,
|
||
@"messageGuid": guid ?: @"",
|
||
@"transferGuid": transferGuid
|
||
});
|
||
} @catch (NSException *exception) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"send-attachment failed: %@", exception.reason]);
|
||
}
|
||
}
|
||
|
||
/// `send-reaction`: builds a reaction IMMessage tied to the target guid.
|
||
static NSDictionary *handleSendReaction(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *selectedMessageGuid = params[@"selectedMessageGuid"];
|
||
NSString *reactionType = params[@"reactionType"];
|
||
NSNumber *partIndexNum = params[@"partIndex"];
|
||
NSInteger partIndex = partIndexNum ? [partIndexNum integerValue] : 0;
|
||
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (!selectedMessageGuid.length) return errorResponse(requestId, @"Missing selectedMessageGuid");
|
||
if (!reactionType.length) return errorResponse(requestId, @"Missing reactionType");
|
||
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
|
||
}
|
||
|
||
long long associatedType = -1;
|
||
NSDictionary *kindMap = @{
|
||
@"love": @2000, @"like": @2001, @"dislike": @2002,
|
||
@"laugh": @2003, @"emphasize": @2004, @"question": @2005,
|
||
@"remove-love": @3000, @"remove-like": @3001, @"remove-dislike": @3002,
|
||
@"remove-laugh": @3003, @"remove-emphasize": @3004, @"remove-question": @3005,
|
||
};
|
||
NSNumber *typeNum = kindMap[reactionType.lowercaseString];
|
||
if (typeNum) associatedType = [typeNum longLongValue];
|
||
if (associatedType <= 0) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Unknown reactionType: %@", reactionType]);
|
||
}
|
||
|
||
// BlueBubblesHelper-verified format for tapbacks:
|
||
// associatedMessageGUID = `p:<partIndex>/<parent-guid>`. Without the
|
||
// prefix the receiver doesn't render the heart on the parent message.
|
||
NSString *associatedRef = [selectedMessageGuid hasPrefix:@"p:"]
|
||
? selectedMessageGuid
|
||
: [NSString stringWithFormat:@"p:%ld/%@",
|
||
(long)partIndex, selectedMessageGuid];
|
||
|
||
// Reaction body needs the verb-style summary text — `Loved "parent
|
||
// text"` — not an empty string. imagent silently drops reactions with
|
||
// empty body. Best-effort: load the parent and quote its text; fall
|
||
// back to a generic phrase if we can't resolve it.
|
||
NSString *verb = @"Loved ";
|
||
switch (associatedType) {
|
||
case 2000: case 3000: verb = @"Loved "; break;
|
||
case 2001: case 3001: verb = @"Liked "; break;
|
||
case 2002: case 3002: verb = @"Disliked "; break;
|
||
case 2003: case 3003: verb = @"Laughed at "; break;
|
||
case 2004: case 3004: verb = @"Emphasized "; break;
|
||
case 2005: case 3005: verb = @"Questioned "; break;
|
||
}
|
||
if (associatedType >= 3000) {
|
||
NSString *removed = @"Removed a like from ";
|
||
switch (associatedType) {
|
||
case 3000: removed = @"Removed a heart from "; break;
|
||
case 3001: removed = @"Removed a like from "; break;
|
||
case 3002: removed = @"Removed a dislike from "; break;
|
||
case 3003: removed = @"Removed a laugh from "; break;
|
||
case 3004: removed = @"Removed an exclamation from "; break;
|
||
case 3005: removed = @"Removed a question mark from "; break;
|
||
}
|
||
verb = removed;
|
||
}
|
||
id parentMsg = nil;
|
||
id parentChatItem = loadParentFirstChatItem(selectedMessageGuid, &parentMsg);
|
||
NSString *parentText = nil;
|
||
if (parentMsg && [parentMsg respondsToSelector:@selector(text)]) {
|
||
id t = [parentMsg performSelector:@selector(text)];
|
||
if ([t isKindOfClass:[NSAttributedString class]]) {
|
||
parentText = [(NSAttributedString *)t string];
|
||
}
|
||
}
|
||
// BB-verified: derive `associatedMessageRange` from the parent's first
|
||
// chat item — `[item messagePartRange]`. Hardcoding `{0,1}` (what we did
|
||
// before) targets the wrong part on multipart parents (e.g. tapback on
|
||
// the second image of a photo grid). For non-text parts (attachments)
|
||
// BB substitutes "an attachment" for the quoted text.
|
||
NSRange targetRange = NSMakeRange(0, 1);
|
||
if (parentChatItem
|
||
&& [parentChatItem respondsToSelector:@selector(messagePartRange)]) {
|
||
targetRange = [(IMMessagePartChatItem *)parentChatItem messagePartRange];
|
||
if (targetRange.length == 0) targetRange = NSMakeRange(0, 1);
|
||
}
|
||
NSString *quoted = parentText.length
|
||
? [NSString stringWithFormat:@"%@“%@”", verb, parentText]
|
||
: [verb stringByAppendingString:@"a message"];
|
||
NSAttributedString *body = buildPlainAttributed(quoted, partIndex);
|
||
|
||
// BB-verified `messageSummaryInfo` shape: `amc` is an integer count
|
||
// (always `@1` for single-target tapbacks), `ams` is the parent text
|
||
// (the receiver's notification preview reads `<verb> "<ams>"`). Earlier
|
||
// we were stuffing the parent guid into `amc` as a string — the
|
||
// resulting `message_summary_info` blob was malformed and on macOS 26
|
||
// imagent silently dropped the reaction.
|
||
NSDictionary *summary = @{ @"amc": @1,
|
||
@"ams": parentText ?: @"" };
|
||
debugLog(@"handleSendReaction: target=%@ type=%lld range={%lu,%lu} body=%@",
|
||
associatedRef, associatedType,
|
||
(unsigned long)targetRange.location, (unsigned long)targetRange.length,
|
||
quoted);
|
||
|
||
// One-shot probe: list every IMMessage class method that mentions
|
||
// "associated" or "instant" so we can see what reaction constructors
|
||
// macOS 26 actually exposes. This is intentionally noisy — gates itself
|
||
// off after the first call. Also dumps IMDPersistentAttachmentController
|
||
// methods so we can see what attachment-staging selectors are exposed.
|
||
static dispatch_once_t probeOnce;
|
||
dispatch_once(&probeOnce, ^{
|
||
Class pac = NSClassFromString(@"IMDPersistentAttachmentController");
|
||
unsigned int pn = 0;
|
||
Method *pm = class_copyMethodList(pac, &pn);
|
||
for (unsigned int i = 0; i < pn; i++) {
|
||
const char *name = sel_getName(method_getName(pm[i]));
|
||
if (strstr(name, "ersistent") || strstr(name, "ttachment")
|
||
|| strstr(name, "ransfer") || strstr(name, "ath")) {
|
||
debugLog(@" -[IMDPersistentAttachmentController %s]", name);
|
||
}
|
||
}
|
||
if (pm) free(pm);
|
||
Class c = NSClassFromString(@"IMMessage");
|
||
unsigned int n = 0;
|
||
Method *m = class_copyMethodList(object_getClass(c), &n);
|
||
for (unsigned int i = 0; i < n; i++) {
|
||
const char *name = sel_getName(method_getName(m[i]));
|
||
if (strstr(name, "ssociated") || strstr(name, "nstantMessage")
|
||
|| strstr(name, "eaction") || strstr(name, "knowledgment")) {
|
||
debugLog(@" +[IMMessage %s]", name);
|
||
}
|
||
}
|
||
if (m) free(m);
|
||
|
||
Class ic = NSClassFromString(@"IMMessageItem");
|
||
n = 0;
|
||
Method *im = class_copyMethodList(ic, &n);
|
||
for (unsigned int i = 0; i < n; i++) {
|
||
const char *name = sel_getName(method_getName(im[i]));
|
||
if (strstr(name, "ssociated") || strstr(name, "ummary")
|
||
|| strstr(name, "ssociatedMessage")) {
|
||
debugLog(@" -[IMMessageItem %s]", name);
|
||
}
|
||
}
|
||
if (im) free(im);
|
||
});
|
||
@try {
|
||
id imMessage = buildIMMessage(body, nil, nil, nil,
|
||
associatedRef,
|
||
associatedType,
|
||
targetRange,
|
||
summary,
|
||
@[], NO, NO);
|
||
if (!imMessage) {
|
||
return errorResponse(requestId, @"Could not build reaction IMMessage");
|
||
}
|
||
[chat performSelector:@selector(sendMessage:) withObject:imMessage];
|
||
debugLog(@"handleSendReaction: dispatched");
|
||
NSString *guid = lastSentMessageGuid(chat);
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": chatGuid,
|
||
@"selectedMessageGuid": selectedMessageGuid,
|
||
@"reactionType": reactionType,
|
||
@"messageGuid": guid ?: @""
|
||
});
|
||
} @catch (NSException *exception) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"send-reaction failed: %@", exception.reason]);
|
||
}
|
||
}
|
||
|
||
/// `notify-anyways`: ask Messages.app to deliver a low-priority notification
|
||
/// for a previously-suppressed message guid.
|
||
static NSDictionary *handleNotifyAnyways(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *messageGuid = params[@"messageGuid"];
|
||
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (!messageGuid.length) return errorResponse(requestId, @"Missing messageGuid");
|
||
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
|
||
}
|
||
|
||
@try {
|
||
// BB-verified macOS 12+ path: `markChatItemAsNotifyRecipient:` is
|
||
// the focus-bypass primitive ("Notify Anyway" UI affordance). Our
|
||
// previous `sendMessageAcknowledgment:forChatItem:withMessageSummaryInfo:withGuid:`
|
||
// with ack=1000 was actually a tapback ack, not a notify-anyway —
|
||
// wrong operation entirely.
|
||
SEL sel = @selector(markChatItemAsNotifyRecipient:);
|
||
if (![chat respondsToSelector:sel]) {
|
||
return errorResponse(requestId, @"markChatItemAsNotifyRecipient: not available");
|
||
}
|
||
id item = findMessageItem(chat, messageGuid);
|
||
if (!item) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Message not found: %@", messageGuid]);
|
||
}
|
||
[chat performSelector:sel withObject:item];
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": chatGuid, @"messageGuid": messageGuid, @"queued": @YES
|
||
});
|
||
} @catch (NSException *exception) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"notify-anyways failed: %@", exception.reason]);
|
||
}
|
||
}
|
||
|
||
#pragma mark - Mutate Handlers (v2)
|
||
|
||
/// `edit-message`: rewrite an existing message via the edit selector
|
||
/// appropriate for the running macOS. Preserves BB's "Compatability" typo.
|
||
static NSDictionary *handleEditMessage(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *messageGuid = params[@"messageGuid"];
|
||
NSString *newText = params[@"editedMessage"];
|
||
NSString *bcText = params[@"backwardsCompatibilityMessage"]
|
||
?: params[@"backwardCompatibilityMessage"];
|
||
NSNumber *partIndexNum = params[@"partIndex"];
|
||
NSInteger partIndex = partIndexNum ? [partIndexNum integerValue] : 0;
|
||
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (!messageGuid.length) return errorResponse(requestId, @"Missing messageGuid");
|
||
if (!newText.length) return errorResponse(requestId, @"Missing editedMessage");
|
||
if (!bcText) bcText = newText;
|
||
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
|
||
}
|
||
if (!gHasEditMessageItem && !gHasEditMessage) {
|
||
return errorResponse(requestId, @"No edit-message selector available on this macOS");
|
||
}
|
||
|
||
NSAttributedString *newBody = buildPlainAttributed(newText, partIndex);
|
||
|
||
id item = findMessageItem(chat, messageGuid);
|
||
if (!item) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Message not found: %@", messageGuid]);
|
||
}
|
||
|
||
@try {
|
||
NSInteger localPartIndex = partIndex;
|
||
if (gHasEditMessageItem) {
|
||
SEL sel = @selector(editMessageItem:atPartIndex:withNewPartText:backwardCompatabilityText:);
|
||
NSMethodSignature *sig = [chat methodSignatureForSelector:sel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:sel];
|
||
[inv setTarget:chat];
|
||
__unsafe_unretained id ci = item;
|
||
[inv setArgument:&ci atIndex:2];
|
||
[inv setArgument:&localPartIndex atIndex:3];
|
||
__unsafe_unretained NSAttributedString *newBodyArg = newBody;
|
||
[inv setArgument:&newBodyArg atIndex:4];
|
||
__unsafe_unretained NSString *bcArg = bcText;
|
||
[inv setArgument:&bcArg atIndex:5];
|
||
[inv invoke];
|
||
} else {
|
||
// macOS 13 path
|
||
SEL sel = @selector(editMessage:atPartIndex:withNewPartText:backwardCompatabilityText:);
|
||
id message = nil;
|
||
if ([item respondsToSelector:@selector(message)]) {
|
||
message = [item performSelector:@selector(message)];
|
||
}
|
||
if (!message) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Message object not found: %@", messageGuid]);
|
||
}
|
||
NSMethodSignature *sig = [chat methodSignatureForSelector:sel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:sel];
|
||
[inv setTarget:chat];
|
||
__unsafe_unretained id msg = message;
|
||
[inv setArgument:&msg atIndex:2];
|
||
[inv setArgument:&localPartIndex atIndex:3];
|
||
__unsafe_unretained NSAttributedString *newBodyArg = newBody;
|
||
[inv setArgument:&newBodyArg atIndex:4];
|
||
__unsafe_unretained NSString *bcArg = bcText;
|
||
[inv setArgument:&bcArg atIndex:5];
|
||
[inv invoke];
|
||
}
|
||
} @catch (NSException *ex) {
|
||
return errorResponse(requestId, ex.reason ?: @"edit-message failed");
|
||
}
|
||
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": chatGuid,
|
||
@"messageGuid": messageGuid,
|
||
@"queued": @YES
|
||
});
|
||
}
|
||
|
||
/// `unsend-message`: retract a part of a sent message via retractMessagePart:.
|
||
static NSDictionary *handleUnsendMessage(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *messageGuid = params[@"messageGuid"];
|
||
NSNumber *partIndexNum = params[@"partIndex"];
|
||
NSInteger partIndex = partIndexNum ? [partIndexNum integerValue] : 0;
|
||
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (!messageGuid.length) return errorResponse(requestId, @"Missing messageGuid");
|
||
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
|
||
}
|
||
if (!gHasRetractMessagePart) {
|
||
return errorResponse(requestId, @"retractMessagePart: not available on this macOS");
|
||
}
|
||
|
||
id messageItem = findMessageItem(chat, messageGuid);
|
||
if (!messageItem) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Message not found: %@", messageGuid]);
|
||
}
|
||
|
||
@try {
|
||
id newChatItems = nil;
|
||
SEL ncSel = @selector(_newChatItems);
|
||
if ([messageItem respondsToSelector:ncSel]) {
|
||
// Route through objc_msgSend to avoid ARC's "performSelector
|
||
// names a selector which retains the object" warning on the
|
||
// underscore-prefixed selector.
|
||
newChatItems = ((id (*)(id, SEL))objc_msgSend)(messageItem, ncSel);
|
||
}
|
||
id target = nil;
|
||
if ([newChatItems isKindOfClass:[NSArray class]]) {
|
||
NSArray *arr = newChatItems;
|
||
if (arr.count == 0) target = messageItem;
|
||
else if (arr.count == 1) target = arr.firstObject;
|
||
else {
|
||
for (id sub in arr) {
|
||
// Aggregate attachment unwrap
|
||
if ([sub respondsToSelector:@selector(aggregateAttachmentParts)]) {
|
||
NSArray *agg = [sub performSelector:@selector(aggregateAttachmentParts)];
|
||
for (id p in agg) {
|
||
if ([p respondsToSelector:@selector(index)]
|
||
&& [(IMMessagePartChatItem *)p index] == partIndex) {
|
||
target = p; break;
|
||
}
|
||
}
|
||
if (target) break;
|
||
}
|
||
if ([sub respondsToSelector:@selector(index)]
|
||
&& [(IMMessagePartChatItem *)sub index] == partIndex) {
|
||
target = sub; break;
|
||
}
|
||
}
|
||
}
|
||
} else if (newChatItems != nil) {
|
||
target = newChatItems;
|
||
} else {
|
||
target = messageItem;
|
||
}
|
||
if (!target) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Message part not found: %ld", (long)partIndex]);
|
||
}
|
||
[chat performSelector:@selector(retractMessagePart:) withObject:target];
|
||
} @catch (NSException *ex) {
|
||
return errorResponse(requestId, ex.reason ?: @"unsend-message failed");
|
||
}
|
||
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": chatGuid,
|
||
@"messageGuid": messageGuid,
|
||
@"queued": @YES
|
||
});
|
||
}
|
||
|
||
/// `delete-message`: remove a single message from the chat.
|
||
static NSDictionary *handleDeleteMessage(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *messageGuid = params[@"messageGuid"];
|
||
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (!messageGuid.length) return errorResponse(requestId, @"Missing messageGuid");
|
||
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Chat not found: %@", chatGuid]);
|
||
}
|
||
|
||
SEL sel = @selector(deleteChatItems:);
|
||
if (![chat respondsToSelector:sel]) {
|
||
return errorResponse(requestId, @"deleteChatItems: not available");
|
||
}
|
||
|
||
id item = findMessageItem(chat, messageGuid);
|
||
if (!item) {
|
||
return errorResponse(requestId,
|
||
[NSString stringWithFormat:@"Message not found: %@", messageGuid]);
|
||
}
|
||
@try {
|
||
[chat performSelector:sel withObject:@[item]];
|
||
} @catch (NSException *ex) {
|
||
return errorResponse(requestId, ex.reason ?: @"delete-message failed");
|
||
}
|
||
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": chatGuid, @"messageGuid": messageGuid, @"queued": @YES
|
||
});
|
||
}
|
||
|
||
#pragma mark - Chat Management Handlers (v2)
|
||
|
||
static NSDictionary *handleStartTyping(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
debugLog(@"handleStartTyping: chatGuid=%@", chatGuid);
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) {
|
||
debugLog(@"handleStartTyping: chat not found");
|
||
return errorResponse(requestId, @"Chat not found");
|
||
}
|
||
BOOL beforeT = NO, afterT = NO;
|
||
if ([chat respondsToSelector:@selector(isCurrentlyTyping)]) {
|
||
beforeT = ((BOOL (*)(id, SEL))objc_msgSend)(chat, @selector(isCurrentlyTyping));
|
||
}
|
||
@try { [chat setLocalUserIsTyping:YES]; }
|
||
@catch (NSException *ex) {
|
||
debugLog(@"handleStartTyping: exception=%@", ex.reason);
|
||
return errorResponse(requestId, ex.reason ?: @"failed");
|
||
}
|
||
if ([chat respondsToSelector:@selector(isCurrentlyTyping)]) {
|
||
afterT = ((BOOL (*)(id, SEL))objc_msgSend)(chat, @selector(isCurrentlyTyping));
|
||
}
|
||
debugLog(@"handleStartTyping: setLocalUserIsTyping:YES beforeIsTyping=%d afterIsTyping=%d "
|
||
@"chatClass=%@", beforeT, afterT, NSStringFromClass([chat class]));
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid, @"typing": @YES});
|
||
}
|
||
|
||
static NSDictionary *handleStopTyping(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
debugLog(@"handleStopTyping: chatGuid=%@", chatGuid);
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
@try { [chat setLocalUserIsTyping:NO]; }
|
||
@catch (NSException *ex) {
|
||
debugLog(@"handleStopTyping: exception=%@", ex.reason);
|
||
return errorResponse(requestId, ex.reason ?: @"failed");
|
||
}
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid, @"typing": @NO});
|
||
}
|
||
|
||
static NSDictionary *handleCheckTypingStatus(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
BOOL typing = NO;
|
||
if ([chat respondsToSelector:@selector(isCurrentlyTyping)]) {
|
||
typing = ((BOOL (*)(id, SEL))objc_msgSend)(chat, @selector(isCurrentlyTyping));
|
||
}
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid, @"typing": @(typing)});
|
||
}
|
||
|
||
static NSDictionary *handleMarkChatRead(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *handle = params[@"handle"];
|
||
id chat = nil;
|
||
if (chatGuid.length) chat = resolveChatByGuid(chatGuid);
|
||
if (!chat && handle.length) chat = findChat(handle);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
@try { [chat performSelector:@selector(markAllMessagesAsRead)]; }
|
||
@catch (NSException *ex) { return errorResponse(requestId, ex.reason ?: @"failed"); }
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid ?: @"", @"marked_as_read": @YES});
|
||
}
|
||
|
||
static NSDictionary *handleMarkChatUnread(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
@try {
|
||
// BB-verified macOS 11+ path: `markLastMessageAsUnread` is the
|
||
// daemon-aware selector that flips read=0 in chat.db AND triggers
|
||
// UI badge refresh. The `setUnreadCount:` we used previously only
|
||
// mutated a local KVO counter that didn't persist.
|
||
if ([chat respondsToSelector:@selector(markLastMessageAsUnread)]) {
|
||
[chat performSelector:@selector(markLastMessageAsUnread)];
|
||
} else {
|
||
return errorResponse(requestId, @"markLastMessageAsUnread not available");
|
||
}
|
||
} @catch (NSException *ex) { return errorResponse(requestId, ex.reason ?: @"failed"); }
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid, @"marked_as_unread": @YES});
|
||
}
|
||
|
||
static NSDictionary *handleAddParticipant(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *address = params[@"address"];
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (!address.length) return errorResponse(requestId, @"Missing address");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
|
||
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;
|
||
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");
|
||
}
|
||
NSMethodSignature *sig = [chat methodSignatureForSelector:sel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:sel];
|
||
[inv setTarget:chat];
|
||
NSArray *handles = @[handle];
|
||
[inv setArgument:&handles atIndex:2];
|
||
NSInteger reason = 0;
|
||
[inv setArgument:&reason atIndex:3];
|
||
[inv invoke];
|
||
} @catch (NSException *ex) { return errorResponse(requestId, ex.reason ?: @"failed"); }
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid, @"address": address, @"added": @YES});
|
||
}
|
||
|
||
static NSDictionary *handleRemoveParticipant(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *address = params[@"address"];
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
if (!address.length) return errorResponse(requestId, @"Missing address");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
|
||
// Find the matching participant handle on the chat itself.
|
||
id targetHandle = nil;
|
||
if ([chat respondsToSelector:@selector(participants)]) {
|
||
for (id h in [chat performSelector:@selector(participants)]) {
|
||
if ([h respondsToSelector:@selector(ID)]
|
||
&& [[h performSelector:@selector(ID)] isEqualToString:address]) {
|
||
targetHandle = h; break;
|
||
}
|
||
}
|
||
}
|
||
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");
|
||
}
|
||
NSMethodSignature *sig = [chat methodSignatureForSelector:sel];
|
||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||
[inv setSelector:sel];
|
||
[inv setTarget:chat];
|
||
NSArray *handles = @[targetHandle];
|
||
[inv setArgument:&handles atIndex:2];
|
||
NSInteger reason = 0;
|
||
[inv setArgument:&reason atIndex:3];
|
||
[inv invoke];
|
||
} @catch (NSException *ex) { return errorResponse(requestId, ex.reason ?: @"failed"); }
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid, @"address": address, @"removed": @YES});
|
||
}
|
||
|
||
static NSDictionary *handleSetDisplayName(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *newName = params[@"newName"] ?: params[@"name"];
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
@try {
|
||
// BB-verified: `_setDisplayName:` (underscore-prefixed) is the
|
||
// private mutator that posts the IDS update so other chat members
|
||
// see the rename. The public `setDisplayName:` we used before was
|
||
// just the KVO setter — it changed the local property without
|
||
// propagating, so renames were sender-only.
|
||
if ([chat respondsToSelector:@selector(_setDisplayName:)]) {
|
||
[chat performSelector:@selector(_setDisplayName:) withObject:newName ?: @""];
|
||
} else {
|
||
return errorResponse(requestId, @"_setDisplayName: not available");
|
||
}
|
||
} @catch (NSException *ex) { return errorResponse(requestId, ex.reason ?: @"failed"); }
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid, @"name": newName ?: @""});
|
||
}
|
||
|
||
static NSDictionary *handleUpdateGroupPhoto(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
NSString *filePath = params[@"filePath"];
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
|
||
@try {
|
||
// BB-verified: group-photo updates go through the file-transfer
|
||
// pipeline, not raw bytes. Stage the photo via prepareOutgoingTransfer
|
||
// (so it lives in IMD's attachments tree), then call
|
||
// sendGroupPhotoUpdate: with the transfer guid. Passing nil/empty
|
||
// file path clears the photo.
|
||
SEL sel = @selector(sendGroupPhotoUpdate:);
|
||
if (![chat respondsToSelector:sel]) {
|
||
return errorResponse(requestId, @"sendGroupPhotoUpdate: not available");
|
||
}
|
||
if (filePath.length == 0) {
|
||
[chat performSelector:sel withObject:nil];
|
||
return successResponse(requestId,
|
||
@{@"chatGuid": chatGuid, @"cleared": @YES, @"size": @0});
|
||
}
|
||
NSURL *fileURL = [NSURL fileURLWithPath:filePath];
|
||
NSString *prepErr = nil;
|
||
IMFileTransfer *transfer = prepareOutgoingTransfer(fileURL,
|
||
[fileURL lastPathComponent], chatGuid, &prepErr);
|
||
if (!transfer || ![transfer guid].length) {
|
||
return errorResponse(requestId,
|
||
prepErr.length ? prepErr : @"Could not prepare group-photo transfer");
|
||
}
|
||
[chat performSelector:sel withObject:[transfer guid]];
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": chatGuid,
|
||
@"cleared": @NO,
|
||
@"transferGuid": [transfer guid]
|
||
});
|
||
} @catch (NSException *ex) { return errorResponse(requestId, ex.reason ?: @"failed"); }
|
||
}
|
||
|
||
static NSDictionary *handleLeaveChat(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
@try {
|
||
if ([chat respondsToSelector:@selector(leaveChat)]) {
|
||
[chat performSelector:@selector(leaveChat)];
|
||
} else {
|
||
return errorResponse(requestId, @"leaveChat not available");
|
||
}
|
||
} @catch (NSException *ex) { return errorResponse(requestId, ex.reason ?: @"failed"); }
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid, @"left": @YES});
|
||
}
|
||
|
||
static NSDictionary *handleDeleteChat(NSInteger requestId, NSDictionary *params) {
|
||
NSString *chatGuid = params[@"chatGuid"];
|
||
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
|
||
IMChat *chat = resolveChatByGuid(chatGuid);
|
||
if (!chat) return errorResponse(requestId, @"Chat not found");
|
||
Class regClass = NSClassFromString(@"IMChatRegistry");
|
||
id reg = regClass ? [regClass performSelector:@selector(sharedInstance)] : nil;
|
||
SEL sel = @selector(deleteChat:);
|
||
if (!reg || ![reg respondsToSelector:sel]) {
|
||
return errorResponse(requestId, @"deleteChat: not available");
|
||
}
|
||
@try {
|
||
[reg performSelector:sel withObject:chat];
|
||
} @catch (NSException *ex) { return errorResponse(requestId, ex.reason ?: @"failed"); }
|
||
return successResponse(requestId, @{@"chatGuid": chatGuid, @"deleted": @YES});
|
||
}
|
||
|
||
/// `create-chat`: vend handles for each address, ask the registry for a chat
|
||
/// instance, optionally set the display name, optionally send an initial
|
||
/// message. Returns the new chat's guid.
|
||
static NSDictionary *handleCreateChat(NSInteger requestId, NSDictionary *params) {
|
||
NSArray *addresses = params[@"addresses"];
|
||
NSString *initialMessage = params[@"message"];
|
||
NSString *displayName = params[@"displayName"] ?: params[@"name"];
|
||
NSString *service = params[@"service"] ?: @"iMessage";
|
||
|
||
if (![addresses isKindOfClass:[NSArray class]] || addresses.count == 0) {
|
||
return errorResponse(requestId, @"Missing addresses array");
|
||
}
|
||
if ([service caseInsensitiveCompare:@"iMessage"] != NSOrderedSame) {
|
||
return errorResponse(requestId, [NSString stringWithFormat:
|
||
@"Unsupported chat-create service: %@", service]);
|
||
}
|
||
service = @"iMessage";
|
||
|
||
Class hrClass = NSClassFromString(@"IMHandleRegistrar");
|
||
id hr = hrClass ? [hrClass performSelector:@selector(sharedInstance)] : nil;
|
||
if (!hr) return errorResponse(requestId, @"IMHandleRegistrar unavailable");
|
||
|
||
NSMutableArray *handles = [NSMutableArray array];
|
||
for (NSString *addr in addresses) {
|
||
if (![addr isKindOfClass:[NSString class]]) continue;
|
||
id h = [hr performSelector:@selector(IMHandleWithID:) withObject:addr];
|
||
if (h) [handles addObject:h];
|
||
}
|
||
if (handles.count == 0) {
|
||
return errorResponse(requestId, @"Could not vend handles for any address");
|
||
}
|
||
|
||
Class regClass = NSClassFromString(@"IMChatRegistry");
|
||
id reg = regClass ? [regClass performSelector:@selector(sharedInstance)] : nil;
|
||
id chat = nil;
|
||
if (handles.count == 1 && [reg respondsToSelector:@selector(chatForIMHandle:)]) {
|
||
chat = [reg performSelector:@selector(chatForIMHandle:) withObject:handles.firstObject];
|
||
} else if ([reg respondsToSelector:@selector(chatForIMHandles:)]) {
|
||
chat = [reg performSelector:@selector(chatForIMHandles:) withObject:handles];
|
||
}
|
||
if (!chat) return errorResponse(requestId, @"Registry could not produce chat");
|
||
|
||
if (displayName.length && [chat respondsToSelector:@selector(_setDisplayName:)]) {
|
||
@try { [chat performSelector:@selector(_setDisplayName:) withObject:displayName]; }
|
||
@catch (__unused NSException *ex) {}
|
||
}
|
||
|
||
NSString *messageGuid = nil;
|
||
if (initialMessage.length) {
|
||
NSAttributedString *body = buildPlainAttributed(initialMessage, 0);
|
||
@try {
|
||
id imMessage = buildIMMessage(body, nil, nil, nil, nil, 0,
|
||
NSMakeRange(0, body.length),
|
||
nil, @[], NO, NO);
|
||
if (imMessage) {
|
||
dispatchIMMessageInChat(chat, imMessage);
|
||
messageGuid = lastSentMessageGuid(chat);
|
||
}
|
||
} @catch (__unused NSException *ex) {}
|
||
}
|
||
|
||
NSString *guid = [chat respondsToSelector:@selector(guid)]
|
||
? [chat performSelector:@selector(guid)] : @"";
|
||
return successResponse(requestId, @{
|
||
@"chatGuid": guid ?: @"",
|
||
@"service": service,
|
||
@"messageGuid": messageGuid ?: @"",
|
||
@"participants": addresses
|
||
});
|
||
}
|
||
|
||
#pragma mark - Introspection Handlers (v2)
|
||
|
||
static NSDictionary *handleSearchMessages(NSInteger requestId, NSDictionary *params) {
|
||
NSString *query = params[@"query"];
|
||
if (![query isKindOfClass:[NSString class]] || query.length == 0) {
|
||
return errorResponse(requestId, @"Missing query");
|
||
}
|
||
// Spotlight-style search across loaded chat items via IMChatHistoryController
|
||
// is not exposed to us cleanly without private headers; return a structured
|
||
// not-implemented response so the CLI can degrade gracefully.
|
||
return successResponse(requestId, @{
|
||
@"query": query,
|
||
@"results": @[],
|
||
@"note": @"server-side search not yet implemented; falls back to chat.db"
|
||
});
|
||
}
|
||
|
||
static NSDictionary *handleGetAccountInfo(NSInteger requestId, NSDictionary *params) {
|
||
Class accClass = NSClassFromString(@"IMAccountController");
|
||
if (!accClass) return errorResponse(requestId, @"IMAccountController unavailable");
|
||
id ctrl = [accClass performSelector:@selector(sharedInstance)];
|
||
if (!ctrl) return errorResponse(requestId, @"controller nil");
|
||
|
||
NSMutableDictionary *info = [NSMutableDictionary dictionary];
|
||
if ([ctrl respondsToSelector:@selector(activeIMessageAccount)]) {
|
||
id account = [ctrl performSelector:@selector(activeIMessageAccount)];
|
||
if (account) {
|
||
NSArray *aliases = nil;
|
||
if ([account respondsToSelector:@selector(vettedAliases)]) {
|
||
aliases = [account performSelector:@selector(vettedAliases)];
|
||
}
|
||
id login = nil;
|
||
if ([account respondsToSelector:@selector(loginIMHandle)]) {
|
||
login = [account performSelector:@selector(loginIMHandle)];
|
||
}
|
||
NSString *loginID = nil;
|
||
if (login && [login respondsToSelector:@selector(ID)]) {
|
||
loginID = [login performSelector:@selector(ID)];
|
||
}
|
||
info[@"vetted_aliases"] = aliases ?: @[];
|
||
info[@"login"] = loginID ?: @"";
|
||
info[@"service"] = @"iMessage";
|
||
}
|
||
}
|
||
return successResponse(requestId, info);
|
||
}
|
||
|
||
static NSDictionary *handleGetNicknameInfo(NSInteger requestId, NSDictionary *params) {
|
||
NSString *address = params[@"address"];
|
||
Class nnClass = NSClassFromString(@"IMNicknameController");
|
||
if (!nnClass) return errorResponse(requestId, @"IMNicknameController unavailable");
|
||
id ctrl = [nnClass performSelector:@selector(sharedController)];
|
||
if (!ctrl) return errorResponse(requestId, @"controller nil");
|
||
|
||
NSMutableDictionary *info = [NSMutableDictionary dictionary];
|
||
if (address.length && [ctrl respondsToSelector:@selector(nicknameForHandle:)]) {
|
||
id nickname = [ctrl performSelector:@selector(nicknameForHandle:) withObject:address];
|
||
info[@"address"] = address;
|
||
info[@"has_nickname"] = @(nickname != nil);
|
||
if (nickname) {
|
||
info[@"description"] = [nickname description] ?: @"";
|
||
}
|
||
}
|
||
return successResponse(requestId, info);
|
||
}
|
||
|
||
static NSDictionary *handleCheckIMessageAvailability(NSInteger requestId, NSDictionary *params) {
|
||
NSString *address = params[@"address"];
|
||
NSString *aliasType = params[@"aliasType"] ?: @"phone";
|
||
if (!address.length) return errorResponse(requestId, @"Missing address");
|
||
Class q = NSClassFromString(@"IDSIDQueryController");
|
||
if (!q) return errorResponse(requestId, @"IDSIDQueryController unavailable");
|
||
id ctrl = [q performSelector:@selector(sharedController)];
|
||
if (!ctrl) return errorResponse(requestId, @"controller nil");
|
||
|
||
NSString *destination = address;
|
||
if ([aliasType isEqualToString:@"phone"]) {
|
||
if (![destination hasPrefix:@"tel:"]) destination = [@"tel:" stringByAppendingString:destination];
|
||
} else if ([aliasType isEqualToString:@"email"]) {
|
||
if (![destination hasPrefix:@"mailto:"]) destination = [@"mailto:" stringByAppendingString:destination];
|
||
}
|
||
|
||
NSInteger status = 0;
|
||
@try {
|
||
SEL sel = @selector(currentIDStatusForDestination:service:);
|
||
if ([ctrl respondsToSelector:sel]) {
|
||
id result = [ctrl performSelector:sel withObject:destination withObject:nil];
|
||
if ([result isKindOfClass:[NSNumber class]]) {
|
||
status = [(NSNumber *)result integerValue];
|
||
}
|
||
}
|
||
} @catch (__unused NSException *ex) {}
|
||
|
||
return successResponse(requestId, @{
|
||
@"address": address,
|
||
@"alias_type": aliasType,
|
||
@"destination": destination,
|
||
@"id_status": @(status),
|
||
@"available": @(status == 1)
|
||
});
|
||
}
|
||
|
||
static NSDictionary *handleDownloadPurgedAttachment(NSInteger requestId, NSDictionary *params) {
|
||
NSString *attachmentGuid = params[@"attachmentGuid"];
|
||
if (!attachmentGuid.length) return errorResponse(requestId, @"Missing attachmentGuid");
|
||
Class ftcClass = NSClassFromString(@"IMFileTransferCenter");
|
||
id ftc = ftcClass ? [ftcClass performSelector:@selector(sharedInstance)] : nil;
|
||
if (!ftc) return errorResponse(requestId, @"FileTransferCenter unavailable");
|
||
|
||
SEL sel = @selector(acceptTransfer:);
|
||
if (![ftc respondsToSelector:sel]) {
|
||
return errorResponse(requestId, @"acceptTransfer: not available");
|
||
}
|
||
@try {
|
||
[ftc performSelector:sel withObject:attachmentGuid];
|
||
} @catch (NSException *ex) { return errorResponse(requestId, ex.reason ?: @"failed"); }
|
||
return successResponse(requestId, @{@"attachmentGuid": attachmentGuid, @"queued": @YES});
|
||
}
|
||
|
||
#pragma mark - Command Router
|
||
|
||
/// Dispatch an action by name, returning a legacy-envelope NSDictionary. Used
|
||
/// by both the v1 single-file IPC path and (after key-stripping) the v2 path.
|
||
static NSDictionary* dispatchAction(NSInteger legacyId, NSString *action,
|
||
NSDictionary *params) {
|
||
if ([action isEqualToString:@"typing"]) {
|
||
return handleTyping(legacyId, params);
|
||
} else if ([action isEqualToString:@"read"]) {
|
||
return handleRead(legacyId, params);
|
||
} else if ([action isEqualToString:@"status"] ||
|
||
[action isEqualToString:@"bridge-status"]) {
|
||
return handleStatus(legacyId, params);
|
||
} else if ([action isEqualToString:@"list_chats"]) {
|
||
return handleListChats(legacyId, params);
|
||
} else if ([action isEqualToString:@"ping"]) {
|
||
return successResponse(legacyId, @{@"pong": @YES});
|
||
}
|
||
// v2 actions
|
||
if ([action isEqualToString:@"send-message"]) return handleSendMessage(legacyId, params);
|
||
if ([action isEqualToString:@"send-multipart"]) return handleSendMultipart(legacyId, params);
|
||
if ([action isEqualToString:@"send-attachment"]) return handleSendAttachment(legacyId, params);
|
||
if ([action isEqualToString:@"send-reaction"]) return handleSendReaction(legacyId, params);
|
||
if ([action isEqualToString:@"notify-anyways"]) return handleNotifyAnyways(legacyId, params);
|
||
if ([action isEqualToString:@"edit-message"]) return handleEditMessage(legacyId, params);
|
||
if ([action isEqualToString:@"unsend-message"]) return handleUnsendMessage(legacyId, params);
|
||
if ([action isEqualToString:@"delete-message"]) return handleDeleteMessage(legacyId, params);
|
||
if ([action isEqualToString:@"start-typing"]) return handleStartTyping(legacyId, params);
|
||
if ([action isEqualToString:@"stop-typing"]) return handleStopTyping(legacyId, params);
|
||
if ([action isEqualToString:@"check-typing-status"]) return handleCheckTypingStatus(legacyId, params);
|
||
if ([action isEqualToString:@"mark-chat-read"]) return handleMarkChatRead(legacyId, params);
|
||
if ([action isEqualToString:@"mark-chat-unread"]) return handleMarkChatUnread(legacyId, params);
|
||
if ([action isEqualToString:@"add-participant"]) return handleAddParticipant(legacyId, params);
|
||
if ([action isEqualToString:@"remove-participant"]) return handleRemoveParticipant(legacyId, params);
|
||
if ([action isEqualToString:@"set-display-name"]) return handleSetDisplayName(legacyId, params);
|
||
if ([action isEqualToString:@"update-group-photo"]) return handleUpdateGroupPhoto(legacyId, params);
|
||
if ([action isEqualToString:@"leave-chat"]) return handleLeaveChat(legacyId, params);
|
||
if ([action isEqualToString:@"delete-chat"]) return handleDeleteChat(legacyId, params);
|
||
if ([action isEqualToString:@"create-chat"]) return handleCreateChat(legacyId, params);
|
||
if ([action isEqualToString:@"search-messages"]) return handleSearchMessages(legacyId, params);
|
||
if ([action isEqualToString:@"get-account-info"]) return handleGetAccountInfo(legacyId, params);
|
||
if ([action isEqualToString:@"get-nickname-info"]) return handleGetNicknameInfo(legacyId, params);
|
||
if ([action isEqualToString:@"check-imessage-availability"])
|
||
return handleCheckIMessageAvailability(legacyId, params);
|
||
if ([action isEqualToString:@"download-purged-attachment"])
|
||
return handleDownloadPurgedAttachment(legacyId, params);
|
||
return errorResponse(legacyId,
|
||
[NSString stringWithFormat:@"Unknown action: %@", action]);
|
||
}
|
||
|
||
static NSDictionary* processCommand(NSDictionary *command) {
|
||
NSNumber *requestIdNum = command[@"id"];
|
||
NSInteger requestId = requestIdNum ? [requestIdNum integerValue] : 0;
|
||
NSString *action = command[@"action"];
|
||
NSDictionary *params = command[@"params"] ?: @{};
|
||
|
||
NSLog(@"[imsg-bridge] Processing command: %@ (id=%ld)", action, (long)requestId);
|
||
return dispatchAction(requestId, action, params);
|
||
}
|
||
|
||
/// Process a v2 envelope: re-route to the shared dispatcher, then strip the
|
||
/// legacy envelope keys and re-wrap with the v2 shape.
|
||
static NSDictionary* processV2Envelope(NSDictionary *envelope) {
|
||
NSString *uuid = envelope[@"id"];
|
||
if (![uuid isKindOfClass:[NSString class]]) uuid = @"";
|
||
NSString *action = envelope[@"action"];
|
||
NSDictionary *params = envelope[@"params"] ?: @{};
|
||
if (![action isKindOfClass:[NSString class]] || action.length == 0) {
|
||
return errorResponseV2(uuid, @"Missing action");
|
||
}
|
||
|
||
NSLog(@"[imsg-bridge v2] action=%@ id=%@", action, uuid);
|
||
|
||
NSDictionary *legacy = dispatchAction(0, action, params);
|
||
if (![legacy isKindOfClass:[NSDictionary class]]) {
|
||
return errorResponseV2(uuid, @"Internal: handler returned non-dictionary");
|
||
}
|
||
|
||
BOOL ok = [legacy[@"success"] boolValue];
|
||
if (!ok) {
|
||
NSString *errMsg = legacy[@"error"];
|
||
return errorResponseV2(uuid, errMsg ?: @"Unknown error");
|
||
}
|
||
|
||
NSMutableDictionary *data = [NSMutableDictionary dictionaryWithDictionary:legacy];
|
||
[data removeObjectForKey:@"id"];
|
||
[data removeObjectForKey:@"success"];
|
||
[data removeObjectForKey:@"error"];
|
||
[data removeObjectForKey:@"timestamp"];
|
||
return successResponseV2(uuid, data);
|
||
}
|
||
|
||
#pragma mark - File-based IPC
|
||
|
||
static void processCommandFile(void) {
|
||
@autoreleasepool {
|
||
initFilePaths();
|
||
|
||
NSError *error = nil;
|
||
NSData *commandData = [NSData dataWithContentsOfFile:kCommandFile options:0 error:&error];
|
||
if (!commandData || error) {
|
||
return;
|
||
}
|
||
|
||
NSDictionary *command = [NSJSONSerialization JSONObjectWithData:commandData
|
||
options:0
|
||
error:&error];
|
||
if (error || ![command isKindOfClass:[NSDictionary class]]) {
|
||
NSDictionary *response = errorResponse(0, @"Invalid JSON in command file");
|
||
NSData *responseData = [NSJSONSerialization dataWithJSONObject:response
|
||
options:NSJSONWritingPrettyPrinted
|
||
error:nil];
|
||
[responseData writeToFile:kResponseFile atomically:YES];
|
||
return;
|
||
}
|
||
|
||
NSDictionary *result = processCommand(command);
|
||
|
||
if (result != nil) {
|
||
NSData *responseData = [NSJSONSerialization dataWithJSONObject:result
|
||
options:NSJSONWritingPrettyPrinted
|
||
error:nil];
|
||
[responseData writeToFile:kResponseFile atomically:YES];
|
||
|
||
// Clear command file to signal processing is complete
|
||
[@"" writeToFile:kCommandFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
|
||
|
||
NSLog(@"[imsg-bridge] Processed command, wrote response");
|
||
}
|
||
}
|
||
}
|
||
|
||
static void startFileWatcher(void) {
|
||
initFilePaths();
|
||
|
||
NSLog(@"[imsg-bridge] Starting file-based IPC");
|
||
NSLog(@"[imsg-bridge] Command file: %@", kCommandFile);
|
||
NSLog(@"[imsg-bridge] Response file: %@", kResponseFile);
|
||
|
||
// Create/clear IPC files
|
||
[@"" writeToFile:kCommandFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
|
||
[@"" writeToFile:kResponseFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
|
||
|
||
// Create lock file with PID to indicate we're ready
|
||
lockFd = open(kLockFile.UTF8String, O_CREAT | O_WRONLY, 0644);
|
||
if (lockFd >= 0) {
|
||
NSString *pidStr = [NSString stringWithFormat:@"%d", getpid()];
|
||
write(lockFd, pidStr.UTF8String, pidStr.length);
|
||
}
|
||
|
||
// Poll command file via NSTimer on the main run loop.
|
||
// NSTimer survives reliably in injected dylib contexts (dispatch_source timers
|
||
// can get deallocated).
|
||
__block NSDate *lastModified = nil;
|
||
NSTimer *timer = [NSTimer timerWithTimeInterval:0.1 repeats:YES block:^(NSTimer *t) {
|
||
@autoreleasepool {
|
||
NSDictionary *attrs = [[NSFileManager defaultManager]
|
||
attributesOfItemAtPath:kCommandFile error:nil];
|
||
NSDate *modDate = attrs[NSFileModificationDate];
|
||
|
||
if (modDate && ![modDate isEqualToDate:lastModified]) {
|
||
NSData *data = [NSData dataWithContentsOfFile:kCommandFile];
|
||
if (data && data.length > 2) {
|
||
lastModified = modDate;
|
||
processCommandFile();
|
||
}
|
||
}
|
||
}
|
||
}];
|
||
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
|
||
fileWatchTimer = timer;
|
||
|
||
NSLog(@"[imsg-bridge] File watcher started, ready for commands");
|
||
}
|
||
|
||
#pragma mark - Inbound Event Observers
|
||
|
||
/// Register NSNotificationCenter observers that translate IMCore notifications
|
||
/// into JSON-lines events on `.imsg-events.jsonl`. These power
|
||
/// `imsg watch --bb-events` for live typing/alias-removal indicators.
|
||
static void registerEventObservers(void) {
|
||
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
|
||
|
||
// IMChatItemsDidChange: fires whenever a chat's item list shifts. We
|
||
// inspect the userInfo to spot inserted IMTypingChatItem instances and
|
||
// emit started-typing / stopped-typing events.
|
||
[nc addObserverForName:@"IMChatItemsDidChangeNotification"
|
||
object:nil
|
||
queue:nil
|
||
usingBlock:^(NSNotification *note) {
|
||
@autoreleasepool {
|
||
id chat = note.object;
|
||
NSString *chatGuid = nil;
|
||
if (chat && [chat respondsToSelector:@selector(guid)]) {
|
||
chatGuid = [chat performSelector:@selector(guid)];
|
||
}
|
||
NSDictionary *userInfo = note.userInfo;
|
||
NSArray *inserted = userInfo[@"__kIMChatValueKey"]
|
||
?: userInfo[@"inserted"];
|
||
if (![inserted isKindOfClass:[NSArray class]]) return;
|
||
for (id item in inserted) {
|
||
NSString *cls = NSStringFromClass([item class]);
|
||
if ([cls containsString:@"TypingChatItem"]) {
|
||
BOOL isCancel = NO;
|
||
if ([item respondsToSelector:@selector(isCancelTypingMessage)]) {
|
||
isCancel = ((BOOL (*)(id, SEL))objc_msgSend)(item,
|
||
@selector(isCancelTypingMessage));
|
||
}
|
||
appendEvent(@{
|
||
@"event": isCancel ? @"stopped-typing" : @"started-typing",
|
||
@"data": @{ @"chatGuid": chatGuid ?: @"" }
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}];
|
||
|
||
// Account aliases removed (e.g., user removed an iMessage email).
|
||
[nc addObserverForName:@"__kIMAccountAliasesRemovedNotification"
|
||
object:nil
|
||
queue:nil
|
||
usingBlock:^(NSNotification *note) {
|
||
appendEvent(@{
|
||
@"event": @"aliases-removed",
|
||
@"data": note.userInfo ?: @{}
|
||
});
|
||
}];
|
||
|
||
NSLog(@"[imsg-bridge] Event observers registered");
|
||
}
|
||
|
||
#pragma mark - v2 Inbox Watcher
|
||
|
||
/// Process a single inbox file end-to-end: read, dispatch, write outbox,
|
||
/// remove inbox. Skips re-processed ids via processedRpcIds.
|
||
static void processV2InboxFile(NSString *uuid) {
|
||
@autoreleasepool {
|
||
if ([processedRpcIds containsObject:uuid]) {
|
||
return;
|
||
}
|
||
[processedRpcIds addObject:uuid];
|
||
|
||
NSString *inPath = [kRpcInDir stringByAppendingPathComponent:
|
||
[uuid stringByAppendingPathExtension:@"json"]];
|
||
NSString *outPath = [kRpcOutDir stringByAppendingPathComponent:
|
||
[uuid stringByAppendingPathExtension:@"json"]];
|
||
|
||
NSError *err = nil;
|
||
NSData *body = [NSData dataWithContentsOfFile:inPath options:0 error:&err];
|
||
if (!body || err) {
|
||
NSLog(@"[imsg-bridge v2] Could not read %@: %@", inPath, err);
|
||
// Remove malformed file so we don't retry forever.
|
||
[[NSFileManager defaultManager] removeItemAtPath:inPath error:nil];
|
||
return;
|
||
}
|
||
|
||
NSDictionary *envelope = [NSJSONSerialization JSONObjectWithData:body
|
||
options:0
|
||
error:&err];
|
||
NSDictionary *response;
|
||
if (!envelope || ![envelope isKindOfClass:[NSDictionary class]]) {
|
||
response = errorResponseV2(uuid, @"Invalid JSON in request");
|
||
} else {
|
||
response = processV2Envelope(envelope);
|
||
}
|
||
|
||
NSData *responseData = [NSJSONSerialization dataWithJSONObject:response
|
||
options:0
|
||
error:&err];
|
||
if (responseData) {
|
||
NSString *tmp = [outPath stringByAppendingPathExtension:@"tmp"];
|
||
[responseData writeToFile:tmp atomically:NO];
|
||
// Atomic rename so the CLI never reads a half-written file.
|
||
rename(tmp.UTF8String, outPath.UTF8String);
|
||
}
|
||
|
||
// Drop the inbox request — we're done with it.
|
||
[[NSFileManager defaultManager] removeItemAtPath:inPath error:nil];
|
||
|
||
// Cap the dedupe set to prevent unbounded growth on long-lived dylibs.
|
||
if (processedRpcIds.count > 1024) {
|
||
[processedRpcIds removeAllObjects];
|
||
}
|
||
}
|
||
}
|
||
|
||
static void scanV2Inbox(void) {
|
||
@autoreleasepool {
|
||
NSError *err = nil;
|
||
NSArray *entries = [[NSFileManager defaultManager]
|
||
contentsOfDirectoryAtPath:kRpcInDir error:&err];
|
||
if (!entries) return;
|
||
for (NSString *name in entries) {
|
||
// Only consume finalized .json files; skip in-flight .tmp.
|
||
if (![name hasSuffix:@".json"]) continue;
|
||
NSString *uuid = [name stringByDeletingPathExtension];
|
||
processV2InboxFile(uuid);
|
||
}
|
||
}
|
||
}
|
||
|
||
static void startV2InboxWatcher(void) {
|
||
initFilePaths();
|
||
|
||
// Ensure the queue dirs exist (CLI also pre-creates them, but be defensive
|
||
// in case a v2-only run happened). Mode 0700 keeps other UIDs / sandboxed
|
||
// peers from being able to enumerate or inject RPC requests, and the
|
||
// symlink check refuses to operate if any path component traverses a
|
||
// link, see pathHasSymlinkComponent for rationale.
|
||
NSError *secureDirError = nil;
|
||
if (!ensureSecureDirectory(kRpcDir, &secureDirError) ||
|
||
!ensureSecureDirectory(kRpcInDir, &secureDirError) ||
|
||
!ensureSecureDirectory(kRpcOutDir, &secureDirError)) {
|
||
NSLog(@"[imsg-bridge v2] Refusing insecure RPC queue path: %@",
|
||
secureDirError.localizedDescription);
|
||
return;
|
||
}
|
||
|
||
NSLog(@"[imsg-bridge v2] Inbox: %@", kRpcInDir);
|
||
NSLog(@"[imsg-bridge v2] Outbox: %@", kRpcOutDir);
|
||
|
||
NSTimer *timer = [NSTimer timerWithTimeInterval:0.1 repeats:YES block:^(NSTimer *t) {
|
||
scanV2Inbox();
|
||
}];
|
||
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
|
||
rpcInboxTimer = timer;
|
||
|
||
NSLog(@"[imsg-bridge v2] Inbox watcher started");
|
||
}
|
||
|
||
#pragma mark - Dylib Entry Point
|
||
|
||
__attribute__((constructor))
|
||
static void injectedInit(void) {
|
||
NSLog(@"[imsg-bridge] Dylib injected into %@", [[NSProcessInfo processInfo] processName]);
|
||
|
||
// Connect to IMDaemon for full IMCore access
|
||
Class daemonClass = NSClassFromString(@"IMDaemonController");
|
||
if (daemonClass) {
|
||
id daemon = [daemonClass performSelector:@selector(sharedInstance)];
|
||
if (daemon && [daemon respondsToSelector:@selector(connectToDaemon)]) {
|
||
[daemon performSelector:@selector(connectToDaemon)];
|
||
NSLog(@"[imsg-bridge] Connected to IMDaemon");
|
||
} else {
|
||
NSLog(@"[imsg-bridge] IMDaemonController available but couldn't connect");
|
||
}
|
||
} else {
|
||
NSLog(@"[imsg-bridge] IMDaemonController class not found");
|
||
}
|
||
|
||
// Delay initialization to let Messages.app fully start
|
||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||
NSLog(@"[imsg-bridge] Initializing after delay...");
|
||
|
||
// Log IMCore status
|
||
Class registryClass = NSClassFromString(@"IMChatRegistry");
|
||
if (registryClass) {
|
||
id registry = [registryClass performSelector:@selector(sharedInstance)];
|
||
if ([registry respondsToSelector:@selector(allExistingChats)]) {
|
||
NSArray *chats = [registry performSelector:@selector(allExistingChats)];
|
||
NSLog(@"[imsg-bridge] IMChatRegistry available with %lu chats",
|
||
(unsigned long)chats.count);
|
||
}
|
||
} else {
|
||
NSLog(@"[imsg-bridge] IMChatRegistry NOT available");
|
||
}
|
||
|
||
probeSelectors();
|
||
startFileWatcher();
|
||
startV2InboxWatcher();
|
||
registerEventObservers();
|
||
});
|
||
}
|
||
|
||
__attribute__((destructor))
|
||
static void injectedCleanup(void) {
|
||
NSLog(@"[imsg-bridge] Cleaning up...");
|
||
|
||
if (fileWatchTimer) {
|
||
[fileWatchTimer invalidate];
|
||
fileWatchTimer = nil;
|
||
}
|
||
if (rpcInboxTimer) {
|
||
[rpcInboxTimer invalidate];
|
||
rpcInboxTimer = nil;
|
||
}
|
||
|
||
if (lockFd >= 0) {
|
||
close(lockFd);
|
||
lockFd = -1;
|
||
}
|
||
|
||
initFilePaths();
|
||
[[NSFileManager defaultManager] removeItemAtPath:kLockFile error:nil];
|
||
}
|