diff --git a/CHANGELOG.md b/CHANGELOG.md index 3414fae..f6fad4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ ### Local Lookups - feat: add `--local` modes for `account`, `whois`, and `nickname` so common introspection can read local history or Contacts without launching the IMCore bridge (#132, thanks @ranaroussi). +### Advanced IMCore +- fix: defer injected bridge bootstrap until after Messages startup so macOS 26 dyld constructor ordering cannot touch ObjC/Foundation/IMCore before the process is ready (#138, thanks @omarshahine). + ## 0.10.0 - 2026-05-28 ### Watch diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 356d63f..e72c930 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -4371,45 +4371,77 @@ static void startV2InboxWatcher(void) { #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..."); +// Bridge bootstrap. Intentionally NOT run from the dylib constructor: macOS 26 +// tightened dyld initializer ordering for platform/system apps, so touching +// ObjC/Foundation/IMCore at constructor time can execute before libSystem has +// finished bootstrapping ("dyld initialized but libSystem has not") and abort +// Messages.app on launch. injectedInit() only schedules this delayed bootstrap; +// the lock file is written after the watchers are installed below. +static void bridgeBootstrap(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + @autoreleasepool { + initFilePaths(); + NSLog(@"[imsg-bridge] Dylib injected into %@", + [[NSProcessInfo processInfo] processName]); + debugLog(@"bootstrap starting in process=%@ pid=%d", + [[NSProcessInfo processInfo] processName], getpid()); + + // 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"); + debugLog(@"connected to IMDaemon"); + } else { + NSLog(@"[imsg-bridge] IMDaemonController available but couldn't connect"); + debugLog(@"IMDaemonController available but couldn't connect"); + } + } else { + NSLog(@"[imsg-bridge] IMDaemonController class not found"); + debugLog(@"IMDaemonController class not found"); + } - // 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); + 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); + debugLog(@"IMChatRegistry available chats=%lu", + (unsigned long)chats.count); + } + } else { + NSLog(@"[imsg-bridge] IMChatRegistry NOT available"); + debugLog(@"IMChatRegistry not available"); } - } else { - NSLog(@"[imsg-bridge] IMChatRegistry NOT available"); + + probeSelectors(); + startFileWatcher(); + startV2InboxWatcher(); + registerEventObservers(); + debugLog(@"bootstrap complete"); } + }); +} - probeSelectors(); - startFileWatcher(); - startV2InboxWatcher(); - registerEventObservers(); +__attribute__((constructor)) +static void injectedInit(void) { + // Keep the constructor tiny: only enqueue onto the main queue with + // libdispatch. Start the startup delay from that first main-queue turn so + // bridgeBootstrap cannot become ready to run before Messages services the + // queue for the first time. + dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), + dispatch_get_main_queue(), ^{ + bridgeBootstrap(); + }); }); } diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index 65da985..3f0bc11 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -148,6 +148,37 @@ func injectedHelperWiresNativePollSend() throws { )) } +@Test +func injectedHelperConstructorOnlySchedulesDelayedBootstrap() throws { + let testFile = URL(fileURLWithPath: #filePath) + let repoRoot = + testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let helper = repoRoot.appendingPathComponent("Sources/IMsgHelper/IMsgInjected.m") + let source = stripObjectiveCComments(try String(contentsOf: helper, encoding: .utf8)) + let constructorBody = try #require(functionBody(named: "injectedInit", in: source)) + let bootstrapBody = try #require(functionBody(named: "bridgeBootstrap", in: source)) + + #expect(constructorBody.contains("dispatch_after")) + #expect(constructorBody.contains("dispatch_async")) + #expect(constructorBody.contains("bridgeBootstrap();")) + #expect(!constructorBody.contains("NSLog")) + #expect(!constructorBody.contains("NSProcessInfo")) + #expect(!constructorBody.contains("NSClassFromString")) + #expect(!constructorBody.contains("IMDaemonController")) + #expect(!constructorBody.contains("startFileWatcher")) + #expect(!constructorBody.contains("startV2InboxWatcher")) + + #expect(bootstrapBody.contains("dispatch_once")) + #expect(bootstrapBody.contains("@autoreleasepool")) + #expect(bootstrapBody.contains("connectToDaemon")) + #expect(bootstrapBody.contains("startFileWatcher()")) + #expect(bootstrapBody.contains("startV2InboxWatcher()")) + #expect(bootstrapBody.contains("registerEventObservers()")) +} + private func stripObjectiveCComments(_ source: String) -> String { source .replacingOccurrences(of: #"/\*[\s\S]*?\*/"#, with: "", options: .regularExpression)