diff --git a/Sources/IMsgCore/Errors.swift b/Sources/IMsgCore/Errors.swift index 7c3c5d2..0c0bd10 100644 --- a/Sources/IMsgCore/Errors.swift +++ b/Sources/IMsgCore/Errors.swift @@ -6,6 +6,7 @@ public enum IMsgError: LocalizedError, Sendable { case invalidService(String) case invalidChatTarget(String) case appleScriptFailure(String) + case typingIndicatorFailed(String) case invalidReaction(String) case chatNotFound(chatID: Int64) @@ -36,6 +37,8 @@ public enum IMsgError: LocalizedError, Sendable { return "Invalid chat target: \(value)" case .appleScriptFailure(let message): return "AppleScript failed: \(message)" + case .typingIndicatorFailed(let message): + return "Typing indicator failed: \(message)" case .invalidReaction(let value): return """ Invalid reaction: \(value) diff --git a/Sources/IMsgCore/TypingIndicator.swift b/Sources/IMsgCore/TypingIndicator.swift new file mode 100644 index 0000000..5c04d35 --- /dev/null +++ b/Sources/IMsgCore/TypingIndicator.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Sends typing indicators for iMessage chats via the IMCore private framework. +/// +/// 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. +public struct TypingIndicator: Sendable { + + /// 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. + 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. + public static func stopTyping(chatIdentifier: String) throws { + try setTyping(chatIdentifier: chatIdentifier, isTyping: false) + } + + /// Show typing indicator for a duration, then automatically stop. + /// - Parameters: + /// - chatIdentifier: The chat identifier string. + /// - duration: Seconds to show the typing indicator. + public static func typeForDuration(chatIdentifier: String, duration: TimeInterval) async throws { + try startTyping(chatIdentifier: chatIdentifier) + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + try stopTyping(chatIdentifier: chatIdentifier) + } + + // MARK: - Private + + private static func setTyping(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()) + throw IMsgError.typingIndicatorFailed( + "Failed to load IMCore framework: \(error)") + } + defer { dlclose(handle) } + + try ensureDaemonConnection(handle: handle) + let chat = try lookupChat(handle: handle, identifier: chatIdentifier) + + let selector = sel_registerName("setLocalUserIsTyping:") + guard let method = class_getInstanceMethod(object_getClass(chat), selector) else { + throw IMsgError.typingIndicatorFailed( + "setLocalUserIsTyping: method not found on IMChat") + } + let implementation = method_getImplementation(method) + + typealias SetTypingFunc = @convention(c) (AnyObject, Selector, Bool) -> Void + let setTypingFunc = unsafeBitCast(implementation, to: SetTypingFunc.self) + setTypingFunc(chat, selector, isTyping) + } + + private static func ensureDaemonConnection(handle: UnsafeMutableRawPointer) throws { + guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else { + throw IMsgError.typingIndicatorFailed("IMDaemonController class not found") + } + + let sharedSel = sel_registerName("sharedInstance") + guard controllerClass.responds(to: sharedSel) else { + throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available") + } + + guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else { + throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance") + } + + let connectSel = sel_registerName("connectToDaemon") + if controller.responds(to: connectSel) { + _ = controller.perform(connectSel) + } + + Thread.sleep(forTimeInterval: 0.5) + } + + private static func lookupChat( + handle: UnsafeMutableRawPointer, identifier: String + ) throws -> NSObject { + guard let registryClass = objc_getClass("IMChatRegistry") as? NSObject.Type else { + throw IMsgError.typingIndicatorFailed("IMChatRegistry class not found") + } + + let sharedSel = sel_registerName("sharedInstance") + guard registryClass.responds(to: sharedSel) else { + throw IMsgError.typingIndicatorFailed("IMChatRegistry.sharedInstance not available") + } + + guard let registry = registryClass.perform(sharedSel)?.takeUnretainedValue() as? NSObject + else { + throw IMsgError.typingIndicatorFailed("Failed to get IMChatRegistry shared instance") + } + + let guidSel = sel_registerName("existingChatWithGUID:") + if registry.responds(to: guidSel) { + if let chat = registry.perform(guidSel, with: identifier)?.takeUnretainedValue() as? NSObject + { + return chat + } + } + + let identSel = sel_registerName("existingChatWithChatIdentifier:") + if registry.responds(to: identSel) { + if let chat = registry.perform(identSel, with: identifier)?.takeUnretainedValue() as? NSObject + { + return chat + } + } + + throw IMsgError.typingIndicatorFailed( + "Chat not found for identifier: \(identifier). " + + "Make sure Messages.app has an active conversation with this contact.") + } +} diff --git a/Sources/imsg/CommandRouter.swift b/Sources/imsg/CommandRouter.swift index e5a8295..ccd7565 100644 --- a/Sources/imsg/CommandRouter.swift +++ b/Sources/imsg/CommandRouter.swift @@ -15,6 +15,7 @@ struct CommandRouter { WatchCommand.spec, SendCommand.spec, ReactCommand.spec, + TypingCommand.spec, RpcCommand.spec, ] let descriptor = CommandDescriptor( diff --git a/Sources/imsg/Commands/TypingCommand.swift b/Sources/imsg/Commands/TypingCommand.swift new file mode 100644 index 0000000..a70366a --- /dev/null +++ b/Sources/imsg/Commands/TypingCommand.swift @@ -0,0 +1,143 @@ +import Commander +import Foundation +import IMsgCore + +enum TypingCommand { + static let spec = CommandSpec( + name: "typing", + abstract: "Send typing indicator to a chat", + discussion: nil, + signature: CommandSignatures.withRuntimeFlags( + CommandSignature( + options: CommandSignatures.baseOptions() + [ + .make(label: "to", names: [.long("to")], help: "phone number or email"), + .make(label: "chatID", names: [.long("chat-id")], help: "chat rowid"), + .make( + label: "chatIdentifier", names: [.long("chat-identifier")], + help: "chat identifier (e.g. iMessage;-;+14155551212)"), + .make(label: "chatGUID", names: [.long("chat-guid")], help: "chat guid"), + .make( + label: "duration", names: [.long("duration")], + help: "how long to show typing (e.g. 5s, 3000ms); omit for start-only"), + .make( + label: "stop", names: [.long("stop")], + help: "stop typing indicator instead of starting"), + .make( + label: "service", names: [.long("service")], + help: "service to use: imessage|sms|auto"), + ] + ) + ), + usageExamples: [ + "imsg typing --to +14155551212", + "imsg typing --to +14155551212 --duration 5s", + "imsg typing --to +14155551212 --stop true", + "imsg typing --chat-identifier \"iMessage;-;+14155551212\"", + ] + ) { values, runtime in + try await run(values: values, runtime: runtime) + } + + static func run( + values: ParsedValues, + runtime: RuntimeOptions, + storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) } + ) async throws { + let dbPath = values.option("db") ?? MessageStore.defaultPath + let recipient = values.option("to") ?? "" + let chatID = values.optionInt64("chatID") + let chatIdentifier = values.option("chatIdentifier") ?? "" + let chatGUID = values.option("chatGUID") ?? "" + let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty + let stopFlag = values.option("stop") == "true" + let durationRaw = values.option("duration") ?? "" + + if !hasChatTarget && recipient.isEmpty { + throw ParsedValuesError.missingOption("to") + } + + let resolvedIdentifier = try resolveIdentifier( + dbPath: dbPath, + recipient: recipient, + chatID: chatID, + chatIdentifier: chatIdentifier, + chatGUID: chatGUID, + service: values.option("service") ?? "imessage", + storeFactory: storeFactory + ) + + if stopFlag { + try TypingIndicator.stopTyping(chatIdentifier: resolvedIdentifier) + if runtime.jsonOutput { + try JSONLines.print(["status": "stopped"]) + } else { + Swift.print("typing indicator stopped") + } + return + } + + if !durationRaw.isEmpty { + let seconds = try parseDurationToSeconds(durationRaw) + try await TypingIndicator.typeForDuration( + chatIdentifier: resolvedIdentifier, duration: seconds) + if runtime.jsonOutput { + try JSONLines.print(["status": "completed", "duration_s": "\(seconds)"]) + } else { + Swift.print("typing indicator shown for \(durationRaw)") + } + return + } + + try TypingIndicator.startTyping(chatIdentifier: resolvedIdentifier) + if runtime.jsonOutput { + try JSONLines.print(["status": "started"]) + } else { + Swift.print("typing indicator started") + } + } + + private static func resolveIdentifier( + dbPath: String, + recipient: String, + chatID: Int64?, + chatIdentifier: String, + chatGUID: String, + service: String, + storeFactory: (String) throws -> MessageStore + ) throws -> String { + if !chatGUID.isEmpty { return chatGUID } + if !chatIdentifier.isEmpty { return chatIdentifier } + if let chatID { + let store = try storeFactory(dbPath) + guard let info = try store.chatInfo(chatID: chatID) else { + throw IMsgError.invalidChatTarget("Unknown chat id \(chatID)") + } + if !info.guid.isEmpty { return info.guid } + return info.identifier + } + let svc = service == "sms" ? "SMS" : "iMessage" + return "\(svc);-;\(recipient)" + } + + private static func parseDurationToSeconds(_ raw: String) throws -> TimeInterval { + let trimmed = raw.trimmingCharacters(in: .whitespaces).lowercased() + if trimmed.hasSuffix("ms") { + let numStr = String(trimmed.dropLast(2)) + guard let ms = Double(numStr), ms > 0 else { + throw IMsgError.typingIndicatorFailed("Invalid duration: \(raw)") + } + return ms / 1000.0 + } + if trimmed.hasSuffix("s") { + let numStr = String(trimmed.dropLast(1)) + guard let s = Double(numStr), s > 0 else { + throw IMsgError.typingIndicatorFailed("Invalid duration: \(raw)") + } + return s + } + guard let s = Double(trimmed), s > 0 else { + throw IMsgError.typingIndicatorFailed("Invalid duration: \(raw). Use e.g. 5s or 3000ms") + } + return s + } +} diff --git a/Sources/imsg/RPCServer.swift b/Sources/imsg/RPCServer.swift index a0352ec..ce8e82f 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -15,12 +15,20 @@ final class RPCServer { private let subscriptions = SubscriptionStore() private let verbose: Bool private let sendMessage: (MessageSendOptions) throws -> Void + private let startTyping: (String) throws -> Void + private let stopTyping: (String) throws -> Void init( store: MessageStore, verbose: Bool, output: RPCOutput = RPCWriter(), - sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) } + sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) }, + startTyping: @escaping (String) throws -> Void = { + try TypingIndicator.startTyping(chatIdentifier: $0) + }, + stopTyping: @escaping (String) throws -> Void = { + try TypingIndicator.stopTyping(chatIdentifier: $0) + } ) { self.store = store self.watcher = MessageWatcher(store: store) @@ -28,6 +36,8 @@ final class RPCServer { self.verbose = verbose self.output = output self.sendMessage = sendMessage + self.startTyping = startTyping + self.stopTyping = stopTyping } func run() async throws { @@ -83,6 +93,10 @@ final class RPCServer { try await handleWatchUnsubscribe(id: id, params: params) case "send": try await handleSend(params: params, id: id) + case "typing.start": + try await handleTyping(params: params, id: id, start: true) + case "typing.stop": + try await handleTyping(params: params, id: id, start: false) default: output.sendError(id: id, error: RPCError.methodNotFound(method)) } @@ -235,6 +249,45 @@ final class RPCServer { respond(id: id, result: ["ok": true]) } + private func handleTyping(params: [String: Any], id: Any?, start: Bool) async throws { + let chatIdentifier = stringParam(params["chat_identifier"]) ?? "" + let chatGUID = stringParam(params["chat_guid"]) ?? "" + let recipient = stringParam(params["to"]) ?? "" + let chatID = int64Param(params["chat_id"]) + let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty + if hasChatTarget && !recipient.isEmpty { + throw RPCError.invalidParams("use to or chat_*; not both") + } + if !hasChatTarget && recipient.isEmpty { + throw RPCError.invalidParams("to is required for direct typing") + } + + let serviceRaw = stringParam(params["service"]) ?? "imessage" + guard let service = MessageService(rawValue: serviceRaw.lowercased()) else { + throw RPCError.invalidParams("invalid service") + } + + var resolved = chatGUID.isEmpty ? chatIdentifier : chatGUID + if let chatID { + if let info = try await cache.info(chatID: chatID) { + resolved = info.guid.isEmpty ? info.identifier : info.guid + } else { + throw RPCError.invalidParams("unknown chat_id \(chatID)") + } + } + if resolved.isEmpty { + let svc = service == .sms ? "SMS" : "iMessage" + resolved = "\(svc);-;\(recipient)" + } + + if start { + try startTyping(resolved) + } else { + try stopTyping(resolved) + } + respond(id: id, result: ["ok": true]) + } + private func handleSend(params: [String: Any], id: Any?) async throws { let text = stringParam(params["text"]) ?? "" let file = stringParam(params["file"]) ?? ""