diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cb4e0..40f1a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - feat: include `thread_originator_guid` in message output (#39, thanks @ruthmade) - feat: expose `destination_caller_id` in message output (#29, thanks @commander-alexander) - fix: detect groups from `;+;` prefix in guid/identifier for RPC payloads (#42, thanks @shivshil) +- feat: add typing indicator command + RPC methods with stricter validation (#41, thanks @kohoj) ## 0.4.0 - 2026-01-07 - feat: surface audio message transcriptions (thanks @antons) 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..ed73b3e --- /dev/null +++ b/Sources/IMsgCore/TypingIndicator.swift @@ -0,0 +1,143 @@ +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 await typeForDuration( + chatIdentifier: chatIdentifier, + duration: duration, + startTyping: { try startTyping(chatIdentifier: $0) }, + stopTyping: { try stopTyping(chatIdentifier: $0) }, + sleep: { try await Task.sleep(nanoseconds: $0) } + ) + } + + // 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) + } + + static func typeForDuration( + chatIdentifier: String, + duration: TimeInterval, + startTyping: (String) throws -> Void, + stopTyping: (String) throws -> Void, + sleep: (UInt64) async throws -> Void + ) async throws { + try startTyping(chatIdentifier) + var stopped = false + defer { + if !stopped { + try? stopTyping(chatIdentifier) + } + } + try await sleep(UInt64(duration * 1_000_000_000)) + try stopTyping(chatIdentifier) + stopped = true + } + + 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..edfcac1 --- /dev/null +++ b/Sources/imsg/Commands/TypingCommand.swift @@ -0,0 +1,147 @@ +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) }, + startTyping: @escaping (String) throws -> Void = { try TypingIndicator.startTyping(chatIdentifier: $0) }, + stopTyping: @escaping (String) throws -> Void = { try TypingIndicator.stopTyping(chatIdentifier: $0) }, + typeForDuration: @escaping (String, TimeInterval) async throws -> Void = { + try await TypingIndicator.typeForDuration(chatIdentifier: $0, duration: $1) + } + ) 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 = try parseStopFlag(values.option("stop")) + let durationRaw = values.option("duration") ?? "" + let serviceRaw = values.option("service") ?? "imessage" + + if hasChatTarget && !recipient.isEmpty { + throw ParsedValuesError.invalidOption("to") + } + if !hasChatTarget && recipient.isEmpty { + throw ParsedValuesError.missingOption("to") + } + + let resolvedIdentifier = try resolveIdentifier( + dbPath: dbPath, + recipient: recipient, + chatID: chatID, + chatIdentifier: chatIdentifier, + chatGUID: chatGUID, + service: serviceRaw, + storeFactory: storeFactory + ) + + if stopFlag { + try stopTyping(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 typeForDuration(resolvedIdentifier, seconds) + if runtime.jsonOutput { + try JSONLines.print(["status": "completed", "duration_s": "\(seconds)"]) + } else { + Swift.print("typing indicator shown for \(durationRaw)") + } + return + } + + try startTyping(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 + } + guard let messageService = MessageService(rawValue: service.lowercased()) else { + throw IMsgError.invalidService(service) + } + let svc = messageService == .sms ? "SMS" : "iMessage" + return "\(svc);-;\(recipient)" + } + + private static func parseStopFlag(_ raw: String?) throws -> Bool { + guard let raw else { return false } + if raw == "true" { return true } + if raw == "false" { return false } + throw ParsedValuesError.invalidOption("stop") + } + + private static func parseDurationToSeconds(_ raw: String) throws -> TimeInterval { + guard let seconds = DurationParser.parse(raw), seconds > 0 else { + throw IMsgError.typingIndicatorFailed( + "Invalid duration: \(raw). Use e.g. 5s, 3000ms, 1m, or 1h") + } + return seconds + } +} 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"]) ?? "" diff --git a/Tests/IMsgCoreTests/TypingIndicatorTests.swift b/Tests/IMsgCoreTests/TypingIndicatorTests.swift new file mode 100644 index 0000000..e3ed939 --- /dev/null +++ b/Tests/IMsgCoreTests/TypingIndicatorTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing + +@testable import IMsgCore + +@Test +func typingIndicatorStopsOnCancellation() async { + var events: [String] = [] + + do { + try await TypingIndicator.typeForDuration( + chatIdentifier: "iMessage;+;chat123", + duration: 1, + startTyping: { _ in events.append("start") }, + stopTyping: { _ in events.append("stop") }, + sleep: { _ in throw CancellationError() } + ) + #expect(Bool(false)) + } catch is CancellationError { + #expect(Bool(true)) + } catch { + #expect(Bool(false)) + } + + #expect(events == ["start", "stop"]) +} + +@Test +func typingIndicatorStopsAfterNormalDuration() async throws { + var events: [String] = [] + var didSleep = false + + try await TypingIndicator.typeForDuration( + chatIdentifier: "iMessage;+;chat123", + duration: 1, + startTyping: { _ in events.append("start") }, + stopTyping: { _ in events.append("stop") }, + sleep: { _ in didSleep = true } + ) + + #expect(didSleep == true) + #expect(events == ["start", "stop"]) +} diff --git a/Tests/imsgTests/RPCServerTests.swift b/Tests/imsgTests/RPCServerTests.swift index 505c91b..7772df6 100644 --- a/Tests/imsgTests/RPCServerTests.swift +++ b/Tests/imsgTests/RPCServerTests.swift @@ -319,6 +319,77 @@ func rpcSendRejectsUnknownChatID() async throws { #expect(int64Value(error?["code"]) == -32602) } +@Test +func rpcTypingStartResolvesSMSRecipient() async throws { + let store = try RPCTestDatabase.makeStore() + let output = TestRPCOutput() + var startedIdentifier: String? + let server = RPCServer( + store: store, + verbose: false, + output: output, + startTyping: { identifier in startedIdentifier = identifier }, + stopTyping: { _ in } + ) + + let line = + #"{"jsonrpc":"2.0","id":20,"method":"typing.start","params":{"to":"+15551234567","service":"sms"}}"# + await server.handleLineForTesting(line) + + #expect(startedIdentifier == "SMS;-;+15551234567") + let result = output.responses.first?["result"] as? [String: Any] + #expect(result?["ok"] as? Bool == true) +} + +@Test +func rpcTypingStopResolvesChatID() async throws { + let store = try RPCTestDatabase.makeStore() + let output = TestRPCOutput() + var stoppedIdentifier: String? + let server = RPCServer( + store: store, + verbose: false, + output: output, + startTyping: { _ in }, + stopTyping: { identifier in stoppedIdentifier = identifier } + ) + + let line = #"{"jsonrpc":"2.0","id":21,"method":"typing.stop","params":{"chat_id":1}}"# + await server.handleLineForTesting(line) + + #expect(stoppedIdentifier == "iMessage;+;chat123") + let result = output.responses.first?["result"] as? [String: Any] + #expect(result?["ok"] as? Bool == true) +} + +@Test +func rpcTypingRejectsInvalidService() async throws { + let store = try RPCTestDatabase.makeStore() + let output = TestRPCOutput() + let server = RPCServer(store: store, verbose: false, output: output) + + let line = + #"{"jsonrpc":"2.0","id":22,"method":"typing.start","params":{"to":"+15551234567","service":"fax"}}"# + await server.handleLineForTesting(line) + + let error = output.errors.first?["error"] as? [String: Any] + #expect(int64Value(error?["code"]) == -32602) +} + +@Test +func rpcTypingRejectsChatAndRecipient() async throws { + let store = try RPCTestDatabase.makeStore() + let output = TestRPCOutput() + let server = RPCServer(store: store, verbose: false, output: output) + + let line = + #"{"jsonrpc":"2.0","id":23,"method":"typing.start","params":{"chat_id":1,"to":"+15551234567"}}"# + await server.handleLineForTesting(line) + + let error = output.errors.first?["error"] as? [String: Any] + #expect(int64Value(error?["code"]) == -32602) +} + @Test func rpcWatchSubscribeEmitsNotificationAndUnsubscribe() async throws { let store = try RPCTestDatabase.makeStore() diff --git a/Tests/imsgTests/TypingCommandTests.swift b/Tests/imsgTests/TypingCommandTests.swift new file mode 100644 index 0000000..8992a49 --- /dev/null +++ b/Tests/imsgTests/TypingCommandTests.swift @@ -0,0 +1,125 @@ +import Commander +import Foundation +import Testing + +@testable import IMsgCore +@testable import imsg + +@Test +func typingCommandRejectsChatAndRecipient() async { + let values = ParsedValues( + positional: [], + options: ["to": ["+15551234567"], "chatIdentifier": ["iMessage;+;chat123"]], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: values) + do { + try await TypingCommand.run( + values: values, + runtime: runtime, + startTyping: { _ in }, + stopTyping: { _ in }, + typeForDuration: { _, _ in } + ) + #expect(Bool(false)) + } catch let error as ParsedValuesError { + #expect(error.description == "Invalid value for option: --to") + } catch { + #expect(Bool(false)) + } +} + +@Test +func typingCommandRejectsInvalidService() async { + let values = ParsedValues( + positional: [], + options: ["to": ["+15551234567"], "service": ["fax"]], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: values) + do { + try await TypingCommand.run( + values: values, + runtime: runtime, + startTyping: { _ in }, + stopTyping: { _ in }, + typeForDuration: { _, _ in } + ) + #expect(Bool(false)) + } catch let error as IMsgError { + switch error { + case .invalidService(let value): + #expect(value == "fax") + default: + #expect(Bool(false)) + } + } catch { + #expect(Bool(false)) + } +} + +@Test +func typingCommandRejectsInvalidStopOption() async { + let values = ParsedValues( + positional: [], + options: ["to": ["+15551234567"], "stop": ["1"]], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: values) + do { + try await TypingCommand.run( + values: values, + runtime: runtime, + startTyping: { _ in }, + stopTyping: { _ in }, + typeForDuration: { _, _ in } + ) + #expect(Bool(false)) + } catch let error as ParsedValuesError { + #expect(error.description == "Invalid value for option: --stop") + } catch { + #expect(Bool(false)) + } +} + +@Test +func typingCommandUsesSMSIdentifierForRecipient() async throws { + let values = ParsedValues( + positional: [], + options: ["to": ["+15551234567"], "service": ["sms"]], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: values) + var startedIdentifier: String? + _ = try await StdoutCapture.capture { + try await TypingCommand.run( + values: values, + runtime: runtime, + startTyping: { identifier in startedIdentifier = identifier }, + stopTyping: { _ in }, + typeForDuration: { _, _ in } + ) + } + #expect(startedIdentifier == "SMS;-;+15551234567") +} + +@Test +func typingCommandParsesMinuteDuration() async throws { + let values = ParsedValues( + positional: [], + options: ["to": ["+15551234567"], "duration": ["1m"]], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: values) + var capturedDuration: TimeInterval? + _ = try await StdoutCapture.capture { + try await TypingCommand.run( + values: values, + runtime: runtime, + startTyping: { _ in }, + stopTyping: { _ in }, + typeForDuration: { _, duration in capturedDuration = duration } + ) + } + #expect(capturedDuration == 60) +}