From d3b38d516b692b96f59fd6a42c7e1ffc6c4b3567 Mon Sep 17 00:00:00 2001 From: Koho Zheng Date: Fri, 6 Feb 2026 15:24:45 +0800 Subject: [PATCH 1/3] feat: add typing indicator support via IMCore private framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements typing indicators for outgoing messages using runtime dynamic loading of Apple's IMCore private framework. This is the only way to programmatically send typing indicators — AppleScript has no equivalent capability. Closes #22 Changes: - IMsgCore: Add TypingIndicator struct with start/stop/duration APIs - CLI: Add 'imsg typing' command with --to, --duration, --stop flags - RPC: Add typing.start and typing.stop methods - Errors: Add typingIndicatorFailed error case Usage: imsg typing --to +14155551212 imsg typing --to +14155551212 --duration 5s imsg typing --to +14155551212 --stop true imsg typing --chat-identifier "iMessage;-;+14155551212" --- Sources/IMsgCore/Errors.swift | 3 + Sources/IMsgCore/TypingIndicator.swift | 120 ++++++++++++++++++ Sources/imsg/CommandRouter.swift | 1 + Sources/imsg/Commands/TypingCommand.swift | 143 ++++++++++++++++++++++ Sources/imsg/RPCServer.swift | 55 ++++++++- 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 Sources/IMsgCore/TypingIndicator.swift create mode 100644 Sources/imsg/Commands/TypingCommand.swift 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"]) ?? "" From 5ae873a2759433fee1ac1ad4b11ee7a479b4c19e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 06:06:50 +0100 Subject: [PATCH 2/3] fix: harden typing command and rpc validation --- Sources/IMsgCore/TypingIndicator.swift | 29 +++- Sources/imsg/Commands/TypingCommand.swift | 56 ++++---- .../IMsgCoreTests/TypingIndicatorTests.swift | 43 ++++++ Tests/imsgTests/RPCServerTests.swift | 71 ++++++++++ Tests/imsgTests/TypingCommandTests.swift | 125 ++++++++++++++++++ 5 files changed, 295 insertions(+), 29 deletions(-) create mode 100644 Tests/IMsgCoreTests/TypingIndicatorTests.swift create mode 100644 Tests/imsgTests/TypingCommandTests.swift diff --git a/Sources/IMsgCore/TypingIndicator.swift b/Sources/IMsgCore/TypingIndicator.swift index 5c04d35..ed73b3e 100644 --- a/Sources/IMsgCore/TypingIndicator.swift +++ b/Sources/IMsgCore/TypingIndicator.swift @@ -27,9 +27,13 @@ public struct TypingIndicator: Sendable { /// - 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) + 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 @@ -58,6 +62,25 @@ public struct TypingIndicator: Sendable { 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") diff --git a/Sources/imsg/Commands/TypingCommand.swift b/Sources/imsg/Commands/TypingCommand.swift index a70366a..edfcac1 100644 --- a/Sources/imsg/Commands/TypingCommand.swift +++ b/Sources/imsg/Commands/TypingCommand.swift @@ -41,7 +41,12 @@ enum TypingCommand { static func run( values: ParsedValues, runtime: RuntimeOptions, - storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) } + 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") ?? "" @@ -49,9 +54,13 @@ enum TypingCommand { 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 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") } @@ -62,12 +71,12 @@ enum TypingCommand { chatID: chatID, chatIdentifier: chatIdentifier, chatGUID: chatGUID, - service: values.option("service") ?? "imessage", + service: serviceRaw, storeFactory: storeFactory ) if stopFlag { - try TypingIndicator.stopTyping(chatIdentifier: resolvedIdentifier) + try stopTyping(resolvedIdentifier) if runtime.jsonOutput { try JSONLines.print(["status": "stopped"]) } else { @@ -78,8 +87,7 @@ enum TypingCommand { if !durationRaw.isEmpty { let seconds = try parseDurationToSeconds(durationRaw) - try await TypingIndicator.typeForDuration( - chatIdentifier: resolvedIdentifier, duration: seconds) + try await typeForDuration(resolvedIdentifier, seconds) if runtime.jsonOutput { try JSONLines.print(["status": "completed", "duration_s": "\(seconds)"]) } else { @@ -88,7 +96,7 @@ enum TypingCommand { return } - try TypingIndicator.startTyping(chatIdentifier: resolvedIdentifier) + try startTyping(resolvedIdentifier) if runtime.jsonOutput { try JSONLines.print(["status": "started"]) } else { @@ -115,29 +123,25 @@ enum TypingCommand { if !info.guid.isEmpty { return info.guid } return info.identifier } - let svc = service == "sms" ? "SMS" : "iMessage" + 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 { - 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") + guard let seconds = DurationParser.parse(raw), seconds > 0 else { + throw IMsgError.typingIndicatorFailed( + "Invalid duration: \(raw). Use e.g. 5s, 3000ms, 1m, or 1h") } - return s + return seconds } } 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) +} From 6cd72386d8a64c890670a0b28ece6a26058ffc94 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 06:30:24 +0100 Subject: [PATCH 3/3] fix: update changelog for typing indicators (#41) (thanks @kohoj) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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)