feat: ship typing/rpc updates and prep 0.5.0

This commit is contained in:
Peter Steinberger 2026-02-16 07:14:15 +01:00
parent a2c0865a54
commit 38fa96a3ee
19 changed files with 710 additions and 600 deletions

View File

@ -1,17 +1,23 @@
# Changelog
## 0.4.1 - Unreleased
## 0.5.0 - Unreleased
- feat: add typing indicator command + RPC methods with stricter validation (#41, thanks @kohoj)
- feat: `--reactions` flag for `watch` command to include tapback events in stream (#26)
- feat: reaction events include `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, `reacted_to_guid` fields
- feat: `imsg react` command to send tapback reactions via UI automation (#24)
- fix: prefer handle sends when chat identifier is a direct handle
- fix: apply history filters before limit (#20, thanks @tommybananas)
- fix: flush watch output immediately when stdout is buffered (#43, thanks @ccaum)
- feat: reaction events include `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, `reacted_to_guid` fields
- feat: add `include_reactions` toggle to `watch.subscribe` RPC and extend RPC reaction metadata fields
- feat: include `thread_originator_guid` in message output (#39, thanks @ruthmade)
- feat: expose `destination_caller_id` in message output (#29, thanks @commander-alexander)
- fix: apply history filters before limit (#20, thanks @tommybananas)
- fix: flush watch output immediately when stdout is buffered (#43, thanks @ccaum)
- fix: prefer handle sends when chat identifier is a direct handle
- fix: detect groups from `;+;` prefix in guid/identifier for RPC payloads (#42, thanks @shivshil)
- feat: add typing indicator command + RPC methods with stricter validation (#41, thanks @kohoj)
- fix: harden `react` AppleScript execution and tighten group-handle detection paths
- refactor: consolidate schema detection, stdout writing, and message/RPC payload mapping paths
- test: split command test suites by domain and align group-handle expectations
- docs: update changelog entries as typing/reaction work landed
- chore: bump unreleased version marker to `0.5.0`
## 0.4.0 - 2026-01-07
- feat: surface audio message transcriptions (thanks @antons)
@ -22,8 +28,13 @@
- ci: switch to make-based lint/test/build
- docs: update build/test/release instructions
- chore: replace pnpm scripts with make targets
- refactor: split message-store query paths for clearer message retrieval internals
- test: keep attachment tests isolated from user attachment directories
- fix: address attachment upload error handling regressions
- docs: refine changelog ordering/notes for patch-deps and 0.4.0 prep
- chore: version housekeeping for the 0.3.1 -> 0.4.0 release transition
## 0.3.0 - 2026-01-02
## 0.3.0 - 2026-01-03
- feat: JSON-RPC server over stdin/stdout (`imsg rpc`) with chats, history, watch, and send
- feat: group chat metadata in JSON/RPC output (participants, chat identifiers, is_group)
- feat: tapback + emoji reaction support in JSON output (#8) — thanks @tylerwince
@ -35,10 +46,19 @@
- docs: add RPC + group chat notes
- test: expand RPC/command coverage, add reaction fixtures, drop unused stdout helper
- test: add coverage for sender fallback
- feat: add IMCore send mode and IMCore-based reaction send path
- fix: stabilize IMCore send and sender fallback behavior
- change: remove private API send mode in favor of IMCore path
- build: add/harden notarized release script checks
- chore: update copyright year to 2026
- test: split message-store fixtures for more isolated reaction/sender coverage
- docs: maintain unreleased/release changelog staging for 0.2.2/0.3.0
- chore: release/prepare metadata updates for 0.3.0 and 0.3.1
## 0.2.1 - 2025-12-30
- fix: avoid crash parsing long attributed bodies (>256 bytes) (thanks @tommybananas)
- docs: prepare/backfill changelog notes for 0.2.1
- chore: bump release version metadata to 0.2.1
## 0.2.0 - 2025-12-28
- feat: Swift 6 rewrite with reusable IMsgCore library target
@ -46,17 +66,25 @@
- feat: event-driven watch using filesystem events (no polling)
- feat: SQLite.swift + PhoneNumberKit + NSAppleScript integration
- fix: ship PhoneNumberKit resource bundle for CLI installs
- fix: patch/avoid PhoneNumberKit bundle lookup crashes across install layouts
- fix: embed Info.plist + AppleEvents entitlement for automation prompts
- fix: fall back to osascript when AppleEvents permission is missing
- fix: retry osascript on transient unknown AppleScript errors
- fix: decode length-prefixed attributed bodies for sent messages
- fix: resolve CLI version detection for symlinked/bundle installs
- chore: SwiftLint + swift-format linting
- change: JSON attachment keys now snake_case
- deprecation note: `--interval` replaced by `--debounce` (no compatibility)
- docs: add release process documentation
- ci: publish release notes from changelog and harden extraction
- chore: reset release versioning during Swift rewrite stabilization
- chore: version.env + generated version source for `--version`
## 0.1.1 - 2025-12-27
- feat: `imsg chats --json`
- fix: drop sqlite `immutable` flag so new messages/replies show up (thanks @zleman1593)
- test: add/stabilize live update regression coverage
- docs: add unreleased entry and backfill/prepare changelog history
- chore: update go dependencies
## 0.1.0 - 2025-12-20
@ -66,3 +94,8 @@
- feat: `imsg send` text and/or one attachment (`--service imessage|sms|auto`, `--region`)
- feat: attachment metadata output (`--attachments`) incl. resolved path + missing flag
- fix: clearer Full Disk Access error for `~/Library/Messages/chat.db`
- fix: coerce attachment aliasing in message parsing
- build: add GoReleaser workflow and tag backfill support
- ci: harden Go/lint environment setup and align toolchain/linter installation
- docs: add repository guidelines/package docs and initial README polish
- chore: bootstrap initial project/release scaffolding and dependency baseline

View File

@ -9,9 +9,9 @@ let package = Package(
.executable(name: "imsg", targets: ["imsg"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.4"),
.package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.2"),
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.1"),
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.5"),
.package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.5"),
],
targets: [
.target(

View File

@ -42,7 +42,7 @@ public enum IMsgError: LocalizedError, Sendable {
case .invalidReaction(let value):
return """
Invalid reaction: \(value)
Valid reactions: love, like, dislike, laugh, emphasis, question
Or use an emoji for custom reactions (e.g., 🎉)
"""

View File

@ -53,12 +53,14 @@ public struct ReactionEvent: Sendable, Equatable {
extension MessageStore {
/// Fetch reaction events (add/remove) after a given rowID.
/// These are the reaction messages themselves, useful for streaming reaction events in watch mode.
public func reactionEventsAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [ReactionEvent] {
public func reactionEventsAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws
-> [ReactionEvent]
{
guard hasReactionColumns else { return [] }
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
var sql = """
SELECT m.ROWID, cmj.chat_id, m.associated_message_type, m.associated_message_guid,
m.handle_id, h.id, m.is_from_me, m.date, IFNULL(m.text, '') AS text,
@ -68,21 +70,21 @@ extension MessageStore {
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
LEFT JOIN message orig ON (orig.guid = m.associated_message_guid
LEFT JOIN message orig ON (orig.guid = m.associated_message_guid
OR m.associated_message_guid LIKE '%/' || orig.guid)
WHERE m.ROWID > ?
AND m.associated_message_type >= 2000
AND m.associated_message_type <= 3006
"""
var bindings: [Binding?] = [afterRowID]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID)
}
sql += " ORDER BY m.ROWID ASC LIMIT ?"
bindings.append(limit)
return try withConnection { db in
var events: [ReactionEvent] = []
for row in try db.prepare(sql, bindings) {
@ -113,21 +115,22 @@ extension MessageStore {
continue
}
events.append(ReactionEvent(
rowID: rowID,
chatID: resolvedChatID,
reactionType: reactionType,
isAdd: isAdd,
sender: sender,
isFromMe: isFromMe,
date: date,
reactedToGUID: decoded.reactedToGUID ?? "",
reactedToID: origRowID,
text: resolvedText
))
events.append(
ReactionEvent(
rowID: rowID,
chatID: resolvedChatID,
reactionType: reactionType,
isAdd: isAdd,
sender: sender,
isFromMe: isFromMe,
date: date,
reactedToGUID: decoded.reactedToGUID ?? "",
reactedToID: origRowID,
text: resolvedText
))
}
return events
}
}
}

View File

@ -7,7 +7,9 @@ public struct MessageWatcherConfiguration: Sendable, Equatable {
/// When true, reaction events (tapback add/remove) are included in the stream
public var includeReactions: Bool
public init(debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100, includeReactions: Bool = false) {
public init(
debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100, includeReactions: Bool = false
) {
self.debounceInterval = debounceInterval
self.batchLimit = batchLimit
self.includeReactions = includeReactions

View File

@ -7,6 +7,7 @@ import Foundation
///
/// Requires macOS 14+, Messages.app signed in, and an existing conversation with the contact.
public struct TypingIndicator: Sendable {
private static let daemonConnectionTracker = DaemonConnectionTracker()
/// Start showing the typing indicator for a chat.
/// - Parameter chatIdentifier: e.g. `"iMessage;-;+14155551212"` or a chat GUID.
@ -47,8 +48,8 @@ public struct TypingIndicator: Sendable {
}
defer { dlclose(handle) }
try ensureDaemonConnection(handle: handle)
let chat = try lookupChat(handle: handle, identifier: chatIdentifier)
try ensureDaemonConnection()
let chat = try lookupChat(identifier: chatIdentifier)
let selector = sel_registerName("setLocalUserIsTyping:")
guard let method = class_getInstanceMethod(object_getClass(chat), selector) else {
@ -81,7 +82,7 @@ public struct TypingIndicator: Sendable {
stopped = true
}
private static func ensureDaemonConnection(handle: UnsafeMutableRawPointer) throws {
private static func ensureDaemonConnection() throws {
guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else {
throw IMsgError.typingIndicatorFailed("IMDaemonController class not found")
}
@ -95,17 +96,40 @@ public struct TypingIndicator: Sendable {
throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance")
}
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.hasAttemptedConnection = true
daemonConnectionTracker.lock.unlock()
return
}
daemonConnectionTracker.lock.lock()
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
if shouldAttemptConnection {
daemonConnectionTracker.hasAttemptedConnection = true
}
daemonConnectionTracker.lock.unlock()
if !shouldAttemptConnection { return }
let connectSel = sel_registerName("connectToDaemon")
if controller.responds(to: connectSel) {
_ = controller.perform(connectSel)
}
Thread.sleep(forTimeInterval: 0.5)
}
private static func lookupChat(
handle: UnsafeMutableRawPointer, identifier: String
) throws -> NSObject {
private static func hasLiveDaemonConnection(_ controller: AnyObject) -> Bool {
let isConnectedSel = sel_registerName("isConnected")
guard controller.responds(to: isConnectedSel) else { return false }
guard let value = controller.perform(isConnectedSel)?.takeUnretainedValue() else {
return false
}
if let number = value as? NSNumber {
return number.boolValue
}
return false
}
private static func lookupChat(identifier: String) throws -> NSObject {
guard let registryClass = objc_getClass("IMChatRegistry") as? NSObject.Type else {
throw IMsgError.typingIndicatorFailed("IMChatRegistry class not found")
}
@ -141,3 +165,8 @@ public struct TypingIndicator: Sendable {
+ "Make sure Messages.app has an active conversation with this contact.")
}
}
private final class DaemonConnectionTracker: @unchecked Sendable {
let lock = NSLock()
var hasAttemptedConnection = false
}

View File

@ -0,0 +1,72 @@
import IMsgCore
struct ChatTargetInput: Sendable {
let recipient: String
let chatID: Int64?
let chatIdentifier: String
let chatGUID: String
var hasChatTarget: Bool {
chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
}
}
struct ResolvedChatTarget: Sendable {
let chatIdentifier: String
let chatGUID: String
var preferredIdentifier: String? {
if !chatGUID.isEmpty { return chatGUID }
if !chatIdentifier.isEmpty { return chatIdentifier }
return nil
}
}
enum ChatTargetResolver {
static func validateRecipientRequirements(
input: ChatTargetInput,
mixedTargetError: Error,
missingRecipientError: Error
) throws {
if input.hasChatTarget && !input.recipient.isEmpty {
throw mixedTargetError
}
if !input.hasChatTarget && input.recipient.isEmpty {
throw missingRecipientError
}
}
static func resolveChatTarget(
input: ChatTargetInput,
lookupChat: (Int64) async throws -> ChatInfo?,
unknownChatError: (Int64) -> Error
) async throws -> ResolvedChatTarget {
var resolvedIdentifier = input.chatIdentifier
var resolvedGUID = input.chatGUID
if let chatID = input.chatID {
guard let info = try await lookupChat(chatID) else {
throw unknownChatError(chatID)
}
resolvedIdentifier = info.identifier
resolvedGUID = info.guid
}
return ResolvedChatTarget(
chatIdentifier: resolvedIdentifier,
chatGUID: resolvedGUID
)
}
static func directTypingIdentifier(
recipient: String,
serviceRaw: String,
invalidServiceError: (String) -> Error
) throws -> String {
guard let service = MessageService(rawValue: serviceRaw.lowercased()) else {
throw invalidServiceError(serviceRaw)
}
let prefix = service == .sms ? "SMS" : "iMessage"
return "\(prefix);-;\(recipient)"
}
}

View File

@ -8,12 +8,12 @@ enum ReactCommand {
abstract: "Send a tapback reaction to the most recent message",
discussion: """
Sends a tapback reaction to the most recent incoming message in the specified chat.
IMPORTANT LIMITATIONS:
- Only reacts to the MOST RECENT incoming message in the conversation
- Requires Messages.app to be running
- Uses UI automation (System Events) which requires accessibility permissions
Reaction types:
love (), like (👍), dislike (👎), laugh (😂), emphasis (), question ()
Or any single emoji for custom reactions (iOS 17+ / macOS 14+)
@ -22,8 +22,9 @@ enum ReactCommand {
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid to react in"),
.make(label: "reaction", names: [.long("reaction"), .short("r")],
help: "reaction type: love, like, dislike, laugh, emphasis, question, or emoji"),
.make(
label: "reaction", names: [.long("reaction"), .short("r")],
help: "reaction type: love, like, dislike, laugh, emphasis, question, or emoji"),
],
flags: []
)
@ -54,7 +55,7 @@ enum ReactCommand {
guard let reactionType = ReactionType.parse(reactionString) else {
throw IMsgError.invalidReaction(reactionString)
}
if case let .custom(emoji) = reactionType, !isSingleEmoji(emoji) {
if case .custom(let emoji) = reactionType, !isSingleEmoji(emoji) {
throw IMsgError.invalidReaction(reactionString)
}
@ -219,7 +220,7 @@ struct ReactResult: Codable {
let chatID: Int64
let reactionType: String
let reactionEmoji: String
enum CodingKeys: String, CodingKey {
case success
case chatID = "chat_id"

View File

@ -42,17 +42,17 @@ enum SendCommand {
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) }
) async throws {
let dbPath = values.option("db") ?? MessageStore.defaultPath
let recipient = values.option("to") ?? ""
let chatID = values.optionInt64("chatID")
let chatIdentifier = values.option("chatIdentifier") ?? ""
let chatGUID = values.option("chatGUID") ?? ""
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
if hasChatTarget && !recipient.isEmpty {
throw ParsedValuesError.invalidOption("to")
}
if !hasChatTarget && recipient.isEmpty {
throw ParsedValuesError.missingOption("to")
}
let input = ChatTargetInput(
recipient: values.option("to") ?? "",
chatID: values.optionInt64("chatID"),
chatIdentifier: values.option("chatIdentifier") ?? "",
chatGUID: values.option("chatGUID") ?? ""
)
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: ParsedValuesError.invalidOption("to"),
missingRecipientError: ParsedValuesError.missingOption("to")
)
let text = values.option("text") ?? ""
let file = values.option("file") ?? ""
@ -65,29 +65,29 @@ enum SendCommand {
}
let region = values.option("region") ?? "US"
var resolvedChatIdentifier = chatIdentifier
var resolvedChatGUID = chatGUID
if let chatID {
let store = try storeFactory(dbPath)
guard let info = try store.chatInfo(chatID: chatID) else {
throw IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in
let store = try storeFactory(dbPath)
return try store.chatInfo(chatID: chatID)
},
unknownChatError: { chatID in
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
}
resolvedChatIdentifier = info.identifier
resolvedChatGUID = info.guid
}
if hasChatTarget && resolvedChatIdentifier.isEmpty && resolvedChatGUID.isEmpty {
)
if input.hasChatTarget && resolvedTarget.preferredIdentifier == nil {
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
}
try sendMessage(
MessageSendOptions(
recipient: recipient,
recipient: input.recipient,
text: text,
attachmentPath: file,
service: service,
region: region,
chatIdentifier: resolvedChatIdentifier,
chatGUID: resolvedChatGUID
chatIdentifier: resolvedTarget.chatIdentifier,
chatGUID: resolvedTarget.chatGUID
))
if runtime.jsonOutput {

View File

@ -42,39 +42,56 @@ enum TypingCommand {
values: ParsedValues,
runtime: RuntimeOptions,
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
startTyping: @escaping (String) throws -> Void = { try TypingIndicator.startTyping(chatIdentifier: $0) },
stopTyping: @escaping (String) throws -> Void = { try TypingIndicator.stopTyping(chatIdentifier: $0) },
startTyping: @escaping (String) throws -> Void = {
try TypingIndicator.startTyping(chatIdentifier: $0)
},
stopTyping: @escaping (String) throws -> Void = {
try TypingIndicator.stopTyping(chatIdentifier: $0)
},
typeForDuration: @escaping (String, TimeInterval) async throws -> Void = {
try await TypingIndicator.typeForDuration(chatIdentifier: $0, duration: $1)
}
) async throws {
let dbPath = values.option("db") ?? MessageStore.defaultPath
let recipient = values.option("to") ?? ""
let chatID = values.optionInt64("chatID")
let chatIdentifier = values.option("chatIdentifier") ?? ""
let chatGUID = values.option("chatGUID") ?? ""
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
let input = ChatTargetInput(
recipient: values.option("to") ?? "",
chatID: values.optionInt64("chatID"),
chatIdentifier: values.option("chatIdentifier") ?? "",
chatGUID: values.option("chatGUID") ?? ""
)
let stopFlag = try parseStopFlag(values.option("stop"))
let durationRaw = values.option("duration") ?? ""
let serviceRaw = values.option("service") ?? "imessage"
if hasChatTarget && !recipient.isEmpty {
throw ParsedValuesError.invalidOption("to")
}
if !hasChatTarget && recipient.isEmpty {
throw ParsedValuesError.missingOption("to")
}
let resolvedIdentifier = try resolveIdentifier(
dbPath: dbPath,
recipient: recipient,
chatID: chatID,
chatIdentifier: chatIdentifier,
chatGUID: chatGUID,
service: serviceRaw,
storeFactory: storeFactory
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: ParsedValuesError.invalidOption("to"),
missingRecipientError: ParsedValuesError.missingOption("to")
)
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in
let store = try storeFactory(dbPath)
return try store.chatInfo(chatID: chatID)
},
unknownChatError: { chatID in
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
}
)
let resolvedIdentifier: String
if let preferred = resolvedTarget.preferredIdentifier {
resolvedIdentifier = preferred
} else if input.hasChatTarget {
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
} else {
resolvedIdentifier = try ChatTargetResolver.directTypingIdentifier(
recipient: input.recipient,
serviceRaw: serviceRaw,
invalidServiceError: { IMsgError.invalidService($0) }
)
}
if stopFlag {
try stopTyping(resolvedIdentifier)
if runtime.jsonOutput {
@ -104,32 +121,6 @@ enum TypingCommand {
}
}
private static func resolveIdentifier(
dbPath: String,
recipient: String,
chatID: Int64?,
chatIdentifier: String,
chatGUID: String,
service: String,
storeFactory: (String) throws -> MessageStore
) throws -> String {
if !chatGUID.isEmpty { return chatGUID }
if !chatIdentifier.isEmpty { return chatIdentifier }
if let chatID {
let store = try storeFactory(dbPath)
guard let info = try store.chatInfo(chatID: chatID) else {
throw IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
}
if !info.guid.isEmpty { return info.guid }
return info.identifier
}
guard let messageService = MessageService(rawValue: service.lowercased()) else {
throw IMsgError.invalidService(service)
}
let svc = messageService == .sms ? "SMS" : "iMessage"
return "\(svc);-;\(recipient)"
}
private static func parseStopFlag(_ raw: String?) throws -> Bool {
guard let raw else { return false }
if raw == "true" { return true }

View File

@ -28,9 +28,9 @@ enum WatchCommand {
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
),
.make(
label: "reactions", names: [.long("reactions")],
label: "reactions", names: [.long("reactions")],
help: "include reaction events (tapback add/remove) in the stream"
)
),
]
)
),

View File

@ -0,0 +1,243 @@
import Foundation
import IMsgCore
extension RPCServer {
func handleChatsList(id: Any?, params: [String: Any]) async throws {
let limit = intParam(params["limit"]) ?? 20
let chats = try store.listChats(limit: max(limit, 1))
var payloads: [[String: Any]] = []
payloads.reserveCapacity(chats.count)
for chat in chats {
let info = try await cache.info(chatID: chat.id)
let participants = try await cache.participants(chatID: chat.id)
let identifier = info?.identifier ?? chat.identifier
let guid = info?.guid ?? ""
let name = (info?.name.isEmpty == false ? info?.name : nil) ?? chat.name
let service = info?.service ?? chat.service
payloads.append(
chatPayload(
id: chat.id,
identifier: identifier,
guid: guid,
name: name,
service: service,
lastMessageAt: chat.lastMessageAt,
participants: participants
))
}
respond(id: id, result: ["chats": payloads])
}
func handleMessagesHistory(id: Any?, params: [String: Any]) async throws {
guard let chatID = int64Param(params["chat_id"]) else {
throw RPCError.invalidParams("chat_id is required")
}
let limit = intParam(params["limit"]) ?? 50
let participants = stringArrayParam(params["participants"])
let startISO = stringParam(params["start"])
let endISO = stringParam(params["end"])
let includeAttachments = boolParam(params["attachments"]) ?? false
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: startISO,
endISO: endISO
)
let filtered = try store.messages(chatID: chatID, limit: max(limit, 1), filter: filter)
var payloads: [[String: Any]] = []
payloads.reserveCapacity(filtered.count)
for message in filtered {
let payload = try await buildMessagePayload(
store: store,
cache: cache,
message: message,
includeAttachments: includeAttachments
)
payloads.append(payload)
}
respond(id: id, result: ["messages": payloads])
}
func handleWatchSubscribe(id: Any?, params: [String: Any]) async throws {
let chatID = int64Param(params["chat_id"])
let sinceRowID = int64Param(params["since_rowid"])
let participants = stringArrayParam(params["participants"])
let startISO = stringParam(params["start"])
let endISO = stringParam(params["end"])
let includeAttachments = boolParam(params["attachments"]) ?? false
let includeReactions = boolParam(params["include_reactions"]) ?? false
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: startISO,
endISO: endISO
)
let config = MessageWatcherConfiguration(includeReactions: includeReactions)
let subID = await subscriptions.allocateID()
let localStore = store
let localWatcher = watcher
let localCache = cache
let localWriter = output
let localFilter = filter
let localChatID = chatID
let localSinceRowID = sinceRowID
let localConfig = config
let localIncludeAttachments = includeAttachments
let task = Task {
do {
for try await message in localWatcher.stream(
chatID: localChatID,
sinceRowID: localSinceRowID,
configuration: localConfig
) {
if Task.isCancelled { return }
if !localFilter.allows(message) { continue }
let payload = try await buildMessagePayload(
store: localStore,
cache: localCache,
message: message,
includeAttachments: localIncludeAttachments
)
localWriter.sendNotification(
method: "message",
params: ["subscription": subID, "message": payload]
)
}
} catch {
localWriter.sendNotification(
method: "error",
params: [
"subscription": subID,
"error": ["message": String(describing: error)],
]
)
}
}
await subscriptions.insert(task, for: subID)
respond(id: id, result: ["subscription": subID])
}
func handleWatchUnsubscribe(id: Any?, params: [String: Any]) async throws {
guard let subID = intParam(params["subscription"]) else {
throw RPCError.invalidParams("subscription is required")
}
if let task = await subscriptions.remove(subID) {
task.cancel()
}
respond(id: id, result: ["ok": true])
}
func handleTyping(params: [String: Any], id: Any?, start: Bool) async throws {
let input = ChatTargetInput(
recipient: stringParam(params["to"]) ?? "",
chatID: int64Param(params["chat_id"]),
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
chatGUID: stringParam(params["chat_guid"]) ?? ""
)
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
missingRecipientError: RPCError.invalidParams("to is required for direct typing")
)
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in try await cache.info(chatID: chatID) },
unknownChatError: { chatID in
RPCError.invalidParams("unknown chat_id \(chatID)")
}
)
let resolvedIdentifier: String
if let preferred = resolvedTarget.preferredIdentifier {
resolvedIdentifier = preferred
} else if input.hasChatTarget {
throw RPCError.invalidParams("missing chat identifier or guid")
} else {
let serviceRaw = stringParam(params["service"]) ?? "imessage"
resolvedIdentifier = try ChatTargetResolver.directTypingIdentifier(
recipient: input.recipient,
serviceRaw: serviceRaw,
invalidServiceError: { _ in RPCError.invalidParams("invalid service") }
)
}
if start {
try startTyping(resolvedIdentifier)
} else {
try stopTyping(resolvedIdentifier)
}
respond(id: id, result: ["ok": true])
}
func handleSend(params: [String: Any], id: Any?) async throws {
let text = stringParam(params["text"]) ?? ""
let file = stringParam(params["file"]) ?? ""
let serviceRaw = stringParam(params["service"]) ?? "auto"
guard let service = MessageService(rawValue: serviceRaw) else {
throw RPCError.invalidParams("invalid service")
}
let region = stringParam(params["region"]) ?? "US"
let input = ChatTargetInput(
recipient: stringParam(params["to"]) ?? "",
chatID: int64Param(params["chat_id"]),
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
chatGUID: stringParam(params["chat_guid"]) ?? ""
)
try ChatTargetResolver.validateRecipientRequirements(
input: input,
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
missingRecipientError: RPCError.invalidParams("to is required for direct sends")
)
if text.isEmpty && file.isEmpty {
throw RPCError.invalidParams("text or file is required")
}
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
input: input,
lookupChat: { chatID in try await cache.info(chatID: chatID) },
unknownChatError: { chatID in
RPCError.invalidParams("unknown chat_id \(chatID)")
}
)
if input.hasChatTarget && resolvedTarget.preferredIdentifier == nil {
throw RPCError.invalidParams("missing chat identifier or guid")
}
try sendMessage(
MessageSendOptions(
recipient: input.recipient,
text: text,
attachmentPath: file,
service: service,
region: region,
chatIdentifier: resolvedTarget.chatIdentifier,
chatGUID: resolvedTarget.chatGUID
)
)
respond(id: id, result: ["ok": true])
}
}
private func buildMessagePayload(
store: MessageStore,
cache: ChatCache,
message: Message,
includeAttachments: Bool
) async throws -> [String: Any] {
let chatInfo = try await cache.info(chatID: message.chatID)
let participants = try await cache.participants(chatID: message.chatID)
let attachments = includeAttachments ? try store.attachments(for: message.rowID) : []
let reactions = includeAttachments ? try store.reactions(for: message.rowID) : []
return try messagePayload(
message: message,
chatInfo: chatInfo,
participants: participants,
attachments: attachments,
reactions: reactions
)
}

View File

@ -0,0 +1,123 @@
import Foundation
import IMsgCore
final class RPCWriter: RPCOutput, Sendable {
func sendResponse(id: Any, result: Any) {
send(["jsonrpc": "2.0", "id": id, "result": result])
}
func sendError(id: Any?, error: RPCError) {
let payload: [String: Any] = [
"jsonrpc": "2.0",
"id": id ?? NSNull(),
"error": error.asDictionary(),
]
send(payload)
}
func sendNotification(method: String, params: Any) {
send(["jsonrpc": "2.0", "method": method, "params": params])
}
private func send(_ object: Any) {
do {
let data = try JSONSerialization.data(withJSONObject: object, options: [])
if let output = String(data: data, encoding: .utf8) {
StdoutWriter.writeLine(output)
}
} catch {
StdoutWriter.writeLine(
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}"
)
}
}
}
struct RPCError: Error {
let code: Int
let message: String
let data: String?
static func parseError(_ message: String) -> RPCError {
RPCError(code: -32700, message: "Parse error", data: message)
}
static func invalidRequest(_ message: String) -> RPCError {
RPCError(code: -32600, message: "Invalid Request", data: message)
}
static func methodNotFound(_ method: String) -> RPCError {
RPCError(code: -32601, message: "Method not found", data: method)
}
static func invalidParams(_ message: String) -> RPCError {
RPCError(code: -32602, message: "Invalid params", data: message)
}
static func internalError(_ message: String) -> RPCError {
RPCError(code: -32603, message: "Internal error", data: message)
}
func asDictionary() -> [String: Any] {
var dict: [String: Any] = [
"code": code,
"message": message,
]
if let data {
dict["data"] = data
}
return dict
}
}
actor SubscriptionStore {
private var nextID = 1
private var tasks: [Int: Task<Void, Never>] = [:]
func allocateID() -> Int {
let id = nextID
nextID += 1
return id
}
func insert(_ task: Task<Void, Never>, for id: Int) {
tasks[id] = task
}
func remove(_ id: Int) -> Task<Void, Never>? {
tasks.removeValue(forKey: id)
}
func cancelAll() {
for task in tasks.values {
task.cancel()
}
tasks.removeAll()
}
}
actor ChatCache {
private let store: MessageStore
private var infoCache: [Int64: ChatInfo] = [:]
private var participantsCache: [Int64: [String]] = [:]
init(store: MessageStore) {
self.store = store
}
func info(chatID: Int64) throws -> ChatInfo? {
if let cached = infoCache[chatID] { return cached }
if let info = try store.chatInfo(chatID: chatID) {
infoCache[chatID] = info
return info
}
return nil
}
func participants(chatID: Int64) throws -> [String] {
if let cached = participantsCache[chatID] { return cached }
let participants = try store.participants(chatID: chatID)
participantsCache[chatID] = participants
return participants
}
}

View File

@ -8,15 +8,15 @@ protocol RPCOutput: Sendable {
}
final class RPCServer {
private let store: MessageStore
private let watcher: MessageWatcher
private let output: RPCOutput
private let cache: ChatCache
private let subscriptions = SubscriptionStore()
private let verbose: Bool
private let sendMessage: (MessageSendOptions) throws -> Void
private let startTyping: (String) throws -> Void
private let stopTyping: (String) throws -> Void
let store: MessageStore
let watcher: MessageWatcher
let output: RPCOutput
let cache: ChatCache
let subscriptions = SubscriptionStore()
let verbose: Bool
let sendMessage: (MessageSendOptions) throws -> Void
let startTyping: (String) throws -> Void
let stopTyping: (String) throws -> Void
init(
store: MessageStore,
@ -53,6 +53,11 @@ final class RPCServer {
await handleLine(line)
}
func respond(id: Any?, result: Any) {
guard let id else { return }
output.sendResponse(id: id, result: result)
}
private func handleLine(_ line: String) async {
guard let data = line.data(using: .utf8) else {
output.sendError(id: nil, error: RPCError.parseError("invalid utf8"))
@ -116,368 +121,4 @@ final class RPCServer {
output.sendError(id: id, error: RPCError.internalError(error.localizedDescription))
}
}
private func respond(id: Any?, result: Any) {
guard let id else { return }
output.sendResponse(id: id, result: result)
}
private func handleChatsList(id: Any?, params: [String: Any]) async throws {
let limit = intParam(params["limit"]) ?? 20
let chats = try store.listChats(limit: max(limit, 1))
var payloads: [[String: Any]] = []
payloads.reserveCapacity(chats.count)
for chat in chats {
let info = try await cache.info(chatID: chat.id)
let participants = try await cache.participants(chatID: chat.id)
let identifier = info?.identifier ?? chat.identifier
let guid = info?.guid ?? ""
let name = (info?.name.isEmpty == false ? info?.name : nil) ?? chat.name
let service = info?.service ?? chat.service
payloads.append(
chatPayload(
id: chat.id,
identifier: identifier,
guid: guid,
name: name,
service: service,
lastMessageAt: chat.lastMessageAt,
participants: participants
))
}
respond(id: id, result: ["chats": payloads])
}
private func handleMessagesHistory(id: Any?, params: [String: Any]) async throws {
guard let chatID = int64Param(params["chat_id"]) else {
throw RPCError.invalidParams("chat_id is required")
}
let limit = intParam(params["limit"]) ?? 50
let participants = stringArrayParam(params["participants"])
let startISO = stringParam(params["start"])
let endISO = stringParam(params["end"])
let includeAttachments = boolParam(params["attachments"]) ?? false
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: startISO,
endISO: endISO
)
let filtered = try store.messages(chatID: chatID, limit: max(limit, 1), filter: filter)
var payloads: [[String: Any]] = []
payloads.reserveCapacity(filtered.count)
for message in filtered {
let payload = try await buildMessagePayload(
store: store,
cache: cache,
message: message,
includeAttachments: includeAttachments
)
payloads.append(payload)
}
respond(id: id, result: ["messages": payloads])
}
private func handleWatchSubscribe(id: Any?, params: [String: Any]) async throws {
let chatID = int64Param(params["chat_id"])
let sinceRowID = int64Param(params["since_rowid"])
let participants = stringArrayParam(params["participants"])
let startISO = stringParam(params["start"])
let endISO = stringParam(params["end"])
let includeAttachments = boolParam(params["attachments"]) ?? false
let includeReactions = boolParam(params["include_reactions"]) ?? false
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: startISO,
endISO: endISO
)
let config = MessageWatcherConfiguration(includeReactions: includeReactions)
let subID = await subscriptions.allocateID()
let localStore = store
let localWatcher = watcher
let localCache = cache
let localWriter = output
let localFilter = filter
let localChatID = chatID
let localSinceRowID = sinceRowID
let localConfig = config
let localIncludeAttachments = includeAttachments
let task = Task {
do {
for try await message in localWatcher.stream(
chatID: localChatID,
sinceRowID: localSinceRowID,
configuration: localConfig
) {
if Task.isCancelled { return }
if !localFilter.allows(message) { continue }
let payload = try await buildMessagePayload(
store: localStore,
cache: localCache,
message: message,
includeAttachments: localIncludeAttachments
)
localWriter.sendNotification(
method: "message",
params: ["subscription": subID, "message": payload]
)
}
} catch {
localWriter.sendNotification(
method: "error",
params: [
"subscription": subID,
"error": ["message": String(describing: error)],
]
)
}
}
await subscriptions.insert(task, for: subID)
respond(id: id, result: ["subscription": subID])
}
private func handleWatchUnsubscribe(id: Any?, params: [String: Any]) async throws {
guard let subID = intParam(params["subscription"]) else {
throw RPCError.invalidParams("subscription is required")
}
if let task = await subscriptions.remove(subID) {
task.cancel()
}
respond(id: id, result: ["ok": true])
}
private func handleTyping(params: [String: Any], id: Any?, start: Bool) async throws {
let chatIdentifier = stringParam(params["chat_identifier"]) ?? ""
let chatGUID = stringParam(params["chat_guid"]) ?? ""
let recipient = stringParam(params["to"]) ?? ""
let chatID = int64Param(params["chat_id"])
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
if hasChatTarget && !recipient.isEmpty {
throw RPCError.invalidParams("use to or chat_*; not both")
}
if !hasChatTarget && recipient.isEmpty {
throw RPCError.invalidParams("to is required for direct typing")
}
let serviceRaw = stringParam(params["service"]) ?? "imessage"
guard let service = MessageService(rawValue: serviceRaw.lowercased()) else {
throw RPCError.invalidParams("invalid service")
}
var resolved = chatGUID.isEmpty ? chatIdentifier : chatGUID
if let chatID {
if let info = try await cache.info(chatID: chatID) {
resolved = info.guid.isEmpty ? info.identifier : info.guid
} else {
throw RPCError.invalidParams("unknown chat_id \(chatID)")
}
}
if resolved.isEmpty {
let svc = service == .sms ? "SMS" : "iMessage"
resolved = "\(svc);-;\(recipient)"
}
if start {
try startTyping(resolved)
} else {
try stopTyping(resolved)
}
respond(id: id, result: ["ok": true])
}
private func handleSend(params: [String: Any], id: Any?) async throws {
let text = stringParam(params["text"]) ?? ""
let file = stringParam(params["file"]) ?? ""
let serviceRaw = stringParam(params["service"]) ?? "auto"
guard let service = MessageService(rawValue: serviceRaw) else {
throw RPCError.invalidParams("invalid service")
}
let region = stringParam(params["region"]) ?? "US"
let chatID = int64Param(params["chat_id"])
let chatIdentifier = stringParam(params["chat_identifier"]) ?? ""
let chatGUID = stringParam(params["chat_guid"]) ?? ""
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
let recipient = stringParam(params["to"]) ?? ""
if hasChatTarget && !recipient.isEmpty {
throw RPCError.invalidParams("use to or chat_*; not both")
}
if !hasChatTarget && recipient.isEmpty {
throw RPCError.invalidParams("to is required for direct sends")
}
if text.isEmpty && file.isEmpty {
throw RPCError.invalidParams("text or file is required")
}
var resolvedChatIdentifier = chatIdentifier
var resolvedChatGUID = chatGUID
if let chatID {
guard let info = try await cache.info(chatID: chatID) else {
throw RPCError.invalidParams("unknown chat_id \(chatID)")
}
resolvedChatIdentifier = info.identifier
resolvedChatGUID = info.guid
}
if hasChatTarget && resolvedChatIdentifier.isEmpty && resolvedChatGUID.isEmpty {
throw RPCError.invalidParams("missing chat identifier or guid")
}
try sendMessage(
MessageSendOptions(
recipient: recipient,
text: text,
attachmentPath: file,
service: service,
region: region,
chatIdentifier: resolvedChatIdentifier,
chatGUID: resolvedChatGUID
)
)
respond(id: id, result: ["ok": true])
}
}
private func buildMessagePayload(
store: MessageStore,
cache: ChatCache,
message: Message,
includeAttachments: Bool
) async throws -> [String: Any] {
let chatInfo = try await cache.info(chatID: message.chatID)
let participants = try await cache.participants(chatID: message.chatID)
let attachments = includeAttachments ? try store.attachments(for: message.rowID) : []
let reactions = includeAttachments ? try store.reactions(for: message.rowID) : []
return try messagePayload(
message: message,
chatInfo: chatInfo,
participants: participants,
attachments: attachments,
reactions: reactions
)
}
private final class RPCWriter: RPCOutput, Sendable {
func sendResponse(id: Any, result: Any) {
send(["jsonrpc": "2.0", "id": id, "result": result])
}
func sendError(id: Any?, error: RPCError) {
let payload: [String: Any] = [
"jsonrpc": "2.0",
"id": id ?? NSNull(),
"error": error.asDictionary(),
]
send(payload)
}
func sendNotification(method: String, params: Any) {
send(["jsonrpc": "2.0", "method": method, "params": params])
}
private func send(_ object: Any) {
do {
let data = try JSONSerialization.data(withJSONObject: object, options: [])
if let output = String(data: data, encoding: .utf8) {
StdoutWriter.writeLine(output)
}
} catch {
StdoutWriter.writeLine(
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}"
)
}
}
}
struct RPCError: Error {
let code: Int
let message: String
let data: String?
static func parseError(_ message: String) -> RPCError {
RPCError(code: -32700, message: "Parse error", data: message)
}
static func invalidRequest(_ message: String) -> RPCError {
RPCError(code: -32600, message: "Invalid Request", data: message)
}
static func methodNotFound(_ method: String) -> RPCError {
RPCError(code: -32601, message: "Method not found", data: method)
}
static func invalidParams(_ message: String) -> RPCError {
RPCError(code: -32602, message: "Invalid params", data: message)
}
static func internalError(_ message: String) -> RPCError {
RPCError(code: -32603, message: "Internal error", data: message)
}
func asDictionary() -> [String: Any] {
var dict: [String: Any] = [
"code": code,
"message": message,
]
if let data {
dict["data"] = data
}
return dict
}
}
private actor SubscriptionStore {
private var nextID = 1
private var tasks: [Int: Task<Void, Never>] = [:]
func allocateID() -> Int {
let id = nextID
nextID += 1
return id
}
func insert(_ task: Task<Void, Never>, for id: Int) {
tasks[id] = task
}
func remove(_ id: Int) -> Task<Void, Never>? {
tasks.removeValue(forKey: id)
}
func cancelAll() {
for task in tasks.values {
task.cancel()
}
tasks.removeAll()
}
}
private actor ChatCache {
private let store: MessageStore
private var infoCache: [Int64: ChatInfo] = [:]
private var participantsCache: [Int64: [String]] = [:]
init(store: MessageStore) {
self.store = store
}
func info(chatID: Int64) throws -> ChatInfo? {
if let cached = infoCache[chatID] { return cached }
if let info = try store.chatInfo(chatID: chatID) {
infoCache[chatID] = info
return info
}
return nil
}
func participants(chatID: Int64) throws -> [String] {
if let cached = participantsCache[chatID] { return cached }
let participants = try store.participants(chatID: chatID)
participantsCache[chatID] = participants
return participants
}
}

View File

@ -11,9 +11,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.4.1</string>
<string>0.5.0</string>
<key>CFBundleVersion</key>
<string>0.4.1</string>
<string>0.5.0</string>
<key>NSAppleEventsUsageDescription</key>
<string>Send messages via Messages.app.</string>
</dict>

View File

@ -1,4 +1,4 @@
// Generated by scripts/generate-version.sh. Do not edit.
enum IMsgVersion {
static let current = "0.4.1"
static let current = "0.5.0"
}

View File

@ -10,10 +10,45 @@ enum CommandTestDatabase {
}
static func makePath() throws -> String {
let path = try makeDatabasePath()
let db = try Connection(path)
try createSchema(db, includeChatHandleJoin: false)
try seedBasicChat(db)
return path
}
static func makePathWithAttachment() throws -> String {
let path = try makePath()
let db = try Connection(path)
try db.run(
"""
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
VALUES (1, '/tmp/file.dat', 'file.dat', 'public.data', 'application/octet-stream', 10, 0)
"""
)
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
return path
}
static func makeStoreForRPC() throws -> MessageStore {
let db = try Connection(.inMemory)
try createSchema(db, includeChatHandleJoin: true)
try seedRPCChat(db)
return try MessageStore(
connection: db,
path: ":memory:",
hasAttributedBody: false,
hasReactionColumns: false
)
}
private static func makeDatabasePath() throws -> String {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("chat.db").path
let db = try Connection(path)
return dir.appendingPathComponent("chat.db").path
}
private static func createSchema(_ db: Connection, includeChatHandleJoin: Bool) throws {
try db.execute(
"""
CREATE TABLE message (
@ -38,6 +73,9 @@ enum CommandTestDatabase {
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
if includeChatHandleJoin {
try db.execute("CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);")
}
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
@ -54,7 +92,9 @@ enum CommandTestDatabase {
);
"""
)
}
private static func seedBasicChat(_ db: Connection) throws {
let now = Date()
try db.run(
"""
@ -71,19 +111,25 @@ enum CommandTestDatabase {
appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
return path
}
static func makePathWithAttachment() throws -> String {
let path = try makePath()
let db = try Connection(path)
private static func seedRPCChat(_ db: Connection) throws {
let now = Date()
try db.run(
"""
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
VALUES (1, '/tmp/file.dat', 'file.dat', 'public.data', 'application/octet-stream', 10, 0)
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES (1, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage')
"""
)
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
return path
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'me@icloud.com')")
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (5, 1, 'hello', ?, 0, 'iMessage')
""",
appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 5)")
}
}

View File

@ -1,83 +1,9 @@
import Foundation
import SQLite
import Testing
@testable import IMsgCore
@testable import imsg
private enum RPCTestDatabase {
static func appleEpoch(_ date: Date) -> Int64 {
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
return Int64(seconds * 1_000_000_000)
}
static func makeStore() throws -> MessageStore {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"""
CREATE TABLE attachment (
ROWID INTEGER PRIMARY KEY,
filename TEXT,
transfer_name TEXT,
uti TEXT,
mime_type TEXT,
total_bytes INTEGER,
is_sticker INTEGER
);
"""
)
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
let now = Date()
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES (1, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage')
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'me@icloud.com')")
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (5, 1, 'hello', ?, 0, 'iMessage')
""",
appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 5)")
return try MessageStore(
connection: db, path: ":memory:", hasAttributedBody: false, hasReactionColumns: false)
}
}
final class TestRPCOutput: RPCOutput, @unchecked Sendable {
private let lock = NSLock()
private(set) var responses: [[String: Any]] = []
@ -117,7 +43,7 @@ private func int64Value(_ value: Any?) -> Int64? {
@Test
func rpcChatsListReturnsChatPayload() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -137,7 +63,7 @@ func rpcChatsListReturnsChatPayload() async throws {
@Test
func rpcMessagesHistoryIncludesChatFields() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -156,7 +82,7 @@ func rpcMessagesHistoryIncludesChatFields() async throws {
@Test
func rpcSendResolvesChatID() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
var captured: MessageSendOptions?
let server = RPCServer(
@ -177,7 +103,7 @@ func rpcSendResolvesChatID() async throws {
@Test
func rpcSendRejectsMissingTextAndFile() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -191,7 +117,7 @@ func rpcSendRejectsMissingTextAndFile() async throws {
@Test
func rpcRejectsInvalidJSON() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -203,7 +129,7 @@ func rpcRejectsInvalidJSON() async throws {
@Test
func rpcRejectsNonObjectRequest() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -215,7 +141,7 @@ func rpcRejectsNonObjectRequest() async throws {
@Test
func rpcRejectsInvalidJSONRPCVersion() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -228,7 +154,7 @@ func rpcRejectsInvalidJSONRPCVersion() async throws {
@Test
func rpcRejectsMissingMethod() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -241,7 +167,7 @@ func rpcRejectsMissingMethod() async throws {
@Test
func rpcReportsMethodNotFound() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -254,7 +180,7 @@ func rpcReportsMethodNotFound() async throws {
@Test
func rpcHistoryRequiresChatID() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -267,7 +193,7 @@ func rpcHistoryRequiresChatID() async throws {
@Test
func rpcSendRejectsInvalidService() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -281,7 +207,7 @@ func rpcSendRejectsInvalidService() async throws {
@Test
func rpcSendRejectsMissingRecipientForDirectSend() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -294,7 +220,7 @@ func rpcSendRejectsMissingRecipientForDirectSend() async throws {
@Test
func rpcSendRejectsChatAndRecipient() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -308,7 +234,7 @@ func rpcSendRejectsChatAndRecipient() async throws {
@Test
func rpcSendRejectsUnknownChatID() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -321,7 +247,7 @@ func rpcSendRejectsUnknownChatID() async throws {
@Test
func rpcTypingStartResolvesSMSRecipient() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
var startedIdentifier: String?
let server = RPCServer(
@ -343,7 +269,7 @@ func rpcTypingStartResolvesSMSRecipient() async throws {
@Test
func rpcTypingStopResolvesChatID() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
var stoppedIdentifier: String?
let server = RPCServer(
@ -364,7 +290,7 @@ func rpcTypingStopResolvesChatID() async throws {
@Test
func rpcTypingRejectsInvalidService() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -378,7 +304,7 @@ func rpcTypingRejectsInvalidService() async throws {
@Test
func rpcTypingRejectsChatAndRecipient() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -392,7 +318,7 @@ func rpcTypingRejectsChatAndRecipient() async throws {
@Test
func rpcWatchSubscribeEmitsNotificationAndUnsubscribe() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)
@ -422,7 +348,7 @@ func rpcWatchSubscribeEmitsNotificationAndUnsubscribe() async throws {
@Test
func rpcWatchUnsubscribeRequiresSubscription() async throws {
let store = try RPCTestDatabase.makeStore()
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(store: store, verbose: false, output: output)

View File

@ -1 +1 @@
MARKETING_VERSION=0.4.1
MARKETING_VERSION=0.5.0