feat: add typing indicator support via IMCore private framework

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"
This commit is contained in:
Koho Zheng 2026-02-06 15:24:45 +08:00 committed by Peter Steinberger
parent c608db6db7
commit 69499d891f
5 changed files with 321 additions and 1 deletions

View File

@ -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)

View File

@ -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.")
}
}

View File

@ -15,6 +15,7 @@ struct CommandRouter {
WatchCommand.spec,
SendCommand.spec,
ReactCommand.spec,
TypingCommand.spec,
RpcCommand.spec,
]
let descriptor = CommandDescriptor(

View File

@ -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
}
}

View File

@ -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"]) ?? ""