feat: port BlueBubbles private-API bridge

Port the BlueBubbles-inspired IMCore bridge surface into imsg with rich sends, message mutation, chat management, account/nickname introspection, live bridge events, and v2 UUID-keyed IPC.

Fixes #60.

Co-authored-by: Omar Shahine <omarshahine@users.noreply.github.com>
This commit is contained in:
Omar Shahine 2026-05-05 22:28:00 -07:00 committed by GitHub
parent bbd6b93a1e
commit c56c24d488
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 3923 additions and 49 deletions

View File

@ -2,6 +2,10 @@
## Unreleased
- feat: port the BlueBubbles-inspired private-API bridge surface for rich sends,
message mutation, chat management, account/nickname introspection, and v2
concurrent bridge IPC (#100, thanks @omarshahine).
## 0.6.0 - 2026-05-05
### More Reliable Live Streams And History

View File

@ -54,6 +54,9 @@ let package = Package(
dependencies: [
"imsg",
"IMsgCore",
],
exclude: [
"README-live.md",
]
),
]

105
README.md
View File

@ -227,9 +227,9 @@ default because immutable reads can miss WAL-backed Messages updates.
Default `send`, `chats`, `history`, `watch`, and read-only `rpc` workflows do
not require IMCore injection.
Advanced features such as `read`, `typing`, `launch`, and IMCore bridge status are
opt-in. They require SIP to be disabled and a helper dylib to be injected into
Messages.app:
Advanced features such as `read`, `typing`, `launch`, bridge-backed rich send,
message mutation, and chat management are opt-in. They require SIP to be
disabled and a helper dylib to be injected into Messages.app:
```bash
make build-dylib
@ -250,6 +250,105 @@ Important limits:
To revert after testing advanced features, re-enable SIP from Recovery mode with
`csrutil enable`.
### Bridge command surface
The bridge implements a manual port of the BlueBubbles private-API surface
inspired by their Apache-2.0 helper, into our own dylib (no third-party
binary). Commands in this section require `imsg launch` first, which means
SIP-disabled DYLD injection into Messages.app. Most commands take a `--chat`
argument that is the chat guid (e.g. `iMessage;-;+15551234567` or
`iMessage;+;chat0000` for groups). Get a chat guid via `imsg chats --json`.
Messaging:
```bash
# Rich send with effect + reply
imsg send-rich --chat 'iMessage;-;+15551234567' --text "boom" \
--effect com.apple.MobileSMS.expressivesend.impact \
--reply-to <messageGuid>
# Text formatting (macOS 15+ Sequoia only): bold/italic/underline/strikethrough
# applied to specific ranges of the message body.
imsg send-rich --chat ... --text 'hello world' \
--format '[{"start":0,"length":5,"styles":["bold"]},
{"start":6,"length":5,"styles":["italic","underline"]}]'
# Or load the ranges from a file
imsg send-rich --chat ... --text "$(cat msg.txt)" --format-file ranges.json
# Multipart send (text-only in v1; per-part textFormatting also supported)
imsg send-multipart --chat 'iMessage;+;chat0000' \
--parts '[{"text":"hi"},
{"text":"there","textFormatting":[{"start":0,"length":5,"styles":["bold"]}]}]'
# Attachment (file or audio)
imsg send-attachment --chat ... --file ~/Pictures/img.jpg
imsg send-attachment --chat ... --file ~/audio.caf --audio
# Tapback (bridge-backed; `imsg react` remains the AppleScript variant)
imsg tapback --chat ... --message <guid> --kind love
imsg tapback --chat ... --message <guid> --kind love --remove
```
Mutate (macOS 13+ — selector availability surfaced in `imsg status`):
```bash
imsg edit --chat ... --message <guid> --new-text "actually..."
imsg unsend --chat ... --message <guid>
imsg delete-message --chat ... --message <guid>
imsg notify-anyways --chat ... --message <guid>
```
Chat management:
```bash
imsg chat-create --addresses '+15551111111,+15552222222' --name 'Crew' --text 'gm'
imsg chat-name --chat ... --name 'Renamed'
imsg chat-photo --chat ... --file ~/Downloads/g.jpg # set
imsg chat-photo --chat ... # clear
imsg chat-add-member --chat ... --address +15553333333
imsg chat-remove-member --chat ... --address +15553333333
imsg chat-leave --chat ...
imsg chat-delete --chat ...
imsg chat-mark --chat ... --read # or --unread
```
`chat-create` currently creates iMessage chats only. SMS sending remains
available through `imsg send --service sms`.
Introspection:
```bash
imsg account # active iMessage account + aliases
imsg whois --address +15551234567 --type phone
imsg whois --address foo@bar.com --type email
imsg nickname --address +15551234567
```
Local history search (does not require the bridge):
```bash
imsg search --query "pizza" --match contains
```
Live events (typing indicators surfaced through the dylib):
```bash
imsg watch --bb-events # merge dylib events into stdout
imsg watch --bb-events --json # one JSON object per event
```
### v2 IPC under the hood
The dylib v1 used a single overwriting `.imsg-command.json` polled at 100ms,
which races when multiple CLI invocations run concurrently. v2 uses a
per-request UUID-keyed queue:
```
~/Library/Containers/com.apple.MobileSMS/Data/
.imsg-bridge-ready PID lock — set when injection is live
.imsg-rpc/in/<uuid>.json requests dropped here by the CLI (atomic rename)
.imsg-rpc/out/<uuid>.json responses written by the dylib (atomic rename)
.imsg-events.jsonl inbound async events (typing, alias-removed)
```
Set `IMSG_BRIDGE_LEGACY_IPC=1` to force the legacy single-file path for
debugging (existing v1 callers / un-rebuilt dylibs continue to work without
this).
## Development
```bash

View File

@ -1,9 +1,10 @@
import Foundation
public enum IMsgError: LocalizedError, Sendable {
public enum IMsgError: LocalizedError, CustomStringConvertible, Sendable {
case permissionDenied(path: String, underlying: Error)
case invalidISODate(String)
case invalidService(String)
case unsupportedService(String)
case invalidChatTarget(String)
case appleScriptFailure(String)
case typingIndicatorFailed(String)
@ -35,6 +36,8 @@ public enum IMsgError: LocalizedError, Sendable {
return "Invalid ISO8601 date: \(value)"
case .invalidService(let value):
return "Invalid service: \(value)"
case .unsupportedService(let value):
return "Unsupported service: \(value)"
case .invalidChatTarget(let value):
return "Invalid chat target: \(value)"
case .appleScriptFailure(let message):
@ -53,4 +56,8 @@ public enum IMsgError: LocalizedError, Sendable {
return "Chat not found: \(chatID)"
}
}
public var description: String {
errorDescription ?? "Unknown imsg error"
}
}

View File

@ -55,23 +55,23 @@ public final class IMCoreBridge: @unchecked Sendable {
"handle": handle,
"typing": typing,
]
_ = try await sendCommand(action: "typing", params: params)
_ = try await invokeBridge(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])
_ = try await invokeBridge(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: [:])
let response = try await invokeBridge(action: .listChats, 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: [:])
return try await invokeBridge(action: .status, params: [:])
}
/// Check availability and return a diagnostic message.
@ -131,7 +131,7 @@ public final class IMCoreBridge: @unchecked Sendable {
break
}
if launcher.isInjectedAndReady() {
if launcher.hasReadyLockFile() {
return (true, "Connected to Messages.app. IMCore features available.")
}
@ -150,22 +150,22 @@ public final class IMCoreBridge: @unchecked Sendable {
// MARK: - Private
private func sendCommand(
action: String, params: [String: Any]
private func invokeBridge(
action: BridgeAction, 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
return try await IMsgBridgeClient.shared.invoke(action: action, params: params)
} catch let error as IMsgBridgeError {
switch error {
case .dylibReturnedError(let message):
if message.contains("Chat not found") {
let handle = params["handle"] as? String ?? "unknown"
throw IMCoreBridgeError.chatNotFound(handle)
}
throw IMCoreBridgeError.operationFailed(message)
default:
throw IMCoreBridgeError.connectionFailed(error.description)
}
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,141 @@
import Foundation
/// One-shot RPC client for the v2 bridge protocol.
///
/// Each call atomically drops a `<uuid>.json` request file into
/// `~/Library/Containers/com.apple.MobileSMS/Data/.imsg-rpc/in/`, then polls
/// `out/<uuid>.json` until the dylib responds (or `timeout` elapses).
///
/// The dylib is shared across CLI invocations: many concurrent `imsg`
/// processes can drop requests at once and each gets routed back to the
/// correct caller via the UUID. There is no global lock on the CLI side.
public final class IMsgBridgeClient: @unchecked Sendable {
public static let shared = IMsgBridgeClient(launcher: MessagesLauncher.shared)
private let launcher: MessagesLauncher
private let useLegacyIPC: Bool
/// Polling cadence while waiting for a response file to appear.
private let pollInterval: TimeInterval = 0.05
public init(launcher: MessagesLauncher, useLegacyIPC: Bool? = nil) {
self.launcher = launcher
if let override = useLegacyIPC {
self.useLegacyIPC = override
} else {
let env = ProcessInfo.processInfo.environment["IMSG_BRIDGE_LEGACY_IPC"]
self.useLegacyIPC = (env == "1" || env == "true")
}
}
/// Whether the dylib is currently injected and has published its ready lock.
public func isReady() -> Bool {
launcher.hasReadyLockFile()
}
// MARK: - High-level API
/// Invoke a v2 bridge action and return its `data` payload on success.
/// Legacy single-file IPC is only used when explicitly requested through
/// `IMSG_BRIDGE_LEGACY_IPC=1`.
public func invoke(
action: BridgeAction,
params: [String: Any] = [:],
timeout: TimeInterval = IMsgBridgeProtocol.defaultResponseTimeout
) async throws -> [String: Any] {
if useLegacyIPC {
try launcher.ensureRunning()
return try await invokeLegacy(action: action, params: params)
}
try launcher.ensureLaunched()
return try await invokeV2(action: action, params: params, timeout: timeout)
}
// MARK: - v2 path
private func invokeV2(
action: BridgeAction,
params: [String: Any],
timeout: TimeInterval
) async throws -> [String: Any] {
let id = UUID().uuidString
let envelope: [String: Any] = [
"v": IMsgBridgeProtocol.version,
"id": id,
"action": action.rawValue,
"params": params,
]
let inboxDir = launcher.bridgeInboxDirectory
let outboxDir = launcher.bridgeOutboxDirectory
try ensureDirectory(inboxDir)
try ensureDirectory(outboxDir)
let tmp = (inboxDir as NSString).appendingPathComponent("\(id).tmp")
let final = (inboxDir as NSString).appendingPathComponent("\(id).json")
let outPath = (outboxDir as NSString).appendingPathComponent("\(id).json")
let payload = try JSONSerialization.data(withJSONObject: envelope, options: [])
try payload.write(to: URL(fileURLWithPath: tmp))
try FileManager.default.moveItem(atPath: tmp, toPath: final)
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000))
guard
let data = try? Data(contentsOf: URL(fileURLWithPath: outPath)),
data.count > 1
else { continue }
// Best-effort cleanup; ignore failures (dylib may also unlink).
try? FileManager.default.removeItem(atPath: outPath)
guard
let raw = try? JSONSerialization.jsonObject(with: data, options: [])
as? [String: Any]
else {
throw IMsgBridgeError.malformedResponse("non-object body")
}
let response = try BridgeResponse.parse(raw)
if response.success {
return response.data
}
throw IMsgBridgeError.dylibReturnedError(response.error ?? "unknown")
}
try? FileManager.default.removeItem(atPath: final)
throw IMsgBridgeError.timeout(action: action.rawValue)
}
// MARK: - Legacy path
private func invokeLegacy(
action: BridgeAction,
params: [String: Any]
) async throws -> [String: Any] {
do {
let raw = try await launcher.sendCommand(action: action.rawValue, params: params)
let response = try BridgeResponse.parse(raw)
if response.success {
return response.data
}
throw IMsgBridgeError.dylibReturnedError(response.error ?? "unknown")
} catch let error as MessagesLauncherError {
throw IMsgBridgeError.bridgeNotReady(error.description)
}
}
private func ensureDirectory(_ path: String) throws {
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: path, isDirectory: &isDir) {
if isDir.boolValue { return }
throw IMsgBridgeError.ioError("\(path) exists and is not a directory")
}
do {
try FileManager.default.createDirectory(
atPath: path, withIntermediateDirectories: true)
} catch {
throw IMsgBridgeError.ioError("mkdir \(path): \(error.localizedDescription)")
}
}
}

View File

@ -0,0 +1,181 @@
import Foundation
/// Wire-level constants and helpers for the v2 imsg dylib bridge protocol.
///
/// v1 (legacy) used a single overwriting `.imsg-command.json` file with a 100ms
/// polling loop in the dylib. That model races when two CLI invocations write
/// concurrently. v2 uses a per-request queue directory: callers atomically
/// rename `<uuid>.tmp` `<uuid>.json` into `.imsg-rpc/in/`, the dylib
/// processes each file once and writes the matching response into
/// `.imsg-rpc/out/<uuid>.json`.
public enum IMsgBridgeProtocol {
/// Current envelope version. Bump when the on-wire shape changes.
public static let version: Int = 2
/// Subdirectory under the Messages.app sandbox container holding RPC files.
public static let rpcDirectoryName: String = ".imsg-rpc"
public static let inboxDirectoryName: String = "in"
public static let outboxDirectoryName: String = "out"
/// Inbound async event log written by the dylib (typing, alias-changes, ).
public static let eventsFileName: String = ".imsg-events.jsonl"
public static let rotatedEventsFileName: String = ".imsg-events.jsonl.1"
public static let eventsRotationBytes: Int = 1 * 1024 * 1024
/// Default per-request timeout for synchronous RPC waits.
public static let defaultResponseTimeout: TimeInterval = 10.0
}
/// All action verbs exposed by the v2 bridge. Names match the BlueBubbles
/// reference vocabulary so traffic shape stays familiar, but each handler is a
/// local rewrite inside `Sources/IMsgHelper/IMsgInjected.m`.
public enum BridgeAction: String, Sendable, CaseIterable {
// Liveness
case ping
case status
case listChats = "list_chats"
// Typing
case typing // legacy compound: { handle, typing: bool }
case startTyping = "start-typing"
case stopTyping = "stop-typing"
case checkTypingStatus = "check-typing-status"
// Read
case read // legacy
case markChatRead = "mark-chat-read"
case markChatUnread = "mark-chat-unread"
// Send
case sendMessage = "send-message"
case sendMultipart = "send-multipart"
case sendAttachment = "send-attachment"
case sendReaction = "send-reaction"
case notifyAnyways = "notify-anyways"
// Mutate
case editMessage = "edit-message"
case unsendMessage = "unsend-message"
case deleteMessage = "delete-message"
// Chat management
case addParticipant = "add-participant"
case removeParticipant = "remove-participant"
case setDisplayName = "set-display-name"
case updateGroupPhoto = "update-group-photo"
case leaveChat = "leave-chat"
case deleteChat = "delete-chat"
case createChat = "create-chat"
// Introspection
case searchMessages = "search-messages"
case getAccountInfo = "get-account-info"
case getNicknameInfo = "get-nickname-info"
case checkImessageAvailability = "check-imessage-availability"
case downloadPurgedAttachment = "download-purged-attachment"
}
/// Reaction kinds (BlueBubbles vocabulary) IMAssociatedMessageType integers.
///
/// Constants are stable across macOS 1115. Add 1000 to the kind id to send a
/// removal (e.g. `love` 2000, `remove-love` 3000).
public enum BridgeReactionKind: String, Sendable, CaseIterable {
case love
case like
case dislike
case laugh
case emphasize
case question
case removeLove = "remove-love"
case removeLike = "remove-like"
case removeDislike = "remove-dislike"
case removeLaugh = "remove-laugh"
case removeEmphasize = "remove-emphasize"
case removeQuestion = "remove-question"
public var associatedMessageType: Int {
switch self {
case .love: return 2000
case .like: return 2001
case .dislike: return 2002
case .laugh: return 2003
case .emphasize: return 2004
case .question: return 2005
case .removeLove: return 3000
case .removeLike: return 3001
case .removeDislike: return 3002
case .removeLaugh: return 3003
case .removeEmphasize: return 3004
case .removeQuestion: return 3005
}
}
}
/// Errors surfaced by `IMsgBridgeClient` and adjacent helpers.
public enum IMsgBridgeError: Error, CustomStringConvertible, Equatable {
case bridgeNotReady(String)
case timeout(action: String)
case malformedResponse(String)
case dylibReturnedError(String)
case ioError(String)
public var description: String {
switch self {
case .bridgeNotReady(let detail): return "imsg bridge not ready: \(detail)"
case .timeout(let action): return "Timed out waiting for response to '\(action)'"
case .malformedResponse(let detail): return "Malformed bridge response: \(detail)"
case .dylibReturnedError(let msg): return "Dylib error: \(msg)"
case .ioError(let detail): return "Bridge IO error: \(detail)"
}
}
}
/// Decoded shape of a v2 bridge response.
///
/// The dylib always writes `{"v":2,"id":"<uuid>","success":<bool>,...}`. On
/// success, action-specific fields land under `data` (or directly at the top
/// level for handlers that haven't been migrated yet). On failure, `error`
/// holds a human-readable string.
public struct BridgeResponse {
public let id: String
public let success: Bool
public let data: [String: Any]
public let error: String?
public init(id: String, success: Bool, data: [String: Any], error: String?) {
self.id = id
self.success = success
self.data = data
self.error = error
}
/// Parse a JSON response object into a `BridgeResponse`. Tolerates v1 shape
/// (no `v` field, integer `id`) so the legacy single-file IPC keeps working.
public static func parse(_ raw: [String: Any]) throws -> BridgeResponse {
let id: String
if let s = raw["id"] as? String {
id = s
} else if let i = raw["id"] as? Int {
id = String(i)
} else if let d = raw["id"] as? Double {
id = String(Int(d))
} else {
id = ""
}
let success = (raw["success"] as? Bool) ?? false
let error = raw["error"] as? String
var data: [String: Any]
if let d = raw["data"] as? [String: Any] {
data = d
} else {
data = raw
for stripped in ["v", "id", "success", "error", "timestamp"] {
data.removeValue(forKey: stripped)
}
}
return BridgeResponse(id: id, success: success, data: data, error: error)
}
}

View File

@ -0,0 +1,163 @@
import Foundation
/// Live tailer for `.imsg-events.jsonl` written by the injected dylib.
///
/// Uses `DispatchSource.makeFileSystemObjectSource` watching `.write`,
/// `.extend`, and `.rename`. On rename (file rotation by the dylib at 1 MiB)
/// the source closes and reopens. Each newly-written full line is decoded as
/// a JSON object and surfaced via the `events` AsyncStream.
///
/// Designed to be co-resident with `MessageWatcher` inside `imsg watch`.
public final class IMsgEventTailer: @unchecked Sendable {
/// One decoded event line. `payloadJSON` is the raw JSON-encoded `data`
/// object (UTF-8 bytes); decode lazily on the consumer side via
/// `JSONSerialization` if you need typed access. Holding raw Data keeps the
/// type Sendable across actor boundaries under Swift 6 strict concurrency.
public struct Event: Sendable {
public let timestamp: String?
public let name: String
public let payloadJSON: Data
public init(timestamp: String?, name: String, payloadJSON: Data) {
self.timestamp = timestamp
self.name = name
self.payloadJSON = payloadJSON
}
/// Decode `payloadJSON` to a dictionary. Returns `[:]` on any error.
public func decodedPayload() -> [String: Any] {
guard
let obj = try? JSONSerialization.jsonObject(with: payloadJSON, options: [])
as? [String: Any]
else { return [:] }
return obj
}
}
private let path: String
private let replayExisting: Bool
private var source: DispatchSourceFileSystemObject?
private var fd: Int32 = -1
private var pending = Data()
private var continuation: AsyncStream<Event>.Continuation?
private let queue = DispatchQueue(label: "imsg.event.tailer")
public init(path: String, replayExisting: Bool = false) {
self.path = path
self.replayExisting = replayExisting
}
/// Start tailing and return an AsyncStream of decoded events. Starts at EOF
/// by default so `watch --bb-events` only emits live events.
public func events() -> AsyncStream<Event> {
return AsyncStream { continuation in
self.continuation = continuation
continuation.onTermination = { @Sendable _ in
self.stop()
}
self.queue.async {
self.openAndStart()
}
}
}
public func stop() {
queue.async { [weak self] in
guard let self else { return }
self.source?.cancel()
self.source = nil
if self.fd >= 0 {
close(self.fd)
self.fd = -1
}
}
}
// MARK: - Private
private func openAndStart() {
if !FileManager.default.fileExists(atPath: path) {
// Create empty file so we can watch it. The dylib appends; missing
// file means injection isn't active yet caller can retry later.
FileManager.default.createFile(atPath: path, contents: Data(), attributes: nil)
}
let fd = open(path, O_RDONLY)
if fd < 0 { return }
self.fd = fd
if replayExisting {
drainAvailable()
} else {
lseek(fd, 0, SEEK_END)
}
let src = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.extend, .write, .rename, .delete],
queue: queue
)
src.setEventHandler { [weak self] in
guard let self else { return }
let mask = src.data
if mask.contains(.rename) || mask.contains(.delete) {
// File rotated by the dylib close and reopen the new file.
self.reopen()
return
}
self.drainAvailable()
}
src.setCancelHandler { [weak self] in
guard let self else { return }
if self.fd >= 0 {
close(self.fd)
self.fd = -1
}
}
src.resume()
self.source = src
}
private func reopen() {
source?.cancel()
source = nil
if fd >= 0 {
close(fd)
fd = -1
}
pending.removeAll(keepingCapacity: true)
// Small delay lets the dylib finish the rename; then start fresh.
queue.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.openAndStart()
}
}
private func drainAvailable() {
guard fd >= 0 else { return }
var buffer = Data(count: 8192)
while true {
let n = buffer.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) -> Int in
guard let base = raw.baseAddress else { return -1 }
return read(fd, base, raw.count)
}
if n <= 0 { break }
pending.append(buffer.prefix(n))
processPending()
}
}
private func processPending() {
while let nl = pending.firstIndex(of: 0x0A) {
let line = pending[..<nl]
pending.removeSubrange(...nl)
guard !line.isEmpty else { continue }
guard
let obj = try? JSONSerialization.jsonObject(with: line, options: [])
as? [String: Any]
else { continue }
let name = (obj["event"] as? String) ?? "unknown"
let ts = obj["ts"] as? String
let data = (obj["data"] as? [String: Any]) ?? [:]
let payloadData = (try? JSONSerialization.data(withJSONObject: data, options: [])) ?? Data()
continuation?.yield(Event(timestamp: ts, name: name, payloadJSON: payloadData))
}
}
}

View File

@ -1,7 +1,7 @@
import Foundation
import SQLite
private struct MessageRowColumns {
struct MessageRowColumns {
static let balloonBundleID = "balloon_bundle_id"
let rowID: String
@ -43,7 +43,7 @@ private struct MessageRowColumns {
}
}
private struct DecodedMessageRow {
struct DecodedMessageRow {
let rowID: Int64
let chatID: Int64
let handleID: Int64?
@ -60,7 +60,7 @@ private struct DecodedMessageRow {
let threadOriginatorGUID: String
}
private struct MessageRowSelection {
struct MessageRowSelection {
let selectList: String
let columns: MessageRowColumns
@ -422,7 +422,7 @@ extension MessageStore {
}
}
private func decodeMessageRow(
func decodeMessageRow(
_ row: Row,
columns: MessageRowColumns,
fallbackChatID: Int64?

View File

@ -0,0 +1,94 @@
import Foundation
import SQLite
private struct SearchMessagesQuery {
let sql: String
let bindings: [Binding?]
let selection: MessageRowSelection
let fallbackChatID: Int64? = nil
init(store: MessageStore, text: String, exact: Bool, limit: Int) {
self.selection = MessageRowSelection(store: store, includeChatID: true)
let reactionFilter =
store.schema.hasReactionColumns
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
: ""
let predicate =
exact
? "IFNULL(m.text, '') = ? COLLATE NOCASE"
: "IFNULL(m.text, '') LIKE ? ESCAPE '\\' COLLATE NOCASE"
let textBinding = exact ? text : SearchMessagesQuery.likePattern(for: text)
self.sql = """
SELECT \(selection.selectList)
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE \(predicate)\(reactionFilter)
ORDER BY m.date DESC, m.ROWID DESC
LIMIT ?
"""
self.bindings = [textBinding, limit]
}
private static func likePattern(for text: String) -> String {
var escaped = ""
for char in text {
if char == "\\" || char == "%" || char == "_" {
escaped.append("\\")
}
escaped.append(char)
}
return "%\(escaped)%"
}
}
extension MessageStore {
public func searchMessages(query text: String, match: String, limit: Int) throws -> [Message] {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
let exact = match.lowercased() == "exact"
let query = SearchMessagesQuery(
store: self,
text: trimmed,
exact: exact,
limit: limit
)
return try withConnection { db in
var messages: [Message] = []
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let decoded = try decodeMessageRow(
row,
columns: query.selection.columns,
fallbackChatID: query.fallbackChatID
)
let replyToGUID = replyToGUID(
associatedGuid: decoded.associatedGUID,
associatedType: decoded.associatedType
)
messages.append(
Message(
rowID: decoded.rowID,
chatID: decoded.chatID,
sender: decoded.sender,
text: decoded.text,
date: decoded.date,
isFromMe: decoded.isFromMe,
service: decoded.service,
handleID: decoded.handleID,
attachmentsCount: decoded.attachments,
guid: decoded.guid,
routing: Message.RoutingMetadata(
replyToGUID: replyToGUID,
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID
)
))
}
return messages
}
}
}

View File

@ -27,6 +27,25 @@ public final class MessagesLauncher: @unchecked Sendable {
NSHomeDirectory() + "/Library/Containers/com.apple.MobileSMS/Data"
}
/// Inbox directory for v2 RPC requests (`<uuid>.json` files dropped here by
/// the CLI; consumed by the dylib).
public var bridgeInboxDirectory: String {
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
+ IMsgBridgeProtocol.inboxDirectoryName
}
/// Outbox directory for v2 RPC responses (`<uuid>.json` files written by
/// the dylib; consumed by the CLI).
public var bridgeOutboxDirectory: String {
containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/"
+ IMsgBridgeProtocol.outboxDirectoryName
}
/// Path to the dylib's append-only event log.
public var bridgeEventsFile: String {
containerPath + "/" + IMsgBridgeProtocol.eventsFileName
}
private let messagesAppPath =
"/System/Applications/Messages.app/Contents/MacOS/Messages"
private let queue = DispatchQueue(label: "imsg.messages.launcher")
@ -49,9 +68,14 @@ public final class MessagesLauncher: @unchecked Sendable {
}
}
/// Check if Messages.app has published the bridge-ready lock file.
public func hasReadyLockFile() -> Bool {
FileManager.default.fileExists(atPath: lockFile)
}
/// 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 {
guard hasReadyLockFile() else {
return false
}
do {
@ -65,7 +89,16 @@ public final class MessagesLauncher: @unchecked Sendable {
/// Ensure Messages.app is running with our dylib injected.
public func ensureRunning() throws {
if isInjectedAndReady() { return }
try launchInjectedMessages()
}
/// Ensure Messages.app is launched with the helper without touching legacy IPC.
public func ensureLaunched() throws {
if hasReadyLockFile() { return }
try launchInjectedMessages()
}
private func launchInjectedMessages() throws {
switch Self.currentSIPStatus() {
case .disabled:
break
@ -87,10 +120,28 @@ public final class MessagesLauncher: @unchecked Sendable {
try? FileManager.default.removeItem(atPath: responseFile)
try? FileManager.default.removeItem(atPath: lockFile)
// Pre-create v2 RPC queue directories so the dylib can FSEvent-watch them
// immediately on startup (FSEventStream registration on a missing path
// silently fails to deliver events).
try? FileManager.default.createDirectory(
atPath: bridgeInboxDirectory, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(
atPath: bridgeOutboxDirectory, withIntermediateDirectories: true)
cleanQueueDirectory(bridgeInboxDirectory)
cleanQueueDirectory(bridgeOutboxDirectory)
try launchWithInjection()
try waitForReady(timeout: 15.0)
}
private func cleanQueueDirectory(_ path: String) {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: path)
else { return }
for entry in entries {
try? FileManager.default.removeItem(atPath: (path as NSString).appendingPathComponent(entry))
}
}
/// Kill Messages.app if running.
public func killMessages() {
let task = Process()

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,27 @@ struct CommandRouter {
StatusCommand.spec,
RpcCommand.spec,
CompletionsCommand.spec,
// Bridge-backed (require `imsg launch` + SIP off)
SendRichCommand.spec,
SendMultipartCommand.spec,
SendAttachmentCommand.spec,
BridgeReactCommand.spec,
EditCommand.spec,
UnsendCommand.spec,
DeleteMessageCommand.spec,
NotifyAnywaysCommand.spec,
ChatCreateCommand.spec,
ChatNameCommand.spec,
ChatPhotoCommand.spec,
ChatAddMemberCommand.spec,
ChatRemoveMemberCommand.spec,
ChatLeaveCommand.spec,
ChatDeleteCommand.spec,
ChatMarkCommand.spec,
SearchCommand.spec,
AccountCommand.spec,
WhoisCommand.spec,
NicknameCommand.spec,
]
let descriptor = CommandDescriptor(
name: rootName,
@ -61,6 +82,8 @@ struct CommandRouter {
do {
try await spec.run(invocation.parsedValues, runtime)
return 0
} catch is BridgeOutput.EmittedError {
return 1
} catch {
StdoutWriter.writeLine(String(describing: error))
return 1

View File

@ -0,0 +1,302 @@
import Commander
import Foundation
import IMsgCore
// MARK: - chat-create
enum ChatCreateCommand {
static let spec = CommandSpec(
name: "chat-create",
abstract: "Create a new chat (1:1 or group)",
discussion: """
Requires `imsg launch` (SIP-disabled, dylib injected). Vends handles for
each address through Messages' private IMCore API and asks IMChatRegistry
to materialize a chat. Optionally sets a display name and sends an
initial message. Chat creation is currently iMessage-only; use
`imsg send --service sms` for SMS sends.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(
label: "addresses", names: [.long("addresses")],
help: "comma-separated handles (phone or email)"),
.make(label: "name", names: [.long("name")], help: "group display name"),
.make(label: "text", names: [.long("text")], help: "initial message body"),
.make(
label: "service", names: [.long("service")], help: "iMessage (default)"),
]
)
),
usageExamples: [
"imsg chat-create --addresses '+15551234567,+15559876543' --name 'Crew' --text 'gm'"
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let raw = values.option("addresses"), !raw.isEmpty else {
throw ParsedValuesError.missingOption("addresses")
}
let addresses = raw.split(separator: ",").map {
String($0).trimmingCharacters(in: .whitespaces)
}
.filter { !$0.isEmpty }
guard !addresses.isEmpty else { throw ParsedValuesError.invalidOption("addresses") }
let service = values.option("service") ?? "iMessage"
guard service.caseInsensitiveCompare("iMessage") == .orderedSame else {
throw IMsgError.unsupportedService(service)
}
var params: [String: Any] = [
"addresses": addresses,
"service": "iMessage",
]
if let text = values.option("text"), !text.isEmpty { params["message"] = text }
if let name = values.option("name"), !name.isEmpty { params["displayName"] = name }
_ = try await BridgeOutput.invokeAndEmit(
action: .createChat, params: params, runtime: runtime
) { data in
let guid = (data["chatGuid"] as? String) ?? ""
return "chat-create: created (guid=\(guid))"
}
}
}
// MARK: - chat-name
enum ChatNameCommand {
static let spec = CommandSpec(
name: "chat-name",
abstract: "Set a chat's display name",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "name", names: [.long("name")], help: "new display name"),
]
)
),
usageExamples: ["imsg chat-name --chat 'iMessage;+;chat0000' --name 'New Name'"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let name = values.option("name") else {
throw ParsedValuesError.missingOption("name")
}
let params: [String: Any] = ["chatGuid": chat, "newName": name]
_ = try await BridgeOutput.invokeAndEmit(
action: .setDisplayName, params: params, runtime: runtime
) { _ in "chat-name: set" }
}
}
// MARK: - chat-photo
enum ChatPhotoCommand {
static let spec = CommandSpec(
name: "chat-photo",
abstract: "Set or clear a group chat photo",
discussion: "Omit --file to clear the existing photo.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "file", names: [.long("file")], help: "path to image (omit to clear)"),
]
)
),
usageExamples: ["imsg chat-photo --chat 'iMessage;+;chat0000' --file ~/Downloads/g.jpg"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
var params: [String: Any] = ["chatGuid": chat]
if let file = values.option("file"), !file.isEmpty {
params["filePath"] = (file as NSString).expandingTildeInPath
}
_ = try await BridgeOutput.invokeAndEmit(
action: .updateGroupPhoto, params: params, runtime: runtime
) { _ in "chat-photo: updated" }
}
}
// MARK: - chat-add-member / chat-remove-member
enum ChatAddMemberCommand {
static let spec = CommandSpec(
name: "chat-add-member",
abstract: "Add a participant to a group chat",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "address", names: [.long("address")], help: "phone or email to add"),
]
)
),
usageExamples: ["imsg chat-add-member --chat 'iMessage;+;chat0000' --address +15551234567"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let addr = values.option("address"), !addr.isEmpty else {
throw ParsedValuesError.missingOption("address")
}
let params: [String: Any] = ["chatGuid": chat, "address": addr]
_ = try await BridgeOutput.invokeAndEmit(
action: .addParticipant, params: params, runtime: runtime
) { _ in "chat-add-member: added" }
}
}
enum ChatRemoveMemberCommand {
static let spec = CommandSpec(
name: "chat-remove-member",
abstract: "Remove a participant from a group chat",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "address", names: [.long("address")], help: "phone or email to remove"),
]
)
),
usageExamples: ["imsg chat-remove-member --chat 'iMessage;+;chat0000' --address +15551234567"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let addr = values.option("address"), !addr.isEmpty else {
throw ParsedValuesError.missingOption("address")
}
let params: [String: Any] = ["chatGuid": chat, "address": addr]
_ = try await BridgeOutput.invokeAndEmit(
action: .removeParticipant, params: params, runtime: runtime
) { _ in "chat-remove-member: removed" }
}
}
// MARK: - chat-leave / chat-delete
enum ChatLeaveCommand {
static let spec = CommandSpec(
name: "chat-leave",
abstract: "Leave a group chat",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid")
]
)
),
usageExamples: ["imsg chat-leave --chat 'iMessage;+;chat0000'"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let params: [String: Any] = ["chatGuid": chat]
_ = try await BridgeOutput.invokeAndEmit(
action: .leaveChat, params: params, runtime: runtime
) { _ in "chat-leave: left" }
}
}
enum ChatDeleteCommand {
static let spec = CommandSpec(
name: "chat-delete",
abstract: "Delete a chat from Messages.app",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid")
]
)
),
usageExamples: ["imsg chat-delete --chat 'iMessage;-;+15551234567'"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let params: [String: Any] = ["chatGuid": chat]
_ = try await BridgeOutput.invokeAndEmit(
action: .deleteChat, params: params, runtime: runtime
) { _ in "chat-delete: deleted" }
}
}
// MARK: - chat-mark
enum ChatMarkCommand {
static let spec = CommandSpec(
name: "chat-mark",
abstract: "Mark a chat as read or unread",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid")
],
flags: [
.make(label: "read", names: [.long("read")], help: "mark as read"),
.make(label: "unread", names: [.long("unread")], help: "mark as unread"),
]
)
),
usageExamples: [
"imsg chat-mark --chat ... --read",
"imsg chat-mark --chat ... --unread",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let read = values.flag("read")
let unread = values.flag("unread")
if read && unread {
throw ParsedValuesError.invalidOption("read")
}
let action: BridgeAction = unread ? .markChatUnread : .markChatRead
let params: [String: Any] = ["chatGuid": chat]
_ = try await BridgeOutput.invokeAndEmit(
action: action, params: params, runtime: runtime
) { _ in "chat-mark: \(unread ? "unread" : "read")" }
}
}

View File

@ -0,0 +1,177 @@
import Commander
import Foundation
import IMsgCore
// MARK: - search
enum SearchCommand {
static let spec = CommandSpec(
name: "search",
abstract: "Search local Messages history",
discussion: """
Searches the local chat.db, not the injected bridge. Use --match exact
for case-insensitive exact text matches; the default is contains.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "query", names: [.long("query")], help: "search query (required)"),
.make(label: "match", names: [.long("match")], help: "exact|contains (default contains)"),
.make(label: "limit", names: [.long("limit")], help: "maximum results (default 50)"),
]
)
),
usageExamples: ["imsg search --query 'pizza tonight' --match contains"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(
values: ParsedValues,
runtime: RuntimeOptions,
contactResolverFactory: @escaping () async -> any ContactResolving = {
await ContactResolver.create()
}
) async throws {
guard let q = values.option("query"), !q.isEmpty else {
throw ParsedValuesError.missingOption("query")
}
let match = values.option("match") ?? "contains"
guard match == "contains" || match == "exact" else {
throw ParsedValuesError.invalidOption("match")
}
let dbPath = values.option("db") ?? MessageStore.defaultPath
let limit = values.optionInt("limit") ?? 50
let store = try MessageStore(path: dbPath)
let messages = try store.searchMessages(query: q, match: match, limit: limit)
let contacts = await contactResolverFactory()
if runtime.jsonOutput {
let cache = ChatCache(store: store)
for message in messages {
let payload = try await buildMessagePayload(
store: store,
cache: cache,
message: message,
includeAttachments: false,
includeReactions: false,
contactResolver: contacts
)
try JSONLines.printObject(payload)
}
return
}
for message in messages {
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
let sender =
message.isFromMe
? message.sender : (contacts.displayName(for: message.sender) ?? message.sender)
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(sender): \(message.text)")
}
}
}
// MARK: - account
enum AccountCommand {
static let spec = CommandSpec(
name: "account",
abstract: "Show the active iMessage account, login, and aliases",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions()
)),
usageExamples: ["imsg account"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
_ = try await BridgeOutput.invokeAndEmit(
action: .getAccountInfo, params: [:], runtime: runtime
) { data in
let login = (data["login"] as? String) ?? ""
let aliases = (data["vetted_aliases"] as? [String]) ?? []
return "account: \(login)\n aliases: \(aliases.joined(separator: ", "))"
}
}
}
// MARK: - whois
enum WhoisCommand {
static let spec = CommandSpec(
name: "whois",
abstract: "Check whether a handle is reachable on iMessage",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "address", names: [.long("address")], help: "phone or email to check"),
.make(label: "type", names: [.long("type")], help: "phone|email"),
]
)
),
usageExamples: [
"imsg whois --address +15551234567 --type phone",
"imsg whois --address foo@bar.com --type email",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let addr = values.option("address"), !addr.isEmpty else {
throw ParsedValuesError.missingOption("address")
}
let aliasType = values.option("type") ?? (addr.contains("@") ? "email" : "phone")
let params: [String: Any] = [
"address": addr,
"aliasType": aliasType,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .checkImessageAvailability, params: params, runtime: runtime
) { data in
let avail = (data["available"] as? Bool) ?? false
let status = (data["id_status"] as? Int) ?? 0
return "whois \(addr): \(avail ? "available" : "unavailable") (id_status=\(status))"
}
}
}
// MARK: - nickname
enum NicknameCommand {
static let spec = CommandSpec(
name: "nickname",
abstract: "Show contact-card / nickname info for a handle",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "address", names: [.long("address")], help: "phone or email")
]
)
),
usageExamples: ["imsg nickname --address +15551234567"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let addr = values.option("address"), !addr.isEmpty else {
throw ParsedValuesError.missingOption("address")
}
let params: [String: Any] = ["address": addr]
_ = try await BridgeOutput.invokeAndEmit(
action: .getNicknameInfo, params: params, runtime: runtime
) { data in
let has = (data["has_nickname"] as? Bool) ?? false
let desc = (data["description"] as? String) ?? ""
return "nickname: \(has ? desc : "(none)")"
}
}
}

View File

@ -0,0 +1,463 @@
import Commander
import Foundation
import IMsgCore
/// Helpers shared by all bridge-backed commands.
enum BridgeOutput {
struct EmittedError: Error {}
static func emit(_ data: [String: Any], runtime: RuntimeOptions, summary: String) {
if runtime.jsonOutput {
try? JSONLines.printObject(data)
} else {
StdoutWriter.writeLine(summary)
}
}
static func emitError(_ message: String, runtime: RuntimeOptions) {
if runtime.jsonOutput {
try? JSONLines.printObject(["success": false, "error": message])
} else {
StdoutWriter.writeLine("error: \(message)")
}
}
/// Invoke a bridge action and emit the result. Returns the data dict on
/// success or nil on failure (after emitting an error message).
static func invokeAndEmit(
action: BridgeAction,
params: [String: Any],
runtime: RuntimeOptions,
summary: (([String: Any]) -> String)
) async throws -> [String: Any] {
do {
let data = try await IMsgBridgeClient.shared.invoke(action: action, params: params)
emit(data, runtime: runtime, summary: summary(data))
return data
} catch {
emitError(String(describing: error), runtime: runtime)
throw EmittedError()
}
}
}
// MARK: - send-rich
enum SendRichCommand {
static let spec = CommandSpec(
name: "send-rich",
abstract: "Send a message via the IMCore bridge (effects, replies, subjects)",
discussion: """
Requires `imsg launch` (SIP-disabled, dylib injected). Unlike `imsg send`
which uses AppleScript, this routes through Messages' private API for
richer features: expressive-send effects, reply targets, subject lines.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(
label: "chat", names: [.long("chat")], help: "chat guid (e.g. iMessage;-;+15551234567)"),
.make(label: "text", names: [.long("text")], help: "message body"),
.make(
label: "effect", names: [.long("effect")],
help: "expressive send id (impact, loud, gentle, invisibleink, confetti, …)"),
.make(label: "subject", names: [.long("subject")], help: "subject line"),
.make(label: "replyTo", names: [.long("reply-to")], help: "guid of message to reply to"),
.make(label: "part", names: [.long("part")], help: "part index (default 0)"),
.make(
label: "format",
names: [.long("format")],
help: "JSON array of {start,length,styles:[...]} ranges (macOS 15+)"),
.make(
label: "formatFile", names: [.long("format-file")],
help: "path to JSON file containing the format ranges array"),
],
flags: [
.make(
label: "noDDScan", names: [.long("no-dd-scan")],
help: "disable data-detector scan deferral")
]
)
),
usageExamples: [
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi'",
"imsg send-rich --chat 'iMessage;-;+15551234567' --text 'BOOM' --effect com.apple.MobileSMS.expressivesend.impact",
"imsg send-rich --chat ... --text 'hello world' --format '[{\"start\":0,\"length\":5,\"styles\":[\"bold\"]}]'",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let text = values.option("text") ?? ""
var params: [String: Any] = [
"chatGuid": chat,
"message": text,
"partIndex": Int(values.option("part") ?? "0") ?? 0,
"ddScan": !values.flag("noDDScan"),
]
if let effect = values.option("effect"), !effect.isEmpty { params["effectId"] = effect }
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
if let reply = values.option("replyTo"), !reply.isEmpty {
params["selectedMessageGuid"] = reply
}
// Optional text formatting (macOS 15+ Sequoia and later). Pass either
// inline JSON via --format or a file path via --format-file. Format:
// [{"start":0,"length":5,"styles":["bold","italic"]}, ...]
let formatRaw: String?
if let inline = values.option("format"), !inline.isEmpty {
formatRaw = inline
} else if let path = values.option("formatFile"), !path.isEmpty {
formatRaw = try String(contentsOfFile: path, encoding: .utf8)
} else {
formatRaw = nil
}
if let raw = formatRaw {
guard
let bytes = raw.data(using: .utf8),
let ranges = try JSONSerialization.jsonObject(with: bytes) as? [[String: Any]]
else {
throw ParsedValuesError.invalidOption("format")
}
params["textFormatting"] = ranges
}
_ = try await BridgeOutput.invokeAndEmit(
action: .sendMessage, params: params, runtime: runtime
) { data in
let guid = (data["messageGuid"] as? String) ?? ""
return guid.isEmpty ? "send-rich: queued" : "send-rich: sent (guid=\(guid))"
}
}
}
// MARK: - send-multipart
enum SendMultipartCommand {
static let spec = CommandSpec(
name: "send-multipart",
abstract: "Send a multi-part message",
discussion: """
Pass --parts as a JSON array (e.g., '[{"text":"hi"},{"text":"there"}]')
or via --parts-file pointing at a .json file. v1 supports text-only
parts; mention/file parts are a future enhancement.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "parts", names: [.long("parts")], help: "JSON array of parts"),
.make(
label: "partsFile", names: [.long("parts-file")],
help: "path to JSON file containing parts array"),
.make(label: "effect", names: [.long("effect")], help: "expressive send id"),
.make(label: "subject", names: [.long("subject")], help: "subject line"),
]
)
),
usageExamples: [
"imsg send-multipart --chat 'iMessage;+;chat0000' --parts '[{\"text\":\"hi\"},{\"text\":\"world\"}]'"
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
let partsRaw: String
if let inline = values.option("parts"), !inline.isEmpty {
partsRaw = inline
} else if let path = values.option("partsFile"), !path.isEmpty {
partsRaw = try String(contentsOfFile: path, encoding: .utf8)
} else {
throw ParsedValuesError.missingOption("parts")
}
guard
let data = partsRaw.data(using: .utf8),
let parts = try JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
throw ParsedValuesError.invalidOption("parts")
}
var params: [String: Any] = ["chatGuid": chat, "parts": parts]
if let effect = values.option("effect"), !effect.isEmpty { params["effectId"] = effect }
if let subject = values.option("subject"), !subject.isEmpty { params["subject"] = subject }
_ = try await BridgeOutput.invokeAndEmit(
action: .sendMultipart, params: params, runtime: runtime
) { data in
let guid = (data["messageGuid"] as? String) ?? ""
let count = (data["parts_count"] as? Int) ?? 0
return "send-multipart: \(count) parts queued (guid=\(guid))"
}
}
}
// MARK: - send-attachment
enum SendAttachmentCommand {
static let spec = CommandSpec(
name: "send-attachment",
abstract: "Send a file attachment via the IMCore bridge",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "file", names: [.long("file")], help: "absolute path to file"),
],
flags: [
.make(label: "audio", names: [.long("audio")], help: "send as audio message")
]
)
),
usageExamples: [
"imsg send-attachment --chat 'iMessage;-;+15551234567' --file ~/Pictures/me.jpg"
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let file = values.option("file"), !file.isEmpty else {
throw ParsedValuesError.missingOption("file")
}
let expanded = (file as NSString).expandingTildeInPath
let params: [String: Any] = [
"chatGuid": chat,
"filePath": expanded,
"isAudioMessage": values.flag("audio"),
]
_ = try await BridgeOutput.invokeAndEmit(
action: .sendAttachment, params: params, runtime: runtime
) { data in
let guid = (data["messageGuid"] as? String) ?? ""
return "send-attachment: queued (guid=\(guid))"
}
}
}
// MARK: - react (BB-style; complements existing AS-backed `react`)
enum BridgeReactCommand {
static let spec = CommandSpec(
name: "tapback",
abstract: "Send a tapback reaction via the IMCore bridge",
discussion: """
`imsg tapback` uses the bridge for reliability across macOS versions.
`imsg react` (AppleScript) remains for SIP-on machines.
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
.make(
label: "kind", names: [.long("kind")],
help: "love|like|dislike|laugh|emphasize|question"),
.make(label: "part", names: [.long("part")], help: "part index"),
],
flags: [
.make(
label: "remove", names: [.long("remove")],
help: "remove this reaction instead of adding")
]
)
),
usageExamples: [
"imsg tapback --chat 'iMessage;-;+15551234567' --message ABCD-EFGH --kind love"
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
guard let kind = values.option("kind"), !kind.isEmpty else {
throw ParsedValuesError.missingOption("kind")
}
let normalized = kind.lowercased()
let prefixed = values.flag("remove") ? "remove-\(normalized)" : normalized
let params: [String: Any] = [
"chatGuid": chat,
"selectedMessageGuid": message,
"reactionType": prefixed,
"partIndex": Int(values.option("part") ?? "0") ?? 0,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .sendReaction, params: params, runtime: runtime
) { _ in "tapback: \(prefixed) sent" }
}
}
// MARK: - edit
enum EditCommand {
static let spec = CommandSpec(
name: "edit",
abstract: "Edit a sent message",
discussion: "Requires macOS 13+ (selector-probed at startup).",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
.make(label: "newText", names: [.long("new-text")], help: "replacement text"),
.make(
label: "bcText",
names: [.long("bc-text")],
help: "backwards-compat text shown to older clients (default: same as new-text)"),
.make(label: "part", names: [.long("part")], help: "part index"),
]
)
),
usageExamples: ["imsg edit --chat ... --message <guid> --new-text 'updated'"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
guard let newText = values.option("newText"), !newText.isEmpty else {
throw ParsedValuesError.missingOption("new-text")
}
let params: [String: Any] = [
"chatGuid": chat,
"messageGuid": message,
"editedMessage": newText,
"backwardsCompatibilityMessage": values.option("bcText") ?? newText,
"partIndex": Int(values.option("part") ?? "0") ?? 0,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .editMessage, params: params, runtime: runtime
) { _ in "edit: queued" }
}
}
// MARK: - unsend
enum UnsendCommand {
static let spec = CommandSpec(
name: "unsend",
abstract: "Retract a sent message",
discussion: "Requires macOS 13+ (selector-probed at startup).",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
.make(label: "part", names: [.long("part")], help: "part index"),
]
)
),
usageExamples: ["imsg unsend --chat ... --message <guid>"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
let params: [String: Any] = [
"chatGuid": chat,
"messageGuid": message,
"partIndex": Int(values.option("part") ?? "0") ?? 0,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .unsendMessage, params: params, runtime: runtime
) { _ in "unsend: queued" }
}
}
// MARK: - delete-message
enum DeleteMessageCommand {
static let spec = CommandSpec(
name: "delete-message",
abstract: "Delete a single message from a chat",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
]
)
),
usageExamples: ["imsg delete-message --chat ... --message <guid>"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
let params: [String: Any] = [
"chatGuid": chat,
"messageGuid": message,
]
_ = try await BridgeOutput.invokeAndEmit(
action: .deleteMessage, params: params, runtime: runtime
) { _ in "delete-message: queued" }
}
}
// MARK: - notify-anyways
enum NotifyAnywaysCommand {
static let spec = CommandSpec(
name: "notify-anyways",
abstract: "Force a notification for a message that was filtered/suppressed",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chat", names: [.long("chat")], help: "chat guid"),
.make(label: "message", names: [.long("message")], help: "target message guid"),
]
)
),
usageExamples: ["imsg notify-anyways --chat ... --message <guid>"]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
guard let chat = values.option("chat"), !chat.isEmpty else {
throw ParsedValuesError.missingOption("chat")
}
guard let message = values.option("message"), !message.isEmpty else {
throw ParsedValuesError.missingOption("message")
}
let params: [String: Any] = ["chatGuid": chat, "messageGuid": message]
_ = try await BridgeOutput.invokeAndEmit(
action: .notifyAnyways, params: params, runtime: runtime
) { _ in "notify-anyways: queued" }
}
}

View File

@ -34,6 +34,22 @@ enum StatusCommand {
}
}()
// Probe the bridge for v2 readiness + selector availability.
var bridgeVersion: Int = 0
var v2Ready: Bool = false
var selectors: [String: Bool] = [:]
if availability.available {
do {
let data = try await IMsgBridgeClient.shared.invoke(
action: .status, params: [:], timeout: 3.0)
bridgeVersion = (data["bridge_version"] as? Int) ?? 0
v2Ready = (data["v2_ready"] as? Bool) ?? false
if let raw = data["selectors"] as? [String: Bool] { selectors = raw }
} catch {
// Bridge probe failure is non-fatal.
}
}
if runtime.jsonOutput {
let payload = StatusPayload(
basicFeatures: true,
@ -41,7 +57,10 @@ enum StatusCommand {
typingIndicators: availability.available,
readReceipts: availability.available,
sip: sipStatus,
message: availability.message
message: availability.message,
bridgeVersion: bridgeVersion,
v2Ready: v2Ready,
selectors: selectors
)
try JSONLines.print(payload)
} else {
@ -57,12 +76,25 @@ enum StatusCommand {
StdoutWriter.writeLine("Advanced features (typing, read receipts):")
if availability.available {
StdoutWriter.writeLine(" Available - IMCore bridge connected")
StdoutWriter.writeLine(
" bridge version: v\(bridgeVersion)\(v2Ready ? " (v2 inbox active)" : "")")
if !selectors.isEmpty {
StdoutWriter.writeLine(" selectors:")
for key in selectors.keys.sorted() {
let ok = selectors[key] ?? false
StdoutWriter.writeLine(" \(key): \(ok ? "" : "")")
}
}
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")
StdoutWriter.writeLine("Available bridge commands:")
StdoutWriter.writeLine(" Send: imsg send-rich, send-multipart, send-attachment, tapback")
StdoutWriter.writeLine(" Mutate: imsg edit, unsend, delete-message, notify-anyways")
StdoutWriter.writeLine(
" Chat: imsg chat-create, chat-name, chat-photo, chat-add/remove-member, chat-leave/delete, chat-mark"
)
StdoutWriter.writeLine(" Introspect: imsg account, whois, nickname")
StdoutWriter.writeLine(" Local DB: imsg search")
StdoutWriter.writeLine(" Watch with events: imsg watch --bb-events")
} else {
StdoutWriter.writeLine(" Not available")
StdoutWriter.writeLine("")
@ -102,6 +134,9 @@ private struct StatusPayload: Encodable {
let readReceipts: Bool
let sip: String
let message: String
let bridgeVersion: Int
let v2Ready: Bool
let selectors: [String: Bool]
enum CodingKeys: String, CodingKey {
case basicFeatures = "basic_features"
@ -110,5 +145,8 @@ private struct StatusPayload: Encodable {
case readReceipts = "read_receipts"
case sip
case message
case bridgeVersion = "bridge_version"
case v2Ready = "v2_ready"
case selectors
}
}

View File

@ -35,6 +35,10 @@ enum WatchCommand {
label: "reactions", names: [.long("reactions")],
help: "include reaction events (tapback add/remove) in the stream"
),
.make(
label: "bbEvents", names: [.long("bb-events")],
help: "include dylib-pushed events (typing, alias-removed) when injection is active"
),
]
)
),
@ -93,6 +97,28 @@ enum WatchCommand {
includeReactions: includeReactions
)
let bbEvents = values.flag("bbEvents")
if bbEvents {
let path = MessagesLauncher.shared.bridgeEventsFile
let tailer = IMsgEventTailer(path: path)
Task {
for await event in tailer.events() {
if runtime.jsonOutput {
var obj: [String: Any] = [
"kind": "bridge-event",
"event": event.name,
]
if let ts = event.timestamp { obj["ts"] = ts }
obj["data"] = event.decodedPayload()
try? JSONLines.printObject(obj)
} else {
let stamp = event.timestamp ?? CLIISO8601.format(Date())
StdoutWriter.writeLine("\(stamp) [bridge] \(event.name)")
}
}
}
}
let stream = streamProvider(watcher, chatID, sinceRowID, config)
for try await message in stream {
if !filter.allows(message) {

View File

@ -0,0 +1,84 @@
import Testing
@testable import IMsgCore
@Suite("IMsgBridgeProtocol")
struct IMsgBridgeProtocolTests {
@Test
func actionRawValuesMatchDylibVocabulary() {
#expect(BridgeAction.sendMessage.rawValue == "send-message")
#expect(BridgeAction.sendReaction.rawValue == "send-reaction")
#expect(BridgeAction.editMessage.rawValue == "edit-message")
#expect(BridgeAction.unsendMessage.rawValue == "unsend-message")
#expect(BridgeAction.createChat.rawValue == "create-chat")
#expect(BridgeAction.searchMessages.rawValue == "search-messages")
#expect(BridgeAction.checkImessageAvailability.rawValue == "check-imessage-availability")
// Legacy compat: the integer-id v1 protocol still uses these names.
#expect(BridgeAction.typing.rawValue == "typing")
#expect(BridgeAction.read.rawValue == "read")
#expect(BridgeAction.listChats.rawValue == "list_chats")
}
@Test
func reactionKindMapsToStableAssociatedTypes() {
#expect(BridgeReactionKind.love.associatedMessageType == 2000)
#expect(BridgeReactionKind.like.associatedMessageType == 2001)
#expect(BridgeReactionKind.dislike.associatedMessageType == 2002)
#expect(BridgeReactionKind.laugh.associatedMessageType == 2003)
#expect(BridgeReactionKind.emphasize.associatedMessageType == 2004)
#expect(BridgeReactionKind.question.associatedMessageType == 2005)
// Removal kinds are exactly +1000.
for kind in BridgeReactionKind.allCases where !kind.rawValue.hasPrefix("remove-") {
let removeName = "remove-\(kind.rawValue)"
let remove = BridgeReactionKind(rawValue: removeName)
#expect(remove != nil, "missing remove case for \(kind.rawValue)")
#expect(remove?.associatedMessageType == kind.associatedMessageType + 1000)
}
}
@Test
func parseAcceptsV2Envelope() throws {
let raw: [String: Any] = [
"v": 2,
"id": "abc-123",
"success": true,
"data": ["messageGuid": "M-1"],
"timestamp": "2026-05-04T12:00:00Z",
]
let response = try BridgeResponse.parse(raw)
#expect(response.id == "abc-123")
#expect(response.success == true)
#expect(response.error == nil)
#expect(response.data["messageGuid"] as? String == "M-1")
}
@Test
func parseAcceptsLegacyEnvelopeWithoutDataKey() throws {
let raw: [String: Any] = [
"id": 42,
"success": true,
"handle": "+15551234567",
"marked_as_read": true,
"timestamp": "2026-05-04T12:00:00Z",
]
let response = try BridgeResponse.parse(raw)
#expect(response.id == "42")
#expect(response.success == true)
#expect(response.data["handle"] as? String == "+15551234567")
#expect(response.data["marked_as_read"] as? Bool == true)
#expect(response.data["timestamp"] == nil, "envelope keys should be stripped")
}
@Test
func parsePropagatesError() throws {
let raw: [String: Any] = [
"v": 2,
"id": "x",
"success": false,
"error": "Chat not found",
]
let response = try BridgeResponse.parse(raw)
#expect(response.success == false)
#expect(response.error == "Chat not found")
}
}

View File

@ -0,0 +1,115 @@
import Foundation
import Testing
@testable import IMsgCore
/// Smoke tests for the events-jsonl tailer. Writes a temp file, appends a few
/// JSON lines, asserts they surface in order through the AsyncStream.
@Suite("IMsgEventTailer")
struct IMsgEventTailerTests {
@Test
func tailerEmitsAppendedLines() async throws {
let dir = NSTemporaryDirectory() + "imsg-tailer-test-\(UUID().uuidString)"
try FileManager.default.createDirectory(
atPath: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: dir) }
let path = (dir as NSString).appendingPathComponent("events.jsonl")
FileManager.default.createFile(atPath: path, contents: Data(), attributes: nil)
let tailer = IMsgEventTailer(path: path)
let stream = tailer.events()
// Append two events on a background task so the tailer has a chance to
// open and start watching before lines arrive.
Task.detached {
try? await Task.sleep(nanoseconds: 200_000_000)
let line1 = """
{"event":"started-typing","data":{"chatGuid":"iMessage;-;+15551"}}
""" + "\n"
let line2 = """
{"event":"stopped-typing","data":{"chatGuid":"iMessage;-;+15551"}}
""" + "\n"
let fp = fopen(path, "a")
if let fp = fp {
line1.utf8CString.withUnsafeBufferPointer { buf in
guard let base = buf.baseAddress else { return }
fwrite(base, 1, strlen(base), fp)
}
line2.utf8CString.withUnsafeBufferPointer { buf in
guard let base = buf.baseAddress else { return }
fwrite(base, 1, strlen(base), fp)
}
fflush(fp)
fclose(fp)
}
}
var collected: [String] = []
let deadline = Date().addingTimeInterval(3.0)
for await event in stream {
collected.append(event.name)
if collected.count >= 2 { break }
if Date() > deadline { break }
}
tailer.stop()
#expect(collected == ["started-typing", "stopped-typing"])
}
@Test
func tailerSkipsExistingLinesByDefault() async throws {
let dir = NSTemporaryDirectory() + "imsg-tailer-test-\(UUID().uuidString)"
try FileManager.default.createDirectory(
atPath: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: dir) }
let path = (dir as NSString).appendingPathComponent("events.jsonl")
let oldLine = """
{"event":"old-typing","data":{"chatGuid":"iMessage;-;+15551"}}
""" + "\n"
FileManager.default.createFile(
atPath: path,
contents: oldLine.data(using: .utf8),
attributes: nil
)
let tailer = IMsgEventTailer(path: path)
let stream = tailer.events()
Task.detached {
try? await Task.sleep(nanoseconds: 200_000_000)
let newLine = """
{"event":"started-typing","data":{"chatGuid":"iMessage;-;+15551"}}
""" + "\n"
let fp = fopen(path, "a")
if let fp = fp {
newLine.utf8CString.withUnsafeBufferPointer { buf in
guard let base = buf.baseAddress else { return }
fwrite(base, 1, strlen(base), fp)
}
fflush(fp)
fclose(fp)
}
}
var first: String?
for await event in stream {
first = event.name
break
}
tailer.stop()
#expect(first == "started-typing")
}
@Test
func eventDecodedPayloadRoundTrip() throws {
let raw: [String: Any] = ["chatGuid": "iMessage;-;+1", "extra": 42]
let data = try JSONSerialization.data(withJSONObject: raw, options: [])
let event = IMsgEventTailer.Event(timestamp: nil, name: "x", payloadJSON: data)
let decoded = event.decodedPayload()
#expect(decoded["chatGuid"] as? String == "iMessage;-;+1")
#expect(decoded["extra"] as? Int == 42)
}
}

View File

@ -0,0 +1,82 @@
import Commander
import Foundation
import IMsgCore
import Testing
@testable import imsg
/// Snapshot of the bridge-backed commands we expect to be wired up. Locks in
/// the surface so an accidental drop from CommandRouter.specs gets caught
/// without exercising any IMCore plumbing.
@Test
func commandRouterIncludesAllBridgeCommands() {
let router = CommandRouter()
let expected: [String] = [
"send-rich", "send-multipart", "send-attachment", "tapback",
"edit", "unsend", "delete-message", "notify-anyways",
"chat-create", "chat-name", "chat-photo",
"chat-add-member", "chat-remove-member",
"chat-leave", "chat-delete", "chat-mark",
"account", "whois", "nickname",
]
let registered = Set(router.specs.map { $0.name })
for name in expected {
#expect(registered.contains(name), "missing bridge command: \(name)")
}
#expect(registered.contains("search"), "missing local search command")
}
@Test
func bridgeMessagingCommandsExposeChatRequirement() async {
// Each new bridge messaging command requires a `--chat` option (the chat
// guid is the universal addressing key in v2). Ensure missing args bubble
// up as a parse-time error rather than dropping into the bridge with empty
// strings.
let router = CommandRouter()
let names = ["send-rich", "edit", "unsend", "delete-message", "tapback"]
for name in names {
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", name])
}
#expect(status == 1, "\(name) should have required missing args")
}
}
@Test
func chatMarkRejectsConflictingFlags() async {
let router = CommandRouter()
let (output, status) = await StdoutCapture.capture {
await router.run(argv: [
"imsg", "chat-mark", "--chat", "iMessage;-;+15551234567", "--read", "--unread",
])
}
#expect(status == 1)
#expect(output.contains("Invalid value for option: --read"))
}
@Test
func chatCreateRejectsUnsupportedServiceBeforeBridgeLaunch() async {
let values = ParsedValues(
positional: [],
options: [
"addresses": ["+15551234567"],
"service": ["SMS"],
],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await ChatCreateCommand.run(values: values, runtime: runtime)
#expect(Bool(false))
} catch let error as IMsgError {
switch error {
case .unsupportedService(let value):
#expect(value == "SMS")
default:
#expect(Bool(false))
}
} catch {
#expect(Bool(false))
}
}

View File

@ -106,6 +106,27 @@ func historyCommandJsonReportsDirectChatMetadata() async throws {
#expect(payload["participants"] as? [String] == ["+123"])
}
@Test
func searchCommandUsesLocalMessageStore() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "query": ["ell"], "match": ["contains"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await SearchCommand.run(
values: values,
runtime: runtime,
contactResolverFactory: { NoOpContactResolver() }
)
}
let payload = try jsonObject(from: output)
#expect(payload["text"] as? String == "hello")
#expect(payload["chat_id"] as? Int == 1)
}
@Test
func historyCommandRunsWithAttachmentsNonJson() async throws {
let path = try CommandTestDatabase.makePathWithAttachment()

View File

@ -0,0 +1,106 @@
# Live bridge smoke tests
These exercises run on a real SIP-disabled Mac with `Messages.app` signed in
and the helper dylib injected. They are gated by `IMSG_LIVE_BRIDGE=1` so they
never run in CI. Each step prints what should happen so you can eyeball the
result in `Messages.app` (the dylib has no way to fake-confirm a UI mutation).
## Prerequisites
```bash
# In Recovery mode
csrutil disable
# Back in normal boot:
make build && make build-dylib
imsg launch # kills + relaunches Messages with DYLD_INSERT
imsg status # expect: bridge version: v2 (v2 inbox active)
```
## Pick a target chat
```bash
imsg chats --limit 10 --json | jq -r '.[] | "\(.guid)\t\(.name // .identifier)"'
export CHAT='iMessage;-;+15551234567' # paste guid from above
```
## 1. send-rich + effects
```bash
imsg send-rich --chat "$CHAT" --text "test from imsg v2"
imsg send-rich --chat "$CHAT" --text "BOOM" \
--effect com.apple.MobileSMS.expressivesend.impact
imsg send-rich --chat "$CHAT" --text "📜 ---" \
--effect com.apple.MobileSMS.expressivesend.invisibleink
```
Expect: each message shows in Messages.app immediately. The 2nd applies the
slam effect; the 3rd shows as invisible ink.
## 2. tapback round-trip
```bash
# Capture the messageGuid of an existing message you want to react to
imsg history --chat-id 1 --limit 1 --json | jq -r '.guid'
export MSG=<paste guid>
imsg tapback --chat "$CHAT" --message "$MSG" --kind love
imsg tapback --chat "$CHAT" --message "$MSG" --kind love --remove
```
Expect: 💖 appears, then disappears.
## 3. edit / unsend (macOS 13+ only)
```bash
imsg send-rich --chat "$CHAT" --text "rough draft"
# Capture the new guid:
imsg history --chat-id 1 --limit 1 --json | jq -r '.guid'
export MSG=<paste guid>
imsg edit --chat "$CHAT" --message "$MSG" --new-text "polished version"
imsg unsend --chat "$CHAT" --message "$MSG"
```
Expect: the message text changes, then a "You unsent a message" placeholder
appears. If `imsg status` shows `editMessageItem: ✗` AND `editMessage: ✗`,
your macOS is too old (pre-13) — these will return an error.
## 4. chat creation + member management
```bash
imsg chat-create --addresses '+15551111111,+15552222222' \
--name 'imsg test' --text 'hello' --json
# Capture the new chatGuid from the JSON output:
export GROUP=<paste chatGuid>
imsg chat-add-member --chat "$GROUP" --address +15553333333
imsg chat-name --chat "$GROUP" --name 'imsg test renamed'
imsg chat-photo --chat "$GROUP" --file ~/Pictures/test.jpg
imsg chat-remove-member --chat "$GROUP" --address +15553333333
imsg chat-leave --chat "$GROUP"
```
`chat-create` is iMessage-only; use `imsg send --service sms` for SMS sends.
Expect: each step is visible in Messages.app within a second or two.
## 5. typing events streaming
```bash
imsg watch --bb-events --json &
# from another device or simulator, type into your conversation
# you should see started-typing / stopped-typing JSON objects emit
kill %1
```
## 6. introspection
```bash
imsg account
imsg whois --address +15551234567 --type phone
imsg nickname --address +15551234567
```
## Cleanup
```bash
killall Messages # un-inject; next launch is normal
csrutil enable # in Recovery, re-enable SIP when done
```