diff --git a/Makefile b/Makefile index daf463d..c3dacad 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,17 @@ SHELL := /bin/bash -.PHONY: help format lint test build imsg clean +.PHONY: help format lint test build imsg clean build-dylib install help: @printf "%s\n" \ - "make format - swift format in-place" \ - "make lint - swift format lint + swiftlint" \ - "make test - sync version, patch deps, run swift test" \ - "make build - universal release build into bin/" \ - "make imsg - clean rebuild + run debug binary (ARGS=...)" \ - "make clean - swift package clean" + "make format - swift format in-place" \ + "make lint - swift format lint + swiftlint" \ + "make test - sync version, patch deps, run swift test" \ + "make build - universal release build into bin/" \ + "make build-dylib - build injectable dylib for Messages.app" \ + "make imsg - clean rebuild + run debug binary (ARGS=...)" \ + "make install - build release binary and install to /usr/local/bin" \ + "make clean - swift package clean" format: swift format --in-place --recursive Sources Tests @@ -24,13 +26,26 @@ test: scripts/patch-deps.sh swift test -build: +build: build-dylib scripts/generate-version.sh swift package resolve scripts/patch-deps.sh scripts/build-universal.sh -imsg: +# Build injectable dylib for Messages.app (DYLD_INSERT_LIBRARIES). +# Uses arm64e architecture to match Messages.app on Apple Silicon. +# Requires SIP disabled on the target machine to inject into system apps. +build-dylib: + @echo "Building imsg-bridge-helper.dylib (injectable)..." + @mkdir -p .build/release + @clang -dynamiclib -arch arm64e -fobjc-arc \ + -Wno-arc-performSelector-leaks \ + -framework Foundation \ + -o .build/release/imsg-bridge-helper.dylib \ + Sources/IMsgHelper/IMsgInjected.m + @echo "Built .build/release/imsg-bridge-helper.dylib" + +imsg: build-dylib scripts/generate-version.sh swift package resolve scripts/patch-deps.sh @@ -40,3 +55,16 @@ imsg: clean: swift package clean + @rm -f .build/release/imsg-bridge-helper.dylib + +install: build-dylib + @echo "Building release binary..." + scripts/generate-version.sh + swift package resolve + scripts/patch-deps.sh + swift build -c release --product imsg + @echo "Installing imsg to /usr/local/bin..." + @mkdir -p /usr/local/bin /usr/local/lib + @cp .build/release/imsg /usr/local/bin/imsg + @cp .build/release/imsg-bridge-helper.dylib /usr/local/lib/imsg-bridge-helper.dylib + @echo "Installed. Run 'imsg launch' to enable advanced features." diff --git a/Sources/IMsgCore/IMCoreBridge.swift b/Sources/IMsgCore/IMCoreBridge.swift new file mode 100644 index 0000000..9a0e856 --- /dev/null +++ b/Sources/IMsgCore/IMCoreBridge.swift @@ -0,0 +1,143 @@ +import Foundation + +public enum IMCoreBridgeError: Error, CustomStringConvertible { + case dylibNotFound + case connectionFailed(String) + case chatNotFound(String) + case operationFailed(String) + + public var description: String { + switch self { + case .dylibNotFound: + return "imsg-bridge-helper.dylib not found. Build with: make build-dylib" + case .connectionFailed(let error): + return "Connection to Messages.app failed: \(error)" + case .chatNotFound(let id): + return "Chat not found: \(id)" + case .operationFailed(let reason): + return "Operation failed: \(reason)" + } + } +} + +/// Bridge to IMCore via DYLD injection into Messages.app. +/// +/// Communicates with an injected dylib inside Messages.app via file-based IPC. +/// The dylib has full access to IMCore because it runs within the Messages.app +/// context with proper entitlements. +/// +/// Requires: +/// - SIP disabled (for `DYLD_INSERT_LIBRARIES` on system apps) +/// - The `imsg-bridge-helper.dylib` built via `make build-dylib` +public final class IMCoreBridge: @unchecked Sendable { + public static let shared = IMCoreBridge() + + private let launcher = MessagesLauncher.shared + + /// Whether the dylib exists on disk (does not check if Messages.app is running). + public var isAvailable: Bool { + let possiblePaths = [ + "/usr/local/lib/imsg-bridge-helper.dylib", + ".build/release/imsg-bridge-helper.dylib", + ".build/debug/imsg-bridge-helper.dylib", + ] + return possiblePaths.contains { FileManager.default.fileExists(atPath: $0) } + } + + private init() {} + + // MARK: - Commands + + /// Set typing indicator for a conversation. + public func setTyping(for handle: String, typing: Bool) async throws { + let params: [String: Any] = [ + "handle": handle, + "typing": typing, + ] + _ = try await sendCommand(action: "typing", params: params) + } + + /// Mark all messages as read in a conversation. + public func markAsRead(handle: String) async throws { + _ = try await sendCommand(action: "read", params: ["handle": handle]) + } + + /// List all available chats (for debugging). + public func listChats() async throws -> [[String: Any]] { + let response = try await sendCommand(action: "list_chats", params: [:]) + return response["chats"] as? [[String: Any]] ?? [] + } + + /// Get detailed status from the injected helper. + public func getStatus() async throws -> [String: Any] { + return try await sendCommand(action: "status", params: [:]) + } + + /// Check availability and return a diagnostic message. + public func checkAvailability() -> (available: Bool, message: String) { + let possiblePaths = [ + "/usr/local/lib/imsg-bridge-helper.dylib", + ".build/release/imsg-bridge-helper.dylib", + ".build/debug/imsg-bridge-helper.dylib", + ] + + var dylibPath: String? + for path in possiblePaths { + if FileManager.default.fileExists(atPath: path) { + dylibPath = path + break + } + } + + guard dylibPath != nil else { + return ( + false, + """ + imsg-bridge-helper.dylib not found. To build: + 1. make build-dylib + 2. Restart imsg + + Note: Advanced features require: + - SIP disabled (for DYLD injection) + - Full Disk Access granted to Terminal + """ + ) + } + + if launcher.isInjectedAndReady() { + return (true, "Connected to Messages.app. IMCore features available.") + } + + do { + try launcher.ensureRunning() + return (true, "Messages.app launched with injection. IMCore features available.") + } catch let error as MessagesLauncherError { + return (false, error.description) + } catch { + return (false, "Failed to connect to Messages.app: \(error.localizedDescription)") + } + } + + // MARK: - Private + + private func sendCommand( + action: String, params: [String: Any] + ) async throws -> [String: Any] { + do { + let response = try await launcher.sendCommand(action: action, params: params) + + if response["success"] as? Bool == true { + return response + } + + let error = response["error"] as? String ?? "Unknown error" + if error.contains("Chat not found") { + let handle = params["handle"] as? String ?? "unknown" + throw IMCoreBridgeError.chatNotFound(handle) + } + throw IMCoreBridgeError.operationFailed(error) + } catch let error as MessagesLauncherError { + throw IMCoreBridgeError.connectionFailed(error.description) + } + } +} diff --git a/Sources/IMsgCore/MessagesLauncher.swift b/Sources/IMsgCore/MessagesLauncher.swift new file mode 100644 index 0000000..8315d87 --- /dev/null +++ b/Sources/IMsgCore/MessagesLauncher.swift @@ -0,0 +1,229 @@ +import Foundation + +/// Manages Messages.app lifecycle for DYLD injection. +/// +/// Kills any running Messages.app, relaunches with `DYLD_INSERT_LIBRARIES` +/// pointing to the imsg-bridge dylib, then waits for the lock file that +/// confirms the dylib is ready for commands. +public final class MessagesLauncher: @unchecked Sendable { + public static let shared = MessagesLauncher() + + // File-based IPC paths — must match the paths in IMsgInjected.m. + // The dylib uses NSHomeDirectory() which resolves to the container path; + // from outside we construct the full container path ourselves. + private var commandFile: String { + containerPath + "/.imsg-command.json" + } + + private var responseFile: String { + containerPath + "/.imsg-response.json" + } + + private var lockFile: String { + containerPath + "/.imsg-bridge-ready" + } + + private var containerPath: String { + NSHomeDirectory() + "/Library/Containers/com.apple.MobileSMS/Data" + } + + private let messagesAppPath = + "/System/Applications/Messages.app/Contents/MacOS/Messages" + private let queue = DispatchQueue(label: "imsg.messages.launcher") + private let lock = NSLock() + + /// Path to the dylib to inject. + public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib" + + private init() { + let possiblePaths = [ + "/usr/local/lib/imsg-bridge-helper.dylib", + ".build/release/imsg-bridge-helper.dylib", + ".build/debug/imsg-bridge-helper.dylib", + ] + for path in possiblePaths { + if FileManager.default.fileExists(atPath: path) { + self.dylibPath = path + break + } + } + } + + /// Check if Messages.app is running with our dylib (lock file exists and responds to ping). + public func isInjectedAndReady() -> Bool { + guard FileManager.default.fileExists(atPath: lockFile) else { + return false + } + do { + let response = try sendCommandSync(action: "ping", params: [:]) + return response["success"] as? Bool == true + } catch { + return false + } + } + + /// Ensure Messages.app is running with our dylib injected. + public func ensureRunning() throws { + if isInjectedAndReady() { return } + + guard FileManager.default.fileExists(atPath: dylibPath) else { + throw MessagesLauncherError.dylibNotFound(dylibPath) + } + + killMessages() + Thread.sleep(forTimeInterval: 1.0) + + // Clean up stale IPC files + try? FileManager.default.removeItem(atPath: commandFile) + try? FileManager.default.removeItem(atPath: responseFile) + try? FileManager.default.removeItem(atPath: lockFile) + + try launchWithInjection() + try waitForReady(timeout: 15.0) + } + + /// Kill Messages.app if running. + public func killMessages() { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + task.arguments = ["Messages"] + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + task.waitUntilExit() + } + + /// Send a command asynchronously. + public func sendCommand( + action: String, params: [String: Any] + ) async throws -> [String: Any] { + try ensureRunning() + // Serialize params to JSON data to cross the Sendable boundary safely + let paramsData = try JSONSerialization.data(withJSONObject: params, options: []) + return try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation<[String: Any], Error>) in + queue.async { + do { + let deserializedParams = + (try? JSONSerialization.jsonObject(with: paramsData, options: [])) + as? [String: Any] ?? [:] + let response = try self.sendCommandSync(action: action, params: deserializedParams) + continuation.resume(returning: response) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + // MARK: - Private + + private func launchWithInjection() throws { + let absoluteDylibPath = + dylibPath.hasPrefix("/") + ? dylibPath + : FileManager.default.currentDirectoryPath + "/" + dylibPath + + guard FileManager.default.fileExists(atPath: absoluteDylibPath) else { + throw MessagesLauncherError.dylibNotFound(absoluteDylibPath) + } + + let task = Process() + task.executableURL = URL(fileURLWithPath: messagesAppPath) + + var environment = ProcessInfo.processInfo.environment + environment["DYLD_INSERT_LIBRARIES"] = absoluteDylibPath + task.environment = environment + + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + + do { + try task.run() + } catch { + throw MessagesLauncherError.launchFailed(error.localizedDescription) + } + } + + private func waitForReady(timeout: TimeInterval) throws { + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + if FileManager.default.fileExists(atPath: lockFile) { + Thread.sleep(forTimeInterval: 0.5) + return + } + Thread.sleep(forTimeInterval: 0.5) + } + + throw MessagesLauncherError.socketTimeout + } + + private func sendCommandSync( + action: String, params: [String: Any] + ) throws -> [String: Any] { + lock.lock() + defer { lock.unlock() } + + let command: [String: Any] = [ + "id": Int(Date().timeIntervalSince1970 * 1000), + "action": action, + "params": params, + ] + + let jsonData = try JSONSerialization.data(withJSONObject: command, options: []) + try jsonData.write(to: URL(fileURLWithPath: commandFile)) + + let deadline = Date().addingTimeInterval(10.0) + while Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + + guard + let responseData = try? Data(contentsOf: URL(fileURLWithPath: responseFile)), + responseData.count > 2 + else { continue } + + // Check if command file was cleared (indicates processing completed) + if let cmdData = try? Data(contentsOf: URL(fileURLWithPath: commandFile)), + cmdData.count <= 2 + { + guard + let response = try? JSONSerialization.jsonObject(with: responseData, options: []) + as? [String: Any] + else { + throw MessagesLauncherError.invalidResponse + } + // Clear response file + try? "".write(toFile: responseFile, atomically: true, encoding: .utf8) + return response + } + } + + throw MessagesLauncherError.socketError("Timeout waiting for response") + } +} + +public enum MessagesLauncherError: Error, CustomStringConvertible { + case dylibNotFound(String) + case launchFailed(String) + case socketTimeout + case socketError(String) + case invalidResponse + + public var description: String { + switch self { + case .dylibNotFound(let path): + return "imsg-bridge-helper.dylib not found at \(path). Build with: make build-dylib" + case .launchFailed(let reason): + return "Failed to launch Messages.app: \(reason)" + case .socketTimeout: + return + "Timeout waiting for Messages.app to initialize. " + + "Ensure SIP is disabled and Messages.app has necessary permissions." + case .socketError(let reason): + return "IPC error: \(reason)" + case .invalidResponse: + return "Invalid response from Messages.app helper" + } + } +} diff --git a/Sources/IMsgCore/TypingIndicator.swift b/Sources/IMsgCore/TypingIndicator.swift index 112cb24..836c669 100644 --- a/Sources/IMsgCore/TypingIndicator.swift +++ b/Sources/IMsgCore/TypingIndicator.swift @@ -1,24 +1,23 @@ import Foundation -/// Sends typing indicators for iMessage chats via the IMCore private framework. +/// Sends typing indicators for iMessage chats. /// -/// Uses runtime `dlopen` to load IMCore — the only way to programmatically toggle -/// typing state. AppleScript has no equivalent capability. -/// -/// Requires macOS 14+, Messages.app signed in, and an existing conversation with the contact. +/// Prefers the IMCore bridge (via DYLD injection into Messages.app) which +/// is reliable on stock macOS with SIP disabled. Falls back to direct +/// IMCore access via `dlopen` when the bridge is unavailable. public struct TypingIndicator: Sendable { private static let daemonConnectionTracker = DaemonConnectionTracker() /// Start showing the typing indicator for a chat. /// - Parameter chatIdentifier: e.g. `"iMessage;-;+14155551212"` or a chat GUID. - /// - Throws: `IMsgError.typingIndicatorFailed` if IMCore is unavailable or chat not found. + /// - Throws: `IMsgError.typingIndicatorFailed` if both bridge and direct IMCore fail. public static func startTyping(chatIdentifier: String) throws { try setTyping(chatIdentifier: chatIdentifier, isTyping: true) } /// Stop showing the typing indicator for a chat. /// - Parameter chatIdentifier: The chat identifier string. - /// - Throws: `IMsgError.typingIndicatorFailed` if IMCore is unavailable or chat not found. + /// - Throws: `IMsgError.typingIndicatorFailed` if both bridge and direct IMCore fail. public static func stopTyping(chatIdentifier: String) throws { try setTyping(chatIdentifier: chatIdentifier, isTyping: false) } @@ -40,6 +39,42 @@ public struct TypingIndicator: Sendable { // MARK: - Private private static func setTyping(chatIdentifier: String, isTyping: Bool) throws { + // Prefer the bridge (dylib injected into Messages.app) + let bridge = IMCoreBridge.shared + if bridge.isAvailable { + do { + try setTypingViaBridge(bridge: bridge, chatIdentifier: chatIdentifier, isTyping: isTyping) + return + } catch { + // Bridge failed — fall through to direct IMCore access + } + } + + // Fallback: direct IMCore access (requires AMFI disabled + XPC plist) + try setTypingDirect(chatIdentifier: chatIdentifier, isTyping: isTyping) + } + + /// Synchronous wrapper for the async bridge call using a Sendable result box. + private static func setTypingViaBridge( + bridge: IMCoreBridge, chatIdentifier: String, isTyping: Bool + ) throws { + let semaphore = DispatchSemaphore(value: 0) + let box = BridgeResultBox() + Task { @Sendable in + do { + try await bridge.setTyping(for: chatIdentifier, typing: isTyping) + } catch { + box.setError(error) + } + semaphore.signal() + } + semaphore.wait() + if let error = box.error { + throw error + } + } + + private static func setTypingDirect(chatIdentifier: String, isTyping: Bool) throws { let frameworkPath = "/System/Library/PrivateFrameworks/IMCore.framework/IMCore" guard let handle = dlopen(frameworkPath, RTLD_LAZY) else { let error = String(cString: dlerror()) @@ -115,6 +150,24 @@ public struct TypingIndicator: Sendable { if controller.responds(to: connectSel) { _ = controller.perform(connectSel) } + + let maxAttempts = 50 + for _ in 0.. Bool { @@ -170,3 +223,21 @@ private final class DaemonConnectionTracker: @unchecked Sendable { let lock = NSLock() var hasAttemptedConnection = false } + +/// Thread-safe box for passing an error out of a Task back to the calling thread. +private final class BridgeResultBox: @unchecked Sendable { + private let lock = NSLock() + private var _error: Error? + + var error: Error? { + lock.lock() + defer { lock.unlock() } + return _error + } + + func setError(_ error: Error) { + lock.lock() + _error = error + lock.unlock() + } +} diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m new file mode 100644 index 0000000..447fb40 --- /dev/null +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -0,0 +1,544 @@ +// +// 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 +#import +#import +#import + +#pragma mark - Constants + +static NSString *kCommandFile = nil; +static NSString *kResponseFile = nil; +static NSString *kLockFile = nil; +static NSTimer *fileWatchTimer = nil; +static int lockFd = -1; + +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"]; + } +} + +#pragma mark - Forward Declarations for IMCore Classes + +@interface IMChatRegistry : NSObject ++ (instancetype)sharedInstance; +- (id)existingChatWithGUID:(NSString *)guid; +- (id)existingChatWithChatIdentifier:(NSString *)identifier; +- (NSArray *)allExistingChats; +@end + +@interface IMChat : NSObject +- (void)setLocalUserIsTyping:(BOOL)typing; +- (void)markAllMessagesAsRead; +- (NSArray *)participants; +- (NSString *)guid; +- (NSString *)chatIdentifier; +@end + +@interface IMHandle : NSObject +- (NSString *)ID; +@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 + +/// 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; + + // 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) + NSArray *prefixes = @[@"iMessage;-;", @"iMessage;+;", @"SMS;-;", @"SMS;+;", @"any;-;", @"any;+;"]; + for (NSString *prefix in prefixes) { + NSString *fullGUID = [prefix stringByAppendingString:identifier]; + 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; + } + } + + // 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 ([identifier hasPrefix:@"+"] || [identifier hasPrefix:@"1"] || + [[NSCharacterSet decimalDigitCharacterSet] + characterIsMember:[identifier characterAtIndex:0]]) { + NSMutableString *digits = [NSMutableString string]; + for (NSUInteger i = 0; i < identifier.length; i++) { + unichar c = [identifier 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]) { + 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]) { + 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]) { + 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"]; + + if (!handle) { + return errorResponse(requestId, @"Missing required parameter: handle"); + } + + BOOL typing = [state boolValue]; + id chat = findChat(handle); + + if (!chat) { + 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); + } + + 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]; + + NSLog(@"[imsg-bridge] Called setLocalUserIsTyping:%@ for %@", + typing ? @"YES" : @"NO", handle); + return successResponse(requestId, @{ + @"handle": handle, + @"typing": @(typing) + }); + } + + return errorResponse(requestId, @"setLocalUserIsTyping: method not available"); + } @catch (NSException *exception) { + return errorResponse(requestId, + [NSString stringWithFormat:@"Failed to set typing: %@", exception.reason]); + } +} + +static NSDictionary* handleRead(NSInteger requestId, NSDictionary *params) { + NSString *handle = params[@"handle"]; + + if (!handle) { + return errorResponse(requestId, @"Missing required parameter: handle"); + } + + id chat = findChat(handle); + + if (!chat) { + return errorResponse(requestId, + [NSString stringWithFormat:@"Chat not found: %@", handle]); + } + + @try { + SEL readSel = @selector(markAllMessagesAsRead); + if ([chat respondsToSelector:readSel]) { + [chat performSelector:readSel]; + 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) { + 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; + } + } + + return successResponse(requestId, @{ + @"injected": @YES, + @"registry_available": @(hasRegistry), + @"chat_count": @(chatCount), + @"typing_available": @(hasRegistry), + @"read_available": @(hasRegistry) + }); +} + +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 - Command Router + +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); + + if ([action isEqualToString:@"typing"]) { + return handleTyping(requestId, params); + } else if ([action isEqualToString:@"read"]) { + return handleRead(requestId, params); + } else if ([action isEqualToString:@"status"]) { + return handleStatus(requestId, params); + } else if ([action isEqualToString:@"list_chats"]) { + return handleListChats(requestId, params); + } else if ([action isEqualToString:@"ping"]) { + return successResponse(requestId, @{@"pong": @YES}); + } else { + return errorResponse(requestId, + [NSString stringWithFormat:@"Unknown action: %@", action]); + } +} + +#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 - 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"); + } + + startFileWatcher(); + }); +} + +__attribute__((destructor)) +static void injectedCleanup(void) { + NSLog(@"[imsg-bridge] Cleaning up..."); + + if (fileWatchTimer) { + [fileWatchTimer invalidate]; + fileWatchTimer = nil; + } + + if (lockFd >= 0) { + close(lockFd); + lockFd = -1; + } + + initFilePaths(); + [[NSFileManager defaultManager] removeItemAtPath:kLockFile error:nil]; +} diff --git a/Sources/imsg/CommandRouter.swift b/Sources/imsg/CommandRouter.swift index ccd7565..f1f2e72 100644 --- a/Sources/imsg/CommandRouter.swift +++ b/Sources/imsg/CommandRouter.swift @@ -16,6 +16,8 @@ struct CommandRouter { SendCommand.spec, ReactCommand.spec, TypingCommand.spec, + LaunchCommand.spec, + StatusCommand.spec, RpcCommand.spec, ] let descriptor = CommandDescriptor( diff --git a/Sources/imsg/Commands/LaunchCommand.swift b/Sources/imsg/Commands/LaunchCommand.swift new file mode 100644 index 0000000..ed070de --- /dev/null +++ b/Sources/imsg/Commands/LaunchCommand.swift @@ -0,0 +1,134 @@ +import Commander +import Foundation +import IMsgCore + +enum LaunchCommand { + static let spec = CommandSpec( + name: "launch", + abstract: "Launch Messages.app with dylib injection", + discussion: """ + Kills any running Messages.app instance, then relaunches it with + DYLD_INSERT_LIBRARIES set to inject the imsg bridge helper dylib. + This enables advanced features like typing indicators and read receipts + that require IMCore framework access. + + Requires SIP (System Integrity Protection) to be disabled. + """, + signature: CommandSignatures.withRuntimeFlags( + CommandSignature( + options: [ + .make( + label: "dylib", names: [.long("dylib")], + help: "Custom path to imsg-bridge-helper.dylib") + ], + flags: [ + .make( + label: "killOnly", names: [.long("kill-only")], + help: "Only kill Messages.app, don't relaunch") + ] + ) + ), + usageExamples: [ + "imsg launch", + "imsg launch --kill-only", + "imsg launch --dylib /path/to/dylib", + "imsg launch --json", + ] + ) { values, runtime in + try await run(values: values, runtime: runtime) + } + + static func run(values: ParsedValues, runtime: RuntimeOptions) async throws { + let killOnly = values.flags.contains("killOnly") + let customDylib = values.option("dylib") + + let launcher = MessagesLauncher.shared + + if !runtime.jsonOutput { + StdoutWriter.writeLine("Killing Messages.app...") + } + launcher.killMessages() + + if killOnly { + try await Task.sleep(nanoseconds: 1_000_000_000) + if runtime.jsonOutput { + try JSONLines.print(["status": "killed", "message": "Messages.app terminated"]) + } else { + StdoutWriter.writeLine("Messages.app terminated") + } + return + } + + let dylibPath = resolveDylibPath(custom: customDylib) + + guard let resolvedPath = dylibPath else { + let error = + "imsg-bridge-helper.dylib not found. Searched:\n" + + " - /usr/local/lib/imsg-bridge-helper.dylib\n" + + " - .build/release/imsg-bridge-helper.dylib\n" + + "Run 'make build-dylib' or specify --dylib " + + if runtime.jsonOutput { + try JSONLines.print(["status": "error", "error": "dylib_not_found", "message": error]) + } else { + StdoutWriter.writeLine(error) + } + throw IMsgError.typingIndicatorFailed("dylib not found") + } + + launcher.dylibPath = resolvedPath + + if !runtime.jsonOutput { + StdoutWriter.writeLine("Using dylib: \(resolvedPath)") + StdoutWriter.writeLine("Launching Messages.app with injection...") + } + + try await Task.sleep(nanoseconds: 2_000_000_000) + + do { + try launcher.ensureRunning() + if runtime.jsonOutput { + try JSONLines.print([ + "status": "launched", + "dylib": resolvedPath, + "message": "Messages.app launched with dylib injection", + ]) + } else { + StdoutWriter.writeLine("Messages.app launched with dylib injection") + } + } catch { + if runtime.jsonOutput { + try JSONLines.print([ + "status": "error", + "dylib": resolvedPath, + "error": "\(error)", + ]) + } else { + StdoutWriter.writeLine("Failed to launch: \(error)") + } + throw error + } + } + + private static func resolveDylibPath(custom: String?) -> String? { + if let custom = custom { + if FileManager.default.fileExists(atPath: custom) { + return custom + } + return nil + } + + let searchPaths = [ + "/usr/local/lib/imsg-bridge-helper.dylib", + ".build/release/imsg-bridge-helper.dylib", + ] + + for path in searchPaths { + if FileManager.default.fileExists(atPath: path) { + return path + } + } + + return nil + } +} diff --git a/Sources/imsg/Commands/StatusCommand.swift b/Sources/imsg/Commands/StatusCommand.swift new file mode 100644 index 0000000..0febecd --- /dev/null +++ b/Sources/imsg/Commands/StatusCommand.swift @@ -0,0 +1,72 @@ +import Commander +import Foundation +import IMsgCore + +enum StatusCommand { + static let spec = CommandSpec( + name: "status", + abstract: "Check availability of imsg advanced features", + discussion: """ + Display the current status of imsg features and permissions. + Shows which advanced features (typing indicators, read receipts) are + available and provides setup instructions if needed. + """, + signature: CommandSignatures.withRuntimeFlags(CommandSignature()), + usageExamples: [ + "imsg status", + "imsg status --json", + ] + ) { values, runtime in + try await run(values: values, runtime: runtime) + } + + static func run(values: ParsedValues, runtime: RuntimeOptions) async throws { + let bridge = IMCoreBridge.shared + let availability = bridge.checkAvailability() + + if runtime.jsonOutput { + try JSONLines.print([ + "basic_features": "true", + "advanced_features": availability.available ? "true" : "false", + "typing_indicators": availability.available ? "true" : "false", + "read_receipts": availability.available ? "true" : "false", + "message": availability.message, + ]) + } else { + StdoutWriter.writeLine("imsg Status Report") + StdoutWriter.writeLine("==================") + StdoutWriter.writeLine("") + StdoutWriter.writeLine("Basic features (send, receive, history):") + StdoutWriter.writeLine(" Available") + StdoutWriter.writeLine("") + StdoutWriter.writeLine("Advanced features (typing, read receipts):") + if availability.available { + StdoutWriter.writeLine(" Available - IMCore bridge connected") + StdoutWriter.writeLine("") + StdoutWriter.writeLine("Available commands:") + StdoutWriter.writeLine(" imsg typing --to ") + StdoutWriter.writeLine(" imsg launch") + StdoutWriter.writeLine(" imsg status") + } else { + StdoutWriter.writeLine(" Not available") + StdoutWriter.writeLine("") + StdoutWriter.writeLine("To enable advanced features:") + StdoutWriter.writeLine(" 1. Disable System Integrity Protection (SIP)") + StdoutWriter.writeLine(" - Restart Mac holding Cmd+R") + StdoutWriter.writeLine(" - Open Terminal from Utilities menu") + StdoutWriter.writeLine(" - Run: csrutil disable") + StdoutWriter.writeLine(" - Restart normally") + StdoutWriter.writeLine("") + StdoutWriter.writeLine(" 2. Grant Full Disk Access") + StdoutWriter.writeLine(" - System Settings > Privacy & Security > Full Disk Access") + StdoutWriter.writeLine(" - Add Terminal or your terminal app") + StdoutWriter.writeLine("") + StdoutWriter.writeLine(" 3. Build and launch:") + StdoutWriter.writeLine(" make build-dylib") + StdoutWriter.writeLine(" imsg launch") + StdoutWriter.writeLine("") + StdoutWriter.writeLine("Note: Basic messaging features work without these steps.") + } + } + } +} diff --git a/Tests/IMsgCoreTests/IMCoreBridgeTests.swift b/Tests/IMsgCoreTests/IMCoreBridgeTests.swift new file mode 100644 index 0000000..1531854 --- /dev/null +++ b/Tests/IMsgCoreTests/IMCoreBridgeTests.swift @@ -0,0 +1,66 @@ +import Foundation +import Testing + +@testable import IMsgCore + +@Test +func imCoreBridgeIsNotAvailableWithoutDylib() { + // In the test environment there's no dylib built, so isAvailable should be false + // unless one happens to exist at a search path. We test the shared instance exists. + let bridge = IMCoreBridge.shared + // Just verify the API exists and doesn't crash + _ = bridge.isAvailable +} + +@Test +func imCoreBridgeCheckAvailabilityReturnsDiagnostic() { + let bridge = IMCoreBridge.shared + let (_, message) = bridge.checkAvailability() + // Should return a non-empty diagnostic message regardless of availability + #expect(!message.isEmpty) +} + +@Test +func messagesLauncherSharedInstanceExists() { + let launcher = MessagesLauncher.shared + // Verify the launcher can be accessed + #expect(launcher.dylibPath.contains("imsg-bridge-helper.dylib")) +} + +@Test +func messagesLauncherIsNotReadyWithoutInjection() { + let launcher = MessagesLauncher.shared + // Without actually launching Messages.app with injection, this should return false + // (unless Messages happens to be running with our dylib, which is unlikely in CI) + _ = launcher.isInjectedAndReady() + // Just verify it doesn't crash +} + +@Test +func messagesLauncherErrorDescriptions() { + let errors: [MessagesLauncherError] = [ + .dylibNotFound("/fake/path"), + .launchFailed("test reason"), + .socketTimeout, + .socketError("test error"), + .invalidResponse, + ] + + for error in errors { + #expect(!error.description.isEmpty) + } +} + +@Test +func imCoreBridgeErrorDescriptions() { + let errors: [IMCoreBridgeError] = [ + .dylibNotFound, + .connectionFailed("test"), + .chatNotFound("test-handle"), + .operationFailed("test reason"), + ] + + for error in errors { + #expect(!error.description.isEmpty) + } +} diff --git a/Tests/imsgTests/LaunchStatusCommandTests.swift b/Tests/imsgTests/LaunchStatusCommandTests.swift new file mode 100644 index 0000000..4947211 --- /dev/null +++ b/Tests/imsgTests/LaunchStatusCommandTests.swift @@ -0,0 +1,52 @@ +import Commander +import Foundation +import Testing + +@testable import IMsgCore +@testable import imsg + +@Test +func commandRouterIncludesLaunchCommand() async { + let router = CommandRouter() + let names = router.specs.map(\.name) + #expect(names.contains("launch")) +} + +@Test +func commandRouterIncludesStatusCommand() async { + let router = CommandRouter() + let names = router.specs.map(\.name) + #expect(names.contains("status")) +} + +@Test +func statusCommandProducesJsonOutput() async throws { + let values = ParsedValues( + positional: [], + options: [:], + flags: ["jsonOutput"] + ) + let runtime = RuntimeOptions(parsedValues: values) + + let (output, _) = await StdoutCapture.capture { + try? await StatusCommand.run(values: values, runtime: runtime) + } + // JSON output should contain expected keys + #expect(output.contains("basic_features")) + #expect(output.contains("advanced_features")) +} + +@Test +func statusCommandProducesTextOutput() async throws { + let values = ParsedValues( + positional: [], + options: [:], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: values) + + let (output, _) = await StdoutCapture.capture { + try? await StatusCommand.run(values: values, runtime: runtime) + } + #expect(output.contains("imsg Status Report")) +}