chore: merge origin/main into pr-31
This commit is contained in:
commit
b78027b251
@ -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)
|
||||
|
||||
@ -2,68 +2,50 @@ import Foundation
|
||||
import SQLite
|
||||
|
||||
extension MessageStore {
|
||||
static func detectAttributedBody(connection: Connection) -> Bool {
|
||||
static func tableColumns(connection: Connection, table: String) -> Set<String> {
|
||||
do {
|
||||
let rows = try connection.prepare("PRAGMA table_info(message)")
|
||||
let rows = try connection.prepare("PRAGMA table_info(\(table))")
|
||||
var columns = Set<String>()
|
||||
for row in rows {
|
||||
if let name = row[1] as? String,
|
||||
name.caseInsensitiveCompare("attributedBody") == .orderedSame
|
||||
{
|
||||
return true
|
||||
if let name = row[1] as? String {
|
||||
columns.insert(name.lowercased())
|
||||
}
|
||||
}
|
||||
return columns
|
||||
} catch {
|
||||
return false
|
||||
return []
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func reactionColumnsPresent(in columns: Set<String>) -> Bool {
|
||||
return columns.contains("guid")
|
||||
&& columns.contains("associated_message_guid")
|
||||
&& columns.contains("associated_message_type")
|
||||
}
|
||||
|
||||
static func detectReactionColumns(connection: Connection) -> Bool {
|
||||
let columns = tableColumns(connection: connection, table: "message")
|
||||
return reactionColumnsPresent(in: columns)
|
||||
}
|
||||
|
||||
static func detectThreadOriginatorGUIDColumn(connection: Connection) -> Bool {
|
||||
return tableColumns(connection: connection, table: "message").contains("thread_originator_guid")
|
||||
}
|
||||
|
||||
static func detectAttributedBody(connection: Connection) -> Bool {
|
||||
return tableColumns(connection: connection, table: "message").contains("attributedbody")
|
||||
}
|
||||
|
||||
static func detectDestinationCallerID(connection: Connection) -> Bool {
|
||||
do {
|
||||
let rows = try connection.prepare("PRAGMA table_info(message)")
|
||||
for row in rows {
|
||||
if let name = row[1] as? String,
|
||||
name.caseInsensitiveCompare("destination_caller_id") == .orderedSame
|
||||
{
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return tableColumns(connection: connection, table: "message").contains("destination_caller_id")
|
||||
}
|
||||
|
||||
static func detectAudioMessageColumn(connection: Connection) -> Bool {
|
||||
do {
|
||||
let rows = try connection.prepare("PRAGMA table_info(message)")
|
||||
for row in rows {
|
||||
if let name = row[1] as? String,
|
||||
name.caseInsensitiveCompare("is_audio_message") == .orderedSame
|
||||
{
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return tableColumns(connection: connection, table: "message").contains("is_audio_message")
|
||||
}
|
||||
|
||||
static func detectAttachmentUserInfo(connection: Connection) -> Bool {
|
||||
do {
|
||||
let rows = try connection.prepare("PRAGMA table_info(attachment)")
|
||||
for row in rows {
|
||||
if let name = row[1] as? String,
|
||||
name.caseInsensitiveCompare("user_info") == .orderedSame
|
||||
{
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return tableColumns(connection: connection, table: "attachment").contains("user_info")
|
||||
}
|
||||
|
||||
static func enhance(error: Error, path: String) -> Error {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -16,6 +16,7 @@ public final class MessageStore: @unchecked Sendable {
|
||||
private let queueKey = DispatchSpecificKey<Void>()
|
||||
let hasAttributedBody: Bool
|
||||
let hasReactionColumns: Bool
|
||||
let hasThreadOriginatorGUIDColumn: Bool
|
||||
let hasDestinationCallerID: Bool
|
||||
let hasAudioMessageColumn: Bool
|
||||
let hasAttachmentUserInfo: Bool
|
||||
@ -30,17 +31,17 @@ public final class MessageStore: @unchecked Sendable {
|
||||
let location = Connection.Location.uri(uri, parameters: [.mode(.readOnly)])
|
||||
self.connection = try Connection(location, readonly: true)
|
||||
self.connection.busyTimeout = 5
|
||||
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: self.connection)
|
||||
self.hasReactionColumns = MessageStore.detectReactionColumns(connection: self.connection)
|
||||
self.hasDestinationCallerID = MessageStore.detectDestinationCallerID(
|
||||
connection: self.connection
|
||||
)
|
||||
self.hasAudioMessageColumn = MessageStore.detectAudioMessageColumn(
|
||||
connection: self.connection
|
||||
)
|
||||
self.hasAttachmentUserInfo = MessageStore.detectAttachmentUserInfo(
|
||||
connection: self.connection
|
||||
let messageColumns = MessageStore.tableColumns(connection: self.connection, table: "message")
|
||||
let attachmentColumns = MessageStore.tableColumns(
|
||||
connection: self.connection,
|
||||
table: "attachment"
|
||||
)
|
||||
self.hasAttributedBody = messageColumns.contains("attributedbody")
|
||||
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
|
||||
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
|
||||
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
|
||||
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
|
||||
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
|
||||
} catch {
|
||||
throw MessageStore.enhance(error: error, path: normalized)
|
||||
}
|
||||
@ -51,6 +52,7 @@ public final class MessageStore: @unchecked Sendable {
|
||||
path: String,
|
||||
hasAttributedBody: Bool? = nil,
|
||||
hasReactionColumns: Bool? = nil,
|
||||
hasThreadOriginatorGUIDColumn: Bool? = nil,
|
||||
hasDestinationCallerID: Bool? = nil,
|
||||
hasAudioMessageColumn: Bool? = nil,
|
||||
hasAttachmentUserInfo: Bool? = nil
|
||||
@ -60,30 +62,37 @@ public final class MessageStore: @unchecked Sendable {
|
||||
self.queue.setSpecific(key: queueKey, value: ())
|
||||
self.connection = connection
|
||||
self.connection.busyTimeout = 5
|
||||
let messageColumns = MessageStore.tableColumns(connection: connection, table: "message")
|
||||
let attachmentColumns = MessageStore.tableColumns(connection: connection, table: "attachment")
|
||||
if let hasAttributedBody {
|
||||
self.hasAttributedBody = hasAttributedBody
|
||||
} else {
|
||||
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: connection)
|
||||
self.hasAttributedBody = messageColumns.contains("attributedbody")
|
||||
}
|
||||
if let hasReactionColumns {
|
||||
self.hasReactionColumns = hasReactionColumns
|
||||
} else {
|
||||
self.hasReactionColumns = MessageStore.detectReactionColumns(connection: connection)
|
||||
self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns)
|
||||
}
|
||||
if let hasThreadOriginatorGUIDColumn {
|
||||
self.hasThreadOriginatorGUIDColumn = hasThreadOriginatorGUIDColumn
|
||||
} else {
|
||||
self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid")
|
||||
}
|
||||
if let hasDestinationCallerID {
|
||||
self.hasDestinationCallerID = hasDestinationCallerID
|
||||
} else {
|
||||
self.hasDestinationCallerID = MessageStore.detectDestinationCallerID(connection: connection)
|
||||
self.hasDestinationCallerID = messageColumns.contains("destination_caller_id")
|
||||
}
|
||||
if let hasAudioMessageColumn {
|
||||
self.hasAudioMessageColumn = hasAudioMessageColumn
|
||||
} else {
|
||||
self.hasAudioMessageColumn = MessageStore.detectAudioMessageColumn(connection: connection)
|
||||
self.hasAudioMessageColumn = messageColumns.contains("is_audio_message")
|
||||
}
|
||||
if let hasAttachmentUserInfo {
|
||||
self.hasAttachmentUserInfo = hasAttachmentUserInfo
|
||||
} else {
|
||||
self.hasAttachmentUserInfo = MessageStore.detectAttachmentUserInfo(connection: connection)
|
||||
self.hasAttachmentUserInfo = attachmentColumns.contains("user_info")
|
||||
}
|
||||
}
|
||||
|
||||
@ -367,23 +376,6 @@ extension MessageStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func detectReactionColumns(connection: Connection) -> Bool {
|
||||
do {
|
||||
let rows = try connection.prepare("PRAGMA table_info(message)")
|
||||
var columns = Set<String>()
|
||||
for row in rows {
|
||||
if let name = row[1] as? String {
|
||||
columns.insert(name.lowercased())
|
||||
}
|
||||
}
|
||||
return columns.contains("guid")
|
||||
&& columns.contains("associated_message_guid")
|
||||
&& columns.contains("associated_message_type")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReactionKey: Hashable {
|
||||
let sender: String
|
||||
let isFromMe: Bool
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))"
|
||||
)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))"
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ enum JSONLines {
|
||||
static func print<T: Encodable>(_ value: T) throws {
|
||||
let line = try encode(value)
|
||||
if !line.isEmpty {
|
||||
Swift.print(line)
|
||||
StdoutWriter.writeLine(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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\"}}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
Sources/imsg/StdoutWriter.swift
Normal file
24
Sources/imsg/StdoutWriter.swift
Normal file
@ -0,0 +1,24 @@
|
||||
import Dispatch
|
||||
import Foundation
|
||||
|
||||
enum StdoutWriter {
|
||||
private static let queue = DispatchQueue(label: "imsg.stdout.writer")
|
||||
|
||||
private static let jsonEncoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.withoutEscapingSlashes]
|
||||
return encoder
|
||||
}()
|
||||
|
||||
static func writeLine(_ line: String) {
|
||||
queue.sync {
|
||||
FileHandle.standardOutput.write(Data((line + "\n").utf8))
|
||||
}
|
||||
}
|
||||
|
||||
static func writeJSONLine<T: Encodable>(_ value: T) throws {
|
||||
let data = try jsonEncoder.encode(value)
|
||||
guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return }
|
||||
writeLine(line)
|
||||
}
|
||||
}
|
||||
@ -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: "")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -100,7 +100,9 @@ func chatsCommandRunsWithJsonOutput() async throws {
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
try await ChatsCommand.spec.run(values, runtime)
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await ChatsCommand.spec.run(values, runtime)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -112,7 +114,9 @@ func historyCommandRunsWithChatID() async throws {
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
try await HistoryCommand.spec.run(values, runtime)
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await HistoryCommand.spec.run(values, runtime)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -124,7 +128,9 @@ func historyCommandRunsWithAttachmentsNonJson() async throws {
|
||||
flags: ["attachments"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
try await HistoryCommand.spec.run(values, runtime)
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await HistoryCommand.spec.run(values, runtime)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -136,7 +142,9 @@ func chatsCommandRunsWithPlainOutput() async throws {
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
try await ChatsCommand.spec.run(values, runtime)
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await ChatsCommand.spec.run(values, runtime)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -281,7 +289,9 @@ func watchCommandRejectsInvalidDebounce() async {
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await WatchCommand.spec.run(values, runtime)
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await WatchCommand.spec.run(values, runtime)
|
||||
}
|
||||
#expect(Bool(false))
|
||||
} catch let error as ParsedValuesError {
|
||||
#expect(error.description.contains("Invalid value"))
|
||||
@ -328,12 +338,14 @@ func watchCommandRunsWithStubStream() async throws {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -397,10 +409,140 @@ func watchCommandRunsWithJsonOutput() async throws {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchCommandFlushesPlainOutput() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let db = try Connection(.inMemory)
|
||||
let store = try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
let message = Message(
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
sender: "+123",
|
||||
text: "hello",
|
||||
date: Date(),
|
||||
isFromMe: false,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 0
|
||||
)
|
||||
let streamProvider:
|
||||
(
|
||||
MessageWatcher,
|
||||
Int64?,
|
||||
Int64?,
|
||||
MessageWatcherConfiguration
|
||||
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(message)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
}
|
||||
#expect(output.contains("hello"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func watchCommandFlushesJsonOutput() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": ["/tmp/unused"], "debounce": ["1ms"]],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
let db = try Connection(.inMemory)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE attachment (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
filename TEXT,
|
||||
transfer_name TEXT,
|
||||
uti TEXT,
|
||||
mime_type TEXT,
|
||||
total_bytes INTEGER,
|
||||
is_sticker INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute(
|
||||
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
let store = try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
let message = Message(
|
||||
rowID: 1,
|
||||
chatID: 1,
|
||||
sender: "+123",
|
||||
text: "hello",
|
||||
date: Date(),
|
||||
isFromMe: false,
|
||||
service: "iMessage",
|
||||
handleID: nil,
|
||||
attachmentsCount: 0
|
||||
)
|
||||
let streamProvider:
|
||||
(
|
||||
MessageWatcher,
|
||||
Int64?,
|
||||
Int64?,
|
||||
MessageWatcherConfiguration
|
||||
) -> AsyncThrowingStream<Message, Error> = { _, _, _, _ in
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(message)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
let (output, _) = try await StdoutCapture.capture {
|
||||
try await WatchCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
storeFactory: { _ in store },
|
||||
streamProvider: streamProvider
|
||||
)
|
||||
}
|
||||
#expect(output.contains("\"text\":\"hello\""))
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
78
Tests/imsgTests/StdoutCapture.swift
Normal file
78
Tests/imsgTests/StdoutCapture.swift
Normal file
@ -0,0 +1,78 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
private actor StdoutCaptureLock {
|
||||
private var isLocked = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func acquire() async {
|
||||
if !isLocked {
|
||||
isLocked = true
|
||||
return
|
||||
}
|
||||
await withCheckedContinuation { continuation in
|
||||
waiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
if waiters.isEmpty {
|
||||
isLocked = false
|
||||
return
|
||||
}
|
||||
let next = waiters.removeFirst()
|
||||
next.resume()
|
||||
}
|
||||
}
|
||||
|
||||
enum StdoutCapture {
|
||||
private static let lock = StdoutCaptureLock()
|
||||
|
||||
static func capture<T>(_ body: () async throws -> T) async rethrows -> (output: String, value: T)
|
||||
{
|
||||
await lock.acquire()
|
||||
|
||||
var fds: [Int32] = [0, 0]
|
||||
guard pipe(&fds) == 0 else {
|
||||
await lock.release()
|
||||
fatalError("pipe() failed")
|
||||
}
|
||||
let readFD = fds[0]
|
||||
let writeFD = fds[1]
|
||||
|
||||
let savedStdout = dup(STDOUT_FILENO)
|
||||
guard savedStdout >= 0 else {
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
await lock.release()
|
||||
fatalError("dup(STDOUT_FILENO) failed")
|
||||
}
|
||||
|
||||
guard dup2(writeFD, STDOUT_FILENO) >= 0 else {
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
close(savedStdout)
|
||||
await lock.release()
|
||||
fatalError("dup2(writeFD, STDOUT_FILENO) failed")
|
||||
}
|
||||
close(writeFD)
|
||||
|
||||
do {
|
||||
let value = try await body()
|
||||
|
||||
_ = dup2(savedStdout, STDOUT_FILENO)
|
||||
close(savedStdout)
|
||||
|
||||
let handle = FileHandle(fileDescriptor: readFD, closeOnDealloc: true)
|
||||
let data = handle.readDataToEndOfFile()
|
||||
await lock.release()
|
||||
return (String(data: data, encoding: .utf8) ?? "", value)
|
||||
} catch {
|
||||
_ = dup2(savedStdout, STDOUT_FILENO)
|
||||
close(savedStdout)
|
||||
close(readFD)
|
||||
await lock.release()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user