diff --git a/CHANGELOG.md b/CHANGELOG.md index 6923a76..7bc4029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Sources/IMsgCore/MessageStore+Helpers.swift b/Sources/IMsgCore/MessageStore+Helpers.swift index c567566..02be78c 100644 --- a/Sources/IMsgCore/MessageStore+Helpers.swift +++ b/Sources/IMsgCore/MessageStore+Helpers.swift @@ -2,68 +2,50 @@ import Foundation import SQLite extension MessageStore { - static func detectAttributedBody(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("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) -> 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 { diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 4dee99d..98e2721 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -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, diff --git a/Sources/IMsgCore/MessageStore.swift b/Sources/IMsgCore/MessageStore.swift index 1c1a0a5..c94a12a 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 @@ -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() - 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/Sources/IMsgCore/Models.swift b/Sources/IMsgCore/Models.swift index 6c4d6c8..da4b9aa 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 @@ -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 diff --git a/Sources/imsg/CommandRouter.swift b/Sources/imsg/CommandRouter.swift index 1ae39c8..e5a8295 100644 --- a/Sources/imsg/CommandRouter.swift +++ b/Sources/imsg/CommandRouter.swift @@ -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 } } 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 a3f682f..a7d6718 100644 --- a/Sources/imsg/Commands/WatchCommand.swift +++ b/Sources/imsg/Commands/WatchCommand.swift @@ -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)))" ) } 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/OutputModels.swift b/Sources/imsg/OutputModels.swift index 047e1d9..f56cdd2 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 @@ -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 diff --git a/Sources/imsg/RPCPayloads.swift b/Sources/imsg/RPCPayloads.swift index a31d36b..415118f 100644 --- a/Sources/imsg/RPCPayloads.swift +++ b/Sources/imsg/RPCPayloads.swift @@ -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 } diff --git a/Sources/imsg/RPCServer.swift b/Sources/imsg/RPCServer.swift index 35fad66..40c01a1 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -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\"}}" + ) } } } 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/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/IMsgCoreTests/MessageStoreTests.swift b/Tests/IMsgCoreTests/MessageStoreTests.swift index 0cd9202..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')") @@ -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() 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 5b99311..0099f40 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 @@ -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 = { _, _, _, _ 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/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/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 + } + } +} 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)