chore: merge origin/main into pr-31

This commit is contained in:
Peter Steinberger 2026-02-16 04:04:01 +01:00
commit b78027b251
23 changed files with 504 additions and 255 deletions

View File

@ -7,6 +7,8 @@
- 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: include `thread_originator_guid` in message output (#39, thanks @ruthmade)
## 0.4.0 - 2026-01-07
- feat: surface audio message transcriptions (thanks @antons)

View File

@ -2,68 +2,50 @@ import Foundation
import SQLite
extension MessageStore {
static func detectAttributedBody(connection: Connection) -> Bool {
static func tableColumns(connection: Connection, table: String) -> Set<String> {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
let rows = try connection.prepare("PRAGMA table_info(\(table))")
var columns = Set<String>()
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("attributedBody") == .orderedSame
{
return true
if let name = row[1] as? String {
columns.insert(name.lowercased())
}
}
return columns
} catch {
return false
return []
}
return false
}
static func reactionColumnsPresent(in columns: Set<String>) -> Bool {
return columns.contains("guid")
&& columns.contains("associated_message_guid")
&& columns.contains("associated_message_type")
}
static func detectReactionColumns(connection: Connection) -> Bool {
let columns = tableColumns(connection: connection, table: "message")
return reactionColumnsPresent(in: columns)
}
static func detectThreadOriginatorGUIDColumn(connection: Connection) -> Bool {
return tableColumns(connection: connection, table: "message").contains("thread_originator_guid")
}
static func detectAttributedBody(connection: Connection) -> Bool {
return tableColumns(connection: connection, table: "message").contains("attributedbody")
}
static func detectDestinationCallerID(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("destination_caller_id") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "message").contains("destination_caller_id")
}
static func detectAudioMessageColumn(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("is_audio_message") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "message").contains("is_audio_message")
}
static func detectAttachmentUserInfo(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(attachment)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("user_info") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
return tableColumns(connection: connection, table: "attachment").contains("user_info")
}
static func enhance(error: Error, path: String) -> Error {

View File

@ -13,6 +13,8 @@ extension MessageStore {
let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL"
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0"
let threadOriginatorColumn =
hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL"
let reactionFilter =
hasReactionColumns
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
@ -22,7 +24,8 @@ extension MessageStore {
\(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id,
\(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
\(bodyColumn) AS body
\(bodyColumn) AS body,
\(threadOriginatorColumn) AS thread_originator_guid
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
@ -57,23 +60,40 @@ extension MessageStore {
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, bindings) {
let rowID = int64Value(row[0]) ?? 0
let handleID = int64Value(row[1])
var sender = stringValue(row[2])
let text = stringValue(row[3])
let date = appleDate(from: int64Value(row[4]))
let isFromMe = boolValue(row[5])
let service = stringValue(row[6])
let isAudioMessage = boolValue(row[7])
let destinationCallerID = stringValue(row[8])
let colRowID = 0
let colHandleID = 1
let colSender = 2
let colText = 3
let colDate = 4
let colIsFromMe = 5
let colService = 6
let colIsAudioMessage = 7
let colDestinationCallerID = 8
let colGUID = 9
let colAssociatedGUID = 10
let colAssociatedType = 11
let colAttachments = 12
let colBody = 13
let colThreadOriginatorGUID = 14
let rowID = int64Value(row[colRowID]) ?? 0
let handleID = int64Value(row[colHandleID])
var sender = stringValue(row[colSender])
let text = stringValue(row[colText])
let date = appleDate(from: int64Value(row[colDate]))
let isFromMe = boolValue(row[colIsFromMe])
let service = stringValue(row[colService])
let isAudioMessage = boolValue(row[colIsAudioMessage])
let destinationCallerID = stringValue(row[colDestinationCallerID])
if sender.isEmpty && !destinationCallerID.isEmpty {
sender = destinationCallerID
}
let guid = stringValue(row[9])
let associatedGuid = stringValue(row[10])
let associatedType = intValue(row[11])
let attachments = intValue(row[12]) ?? 0
let body = dataValue(row[13])
let guid = stringValue(row[colGUID])
let associatedGuid = stringValue(row[colAssociatedGUID])
let associatedType = intValue(row[colAssociatedType])
let attachments = intValue(row[colAttachments]) ?? 0
let body = dataValue(row[colBody])
let threadOriginatorGUID = stringValue(row[colThreadOriginatorGUID])
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
resolvedText = transcription
@ -94,7 +114,8 @@ extension MessageStore {
handleID: handleID,
attachmentsCount: attachments,
guid: guid,
replyToGUID: replyToGUID
replyToGUID: replyToGUID,
threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID
))
}
return messages
@ -112,6 +133,8 @@ extension MessageStore {
let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL"
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0"
let threadOriginatorColumn =
hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL"
// Only filter out reactions if includeReactions is false
let reactionFilter: String
if includeReactions {
@ -126,7 +149,8 @@ extension MessageStore {
\(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id,
\(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
\(bodyColumn) AS body
\(bodyColumn) AS body,
\(threadOriginatorColumn) AS thread_originator_guid
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
@ -143,24 +167,42 @@ extension MessageStore {
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, bindings) {
let rowID = int64Value(row[0]) ?? 0
let resolvedChatID = int64Value(row[1]) ?? chatID ?? 0
let handleID = int64Value(row[2])
var sender = stringValue(row[3])
let text = stringValue(row[4])
let date = appleDate(from: int64Value(row[5]))
let isFromMe = boolValue(row[6])
let service = stringValue(row[7])
let isAudioMessage = boolValue(row[8])
let destinationCallerID = stringValue(row[9])
let colRowID = 0
let colChatID = 1
let colHandleID = 2
let colSender = 3
let colText = 4
let colDate = 5
let colIsFromMe = 6
let colService = 7
let colIsAudioMessage = 8
let colDestinationCallerID = 9
let colGUID = 10
let colAssociatedGUID = 11
let colAssociatedType = 12
let colAttachments = 13
let colBody = 14
let colThreadOriginatorGUID = 15
let rowID = int64Value(row[colRowID]) ?? 0
let resolvedChatID = int64Value(row[colChatID]) ?? chatID ?? 0
let handleID = int64Value(row[colHandleID])
var sender = stringValue(row[colSender])
let text = stringValue(row[colText])
let date = appleDate(from: int64Value(row[colDate]))
let isFromMe = boolValue(row[colIsFromMe])
let service = stringValue(row[colService])
let isAudioMessage = boolValue(row[colIsAudioMessage])
let destinationCallerID = stringValue(row[colDestinationCallerID])
if sender.isEmpty && !destinationCallerID.isEmpty {
sender = destinationCallerID
}
let guid = stringValue(row[10])
let associatedGuid = stringValue(row[11])
let associatedType = intValue(row[12])
let attachments = intValue(row[13]) ?? 0
let body = dataValue(row[14])
let guid = stringValue(row[colGUID])
let associatedGuid = stringValue(row[colAssociatedGUID])
let associatedType = intValue(row[colAssociatedType])
let attachments = intValue(row[colAttachments]) ?? 0
let body = dataValue(row[colBody])
let threadOriginatorGUID = stringValue(row[colThreadOriginatorGUID])
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
resolvedText = transcription
@ -198,6 +240,7 @@ extension MessageStore {
attachmentsCount: attachments,
guid: guid,
replyToGUID: replyToGUID,
threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID,
isReaction: isReactionEvent,
reactionType: reactionType,
isReactionAdd: isReactionAdd,

View File

@ -16,6 +16,7 @@ public final class MessageStore: @unchecked Sendable {
private let queueKey = DispatchSpecificKey<Void>()
let hasAttributedBody: Bool
let hasReactionColumns: Bool
let hasThreadOriginatorGUIDColumn: Bool
let hasDestinationCallerID: Bool
let hasAudioMessageColumn: Bool
let hasAttachmentUserInfo: Bool
@ -30,17 +31,17 @@ public final class MessageStore: @unchecked Sendable {
let location = Connection.Location.uri(uri, parameters: [.mode(.readOnly)])
self.connection = try Connection(location, readonly: true)
self.connection.busyTimeout = 5
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: self.connection)
self.hasReactionColumns = MessageStore.detectReactionColumns(connection: self.connection)
self.hasDestinationCallerID = MessageStore.detectDestinationCallerID(
connection: self.connection
)
self.hasAudioMessageColumn = MessageStore.detectAudioMessageColumn(
connection: self.connection
)
self.hasAttachmentUserInfo = MessageStore.detectAttachmentUserInfo(
connection: self.connection
let messageColumns = MessageStore.tableColumns(connection: self.connection, table: "message")
let attachmentColumns = MessageStore.tableColumns(
connection: self.connection,
table: "attachment"
)
self.hasAttributedBody = messageColumns.contains("attributedbody")
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
} catch {
throw MessageStore.enhance(error: error, path: normalized)
}
@ -51,6 +52,7 @@ public final class MessageStore: @unchecked Sendable {
path: String,
hasAttributedBody: Bool? = nil,
hasReactionColumns: Bool? = nil,
hasThreadOriginatorGUIDColumn: Bool? = nil,
hasDestinationCallerID: Bool? = nil,
hasAudioMessageColumn: Bool? = nil,
hasAttachmentUserInfo: Bool? = nil
@ -60,30 +62,37 @@ public final class MessageStore: @unchecked Sendable {
self.queue.setSpecific(key: queueKey, value: ())
self.connection = connection
self.connection.busyTimeout = 5
let messageColumns = MessageStore.tableColumns(connection: connection, table: "message")
let attachmentColumns = MessageStore.tableColumns(connection: connection, table: "attachment")
if let hasAttributedBody {
self.hasAttributedBody = hasAttributedBody
} else {
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: connection)
self.hasAttributedBody = messageColumns.contains("attributedbody")
}
if let hasReactionColumns {
self.hasReactionColumns = hasReactionColumns
} else {
self.hasReactionColumns = MessageStore.detectReactionColumns(connection: connection)
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
}
if let hasThreadOriginatorGUIDColumn {
self.hasThreadOriginatorGUIDColumn = hasThreadOriginatorGUIDColumn
} else {
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
}
if let hasDestinationCallerID {
self.hasDestinationCallerID = hasDestinationCallerID
} else {
self.hasDestinationCallerID = MessageStore.detectDestinationCallerID(connection: connection)
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
}
if let hasAudioMessageColumn {
self.hasAudioMessageColumn = hasAudioMessageColumn
} else {
self.hasAudioMessageColumn = MessageStore.detectAudioMessageColumn(connection: connection)
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
}
if let hasAttachmentUserInfo {
self.hasAttachmentUserInfo = hasAttachmentUserInfo
} else {
self.hasAttachmentUserInfo = MessageStore.detectAttachmentUserInfo(connection: connection)
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
}
}
@ -367,23 +376,6 @@ extension MessageStore {
return nil
}
private static func detectReactionColumns(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
var columns = Set<String>()
for row in rows {
if let name = row[1] as? String {
columns.insert(name.lowercased())
}
}
return columns.contains("guid")
&& columns.contains("associated_message_guid")
&& columns.contains("associated_message_type")
} catch {
return false
}
}
private struct ReactionKey: Hashable {
let sender: String
let isFromMe: Bool

View File

@ -221,6 +221,7 @@ public struct Message: Sendable, Equatable {
public let chatID: Int64
public let guid: String
public let replyToGUID: String?
public let threadOriginatorGUID: String?
public let sender: String
public let text: String
public let date: Date
@ -251,6 +252,7 @@ public struct Message: Sendable, Equatable {
attachmentsCount: Int,
guid: String = "",
replyToGUID: String? = nil,
threadOriginatorGUID: String? = nil,
isReaction: Bool = false,
reactionType: ReactionType? = nil,
isReactionAdd: Bool? = nil,
@ -260,6 +262,7 @@ public struct Message: Sendable, Equatable {
self.chatID = chatID
self.guid = guid
self.replyToGUID = replyToGUID
self.threadOriginatorGUID = threadOriginatorGUID
self.sender = sender
self.text = text
self.date = date

View File

@ -34,7 +34,7 @@ struct CommandRouter {
func run(argv: [String]) async -> Int32 {
let argv = normalizeArguments(argv)
if argv.contains("--version") || argv.contains("-V") {
Swift.print(version)
StdoutWriter.writeLine(version)
return 0
}
if argv.count <= 1 || argv.contains("--help") || argv.contains("-h") {
@ -47,7 +47,7 @@ struct CommandRouter {
guard let commandName = invocation.path.last,
let spec = specs.first(where: { $0.name == commandName })
else {
Swift.print("Unknown command")
StdoutWriter.writeLine("Unknown command")
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
return 1
}
@ -56,17 +56,17 @@ struct CommandRouter {
try await spec.run(invocation.parsedValues, runtime)
return 0
} catch {
Swift.print(error)
StdoutWriter.writeLine(String(describing: error))
return 1
}
} catch let error as CommanderProgramError {
Swift.print(error.description)
StdoutWriter.writeLine(error.description)
if case .missingSubcommand = error {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
}
return 1
} catch {
Swift.print(error)
StdoutWriter.writeLine(String(describing: error))
return 1
}
}

View File

@ -26,14 +26,14 @@ enum ChatsCommand {
if runtime.jsonOutput {
for chat in chats {
try JSONLines.print(ChatPayload(chat: chat))
try StdoutWriter.writeJSONLine(ChatPayload(chat: chat))
}
return
}
for chat in chats {
let last = CLIISO8601.format(chat.lastMessageAt)
Swift.print("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)")
StdoutWriter.writeLine("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)")
}
}
}

View File

@ -57,7 +57,7 @@ enum HistoryCommand {
attachments: attachments,
reactions: reactions
)
try JSONLines.print(payload)
try StdoutWriter.writeJSONLine(payload)
}
return
}
@ -65,18 +65,18 @@ enum HistoryCommand {
for message in filtered {
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
if message.attachmentsCount > 0 {
if showAttachments {
let metas = try store.attachments(for: message.rowID)
for meta in metas {
let name = displayName(for: meta)
Swift.print(
StdoutWriter.writeLine(
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
)
}
} else {
Swift.print(
StdoutWriter.writeLine(
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
)
}

View File

@ -91,9 +91,9 @@ enum SendCommand {
))
if runtime.jsonOutput {
try JSONLines.print(["status": "sent"])
try StdoutWriter.writeJSONLine(["status": "sent"])
} else {
Swift.print("sent")
StdoutWriter.writeLine("sent")
}
}
}

View File

@ -95,32 +95,31 @@ enum WatchCommand {
attachments: attachments,
reactions: reactions
)
try JSONLines.print(payload)
try StdoutWriter.writeJSONLine(payload)
continue
}
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
// Format reaction events differently
if message.isReaction, let reactionType = message.reactionType {
let action = (message.isReactionAdd ?? true) ? "added" : "removed"
let targetGUID = message.reactedToGUID ?? "unknown"
Swift.print("\(timestamp) [\(direction)] \(message.sender) \(action) \(reactionType.emoji) reaction to \(targetGUID)")
StdoutWriter.writeLine(
"\(timestamp) [\(direction)] \(message.sender) \(action) \(reactionType.emoji) reaction to \(targetGUID)"
)
continue
}
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
if message.attachmentsCount > 0 {
if showAttachments {
let metas = try store.attachments(for: message.rowID)
for meta in metas {
let name = displayName(for: meta)
Swift.print(
StdoutWriter.writeLine(
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
)
}
} else {
Swift.print(
StdoutWriter.writeLine(
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
)
}

View File

@ -4,13 +4,13 @@ import Foundation
struct HelpPrinter {
static func printRoot(version: String, rootName: String, commands: [CommandSpec]) {
for line in renderRoot(version: version, rootName: rootName, commands: commands) {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}
static func printCommand(rootName: String, spec: CommandSpec) {
for line in renderCommand(rootName: rootName, spec: spec) {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}

View File

@ -15,7 +15,7 @@ enum JSONLines {
static func print<T: Encodable>(_ value: T) throws {
let line = try encode(value)
if !line.isEmpty {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}
}

View File

@ -30,6 +30,7 @@ struct MessagePayload: Codable {
let chatID: Int64
let guid: String
let replyToGUID: String?
let threadOriginatorGUID: String?
let sender: String
let isFromMe: Bool
let text: String
@ -49,6 +50,7 @@ struct MessagePayload: Codable {
self.chatID = message.chatID
self.guid = message.guid
self.replyToGUID = message.replyToGUID
self.threadOriginatorGUID = message.threadOriginatorGUID
self.sender = message.sender
self.isFromMe = message.isFromMe
self.text = message.text
@ -77,6 +79,7 @@ struct MessagePayload: Codable {
case chatID = "chat_id"
case guid
case replyToGUID = "reply_to_guid"
case threadOriginatorGUID = "thread_originator_guid"
case sender
case isFromMe = "is_from_me"
case text

View File

@ -65,6 +65,9 @@ func messagePayload(
payload["reacted_to_guid"] = reactedToGUID
}
}
if let threadOriginatorGUID = message.threadOriginatorGUID, !threadOriginatorGUID.isEmpty {
payload["thread_originator_guid"] = threadOriginatorGUID
}
return payload
}

View File

@ -287,8 +287,6 @@ private func buildMessagePayload(
}
private final class RPCWriter: RPCOutput, @unchecked Sendable {
private let queue = DispatchQueue(label: "imsg.rpc.writer")
func sendResponse(id: Any, result: Any) {
send(["jsonrpc": "2.0", "id": id, "result": result])
}
@ -307,21 +305,15 @@ private final class RPCWriter: RPCOutput, @unchecked Sendable {
}
private func send(_ object: Any) {
queue.sync {
do {
let data = try JSONSerialization.data(withJSONObject: object, options: [])
if let output = String(data: data, encoding: .utf8) {
FileHandle.standardOutput.write(Data(output.utf8))
FileHandle.standardOutput.write(Data("\n".utf8))
}
} catch {
if let fallback =
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}\n"
.data(using: .utf8)
{
FileHandle.standardOutput.write(fallback)
}
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\"}}"
)
}
}
}

View File

@ -0,0 +1,24 @@
import Dispatch
import Foundation
enum StdoutWriter {
private static let queue = DispatchQueue(label: "imsg.stdout.writer")
private static let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
return encoder
}()
static func writeLine(_ line: String) {
queue.sync {
FileHandle.standardOutput.write(Data((line + "\n").utf8))
}
}
static func writeJSONLine<T: Encodable>(_ value: T) throws {
let data = try jsonEncoder.encode(value)
guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return }
writeLine(line)
}
}

View File

@ -4,7 +4,8 @@ import Testing
@testable import IMsgCore
private func normalizeForTest(_ input: String) -> String {
let result = input
let result =
input
.replacingOccurrences(of: "imessage:", with: "")
.replacingOccurrences(of: "sms:", with: "")
.replacingOccurrences(of: "auto:", with: "")

View File

@ -4,6 +4,32 @@ import Testing
@testable import IMsgCore
private func makeInMemoryMessageDB(includeThreadOriginatorGUID: Bool = false) throws -> Connection {
let db = try Connection(.inMemory)
let threadOriginatorColumn = includeThreadOriginatorGUID ? "thread_originator_guid TEXT," : ""
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
\(threadOriginatorColumn)
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
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);")
return db
}
@Test
func listChatsReturnsChat() throws {
let store = try TestDatabase.makeStore()
@ -113,26 +139,7 @@ func messagesAfterReturnsMessages() throws {
@Test
func messagesAfterExcludesReactionRows() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
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);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -172,26 +179,7 @@ func messagesAfterExcludesReactionRows() throws {
@Test
func messagesExcludeReactionRows() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
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);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -220,26 +208,7 @@ func messagesExcludeReactionRows() throws {
@Test
func messagesExposeReplyToGuid() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
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);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -269,26 +238,7 @@ func messagesExposeReplyToGuid() throws {
@Test
func messagesReplyToGuidHandlesNoPrefix() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
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);")
let db = try makeInMemoryMessageDB()
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
@ -315,6 +265,30 @@ func messagesReplyToGuidHandlesNoPrefix() throws {
#expect(reply?.replyToGUID == "msg-guid-1")
}
@Test
func messagesExposeThreadOriginatorGuidWhenAvailable() throws {
let db = try makeInMemoryMessageDB(includeThreadOriginatorGUID: true)
let now = Date()
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
thread_originator_guid, date, is_from_me, service
)
VALUES (1, 1, 'hello', 'msg-guid-1', NULL, 0, 'thread-guid-1', ?, 0, 'iMessage')
""",
TestDatabase.appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
let store = try MessageStore(connection: db, path: ":memory:")
let messages = try store.messages(chatID: 1, limit: 10)
let message = messages.first { $0.rowID == 1 }
#expect(message?.threadOriginatorGUID == "thread-guid-1")
}
@Test
func attachmentsByMessageReturnsMetadata() throws {
let store = try TestDatabase.makeStore()

View File

@ -4,25 +4,31 @@ import Testing
@testable import imsg
@Test
func commandRouterPrintsVersionFromEnv() async throws {
func commandRouterPrintsVersionFromEnv() async {
setenv("IMSG_VERSION", "9.9.9-test", 1)
defer { unsetenv("IMSG_VERSION") }
let router = CommandRouter()
#expect(router.version == "9.9.9-test")
let status = await router.run(argv: ["imsg", "--version"])
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "--version"])
}
#expect(status == 0)
}
@Test
func commandRouterPrintsHelp() async {
let router = CommandRouter()
let status = await router.run(argv: ["imsg", "--help"])
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "--help"])
}
#expect(status == 0)
}
@Test
func commandRouterUnknownCommand() async {
let router = CommandRouter()
let status = await router.run(argv: ["imsg", "nope"])
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "nope"])
}
#expect(status == 1)
}

View File

@ -100,7 +100,9 @@ func chatsCommandRunsWithJsonOutput() async throws {
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
try await ChatsCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await ChatsCommand.spec.run(values, runtime)
}
}
@Test
@ -112,7 +114,9 @@ func historyCommandRunsWithChatID() async throws {
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
try await HistoryCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await HistoryCommand.spec.run(values, runtime)
}
}
@Test
@ -124,7 +128,9 @@ func historyCommandRunsWithAttachmentsNonJson() async throws {
flags: ["attachments"]
)
let runtime = RuntimeOptions(parsedValues: values)
try await HistoryCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await HistoryCommand.spec.run(values, runtime)
}
}
@Test
@ -136,7 +142,9 @@ func chatsCommandRunsWithPlainOutput() async throws {
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
try await ChatsCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await ChatsCommand.spec.run(values, runtime)
}
}
@Test
@ -281,7 +289,9 @@ func watchCommandRejectsInvalidDebounce() async {
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await WatchCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await WatchCommand.spec.run(values, runtime)
}
#expect(Bool(false))
} catch let error as ParsedValuesError {
#expect(error.description.contains("Invalid value"))
@ -328,12 +338,14 @@ func watchCommandRunsWithStubStream() async throws {
continuation.finish()
}
}
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
_ = try await StdoutCapture.capture {
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
}
}
@Test
@ -397,10 +409,140 @@ func watchCommandRunsWithJsonOutput() async throws {
continuation.finish()
}
}
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
_ = try await StdoutCapture.capture {
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
}
}
@Test
func watchCommandFlushesPlainOutput() async throws {
let values = ParsedValues(
positional: [],
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
let db = try Connection(.inMemory)
let store = try MessageStore(
connection: db,
path: ":memory:",
hasAttributedBody: false,
hasReactionColumns: false
)
let message = Message(
rowID: 1,
chatID: 1,
sender: "+123",
text: "hello",
date: Date(),
isFromMe: false,
service: "iMessage",
handleID: nil,
attachmentsCount: 0
)
let streamProvider:
(
MessageWatcher,
Int64?,
Int64?,
MessageWatcherConfiguration
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
AsyncThrowingStream { continuation in
continuation.yield(message)
continuation.finish()
}
}
let (output, _) = try await StdoutCapture.capture {
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
}
#expect(output.contains("hello"))
}
@Test
func watchCommandFlushesJsonOutput() async throws {
let values = ParsedValues(
positional: [],
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let db = try Connection(.inMemory)
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);")
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
let store = try MessageStore(
connection: db,
path: ":memory:",
hasAttributedBody: false,
hasReactionColumns: false
)
let message = Message(
rowID: 1,
chatID: 1,
sender: "+123",
text: "hello",
date: Date(),
isFromMe: false,
service: "iMessage",
handleID: nil,
attachmentsCount: 0
)
let streamProvider:
(
MessageWatcher,
Int64?,
Int64?,
MessageWatcherConfiguration
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
AsyncThrowingStream { continuation in
continuation.yield(message)
continuation.finish()
}
}
let (output, _) = try await StdoutCapture.capture {
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
}
#expect(output.contains("\"text\":\"hello\""))
}

View File

@ -42,7 +42,8 @@ func messagePayloadIncludesChatFields() {
handleID: nil,
attachmentsCount: 1,
guid: "msg-guid-5",
replyToGUID: "msg-guid-1"
replyToGUID: "msg-guid-1",
threadOriginatorGUID: "thread-guid-5"
)
let chatInfo = ChatInfo(
id: 10,
@ -79,6 +80,7 @@ func messagePayloadIncludesChatFields() {
#expect(payload["chat_id"] as? Int64 == 10)
#expect(payload["guid"] as? String == "msg-guid-5")
#expect(payload["reply_to_guid"] as? String == "msg-guid-1")
#expect(payload["thread_originator_guid"] as? String == "thread-guid-5")
#expect(payload["chat_identifier"] as? String == "iMessage;+;chat123")
#expect(payload["chat_name"] as? String == "Group")
#expect(payload["is_group"] as? Bool == true)
@ -111,6 +113,7 @@ func messagePayloadOmitsEmptyReplyToGuid() {
reactions: []
)
#expect(payload["reply_to_guid"] == nil)
#expect(payload["thread_originator_guid"] == nil)
#expect(payload["guid"] as? String == "msg-guid-6")
}

View File

@ -0,0 +1,78 @@
import Darwin
import Foundation
private actor StdoutCaptureLock {
private var isLocked = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func acquire() async {
if !isLocked {
isLocked = true
return
}
await withCheckedContinuation { continuation in
waiters.append(continuation)
}
}
func release() {
if waiters.isEmpty {
isLocked = false
return
}
let next = waiters.removeFirst()
next.resume()
}
}
enum StdoutCapture {
private static let lock = StdoutCaptureLock()
static func capture<T>(_ body: () async throws -> T) async rethrows -> (output: String, value: T)
{
await lock.acquire()
var fds: [Int32] = [0, 0]
guard pipe(&fds) == 0 else {
await lock.release()
fatalError("pipe() failed")
}
let readFD = fds[0]
let writeFD = fds[1]
let savedStdout = dup(STDOUT_FILENO)
guard savedStdout >= 0 else {
close(readFD)
close(writeFD)
await lock.release()
fatalError("dup(STDOUT_FILENO) failed")
}
guard dup2(writeFD, STDOUT_FILENO) >= 0 else {
close(readFD)
close(writeFD)
close(savedStdout)
await lock.release()
fatalError("dup2(writeFD, STDOUT_FILENO) failed")
}
close(writeFD)
do {
let value = try await body()
_ = dup2(savedStdout, STDOUT_FILENO)
close(savedStdout)
let handle = FileHandle(fileDescriptor: readFD, closeOnDealloc: true)
let data = handle.readDataToEndOfFile()
await lock.release()
return (String(data: data, encoding: .utf8) ?? "", value)
} catch {
_ = dup2(savedStdout, STDOUT_FILENO)
close(savedStdout)
close(readFD)
await lock.release()
throw error
}
}
}

View File

@ -83,7 +83,8 @@ func outputModelsEncodeExpectedKeys() throws {
handleID: nil,
attachmentsCount: 0,
guid: "msg-guid-7",
replyToGUID: "msg-guid-1"
replyToGUID: "msg-guid-1",
threadOriginatorGUID: "thread-guid-7"
)
let attachment = AttachmentMeta(
filename: "file.dat",
@ -110,6 +111,7 @@ func outputModelsEncodeExpectedKeys() throws {
#expect(messageObject?["chat_id"] as? Int64 == 1)
#expect(messageObject?["guid"] as? String == "msg-guid-7")
#expect(messageObject?["reply_to_guid"] as? String == "msg-guid-1")
#expect(messageObject?["thread_originator_guid"] as? String == "thread-guid-7")
#expect(messageObject?["created_at"] != nil)
let attachmentPayload = AttachmentPayload(meta: attachment)