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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 67 additions & 35 deletions Sources/IMsgHelper/IMsgInjected.m
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
}

Expand Down
31 changes: 31 additions & 0 deletions Tests/imsgTests/BridgeCommandRegistrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down