Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions Sources/IMsgCore/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
143 changes: 143 additions & 0 deletions Sources/IMsgCore/TypingIndicator.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
1 change: 1 addition & 0 deletions Sources/imsg/CommandRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct CommandRouter {
WatchCommand.spec,
SendCommand.spec,
ReactCommand.spec,
TypingCommand.spec,
RpcCommand.spec,
]
let descriptor = CommandDescriptor(
Expand Down
147 changes: 147 additions & 0 deletions Sources/imsg/Commands/TypingCommand.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading