imsg/Sources/IMsgCore/TypingIndicator.swift
Peter Steinberger e833e0c898
Some checks are pending
CI / macos (push) Waiting to run
CI / linux-read-core (push) Waiting to run
pages / Deploy docs (push) Waiting to run
feat: add linux read-only build (#106)
2026-05-07 01:29:26 +01:00

346 lines
12 KiB
Swift

import Foundation
#if os(macOS)
/// Sends typing indicators for iMessage chats.
///
/// Prefers the IMCore bridge (via DYLD injection into Messages.app) which
/// is reliable on stock macOS with SIP disabled. Falls back to direct
/// IMCore access via `dlopen` when the bridge is unavailable.
public struct TypingIndicator: Sendable {
private static let daemonConnectionTracker = DaemonConnectionTracker()
/// Start showing the typing indicator for a chat.
/// - Parameter chatIdentifier: e.g. `"iMessage;-;+14155551212"` or a chat GUID.
/// - Throws: `IMsgError.typingIndicatorFailed` if both bridge and direct IMCore fail.
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 both bridge and direct IMCore fail.
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 {
// Prefer the bridge (dylib injected into Messages.app)
let bridge = IMCoreBridge.shared
if bridge.isAvailable {
do {
try setTypingViaBridge(bridge: bridge, chatIdentifier: chatIdentifier, isTyping: isTyping)
return
} catch {
// Bridge failed fall through to direct IMCore access
}
}
// Fallback: direct IMCore access (requires AMFI disabled + XPC plist)
try setTypingDirect(chatIdentifier: chatIdentifier, isTyping: isTyping)
}
/// Synchronous wrapper for the async bridge call using a Sendable result box.
private static func setTypingViaBridge(
bridge: IMCoreBridge, chatIdentifier: String, isTyping: Bool
) throws {
let semaphore = DispatchSemaphore(value: 0)
let box = BridgeResultBox()
Task { @Sendable in
do {
try await bridge.setTyping(for: chatIdentifier, typing: isTyping)
} catch {
box.setError(error)
}
semaphore.signal()
}
semaphore.wait()
if let error = box.error {
throw error
}
}
private static func setTypingDirect(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()
let chat = try lookupChat(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() 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")
}
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.hasAttemptedConnection = true
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
daemonConnectionTracker.lock.lock()
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
if shouldAttemptConnection {
daemonConnectionTracker.hasAttemptedConnection = true
}
daemonConnectionTracker.lock.unlock()
if !shouldAttemptConnection { return }
let connectSel = sel_registerName("connectToDaemon")
if controller.responds(to: connectSel) {
_ = controller.perform(connectSel)
}
let maxAttempts = 50
for _ in 0..<maxAttempts {
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
Thread.sleep(forTimeInterval: 0.1)
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
}
if !hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = true
daemonConnectionTracker.lock.unlock()
throw IMsgError.typingIndicatorFailed(
daemonUnavailableMessage()
)
}
}
private static func hasLiveDaemonConnection(_ controller: AnyObject) -> Bool {
let isConnectedSel = sel_registerName("isConnected")
guard controller.responds(to: isConnectedSel) else { return false }
guard let value = controller.perform(isConnectedSel)?.takeUnretainedValue() else {
return false
}
if let number = value as? NSNumber {
return number.boolValue
}
return false
}
private static func lookupChat(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 candidates = chatLookupCandidates(for: identifier)
let guidSel = sel_registerName("existingChatWithGUID:")
if registry.responds(to: guidSel) {
for candidate in candidates {
if let chat = registry.perform(guidSel, with: candidate)?.takeUnretainedValue()
as? NSObject
{
return chat
}
}
}
let identSel = sel_registerName("existingChatWithChatIdentifier:")
if registry.responds(to: identSel) {
for candidate in candidates {
if let chat = registry.perform(identSel, with: candidate)?.takeUnretainedValue()
as? NSObject
{
return chat
}
}
}
daemonConnectionTracker.lock.lock()
let connectionKnownUnavailable = daemonConnectionTracker.connectionKnownUnavailable
daemonConnectionTracker.lock.unlock()
if connectionKnownUnavailable {
throw IMsgError.typingIndicatorFailed(daemonUnavailableMessage())
}
throw IMsgError.typingIndicatorFailed(
"Chat not found for identifier: \(identifier). "
+ "Make sure Messages.app has an active conversation with this contact.")
}
static func daemonUnavailableMessage() -> String {
"Failed to connect to imagent (Messages daemon) for IMCore typing indicators. "
+ "On macOS 26/Tahoe, imagent can reject third-party clients without "
+ "Apple-private entitlements, and Messages.app may also block the injected "
+ "bridge via library validation. Run 'imsg status' and 'imsg launch' to "
+ "verify advanced feature setup. Normal 'send', 'history', and 'watch' "
+ "commands do not use this IMCore path."
}
static func chatLookupCandidates(for identifier: String) -> [String] {
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
let bareIdentifier = stripKnownChatPrefix(trimmed) ?? trimmed
var candidates = [trimmed]
if bareIdentifier != trimmed {
candidates.append(bareIdentifier)
}
for prefix in chatIdentifierPrefixes {
candidates.append(prefix + bareIdentifier)
}
return dedupe(candidates)
}
private static let chatIdentifierPrefixes = [
"iMessage;-;",
"iMessage;+;",
"SMS;-;",
"SMS;+;",
"any;-;",
"any;+;",
]
private static func stripKnownChatPrefix(_ value: String) -> String? {
for prefix in chatIdentifierPrefixes where value.hasPrefix(prefix) {
return String(value.dropFirst(prefix.count))
}
return nil
}
private static func dedupe(_ values: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for value in values where !value.isEmpty {
if seen.insert(value).inserted {
result.append(value)
}
}
return result
}
}
#else
/// Non-macOS stub. Linux can read copied databases, but typing indicators
/// require private IMCore APIs inside Messages.app.
public struct TypingIndicator: Sendable {
public static func startTyping(chatIdentifier: String) throws {
_ = chatIdentifier
throw unsupported()
}
public static func stopTyping(chatIdentifier: String) throws {
_ = chatIdentifier
throw unsupported()
}
public static func typeForDuration(chatIdentifier: String, duration: TimeInterval) async throws
{
_ = chatIdentifier
_ = duration
throw unsupported()
}
private static func unsupported() -> IMsgError {
IMsgError.typingIndicatorFailed(
"Typing indicators require Messages.app/IMCore and are only supported on macOS.")
}
}
#endif
#if os(macOS)
private final class DaemonConnectionTracker: @unchecked Sendable {
let lock = NSLock()
var hasAttemptedConnection = false
var connectionKnownUnavailable = false
}
/// Thread-safe box for passing an error out of a Task back to the calling thread.
private final class BridgeResultBox: @unchecked Sendable {
private let lock = NSLock()
private var _error: Error?
var error: Error? {
lock.lock()
defer { lock.unlock() }
return _error
}
func setError(_ error: Error) {
lock.lock()
_error = error
lock.unlock()
}
}
#endif