From 057b7c5a91a0ae49a784038ec061698670d9d6a2 Mon Sep 17 00:00:00 2001 From: Ru Date: Wed, 4 Feb 2026 10:20:56 -0600 Subject: [PATCH 1/5] feat: add thread_originator_guid to message output Adds thread_originator_guid field to JSON output for history, watch, and RPC. This field contains the GUID of the message being replied to when users use iMessage's inline reply feature. This is the correct field for reply detection - it matches the UI's reply target, unlike reply_to_guid which can point to different messages. Closes #30 Co-Authored-By: Claude --- Sources/IMsgCore/MessageStore+Messages.swift | 16 ++++++++++++---- Sources/IMsgCore/Models.swift | 5 ++++- Sources/imsg/OutputModels.swift | 3 +++ Sources/imsg/RPCPayloads.swift | 3 +++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 0ccee4a..2694331 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -13,6 +13,7 @@ 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 = hasReactionColumns ? "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 +23,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 @@ -74,6 +76,7 @@ extension MessageStore { let associatedType = intValue(row[11]) let attachments = intValue(row[12]) ?? 0 let body = dataValue(row[13]) + let threadOriginatorGUID = stringValue(row[14]) var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text if isAudioMessage, let transcription = try audioTranscription(for: rowID) { resolvedText = transcription @@ -94,7 +97,8 @@ extension MessageStore { handleID: handleID, attachmentsCount: attachments, guid: guid, - replyToGUID: replyToGUID + replyToGUID: replyToGUID, + threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID )) } return messages @@ -108,6 +112,7 @@ 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 = hasReactionColumns ? "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)" @@ -117,7 +122,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 @@ -152,6 +158,7 @@ extension MessageStore { let associatedType = intValue(row[12]) let attachments = intValue(row[13]) ?? 0 let body = dataValue(row[14]) + let threadOriginatorGUID = stringValue(row[15]) var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text if isAudioMessage, let transcription = try audioTranscription(for: rowID) { resolvedText = transcription @@ -172,7 +179,8 @@ extension MessageStore { handleID: handleID, attachmentsCount: attachments, guid: guid, - replyToGUID: replyToGUID + replyToGUID: replyToGUID, + threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID )) } return messages diff --git a/Sources/IMsgCore/Models.swift b/Sources/IMsgCore/Models.swift index ca15e9d..de2e893 100644 --- a/Sources/IMsgCore/Models.swift +++ b/Sources/IMsgCore/Models.swift @@ -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 @@ -240,12 +241,14 @@ public struct Message: Sendable, Equatable { handleID: Int64?, attachmentsCount: Int, guid: String = "", - replyToGUID: String? = nil + replyToGUID: String? = nil, + threadOriginatorGUID: String? = nil ) { self.rowID = rowID self.chatID = chatID self.guid = guid self.replyToGUID = replyToGUID + self.threadOriginatorGUID = threadOriginatorGUID self.sender = sender self.text = text self.date = date diff --git a/Sources/imsg/OutputModels.swift b/Sources/imsg/OutputModels.swift index ef8e477..25dd1b7 100644 --- a/Sources/imsg/OutputModels.swift +++ b/Sources/imsg/OutputModels.swift @@ -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 @@ -42,6 +43,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 @@ -55,6 +57,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 diff --git a/Sources/imsg/RPCPayloads.swift b/Sources/imsg/RPCPayloads.swift index ae71090..5e3c8ad 100644 --- a/Sources/imsg/RPCPayloads.swift +++ b/Sources/imsg/RPCPayloads.swift @@ -51,6 +51,9 @@ func messagePayload( if let replyToGUID = message.replyToGUID, !replyToGUID.isEmpty { payload["reply_to_guid"] = replyToGUID } + if let threadOriginatorGUID = message.threadOriginatorGUID, !threadOriginatorGUID.isEmpty { + payload["thread_originator_guid"] = threadOriginatorGUID + } return payload } From f9258472c84d2aed66113e01fabb74c77c0fed4c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 14:13:35 +0100 Subject: [PATCH 2/5] fix: detect thread_originator_guid column (#39) (thanks @ruthmade) --- CHANGELOG.md | 1 + Sources/IMsgCore/MessageStore+Helpers.swift | 16 +++++++ Sources/IMsgCore/MessageStore+Messages.swift | 6 ++- Sources/IMsgCore/MessageStore.swift | 12 ++++++ Tests/IMsgCoreTests/MessageStoreTests.swift | 44 ++++++++++++++++++++ Tests/imsgTests/RPCPayloadsTests.swift | 5 ++- Tests/imsgTests/UtilitiesTests.swift | 4 +- 7 files changed, 84 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c03458..d4c92e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - fix: prefer handle sends when chat identifier is a direct handle - fix: apply history filters before limit (#20, thanks @tommybananas) +- feat: include `thread_originator_guid` in message output (#39, thanks @ruthmade) ## 0.4.0 - 2026-01-07 - feat: surface audio message transcriptions (thanks @antons) diff --git a/Sources/IMsgCore/MessageStore+Helpers.swift b/Sources/IMsgCore/MessageStore+Helpers.swift index c567566..94d2ea7 100644 --- a/Sources/IMsgCore/MessageStore+Helpers.swift +++ b/Sources/IMsgCore/MessageStore+Helpers.swift @@ -2,6 +2,22 @@ import Foundation import SQLite extension MessageStore { + static func detectThreadOriginatorGUIDColumn(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("thread_originator_guid") == .orderedSame + { + return true + } + } + } catch { + return false + } + return false + } + static func detectAttributedBody(connection: Connection) -> Bool { do { let rows = try connection.prepare("PRAGMA table_info(message)") diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 2694331..18716b6 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -13,7 +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 = hasReactionColumns ? "m.thread_originator_guid" : "NULL" + 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)" @@ -112,7 +113,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 = hasReactionColumns ? "m.thread_originator_guid" : "NULL" + 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)" diff --git a/Sources/IMsgCore/MessageStore.swift b/Sources/IMsgCore/MessageStore.swift index 7a4ff63..b4e7755 100644 --- a/Sources/IMsgCore/MessageStore.swift +++ b/Sources/IMsgCore/MessageStore.swift @@ -16,6 +16,7 @@ public final class MessageStore: @unchecked Sendable { private let queueKey = DispatchSpecificKey() let hasAttributedBody: Bool let hasReactionColumns: Bool + let hasThreadOriginatorGUIDColumn: Bool let hasDestinationCallerID: Bool let hasAudioMessageColumn: Bool let hasAttachmentUserInfo: Bool @@ -32,6 +33,9 @@ public final class MessageStore: @unchecked Sendable { self.connection.busyTimeout = 5 self.hasAttributedBody = MessageStore.detectAttributedBody(connection: self.connection) self.hasReactionColumns = MessageStore.detectReactionColumns(connection: self.connection) + self.hasThreadOriginatorGUIDColumn = MessageStore.detectThreadOriginatorGUIDColumn( + connection: self.connection + ) self.hasDestinationCallerID = MessageStore.detectDestinationCallerID( connection: self.connection ) @@ -51,6 +55,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 @@ -70,6 +75,13 @@ public final class MessageStore: @unchecked Sendable { } else { self.hasReactionColumns = MessageStore.detectReactionColumns(connection: connection) } + if let hasThreadOriginatorGUIDColumn { + self.hasThreadOriginatorGUIDColumn = hasThreadOriginatorGUIDColumn + } else { + self.hasThreadOriginatorGUIDColumn = MessageStore.detectThreadOriginatorGUIDColumn( + connection: connection + ) + } if let hasDestinationCallerID { self.hasDestinationCallerID = hasDestinationCallerID } else { diff --git a/Tests/IMsgCoreTests/MessageStoreTests.swift b/Tests/IMsgCoreTests/MessageStoreTests.swift index 0cd9202..bdc59b7 100644 --- a/Tests/IMsgCoreTests/MessageStoreTests.swift +++ b/Tests/IMsgCoreTests/MessageStoreTests.swift @@ -315,6 +315,50 @@ func messagesReplyToGuidHandlesNoPrefix() throws { #expect(reply?.replyToGUID == "msg-guid-1") } +@Test +func messagesExposeThreadOriginatorGuidWhenAvailable() 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, + thread_originator_guid TEXT, + 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 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() diff --git a/Tests/imsgTests/RPCPayloadsTests.swift b/Tests/imsgTests/RPCPayloadsTests.swift index f4deb6a..f290df6 100644 --- a/Tests/imsgTests/RPCPayloadsTests.swift +++ b/Tests/imsgTests/RPCPayloadsTests.swift @@ -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") } diff --git a/Tests/imsgTests/UtilitiesTests.swift b/Tests/imsgTests/UtilitiesTests.swift index 1087171..753db1e 100644 --- a/Tests/imsgTests/UtilitiesTests.swift +++ b/Tests/imsgTests/UtilitiesTests.swift @@ -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) From 5b5c8bcc506903eb2d93996407ceb917ffc21add Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 14:31:14 +0100 Subject: [PATCH 3/5] refactor: consolidate schema detection --- Sources/IMsgCore/MessageStore+Helpers.swift | 86 ++++-------- Sources/IMsgCore/MessageStore+Messages.swift | 95 ++++++++----- Sources/IMsgCore/MessageStore.swift | 56 +++----- Tests/IMsgCoreTests/MessageStoreTests.swift | 132 +++++-------------- 4 files changed, 139 insertions(+), 230 deletions(-) diff --git a/Sources/IMsgCore/MessageStore+Helpers.swift b/Sources/IMsgCore/MessageStore+Helpers.swift index 94d2ea7..02be78c 100644 --- a/Sources/IMsgCore/MessageStore+Helpers.swift +++ b/Sources/IMsgCore/MessageStore+Helpers.swift @@ -2,84 +2,50 @@ import Foundation import SQLite extension MessageStore { - static func detectThreadOriginatorGUIDColumn(connection: Connection) -> Bool { + static func tableColumns(connection: Connection, table: String) -> Set { do { - let rows = try connection.prepare("PRAGMA table_info(message)") + let rows = try connection.prepare("PRAGMA table_info(\(table))") + var columns = Set() for row in rows { - if let name = row[1] as? String, - name.caseInsensitiveCompare("thread_originator_guid") == .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) -> 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 { - do { - let rows = try connection.prepare("PRAGMA table_info(message)") - for row in rows { - if let name = row[1] as? String, - name.caseInsensitiveCompare("attributedBody") == .orderedSame - { - return true - } - } - } catch { - return false - } - return false + 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 { diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 18716b6..c602f38 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -60,24 +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 threadOriginatorGUID = stringValue(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 @@ -142,25 +158,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 threadOriginatorGUID = stringValue(row[15]) + 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 diff --git a/Sources/IMsgCore/MessageStore.swift b/Sources/IMsgCore/MessageStore.swift index b4e7755..739838f 100644 --- a/Sources/IMsgCore/MessageStore.swift +++ b/Sources/IMsgCore/MessageStore.swift @@ -31,20 +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.hasThreadOriginatorGUIDColumn = MessageStore.detectThreadOriginatorGUIDColumn( - 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) } @@ -65,37 +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 = MessageStore.detectThreadOriginatorGUIDColumn( - connection: connection - ) + 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") } } @@ -379,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() - 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 diff --git a/Tests/IMsgCoreTests/MessageStoreTests.swift b/Tests/IMsgCoreTests/MessageStoreTests.swift index bdc59b7..44cd525 100644 --- a/Tests/IMsgCoreTests/MessageStoreTests.swift +++ b/Tests/IMsgCoreTests/MessageStoreTests.swift @@ -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')") @@ -317,27 +267,7 @@ func messagesReplyToGuidHandlesNoPrefix() throws { @Test func messagesExposeThreadOriginatorGuidWhenAvailable() 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, - thread_originator_guid TEXT, - 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(includeThreadOriginatorGUID: true) let now = Date() try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')") From ee3c085070cf6b9f94da6f841c7520bdca87bbe4 Mon Sep 17 00:00:00 2001 From: Carl Caum Date: Sun, 15 Feb 2026 18:17:42 -0800 Subject: [PATCH 4/5] Fix watch command stdout buffering (#43) * Fix watch command stdout buffering The watch command was not producing any output because stdout was not being flushed after printing JSON lines. This caused the watch functionality to appear broken even though message detection was working correctly. Added fflush(stdout) call after Swift.print() to ensure immediate output delivery, fixing both CLI watch mode and RPC watch notifications. Fixes: Messages detected but not displayed Testing: Verified with 'imsg watch --chat-id 1 --json' - messages now appear immediately * fix: flush watch stdout buffering (#43) (thanks @ccaum) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + Sources/imsg/Commands/WatchCommand.swift | 3 + .../MessageSenderChatTargetTests.swift | 3 +- Tests/imsgTests/CommandTests.swift | 128 ++++++++++++++++++ Tests/imsgTests/StdoutCapture.swift | 78 +++++++++++ 5 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 Tests/imsgTests/StdoutCapture.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c92e3..846641e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - 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 diff --git a/Sources/imsg/Commands/WatchCommand.swift b/Sources/imsg/Commands/WatchCommand.swift index 72b3fb8..abc86a7 100644 --- a/Sources/imsg/Commands/WatchCommand.swift +++ b/Sources/imsg/Commands/WatchCommand.swift @@ -1,4 +1,5 @@ import Commander +import Darwin import Foundation import IMsgCore @@ -90,6 +91,7 @@ enum WatchCommand { reactions: reactions ) try JSONLines.print(payload) + fflush(stdout) continue } let direction = message.isFromMe ? "sent" : "recv" @@ -110,6 +112,7 @@ enum WatchCommand { ) } } + fflush(stdout) } } } diff --git a/Tests/IMsgCoreTests/MessageSenderChatTargetTests.swift b/Tests/IMsgCoreTests/MessageSenderChatTargetTests.swift index 5e8ef54..71b12d0 100644 --- a/Tests/IMsgCoreTests/MessageSenderChatTargetTests.swift +++ b/Tests/IMsgCoreTests/MessageSenderChatTargetTests.swift @@ -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: "") diff --git a/Tests/imsgTests/CommandTests.swift b/Tests/imsgTests/CommandTests.swift index 9f5a7a3..c389875 100644 --- a/Tests/imsgTests/CommandTests.swift +++ b/Tests/imsgTests/CommandTests.swift @@ -327,3 +327,131 @@ func watchCommandRunsWithJsonOutput() async throws { 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 = { _, _, _, _ 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 = { _, _, _, _ 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\"")) +} diff --git a/Tests/imsgTests/StdoutCapture.swift b/Tests/imsgTests/StdoutCapture.swift new file mode 100644 index 0000000..e937a29 --- /dev/null +++ b/Tests/imsgTests/StdoutCapture.swift @@ -0,0 +1,78 @@ +import Darwin +import Foundation + +private actor StdoutCaptureLock { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + 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(_ 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 + } + } +} From 1b27e3b12c968e9d043e3c4eda9f23d602438f34 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 03:49:39 +0100 Subject: [PATCH 5/5] refactor: centralize stdout writing (#49) --- Sources/imsg/CommandRouter.swift | 10 ++--- Sources/imsg/Commands/ChatsCommand.swift | 4 +- Sources/imsg/Commands/HistoryCommand.swift | 8 ++-- Sources/imsg/Commands/SendCommand.swift | 4 +- Sources/imsg/Commands/WatchCommand.swift | 11 ++--- Sources/imsg/HelpPrinter.swift | 4 +- Sources/imsg/JSONLines.swift | 2 +- Sources/imsg/RPCServer.swift | 24 ++++------- Sources/imsg/StdoutWriter.swift | 24 +++++++++++ Tests/imsgTests/CommandRouterTests.swift | 14 +++++-- Tests/imsgTests/CommandTests.swift | 48 ++++++++++++++-------- 11 files changed, 93 insertions(+), 60 deletions(-) create mode 100644 Sources/imsg/StdoutWriter.swift diff --git a/Sources/imsg/CommandRouter.swift b/Sources/imsg/CommandRouter.swift index 7d819f9..d198e6f 100644 --- a/Sources/imsg/CommandRouter.swift +++ b/Sources/imsg/CommandRouter.swift @@ -33,7 +33,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") { @@ -46,7 +46,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 } @@ -55,17 +55,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 } } diff --git a/Sources/imsg/Commands/ChatsCommand.swift b/Sources/imsg/Commands/ChatsCommand.swift index dae76dc..baa447b 100644 --- a/Sources/imsg/Commands/ChatsCommand.swift +++ b/Sources/imsg/Commands/ChatsCommand.swift @@ -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)") } } } diff --git a/Sources/imsg/Commands/HistoryCommand.swift b/Sources/imsg/Commands/HistoryCommand.swift index 2d6c057..407eb79 100644 --- a/Sources/imsg/Commands/HistoryCommand.swift +++ b/Sources/imsg/Commands/HistoryCommand.swift @@ -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)))" ) } diff --git a/Sources/imsg/Commands/SendCommand.swift b/Sources/imsg/Commands/SendCommand.swift index 3c9a707..3fdad4f 100644 --- a/Sources/imsg/Commands/SendCommand.swift +++ b/Sources/imsg/Commands/SendCommand.swift @@ -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") } } } diff --git a/Sources/imsg/Commands/WatchCommand.swift b/Sources/imsg/Commands/WatchCommand.swift index abc86a7..36b2797 100644 --- a/Sources/imsg/Commands/WatchCommand.swift +++ b/Sources/imsg/Commands/WatchCommand.swift @@ -1,5 +1,4 @@ import Commander -import Darwin import Foundation import IMsgCore @@ -90,29 +89,27 @@ enum WatchCommand { attachments: attachments, reactions: reactions ) - try JSONLines.print(payload) - fflush(stdout) + try StdoutWriter.writeJSONLine(payload) continue } 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)))" ) } } - fflush(stdout) } } } diff --git a/Sources/imsg/HelpPrinter.swift b/Sources/imsg/HelpPrinter.swift index 14a9209..a0bba67 100644 --- a/Sources/imsg/HelpPrinter.swift +++ b/Sources/imsg/HelpPrinter.swift @@ -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) } } diff --git a/Sources/imsg/JSONLines.swift b/Sources/imsg/JSONLines.swift index a34e46f..893a4be 100644 --- a/Sources/imsg/JSONLines.swift +++ b/Sources/imsg/JSONLines.swift @@ -15,7 +15,7 @@ enum JSONLines { static func print(_ value: T) throws { let line = try encode(value) if !line.isEmpty { - Swift.print(line) + StdoutWriter.writeLine(line) } } } diff --git a/Sources/imsg/RPCServer.swift b/Sources/imsg/RPCServer.swift index 6109005..4f0a470 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -286,8 +286,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]) } @@ -306,21 +304,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\"}}" + ) } } } diff --git a/Sources/imsg/StdoutWriter.swift b/Sources/imsg/StdoutWriter.swift new file mode 100644 index 0000000..acdebef --- /dev/null +++ b/Sources/imsg/StdoutWriter.swift @@ -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(_ value: T) throws { + let data = try jsonEncoder.encode(value) + guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return } + writeLine(line) + } +} diff --git a/Tests/imsgTests/CommandRouterTests.swift b/Tests/imsgTests/CommandRouterTests.swift index f55f77b..82fefa0 100644 --- a/Tests/imsgTests/CommandRouterTests.swift +++ b/Tests/imsgTests/CommandRouterTests.swift @@ -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) } diff --git a/Tests/imsgTests/CommandTests.swift b/Tests/imsgTests/CommandTests.swift index c389875..4e32761 100644 --- a/Tests/imsgTests/CommandTests.swift +++ b/Tests/imsgTests/CommandTests.swift @@ -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 @@ -204,7 +212,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")) @@ -251,12 +261,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 @@ -320,12 +332,14 @@ 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