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:
parent
bbd6b93a1e
commit
c56c24d488
@ -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
|
||||
|
||||
@ -54,6 +54,9 @@ let package = Package(
|
||||
dependencies: [
|
||||
"imsg",
|
||||
"IMsgCore",
|
||||
],
|
||||
exclude: [
|
||||
"README-live.md",
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
105
README.md
105
README.md
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
141
Sources/IMsgCore/IMsgBridgeClient.swift
Normal file
141
Sources/IMsgCore/IMsgBridgeClient.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
181
Sources/IMsgCore/IMsgBridgeProtocol.swift
Normal file
181
Sources/IMsgCore/IMsgBridgeProtocol.swift
Normal 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 11–15. 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)
|
||||
}
|
||||
}
|
||||
163
Sources/IMsgCore/IMsgEventTailer.swift
Normal file
163
Sources/IMsgCore/IMsgEventTailer.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
|
||||
94
Sources/IMsgCore/MessageStore+Search.swift
Normal file
94
Sources/IMsgCore/MessageStore+Search.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
@ -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
|
||||
|
||||
302
Sources/imsg/Commands/BridgeChatCommands.swift
Normal file
302
Sources/imsg/Commands/BridgeChatCommands.swift
Normal 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")" }
|
||||
}
|
||||
}
|
||||
177
Sources/imsg/Commands/BridgeIntroCommands.swift
Normal file
177
Sources/imsg/Commands/BridgeIntroCommands.swift
Normal 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)")"
|
||||
}
|
||||
}
|
||||
}
|
||||
463
Sources/imsg/Commands/BridgeMessagingCommands.swift
Normal file
463
Sources/imsg/Commands/BridgeMessagingCommands.swift
Normal 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" }
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
84
Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift
Normal file
84
Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
115
Tests/IMsgCoreTests/IMsgEventTailerTests.swift
Normal file
115
Tests/IMsgCoreTests/IMsgEventTailerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
82
Tests/imsgTests/BridgeCommandRegistrationTests.swift
Normal file
82
Tests/imsgTests/BridgeCommandRegistrationTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
106
Tests/imsgTests/README-live.md
Normal file
106
Tests/imsgTests/README-live.md
Normal 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
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user