feat: add advanced message controls

This commit is contained in:
Peter Steinberger 2026-04-27 02:11:23 +01:00
parent c9fa1c2003
commit bf2d02e5e7
No known key found for this signature in database
20 changed files with 2246 additions and 7 deletions

View File

@ -0,0 +1,62 @@
---
name: imsg
description: Use for local iMessage/SMS archive reads, chat history, watch, and explicitly requested sends.
---
# imsg
Use this for Messages.app history, chat lookup, streaming, and sends. Reading is local DB access; sending uses Messages automation and must be explicitly requested.
## Sources
- DB: `~/Library/Messages/chat.db`
- Repo: `~/Projects/imsg`
- CLI: `imsg`
- JSON output is NDJSON; pipe to `jq -s` for arrays.
## Read Workflow
Check DB access:
```bash
sqlite3 ~/Library/Messages/chat.db 'pragma quick_check;'
```
List chats:
```bash
imsg chats --json | jq -s
```
Read a chat:
```bash
imsg history --chat-id ID --json | jq -s
```
Use `--attachments` when attachment metadata matters. Use `--start`/`--end` with absolute timestamps for date-scoped questions.
## Sends
Only send, react, mark read, or show typing when the user explicitly asks. Prefer dry wording in the final confirmation: recipient, service, and what was sent.
Common send command:
```bash
imsg send --to "+15551234567" --text "message" --service auto
```
## Verification
For repo edits:
```bash
make test
make build
```
For live read proof:
```bash
imsg chats --limit 3 --json | jq -s
```

1
.gitignore vendored
View File

@ -39,6 +39,7 @@ Package.resolved
# Build artifacts
bin/
slides/
# Node.js / pnpm
pnpm-lock.yaml

View File

@ -1,15 +1,16 @@
SHELL := /bin/bash
.PHONY: help format lint test build imsg clean
.PHONY: help format lint test build imsg clean build-dylib
help:
@printf "%s\n" \
"make format - swift format in-place" \
"make lint - swift format lint + swiftlint" \
"make test - sync version, patch deps, run swift test" \
"make build - universal release build into bin/" \
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
"make clean - swift package clean"
"make format - swift format in-place" \
"make lint - swift format lint + swiftlint" \
"make test - sync version, patch deps, run swift test" \
"make build - universal release build into bin/" \
"make build-dylib - build injectable dylib for Messages.app" \
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
"make clean - swift package clean"
format:
swift format --in-place --recursive Sources Tests
@ -30,6 +31,19 @@ build:
scripts/patch-deps.sh
scripts/build-universal.sh
# Build injectable dylib for Messages.app (DYLD_INSERT_LIBRARIES).
# Uses arm64e architecture to match Messages.app on Apple Silicon.
# Requires SIP disabled on the target machine to inject into system apps.
build-dylib:
@echo "Building imsg-bridge-helper.dylib (injectable)..."
@mkdir -p .build/release
@clang -dynamiclib -arch arm64e -fobjc-arc \
-Wno-arc-performSelector-leaks \
-framework Foundation \
-o .build/release/imsg-bridge-helper.dylib \
Sources/IMsgHelper/IMsgInjected.m
@echo "Built .build/release/imsg-bridge-helper.dylib"
imsg:
scripts/generate-version.sh
swift package resolve
@ -40,3 +54,4 @@ imsg:
clean:
swift package clean
@rm -f .build/release/imsg-bridge-helper.dylib

View File

@ -10,6 +10,7 @@ A macOS Messages.app CLI to send, read, and stream iMessage/SMS (with attachment
- Filters: participants, start/end time, JSON output for tooling.
- Read-only DB access (`mode=ro`), no DB writes.
- Event-driven watch via filesystem events.
- Optional advanced IMCore features (`typing`, `launch`, `status`) behind explicit SIP-off setup.
## Requirements
- macOS 14+ with Messages.app signed in.
@ -28,6 +29,10 @@ make build
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]`
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--debounce 250ms] [--attachments] [--participants …] [--start …] [--end …] [--json]`
- `imsg send --to <handle> [--text "hi"] [--file /path/img.jpg] [--service imessage|sms|auto] [--region US]`
- `imsg read --to <handle> [--chat-id <id> | --chat-identifier <id> | --chat-guid <guid>]`
- `imsg typing --to <handle> [--duration 5s] [--stop true] [--service imessage|sms|auto]`
- `imsg status [--json]` — advanced feature and SIP status
- `imsg launch [--dylib <path>] [--kill-only] [--json]`
### Quick samples
```
@ -48,6 +53,18 @@ imsg watch --chat-id 1 --attachments --debounce 250ms
# send a picture
imsg send --to "+14155551212" --text "hi" --file ~/Desktop/pic.jpg --service imessage
# mark a chat as read
imsg read --to "+14155551212"
# advanced status check
imsg status
# launch Messages with injection (SIP must be disabled first)
imsg launch
# show typing indicator for 5s
imsg typing --to "+14155551212" --duration 5s
```
## Attachment notes
@ -65,6 +82,23 @@ If you see “unable to open database file” or empty output:
2) Ensure Messages.app is signed in and `~/Library/Messages/chat.db` exists.
3) For send, allow the terminal under System Settings → Privacy & Security → Automation → Messages.
## Advanced Features (SIP-Off Only)
Advanced features (`typing`, `launch`, IMCore bridge) require injecting a helper dylib into `Messages.app`.
Important:
- This is opt-in only. Default send/history/watch flows do not need injection.
- `imsg launch` refuses to inject when SIP is enabled.
- `imsg status` is read-only and does not auto-launch or auto-inject.
Setup:
1) Disable SIP from Recovery mode: `csrutil disable`
2) Grant Full Disk Access to your terminal
3) Build helper dylib: `make build-dylib`
4) Launch with injection: `imsg launch`
5) Verify: `imsg status`
To revert after testing, re-enable SIP in Recovery mode: `csrutil enable`.
## Testing
```bash
make test

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,168 @@
import Foundation
public enum IMCoreBridgeError: Error, CustomStringConvertible {
case dylibNotFound
case connectionFailed(String)
case chatNotFound(String)
case operationFailed(String)
public var description: String {
switch self {
case .dylibNotFound:
return "imsg-bridge-helper.dylib not found. Build with: make build-dylib"
case .connectionFailed(let error):
return "Connection to Messages.app failed: \(error)"
case .chatNotFound(let id):
return "Chat not found: \(id)"
case .operationFailed(let reason):
return "Operation failed: \(reason)"
}
}
}
/// Bridge to IMCore via DYLD injection into Messages.app.
///
/// Communicates with an injected dylib inside Messages.app via file-based IPC.
/// The dylib has full access to IMCore because it runs within the Messages.app
/// context with proper entitlements.
///
/// Requires:
/// - SIP disabled (for `DYLD_INSERT_LIBRARIES` on system apps)
/// - The `imsg-bridge-helper.dylib` built via `make build-dylib`
public final class IMCoreBridge: @unchecked Sendable {
public static let shared = IMCoreBridge()
private let launcher = MessagesLauncher.shared
/// Whether the dylib exists on disk (does not check if Messages.app is running).
public var isAvailable: Bool {
let possiblePaths = [
"/usr/local/lib/imsg-bridge-helper.dylib",
".build/release/imsg-bridge-helper.dylib",
".build/debug/imsg-bridge-helper.dylib",
]
return possiblePaths.contains { FileManager.default.fileExists(atPath: $0) }
}
private init() {}
// MARK: - Commands
/// Set typing indicator for a conversation.
public func setTyping(for handle: String, typing: Bool) async throws {
let params: [String: Any] = [
"handle": handle,
"typing": typing,
]
_ = try await sendCommand(action: "typing", params: params)
}
/// Mark all messages as read in a conversation.
public func markAsRead(handle: String) async throws {
_ = try await sendCommand(action: "read", params: ["handle": handle])
}
/// List all available chats (for debugging).
public func listChats() async throws -> [[String: Any]] {
let response = try await sendCommand(action: "list_chats", params: [:])
return response["chats"] as? [[String: Any]] ?? []
}
/// Get detailed status from the injected helper.
public func getStatus() async throws -> [String: Any] {
return try await sendCommand(action: "status", params: [:])
}
/// Check availability and return a diagnostic message.
public func checkAvailability() -> (available: Bool, message: String) {
let possiblePaths = [
"/usr/local/lib/imsg-bridge-helper.dylib",
".build/release/imsg-bridge-helper.dylib",
".build/debug/imsg-bridge-helper.dylib",
]
var dylibPath: String?
for path in possiblePaths {
if FileManager.default.fileExists(atPath: path) {
dylibPath = path
break
}
}
guard dylibPath != nil else {
return (
false,
"""
imsg-bridge-helper.dylib not found. To build:
1. make build-dylib
2. Restart imsg
Note: Advanced features require:
- SIP disabled (for DYLD injection)
- Full Disk Access granted to Terminal
"""
)
}
switch MessagesLauncher.currentSIPStatus() {
case .enabled:
return (
false,
"""
System Integrity Protection (SIP) is enabled.
Advanced IMCore features are intentionally disabled.
To enable advanced features:
1. Disable SIP in Recovery mode (`csrutil disable`)
2. Run `make build-dylib`
3. Run `imsg launch`
"""
)
case .unknown(let details):
return (
false,
"""
Unable to determine SIP status. Refusing to auto-inject Messages.app.
Details: \(details)
"""
)
case .disabled:
break
}
if launcher.isInjectedAndReady() {
return (true, "Connected to Messages.app. IMCore features available.")
}
return (
false,
"""
SIP is disabled and the helper dylib is present, but Messages.app is not currently injected.
Run `imsg launch` to enable advanced IMCore features.
"""
)
}
// MARK: - Private
private func sendCommand(
action: String, params: [String: Any]
) async throws -> [String: Any] {
do {
let response = try await launcher.sendCommand(action: action, params: params)
if response["success"] as? Bool == true {
return response
}
let error = response["error"] as? String ?? "Unknown error"
if error.contains("Chat not found") {
let handle = params["handle"] as? String ?? "unknown"
throw IMCoreBridgeError.chatNotFound(handle)
}
throw IMCoreBridgeError.operationFailed(error)
} catch let error as MessagesLauncherError {
throw IMCoreBridgeError.connectionFailed(error.description)
}
}
}

View File

@ -0,0 +1,288 @@
import Foundation
/// Manages Messages.app lifecycle for DYLD injection.
///
/// Kills any running Messages.app, relaunches with `DYLD_INSERT_LIBRARIES`
/// pointing to the imsg-bridge dylib, then waits for the lock file that
/// confirms the dylib is ready for commands.
public final class MessagesLauncher: @unchecked Sendable {
public static let shared = MessagesLauncher()
// File-based IPC paths must match the paths in IMsgInjected.m.
// The dylib uses NSHomeDirectory() which resolves to the container path;
// from outside we construct the full container path ourselves.
private var commandFile: String {
containerPath + "/.imsg-command.json"
}
private var responseFile: String {
containerPath + "/.imsg-response.json"
}
private var lockFile: String {
containerPath + "/.imsg-bridge-ready"
}
private var containerPath: String {
NSHomeDirectory() + "/Library/Containers/com.apple.MobileSMS/Data"
}
private let messagesAppPath =
"/System/Applications/Messages.app/Contents/MacOS/Messages"
private let queue = DispatchQueue(label: "imsg.messages.launcher")
private let lock = NSLock()
/// Path to the dylib to inject.
public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib"
private init() {
let possiblePaths = [
"/usr/local/lib/imsg-bridge-helper.dylib",
".build/release/imsg-bridge-helper.dylib",
".build/debug/imsg-bridge-helper.dylib",
]
for path in possiblePaths {
if FileManager.default.fileExists(atPath: path) {
self.dylibPath = path
break
}
}
}
/// Check if Messages.app is running with our dylib (lock file exists and responds to ping).
public func isInjectedAndReady() -> Bool {
guard FileManager.default.fileExists(atPath: lockFile) else {
return false
}
do {
let response = try sendCommandSync(action: "ping", params: [:])
return response["success"] as? Bool == true
} catch {
return false
}
}
/// Ensure Messages.app is running with our dylib injected.
public func ensureRunning() throws {
if isInjectedAndReady() { return }
switch Self.currentSIPStatus() {
case .disabled:
break
case .enabled:
throw MessagesLauncherError.sipEnabled
case .unknown(let details):
throw MessagesLauncherError.sipStatusUnknown(details)
}
guard FileManager.default.fileExists(atPath: dylibPath) else {
throw MessagesLauncherError.dylibNotFound(dylibPath)
}
killMessages()
Thread.sleep(forTimeInterval: 1.0)
// Clean up stale IPC files
try? FileManager.default.removeItem(atPath: commandFile)
try? FileManager.default.removeItem(atPath: responseFile)
try? FileManager.default.removeItem(atPath: lockFile)
try launchWithInjection()
try waitForReady(timeout: 15.0)
}
/// Kill Messages.app if running.
public func killMessages() {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
task.arguments = ["Messages"]
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
try? task.run()
task.waitUntilExit()
}
/// Send a command asynchronously.
public func sendCommand(
action: String, params: [String: Any]
) async throws -> [String: Any] {
try ensureRunning()
// Serialize params to JSON data to cross the Sendable boundary safely
let paramsData = try JSONSerialization.data(withJSONObject: params, options: [])
return try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<[String: Any], Error>) in
queue.async {
do {
let deserializedParams =
(try? JSONSerialization.jsonObject(with: paramsData, options: []))
as? [String: Any] ?? [:]
let response = try self.sendCommandSync(action: action, params: deserializedParams)
continuation.resume(returning: response)
} catch {
continuation.resume(throwing: error)
}
}
}
}
// MARK: - Private
private static func csrutilStatusOutput() -> String? {
let task = Process()
let output = Pipe()
task.executableURL = URL(fileURLWithPath: "/usr/bin/csrutil")
task.arguments = ["status"]
task.standardOutput = output
task.standardError = output
do {
try task.run()
} catch {
return nil
}
task.waitUntilExit()
let data = output.fileHandleForReading.readDataToEndOfFile()
guard let text = String(data: data, encoding: .utf8) else { return nil }
return text.trimmingCharacters(in: .whitespacesAndNewlines)
}
public enum SIPStatus: Equatable, Sendable {
case enabled
case disabled
case unknown(String)
}
public static func currentSIPStatus() -> SIPStatus {
guard let output = csrutilStatusOutput(), !output.isEmpty else {
return .unknown("Unable to run `csrutil status`.")
}
let lowered = output.lowercased()
if lowered.contains("disabled") {
return .disabled
}
if lowered.contains("enabled") {
return .enabled
}
return .unknown(output)
}
private func launchWithInjection() throws {
let absoluteDylibPath =
dylibPath.hasPrefix("/")
? dylibPath
: FileManager.default.currentDirectoryPath + "/" + dylibPath
guard FileManager.default.fileExists(atPath: absoluteDylibPath) else {
throw MessagesLauncherError.dylibNotFound(absoluteDylibPath)
}
let task = Process()
task.executableURL = URL(fileURLWithPath: messagesAppPath)
var environment = ProcessInfo.processInfo.environment
environment["DYLD_INSERT_LIBRARIES"] = absoluteDylibPath
task.environment = environment
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
do {
try task.run()
} catch {
throw MessagesLauncherError.launchFailed(error.localizedDescription)
}
}
private func waitForReady(timeout: TimeInterval) throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: lockFile) {
Thread.sleep(forTimeInterval: 0.5)
return
}
Thread.sleep(forTimeInterval: 0.5)
}
throw MessagesLauncherError.socketTimeout
}
private func sendCommandSync(
action: String, params: [String: Any]
) throws -> [String: Any] {
lock.lock()
defer { lock.unlock() }
let command: [String: Any] = [
"id": Int(Date().timeIntervalSince1970 * 1000),
"action": action,
"params": params,
]
let jsonData = try JSONSerialization.data(withJSONObject: command, options: [])
try jsonData.write(to: URL(fileURLWithPath: commandFile))
let deadline = Date().addingTimeInterval(10.0)
while Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
guard
let responseData = try? Data(contentsOf: URL(fileURLWithPath: responseFile)),
responseData.count > 2
else { continue }
// Check if command file was cleared (indicates processing completed)
if let cmdData = try? Data(contentsOf: URL(fileURLWithPath: commandFile)),
cmdData.count <= 2
{
guard
let response = try? JSONSerialization.jsonObject(with: responseData, options: [])
as? [String: Any]
else {
throw MessagesLauncherError.invalidResponse
}
// Clear response file
try? "".write(toFile: responseFile, atomically: true, encoding: .utf8)
return response
}
}
throw MessagesLauncherError.socketError("Timeout waiting for response")
}
}
public enum MessagesLauncherError: Error, CustomStringConvertible {
case dylibNotFound(String)
case launchFailed(String)
case sipEnabled
case sipStatusUnknown(String)
case socketTimeout
case socketError(String)
case invalidResponse
public var description: String {
switch self {
case .dylibNotFound(let path):
return "imsg-bridge-helper.dylib not found at \(path). Build with: make build-dylib"
case .launchFailed(let reason):
return "Failed to launch Messages.app: \(reason)"
case .sipEnabled:
return
"System Integrity Protection (SIP) is enabled. "
+ "Refusing to inject into Messages.app. "
+ "Disable SIP in Recovery mode before using `imsg launch`."
case .sipStatusUnknown(let details):
return
"Unable to determine SIP status. "
+ "Refusing to inject into Messages.app. "
+ "Details: \(details)"
case .socketTimeout:
return
"Timeout waiting for Messages.app to initialize. "
+ "Ensure SIP is disabled and Messages.app has necessary permissions."
case .socketError(let reason):
return "IPC error: \(reason)"
case .invalidResponse:
return "Invalid response from Messages.app helper"
}
}
}

View File

@ -0,0 +1,243 @@
import Foundation
/// 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.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) {
return
}
Thread.sleep(forTimeInterval: 0.1)
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
}
if !hasLiveDaemonConnection(controller) {
throw IMsgError.typingIndicatorFailed(
"Failed to connect to imagent (iMessage daemon). "
+ "This requires either SIP disabled with 'imsg launch', "
+ "or system modifications (AMFI disabled + XPC plist). "
+ "Run 'imsg status' for setup instructions."
)
}
}
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 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.")
}
}
private final class DaemonConnectionTracker: @unchecked Sendable {
let lock = NSLock()
var hasAttemptedConnection = 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()
}
}

View File

@ -0,0 +1,544 @@
//
// IMsgInjected.m
// IMsgHelper - Injectable dylib for Messages.app
//
// This dylib is injected into Messages.app via DYLD_INSERT_LIBRARIES
// to gain access to IMCore's chat registry and messaging functions.
// It provides file-based IPC for the CLI to send commands.
//
// Requires SIP disabled for DYLD_INSERT_LIBRARIES to work on system apps.
//
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <objc/message.h>
#import <unistd.h>
#pragma mark - Constants
static NSString *kCommandFile = nil;
static NSString *kResponseFile = nil;
static NSString *kLockFile = nil;
static NSTimer *fileWatchTimer = nil;
static int lockFd = -1;
static void initFilePaths(void) {
if (kCommandFile == nil) {
// Messages.app runs in a container; NSHomeDirectory() resolves to
// ~/Library/Containers/com.apple.MobileSMS/Data inside the sandbox.
NSString *containerPath = NSHomeDirectory();
kCommandFile = [containerPath stringByAppendingPathComponent:@".imsg-command.json"];
kResponseFile = [containerPath stringByAppendingPathComponent:@".imsg-response.json"];
kLockFile = [containerPath stringByAppendingPathComponent:@".imsg-bridge-ready"];
}
}
#pragma mark - Forward Declarations for IMCore Classes
@interface IMChatRegistry : NSObject
+ (instancetype)sharedInstance;
- (id)existingChatWithGUID:(NSString *)guid;
- (id)existingChatWithChatIdentifier:(NSString *)identifier;
- (NSArray *)allExistingChats;
@end
@interface IMChat : NSObject
- (void)setLocalUserIsTyping:(BOOL)typing;
- (void)markAllMessagesAsRead;
- (NSArray *)participants;
- (NSString *)guid;
- (NSString *)chatIdentifier;
@end
@interface IMHandle : NSObject
- (NSString *)ID;
@end
#pragma mark - JSON Response Helpers
static NSDictionary* successResponse(NSInteger requestId, NSDictionary *data) {
NSMutableDictionary *response = [NSMutableDictionary dictionaryWithDictionary:data ?: @{}];
response[@"id"] = @(requestId);
response[@"success"] = @YES;
response[@"timestamp"] = [[NSISO8601DateFormatter new] stringFromDate:[NSDate date]];
return response;
}
static NSDictionary* errorResponse(NSInteger requestId, NSString *error) {
return @{
@"id": @(requestId),
@"success": @NO,
@"error": error ?: @"Unknown error",
@"timestamp": [[NSISO8601DateFormatter new] stringFromDate:[NSDate date]]
};
}
#pragma mark - Chat Resolution
/// Try multiple methods to find a chat, including GUID lookup, chat identifier,
/// and participant matching with phone number normalization.
static id findChat(NSString *identifier) {
Class registryClass = NSClassFromString(@"IMChatRegistry");
if (!registryClass) {
NSLog(@"[imsg-bridge] IMChatRegistry class not found");
return nil;
}
id registry = [registryClass performSelector:@selector(sharedInstance)];
if (!registry) {
NSLog(@"[imsg-bridge] Could not get IMChatRegistry instance");
return nil;
}
id chat = nil;
// Method 1: Try existingChatWithGUID: with the identifier as-is (if it looks like a GUID)
SEL guidSel = @selector(existingChatWithGUID:);
if ([registry respondsToSelector:guidSel]) {
if ([identifier containsString:@";"]) {
chat = [registry performSelector:guidSel withObject:identifier];
if (chat) {
NSLog(@"[imsg-bridge] Found chat via existingChatWithGUID: %@", identifier);
return chat;
}
}
// Try constructing GUIDs with common prefixes (iMessage, SMS, any)
NSArray *prefixes = @[@"iMessage;-;", @"iMessage;+;", @"SMS;-;", @"SMS;+;", @"any;-;", @"any;+;"];
for (NSString *prefix in prefixes) {
NSString *fullGUID = [prefix stringByAppendingString:identifier];
chat = [registry performSelector:guidSel withObject:fullGUID];
if (chat) {
NSLog(@"[imsg-bridge] Found chat via existingChatWithGUID: %@", fullGUID);
return chat;
}
}
}
// Method 2: Try existingChatWithChatIdentifier:
SEL identSel = @selector(existingChatWithChatIdentifier:);
if ([registry respondsToSelector:identSel]) {
chat = [registry performSelector:identSel withObject:identifier];
if (chat) {
NSLog(@"[imsg-bridge] Found chat via existingChatWithChatIdentifier: %@", identifier);
return chat;
}
}
// Method 3: Iterate all chats and match by participant
SEL allChatsSel = @selector(allExistingChats);
if ([registry respondsToSelector:allChatsSel]) {
NSArray *allChats = [registry performSelector:allChatsSel];
if (!allChats) {
NSLog(@"[imsg-bridge] allExistingChats returned nil");
return nil;
}
NSLog(@"[imsg-bridge] Searching %lu chats for identifier: %@",
(unsigned long)allChats.count, identifier);
// Normalize the search identifier for phone number matching
NSString *normalizedIdentifier = nil;
if ([identifier hasPrefix:@"+"] || [identifier hasPrefix:@"1"] ||
[[NSCharacterSet decimalDigitCharacterSet]
characterIsMember:[identifier characterAtIndex:0]]) {
NSMutableString *digits = [NSMutableString string];
for (NSUInteger i = 0; i < identifier.length; i++) {
unichar c = [identifier characterAtIndex:i];
if ([[NSCharacterSet decimalDigitCharacterSet] characterIsMember:c]) {
[digits appendFormat:@"%C", c];
}
}
normalizedIdentifier = [digits copy];
}
for (id aChat in allChats) {
// Check GUID
if ([aChat respondsToSelector:@selector(guid)]) {
NSString *chatGUID = [aChat performSelector:@selector(guid)];
if ([chatGUID isEqualToString:identifier]) {
NSLog(@"[imsg-bridge] Found chat by GUID exact match: %@", chatGUID);
return aChat;
}
}
// Check chatIdentifier
if ([aChat respondsToSelector:@selector(chatIdentifier)]) {
NSString *chatId = [aChat performSelector:@selector(chatIdentifier)];
if ([chatId isEqualToString:identifier]) {
NSLog(@"[imsg-bridge] Found chat by chatIdentifier exact match: %@", chatId);
return aChat;
}
}
// Check participants
if ([aChat respondsToSelector:@selector(participants)]) {
NSArray *participants = [aChat performSelector:@selector(participants)];
if (!participants) continue;
for (id handle in participants) {
if ([handle respondsToSelector:@selector(ID)]) {
NSString *handleID = [handle performSelector:@selector(ID)];
if ([handleID isEqualToString:identifier]) {
NSLog(@"[imsg-bridge] Found chat by participant exact match: %@", handleID);
return aChat;
}
// Normalized phone number match
if (normalizedIdentifier && normalizedIdentifier.length >= 10) {
NSMutableString *handleDigits = [NSMutableString string];
for (NSUInteger i = 0; i < handleID.length; i++) {
unichar c = [handleID characterAtIndex:i];
if ([[NSCharacterSet decimalDigitCharacterSet] characterIsMember:c]) {
[handleDigits appendFormat:@"%C", c];
}
}
if (handleDigits.length >= 10 &&
([handleDigits hasSuffix:normalizedIdentifier] ||
[normalizedIdentifier hasSuffix:handleDigits])) {
NSLog(@"[imsg-bridge] Found chat by normalized phone match: %@ ~ %@",
handleID, identifier);
return aChat;
}
}
}
}
}
}
}
NSLog(@"[imsg-bridge] Chat not found for identifier: %@", identifier);
return nil;
}
#pragma mark - Command Handlers
static NSDictionary* handleTyping(NSInteger requestId, NSDictionary *params) {
NSString *handle = params[@"handle"];
NSNumber *state = params[@"typing"] ?: params[@"state"];
if (!handle) {
return errorResponse(requestId, @"Missing required parameter: handle");
}
BOOL typing = [state boolValue];
id chat = findChat(handle);
if (!chat) {
return errorResponse(requestId,
[NSString stringWithFormat:@"Chat not found: %@", handle]);
}
@try {
// Gather diagnostic info
NSString *chatGUID = @"unknown";
NSString *chatIdent = @"unknown";
NSString *chatClass = NSStringFromClass([chat class]);
BOOL supportsTyping = YES;
if ([chat respondsToSelector:@selector(guid)]) {
chatGUID = [chat performSelector:@selector(guid)] ?: @"nil";
}
if ([chat respondsToSelector:@selector(chatIdentifier)]) {
chatIdent = [chat performSelector:@selector(chatIdentifier)] ?: @"nil";
}
SEL supportsSel = @selector(supportsSendingTypingIndicators);
if ([chat respondsToSelector:supportsSel]) {
supportsTyping = ((BOOL (*)(id, SEL))objc_msgSend)(chat, supportsSel);
}
NSLog(@"[imsg-bridge] Chat found: class=%@, guid=%@, identifier=%@, supportsTyping=%@",
chatClass, chatGUID, chatIdent, supportsTyping ? @"YES" : @"NO");
SEL typingSel = @selector(setLocalUserIsTyping:);
if ([chat respondsToSelector:typingSel]) {
NSMethodSignature *sig = [chat methodSignatureForSelector:typingSel];
if (!sig) {
return errorResponse(requestId,
@"Could not get method signature for setLocalUserIsTyping:");
}
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
[inv setSelector:typingSel];
[inv setTarget:chat];
[inv setArgument:&typing atIndex:2];
[inv invoke];
NSLog(@"[imsg-bridge] Called setLocalUserIsTyping:%@ for %@",
typing ? @"YES" : @"NO", handle);
return successResponse(requestId, @{
@"handle": handle,
@"typing": @(typing)
});
}
return errorResponse(requestId, @"setLocalUserIsTyping: method not available");
} @catch (NSException *exception) {
return errorResponse(requestId,
[NSString stringWithFormat:@"Failed to set typing: %@", exception.reason]);
}
}
static NSDictionary* handleRead(NSInteger requestId, NSDictionary *params) {
NSString *handle = params[@"handle"];
if (!handle) {
return errorResponse(requestId, @"Missing required parameter: handle");
}
id chat = findChat(handle);
if (!chat) {
return errorResponse(requestId,
[NSString stringWithFormat:@"Chat not found: %@", handle]);
}
@try {
SEL readSel = @selector(markAllMessagesAsRead);
if ([chat respondsToSelector:readSel]) {
[chat performSelector:readSel];
NSLog(@"[imsg-bridge] Marked all messages as read for %@", handle);
return successResponse(requestId, @{
@"handle": handle,
@"marked_as_read": @YES
});
} else {
return errorResponse(requestId, @"markAllMessagesAsRead method not available");
}
} @catch (NSException *exception) {
return errorResponse(requestId,
[NSString stringWithFormat:@"Failed to mark as read: %@", exception.reason]);
}
}
static NSDictionary* handleStatus(NSInteger requestId, NSDictionary *params) {
Class registryClass = NSClassFromString(@"IMChatRegistry");
BOOL hasRegistry = (registryClass != nil);
NSUInteger chatCount = 0;
if (hasRegistry) {
id registry = [registryClass performSelector:@selector(sharedInstance)];
if ([registry respondsToSelector:@selector(allExistingChats)]) {
NSArray *chats = [registry performSelector:@selector(allExistingChats)];
chatCount = chats.count;
}
}
return successResponse(requestId, @{
@"injected": @YES,
@"registry_available": @(hasRegistry),
@"chat_count": @(chatCount),
@"typing_available": @(hasRegistry),
@"read_available": @(hasRegistry)
});
}
static NSDictionary* handleListChats(NSInteger requestId, NSDictionary *params) {
Class registryClass = NSClassFromString(@"IMChatRegistry");
if (!registryClass) {
return errorResponse(requestId, @"IMChatRegistry not available");
}
id registry = [registryClass performSelector:@selector(sharedInstance)];
if (!registry) {
return errorResponse(requestId, @"Could not get IMChatRegistry instance");
}
NSMutableArray *chatList = [NSMutableArray array];
if ([registry respondsToSelector:@selector(allExistingChats)]) {
NSArray *allChats = [registry performSelector:@selector(allExistingChats)];
for (id chat in allChats) {
NSMutableDictionary *chatInfo = [NSMutableDictionary dictionary];
if ([chat respondsToSelector:@selector(guid)]) {
chatInfo[@"guid"] = [chat performSelector:@selector(guid)] ?: @"";
}
if ([chat respondsToSelector:@selector(chatIdentifier)]) {
chatInfo[@"identifier"] = [chat performSelector:@selector(chatIdentifier)] ?: @"";
}
if ([chat respondsToSelector:@selector(participants)]) {
NSMutableArray *handles = [NSMutableArray array];
NSArray *participants = [chat performSelector:@selector(participants)];
for (id handle in participants) {
if ([handle respondsToSelector:@selector(ID)]) {
[handles addObject:[handle performSelector:@selector(ID)] ?: @""];
}
}
chatInfo[@"participants"] = handles;
}
[chatList addObject:chatInfo];
}
}
return successResponse(requestId, @{
@"chats": chatList,
@"count": @(chatList.count)
});
}
#pragma mark - Command Router
static NSDictionary* processCommand(NSDictionary *command) {
NSNumber *requestIdNum = command[@"id"];
NSInteger requestId = requestIdNum ? [requestIdNum integerValue] : 0;
NSString *action = command[@"action"];
NSDictionary *params = command[@"params"] ?: @{};
NSLog(@"[imsg-bridge] Processing command: %@ (id=%ld)", action, (long)requestId);
if ([action isEqualToString:@"typing"]) {
return handleTyping(requestId, params);
} else if ([action isEqualToString:@"read"]) {
return handleRead(requestId, params);
} else if ([action isEqualToString:@"status"]) {
return handleStatus(requestId, params);
} else if ([action isEqualToString:@"list_chats"]) {
return handleListChats(requestId, params);
} else if ([action isEqualToString:@"ping"]) {
return successResponse(requestId, @{@"pong": @YES});
} else {
return errorResponse(requestId,
[NSString stringWithFormat:@"Unknown action: %@", action]);
}
}
#pragma mark - File-based IPC
static void processCommandFile(void) {
@autoreleasepool {
initFilePaths();
NSError *error = nil;
NSData *commandData = [NSData dataWithContentsOfFile:kCommandFile options:0 error:&error];
if (!commandData || error) {
return;
}
NSDictionary *command = [NSJSONSerialization JSONObjectWithData:commandData
options:0
error:&error];
if (error || ![command isKindOfClass:[NSDictionary class]]) {
NSDictionary *response = errorResponse(0, @"Invalid JSON in command file");
NSData *responseData = [NSJSONSerialization dataWithJSONObject:response
options:NSJSONWritingPrettyPrinted
error:nil];
[responseData writeToFile:kResponseFile atomically:YES];
return;
}
NSDictionary *result = processCommand(command);
if (result != nil) {
NSData *responseData = [NSJSONSerialization dataWithJSONObject:result
options:NSJSONWritingPrettyPrinted
error:nil];
[responseData writeToFile:kResponseFile atomically:YES];
// Clear command file to signal processing is complete
[@"" writeToFile:kCommandFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
NSLog(@"[imsg-bridge] Processed command, wrote response");
}
}
}
static void startFileWatcher(void) {
initFilePaths();
NSLog(@"[imsg-bridge] Starting file-based IPC");
NSLog(@"[imsg-bridge] Command file: %@", kCommandFile);
NSLog(@"[imsg-bridge] Response file: %@", kResponseFile);
// Create/clear IPC files
[@"" writeToFile:kCommandFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
[@"" writeToFile:kResponseFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
// Create lock file with PID to indicate we're ready
lockFd = open(kLockFile.UTF8String, O_CREAT | O_WRONLY, 0644);
if (lockFd >= 0) {
NSString *pidStr = [NSString stringWithFormat:@"%d", getpid()];
write(lockFd, pidStr.UTF8String, pidStr.length);
}
// Poll command file via NSTimer on the main run loop.
// NSTimer survives reliably in injected dylib contexts (dispatch_source timers
// can get deallocated).
__block NSDate *lastModified = nil;
NSTimer *timer = [NSTimer timerWithTimeInterval:0.1 repeats:YES block:^(NSTimer *t) {
@autoreleasepool {
NSDictionary *attrs = [[NSFileManager defaultManager]
attributesOfItemAtPath:kCommandFile error:nil];
NSDate *modDate = attrs[NSFileModificationDate];
if (modDate && ![modDate isEqualToDate:lastModified]) {
NSData *data = [NSData dataWithContentsOfFile:kCommandFile];
if (data && data.length > 2) {
lastModified = modDate;
processCommandFile();
}
}
}
}];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
fileWatchTimer = timer;
NSLog(@"[imsg-bridge] File watcher started, ready for commands");
}
#pragma mark - Dylib Entry Point
__attribute__((constructor))
static void injectedInit(void) {
NSLog(@"[imsg-bridge] Dylib injected into %@", [[NSProcessInfo processInfo] processName]);
// Connect to IMDaemon for full IMCore access
Class daemonClass = NSClassFromString(@"IMDaemonController");
if (daemonClass) {
id daemon = [daemonClass performSelector:@selector(sharedInstance)];
if (daemon && [daemon respondsToSelector:@selector(connectToDaemon)]) {
[daemon performSelector:@selector(connectToDaemon)];
NSLog(@"[imsg-bridge] Connected to IMDaemon");
} else {
NSLog(@"[imsg-bridge] IMDaemonController available but couldn't connect");
}
} else {
NSLog(@"[imsg-bridge] IMDaemonController class not found");
}
// Delay initialization to let Messages.app fully start
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
NSLog(@"[imsg-bridge] Initializing after delay...");
// Log IMCore status
Class registryClass = NSClassFromString(@"IMChatRegistry");
if (registryClass) {
id registry = [registryClass performSelector:@selector(sharedInstance)];
if ([registry respondsToSelector:@selector(allExistingChats)]) {
NSArray *chats = [registry performSelector:@selector(allExistingChats)];
NSLog(@"[imsg-bridge] IMChatRegistry available with %lu chats",
(unsigned long)chats.count);
}
} else {
NSLog(@"[imsg-bridge] IMChatRegistry NOT available");
}
startFileWatcher();
});
}
__attribute__((destructor))
static void injectedCleanup(void) {
NSLog(@"[imsg-bridge] Cleaning up...");
if (fileWatchTimer) {
[fileWatchTimer invalidate];
fileWatchTimer = nil;
}
if (lockFd >= 0) {
close(lockFd);
lockFd = -1;
}
initFilePaths();
[[NSFileManager defaultManager] removeItemAtPath:kLockFile error:nil];
}

View File

@ -57,4 +57,16 @@ enum ChatTargetResolver {
chatGUID: resolvedGUID
)
}
static func directTypingIdentifier(
recipient: String,
serviceRaw: String,
invalidServiceError: (String) -> Error
) throws -> String {
guard let service = MessageService(rawValue: serviceRaw.lowercased()) else {
throw invalidServiceError(serviceRaw)
}
let prefix = service == .sms ? "SMS" : "iMessage"
return "\(prefix);-;\(recipient)"
}
}

View File

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

View File

@ -0,0 +1,155 @@
import Commander
import Foundation
import IMsgCore
enum LaunchCommand {
static let spec = CommandSpec(
name: "launch",
abstract: "Launch Messages.app with dylib injection",
discussion: """
Kills any running Messages.app instance, then relaunches it with
DYLD_INSERT_LIBRARIES set to inject the imsg bridge helper dylib.
This enables advanced features like typing indicators and read receipts
that require IMCore framework access.
Requires SIP (System Integrity Protection) to be disabled.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: [
.make(
label: "dylib", names: [.long("dylib")],
help: "Custom path to imsg-bridge-helper.dylib")
],
flags: [
.make(
label: "killOnly", names: [.long("kill-only")],
help: "Only kill Messages.app, don't relaunch")
]
)
),
usageExamples: [
"imsg launch",
"imsg launch --kill-only",
"imsg launch --dylib /path/to/dylib",
"imsg launch --json",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
let killOnly = values.flags.contains("killOnly")
let customDylib = values.option("dylib")
let launcher = MessagesLauncher.shared
if killOnly {
if !runtime.jsonOutput {
StdoutWriter.writeLine("Killing Messages.app...")
}
launcher.killMessages()
try await Task.sleep(nanoseconds: 1_000_000_000)
if runtime.jsonOutput {
try JSONLines.print(["status": "killed", "message": "Messages.app terminated"])
} else {
StdoutWriter.writeLine("Messages.app terminated")
}
return
}
switch MessagesLauncher.currentSIPStatus() {
case .enabled:
let message =
"SIP is enabled. Refusing to inject into Messages.app. "
+ "Disable SIP in Recovery mode (`csrutil disable`) before running `imsg launch`."
if runtime.jsonOutput {
try JSONLines.print(["status": "error", "error": "sip_enabled", "message": message])
} else {
StdoutWriter.writeLine(message)
}
throw IMsgError.typingIndicatorFailed(message)
case .unknown(let details):
let message =
"Unable to determine SIP status. Refusing to inject into Messages.app. Details: \(details)"
if runtime.jsonOutput {
try JSONLines.print(["status": "error", "error": "sip_unknown", "message": message])
} else {
StdoutWriter.writeLine(message)
}
throw IMsgError.typingIndicatorFailed(message)
case .disabled:
break
}
let dylibPath = resolveDylibPath(custom: customDylib)
guard let resolvedPath = dylibPath else {
let error =
"imsg-bridge-helper.dylib not found. Searched:\n"
+ " - /usr/local/lib/imsg-bridge-helper.dylib\n"
+ " - .build/release/imsg-bridge-helper.dylib\n"
+ "Run 'make build-dylib' or specify --dylib <path>"
if runtime.jsonOutput {
try JSONLines.print(["status": "error", "error": "dylib_not_found", "message": error])
} else {
StdoutWriter.writeLine(error)
}
throw IMsgError.typingIndicatorFailed("dylib not found")
}
launcher.dylibPath = resolvedPath
if !runtime.jsonOutput {
StdoutWriter.writeLine("Using dylib: \(resolvedPath)")
StdoutWriter.writeLine("Launching Messages.app with injection...")
}
do {
try launcher.ensureRunning()
if runtime.jsonOutput {
try JSONLines.print([
"status": "launched",
"dylib": resolvedPath,
"message": "Messages.app launched with dylib injection",
])
} else {
StdoutWriter.writeLine("Messages.app launched with dylib injection")
}
} catch {
if runtime.jsonOutput {
try JSONLines.print([
"status": "error",
"dylib": resolvedPath,
"error": "\(error)",
])
} else {
StdoutWriter.writeLine("Failed to launch: \(error)")
}
throw error
}
}
private static func resolveDylibPath(custom: String?) -> String? {
if let custom = custom {
if FileManager.default.fileExists(atPath: custom) {
return custom
}
return nil
}
let searchPaths = [
"/usr/local/lib/imsg-bridge-helper.dylib",
".build/release/imsg-bridge-helper.dylib",
]
for path in searchPaths {
if FileManager.default.fileExists(atPath: path) {
return path
}
}
return nil
}
}

View File

@ -0,0 +1,99 @@
import Commander
import Foundation
import IMsgCore
enum ReadCommand {
static let spec = CommandSpec(
name: "read",
abstract: "Mark messages as read for a chat",
discussion: """
Marks messages as read via IMCore advanced features.
Requires SIP disabled and Messages launched with `imsg launch`.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(
label: "to",
names: [.long("to"), .aliasLong("handle")],
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"),
]
)
),
usageExamples: [
"imsg read --to +14155551212",
"imsg read --handle steipete@gmail.com",
"imsg read --chat-id 1",
"imsg read --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) },
markAsRead: @escaping (String) async throws -> Void = {
try await IMCoreBridge.shared.markAsRead(handle: $0)
}
) async throws {
let dbPath = values.option("db") ?? MessageStore.defaultPath
let input = ChatTargetInput(
recipient: values.option("to") ?? "",
chatID: values.optionInt64("chatID"),
chatIdentifier: values.option("chatIdentifier") ?? "",
chatGUID: values.option("chatGUID") ?? ""
)
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: ParsedValuesError.invalidOption("to"),
missingRecipientError: ParsedValuesError.missingOption("to")
)
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in
let store = try storeFactory(dbPath)
return try store.chatInfo(chatID: chatID)
},
unknownChatError: { chatID in
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
}
)
let resolvedIdentifier: String
if let preferred = resolvedTarget.preferredIdentifier {
resolvedIdentifier = preferred
} else if input.hasChatTarget {
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
} else {
resolvedIdentifier = input.recipient
}
try await markAsRead(resolvedIdentifier)
if runtime.jsonOutput {
try JSONLines.print(ReadResult(success: true, handle: resolvedIdentifier, markedAsRead: true))
} else {
Swift.print("marked as read: \(resolvedIdentifier)")
}
}
}
private struct ReadResult: Codable {
let success: Bool
let handle: String
let markedAsRead: Bool
enum CodingKeys: String, CodingKey {
case success
case handle
case markedAsRead = "marked_as_read"
}
}

View File

@ -0,0 +1,106 @@
import Commander
import Foundation
import IMsgCore
enum StatusCommand {
static let spec = CommandSpec(
name: "status",
abstract: "Check availability of imsg advanced features",
discussion: """
Display the current status of imsg features and permissions.
Shows which advanced features (typing indicators, read receipts) are
available and provides setup instructions if needed.
""",
signature: CommandSignatures.withRuntimeFlags(CommandSignature()),
usageExamples: [
"imsg status",
"imsg status --json",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
let bridge = IMCoreBridge.shared
let availability = bridge.checkAvailability()
let sipStatus: String = {
switch MessagesLauncher.currentSIPStatus() {
case .enabled:
return "enabled"
case .disabled:
return "disabled"
case .unknown:
return "unknown"
}
}()
if runtime.jsonOutput {
let payload = StatusPayload(
basicFeatures: true,
advancedFeatures: availability.available,
typingIndicators: availability.available,
readReceipts: availability.available,
sip: sipStatus,
message: availability.message
)
try JSONLines.print(payload)
} else {
StdoutWriter.writeLine("imsg Status Report")
StdoutWriter.writeLine("==================")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Basic features (send, receive, history):")
StdoutWriter.writeLine(" Available")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("System Integrity Protection (SIP):")
StdoutWriter.writeLine(" \(sipStatus)")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Advanced features (typing, read receipts):")
if availability.available {
StdoutWriter.writeLine(" Available - IMCore bridge connected")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Available commands:")
StdoutWriter.writeLine(" imsg read --to <handle>")
StdoutWriter.writeLine(" imsg typing --to <handle>")
StdoutWriter.writeLine(" imsg launch")
StdoutWriter.writeLine(" imsg status")
} else {
StdoutWriter.writeLine(" Not available")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("To enable advanced features:")
StdoutWriter.writeLine(" 1. Disable System Integrity Protection (SIP)")
StdoutWriter.writeLine(" - Restart Mac holding Cmd+R")
StdoutWriter.writeLine(" - Open Terminal from Utilities menu")
StdoutWriter.writeLine(" - Run: csrutil disable")
StdoutWriter.writeLine(" - Restart normally")
StdoutWriter.writeLine("")
StdoutWriter.writeLine(" 2. Grant Full Disk Access")
StdoutWriter.writeLine(" - System Settings > Privacy & Security > Full Disk Access")
StdoutWriter.writeLine(" - Add Terminal or your terminal app")
StdoutWriter.writeLine("")
StdoutWriter.writeLine(" 3. Build and launch:")
StdoutWriter.writeLine(" make build-dylib")
StdoutWriter.writeLine(" imsg launch")
StdoutWriter.writeLine("")
StdoutWriter.writeLine("Note: Basic messaging features work without these steps.")
}
}
}
}
private struct StatusPayload: Encodable {
let basicFeatures: Bool
let advancedFeatures: Bool
let typingIndicators: Bool
let readReceipts: Bool
let sip: String
let message: String
enum CodingKeys: String, CodingKey {
case basicFeatures = "basic_features"
case advancedFeatures = "advanced_features"
case typingIndicators = "typing_indicators"
case readReceipts = "read_receipts"
case sip
case message
}
}

View File

@ -0,0 +1,141 @@
import Commander
import Foundation
import IMsgCore
enum TypingCommand {
static let spec = CommandSpec(
name: "typing",
abstract: "Send typing indicator to a chat",
discussion: """
Sends typing indicators via IMCore advanced features.
Requires SIP disabled and Messages launched with `imsg launch`.
""",
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 input = ChatTargetInput(
recipient: values.option("to") ?? "",
chatID: values.optionInt64("chatID"),
chatIdentifier: values.option("chatIdentifier") ?? "",
chatGUID: values.option("chatGUID") ?? ""
)
let stopFlag = try parseStopFlag(values.option("stop"))
let durationRaw = values.option("duration") ?? ""
let serviceRaw = values.option("service") ?? "imessage"
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: ParsedValuesError.invalidOption("to"),
missingRecipientError: ParsedValuesError.missingOption("to")
)
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in
let store = try storeFactory(dbPath)
return try store.chatInfo(chatID: chatID)
},
unknownChatError: { chatID in
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
}
)
let resolvedIdentifier: String
if let preferred = resolvedTarget.preferredIdentifier {
resolvedIdentifier = preferred
} else if input.hasChatTarget {
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
} else {
resolvedIdentifier = try ChatTargetResolver.directTypingIdentifier(
recipient: input.recipient,
serviceRaw: serviceRaw,
invalidServiceError: { IMsgError.invalidService($0) }
)
}
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 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
}
}

View File

@ -0,0 +1,66 @@
import Foundation
import Testing
@testable import IMsgCore
@Test
func imCoreBridgeIsNotAvailableWithoutDylib() {
// In the test environment there's no dylib built, so isAvailable should be false
// unless one happens to exist at a search path. We test the shared instance exists.
let bridge = IMCoreBridge.shared
// Just verify the API exists and doesn't crash
_ = bridge.isAvailable
}
@Test
func imCoreBridgeCheckAvailabilityReturnsDiagnostic() {
let bridge = IMCoreBridge.shared
let (_, message) = bridge.checkAvailability()
// Should return a non-empty diagnostic message regardless of availability
#expect(!message.isEmpty)
}
@Test
func messagesLauncherSharedInstanceExists() {
let launcher = MessagesLauncher.shared
// Verify the launcher can be accessed
#expect(launcher.dylibPath.contains("imsg-bridge-helper.dylib"))
}
@Test
func messagesLauncherIsNotReadyWithoutInjection() {
let launcher = MessagesLauncher.shared
// Without actually launching Messages.app with injection, this should return false
// (unless Messages happens to be running with our dylib, which is unlikely in CI)
_ = launcher.isInjectedAndReady()
// Just verify it doesn't crash
}
@Test
func messagesLauncherErrorDescriptions() {
let errors: [MessagesLauncherError] = [
.dylibNotFound("/fake/path"),
.launchFailed("test reason"),
.socketTimeout,
.socketError("test error"),
.invalidResponse,
]
for error in errors {
#expect(!error.description.isEmpty)
}
}
@Test
func imCoreBridgeErrorDescriptions() {
let errors: [IMCoreBridgeError] = [
.dylibNotFound,
.connectionFailed("test"),
.chatNotFound("test-handle"),
.operationFailed("test reason"),
]
for error in errors {
#expect(!error.description.isEmpty)
}
}

View File

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

View File

@ -0,0 +1,59 @@
import Commander
import Foundation
import Testing
@testable import IMsgCore
@testable import imsg
@Test
func commandRouterIncludesLaunchCommand() async {
let router = CommandRouter()
let names = router.specs.map(\.name)
#expect(names.contains("launch"))
}
@Test
func commandRouterIncludesReadCommand() async {
let router = CommandRouter()
let names = router.specs.map(\.name)
#expect(names.contains("read"))
}
@Test
func commandRouterIncludesStatusCommand() async {
let router = CommandRouter()
let names = router.specs.map(\.name)
#expect(names.contains("status"))
}
@Test
func statusCommandProducesJsonOutput() async throws {
let values = ParsedValues(
positional: [],
options: [:],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = await StdoutCapture.capture {
try? await StatusCommand.run(values: values, runtime: runtime)
}
// JSON output should contain expected keys
#expect(output.contains("basic_features"))
#expect(output.contains("advanced_features"))
}
@Test
func statusCommandProducesTextOutput() async throws {
let values = ParsedValues(
positional: [],
options: [:],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = await StdoutCapture.capture {
try? await StatusCommand.run(values: values, runtime: runtime)
}
#expect(output.contains("imsg Status Report"))
}

View File

@ -0,0 +1,71 @@
import Commander
import Foundation
import Testing
@testable import IMsgCore
@testable import imsg
@Test
func readCommandRejectsChatAndRecipient() async {
let values = ParsedValues(
positional: [],
options: ["to": ["+15551234567"], "chatIdentifier": ["iMessage;+;chat123"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await ReadCommand.run(
values: values,
runtime: runtime,
markAsRead: { _ in }
)
#expect(Bool(false))
} catch let error as ParsedValuesError {
#expect(error.description == "Invalid value for option: --to")
} catch {
#expect(Bool(false))
}
}
@Test
func readCommandRunsWithRecipient() async throws {
let values = ParsedValues(
positional: [],
options: ["to": ["+15551234567"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var capturedHandle: String?
_ = try await StdoutCapture.capture {
try await ReadCommand.run(
values: values,
runtime: runtime,
markAsRead: { handle in capturedHandle = handle }
)
}
#expect(capturedHandle == "+15551234567")
}
@Test
func readCommandResolvesChatID() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var capturedHandle: String?
_ = try await StdoutCapture.capture {
try await ReadCommand.run(
values: values,
runtime: runtime,
markAsRead: { handle in capturedHandle = handle }
)
}
#expect(capturedHandle == "iMessage;+;chat123")
}

View File

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