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:
parent
c608db6db7
commit
69499d891f
@ -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)
|
||||
|
||||
120
Sources/IMsgCore/TypingIndicator.swift
Normal file
120
Sources/IMsgCore/TypingIndicator.swift
Normal 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.")
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ struct CommandRouter {
|
||||
WatchCommand.spec,
|
||||
SendCommand.spec,
|
||||
ReactCommand.spec,
|
||||
TypingCommand.spec,
|
||||
RpcCommand.spec,
|
||||
]
|
||||
let descriptor = CommandDescriptor(
|
||||
|
||||
143
Sources/imsg/Commands/TypingCommand.swift
Normal file
143
Sources/imsg/Commands/TypingCommand.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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"]) ?? ""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user