Skip to content

feat: IMCore bridge via dylib injection for typing indicators#59

Open
alexrudloffBot wants to merge 7 commits intosteipete:mainfrom
alexrudloffBot:feat/dylib-injection-bridge
Open

feat: IMCore bridge via dylib injection for typing indicators#59
alexrudloffBot wants to merge 7 commits intosteipete:mainfrom
alexrudloffBot:feat/dylib-injection-bridge

Conversation

@alexrudloffBot
Copy link

Problem

imsg typing does not work because IMChatRegistry is empty when called from a standalone CLI process. Apple's entitlement wall blocks XPC connections to imagent. Documented in #58.

Solution

Inject a helper dylib into Messages.app via DYLD_INSERT_LIBRARIES. Messages.app already has the required entitlements, so IMCore works fully inside its process. The CLI communicates with the injected dylib through file-based IPC (JSON files in the Messages container).

Same approach as BlueBubbles and imsg-plus.

Architecture

CLI (imsg) <-> JSON files <-> Dylib (inside Messages.app) <-> IMCore/IMDaemon

New files:

  • Sources/IMsgHelper/IMsgInjected.m — Obj-C injectable dylib (__attribute__((constructor))). Connects to IMDaemon, polls commands via NSTimer.
  • Sources/IMsgCore/MessagesLauncher.swift — Kill/relaunch Messages.app with DYLD_INSERT_LIBRARIES.
  • Sources/IMsgCore/IMCoreBridge.swift — Swift API wrapping file IPC.

Modified:

  • TypingIndicator.swift — Prefers bridge, falls back to direct IMCore.
  • Makefilebuild-dylib target.

New commands: imsg launch, imsg status

Also fixes

GUID prefix mismatch (#58): modern macOS uses any;-; prefix. The dylib tries all prefixes plus participant matching with phone normalization.

Requirements

  • SIP disabled
  • Full Disk Access
  • macOS 14+

Tested

  • make build-dylib + swift build — clean
  • Typing indicators confirmed working on recipient device

Closes #58

Objective-C dylib that gets injected into Messages.app via
DYLD_INSERT_LIBRARIES. Provides file-based IPC (JSON command/response
files in the Messages.app container) with a 100ms polling timer on the
main run loop.

Supports commands: typing, read, status, list_chats, ping.

Chat resolution tries multiple methods:
- existingChatWithGUID: with all known prefixes (iMessage, SMS, any)
- existingChatWithChatIdentifier:
- Participant matching with phone number normalization

Requires SIP disabled for DYLD_INSERT_LIBRARIES to work on system apps.
MessagesLauncher manages the Messages.app lifecycle:
- Kills running instance, relaunches with DYLD_INSERT_LIBRARIES
- Waits for lock file confirming dylib initialization
- Sends commands via file-based IPC (JSON files in container)
- Provides async sendCommand() API

IMCoreBridge wraps the launcher with a high-level Swift API:
- setTyping(for:typing:) - typing indicators
- markAsRead(handle:) - read receipts
- listChats() / getStatus() - debugging
- checkAvailability() - diagnostic messages

Both are @unchecked Sendable singletons following imsg's patterns.
- launch: Kills Messages.app, relaunches with dylib injection.
  Supports --dylib for custom path, --kill-only to just terminate.
- status: Shows diagnostic report of feature availability.
  Reports basic vs advanced feature status with setup instructions.
- Both support --json output.
- Registered in CommandRouter between typing and rpc.
TypingIndicator.setTyping() now tries the IMCoreBridge first (dylib
injected into Messages.app via DYLD_INSERT_LIBRARIES). If the bridge
is unavailable or fails, falls back to the existing direct IMCore
access via dlopen.

This fixes the core issue: standalone CLI processes can't connect to
imagent via XPC due to Apple's entitlement wall. The bridge runs
inside Messages.app which already has the required entitlements.

Uses a thread-safe BridgeResultBox to bridge async/sync boundary
in a Swift 6 strict concurrency compatible way.
- build-dylib: Compiles IMsgInjected.m as arm64e dynamic library
- imsg target now depends on build-dylib
- install target copies both binary and dylib to /usr/local/
- clean removes the built dylib
- Suppresses expected performSelector ARC warnings
- IMCoreBridgeTests: Verify shared instance, availability check,
  error descriptions, and launcher properties
- LaunchStatusCommandTests: Verify commands are registered in router,
  status produces both JSON and text output
@evansking
Copy link

Heads up, I'm pretty sure this won't work on macOS 26 (Sequoia/Tahoe) due to library-validation enforcement, even with SIP disabled.

The Issue

macOS 26 introduced a new code signature flag on Messages.app that blocks dylib injection:

$ codesign -dv /System/Applications/Messages.app 2>&1 | grep flags
CodeDirectory v=20400 size=2764 flags=0x2000(library-validation)
                                         ^^^^^^^^^^^^^^^^^^^^^^^^^^

This flag prevents DYLD_INSERT_LIBRARIES from loading third-party/unsigned dylibs into Messages.app, even with SIP disabled. The injection silently fails at the kernel level.

What I've Tested

  1. ✅ Dylib compiles and exists
  2. ✅ SIP is disabled
  3. ✅ Messages.app launches with DYLD_INSERT_LIBRARIES set
  4. ❌ Dylib never loads (no IPC files created, no injection logs)
  5. ❌ Advanced features remain unavailable

Why This Happens

The library-validation flag requires all loaded libraries to:

  • Be signed by Apple, OR
  • Have the same team identifier as the main executable

Since the injected dylib is ad-hoc signed and Messages.app is Apple-signed, the kernel blocks loading.

Potential Solutions?

  1. Remove library-validation - Requires unsealing the system volume, breaks on updates
  2. Different approach - AppleScript/JXA? XPC services? Private APIs from external process?
  3. Document macOS version compatibility - Works on ≤15, broken on 26+

Has anyone tested this on macOS 26? Or have ideas for alternative approaches that don't require dylib injection?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

typing: IMChatRegistry never populates — entitlement blocker + any;-; GUID mismatch

2 participants