imsg/Sources/IMsgCore/MessageStore.swift
2025-12-28 17:17:40 +01:00

280 lines
9.5 KiB
Swift

import Foundation
import SQLite
public final class MessageStore: @unchecked Sendable {
public static let appleEpochOffset: TimeInterval = 978_307_200
public static var defaultPath: String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return NSString(string: home).appendingPathComponent("Library/Messages/chat.db")
}
public let path: String
private let connection: Connection
private let queue: DispatchQueue
private let queueKey = DispatchSpecificKey<Void>()
private let hasAttributedBody: Bool
public init(path: String = MessageStore.defaultPath) throws {
let normalized = NSString(string: path).expandingTildeInPath
self.path = normalized
self.queue = DispatchQueue(label: "imsg.db", qos: .userInitiated)
self.queue.setSpecific(key: queueKey, value: ())
do {
let uri = URL(fileURLWithPath: normalized).absoluteString
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)
} catch {
throw MessageStore.enhance(error: error, path: normalized)
}
}
init(connection: Connection, path: String, hasAttributedBody: Bool? = nil) throws {
self.path = path
self.queue = DispatchQueue(label: "imsg.db.test", qos: .userInitiated)
self.queue.setSpecific(key: queueKey, value: ())
self.connection = connection
self.connection.busyTimeout = 5
if let hasAttributedBody {
self.hasAttributedBody = hasAttributedBody
} else {
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: connection)
}
}
public func listChats(limit: Int) throws -> [Chat] {
let sql = """
SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, c.service_name,
MAX(m.date) AS last_date
FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
JOIN message m ON m.ROWID = cmj.message_id
GROUP BY c.ROWID
ORDER BY last_date DESC
LIMIT ?
"""
return try withConnection { db in
var chats: [Chat] = []
for row in try db.prepare(sql, limit) {
let id = int64Value(row[0]) ?? 0
let name = stringValue(row[1])
let identifier = stringValue(row[2])
let service = stringValue(row[3])
let lastDate = appleDate(from: int64Value(row[4]))
chats.append(
Chat(
id: id, identifier: identifier, name: name, service: service, lastMessageAt: lastDate))
}
return chats
}
}
public func messages(chatID: Int64, limit: Int) throws -> [Message] {
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
let sql = """
SELECT m.ROWID, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
\(bodyColumn) AS body
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE cmj.chat_id = ?
ORDER BY m.date DESC
LIMIT ?
"""
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, chatID, limit) {
let rowID = int64Value(row[0]) ?? 0
let handleID = int64Value(row[1])
let 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 attachments = intValue(row[7]) ?? 0
let body = dataValue(row[8])
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
messages.append(
Message(
rowID: rowID,
chatID: chatID,
sender: sender,
text: resolvedText,
date: date,
isFromMe: isFromMe,
service: service,
handleID: handleID,
attachmentsCount: attachments
))
}
return messages
}
}
public func messagesAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [Message] {
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
var sql = """
SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
\(bodyColumn) AS body
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
WHERE m.ROWID > ?
"""
var bindings: [Binding?] = [afterRowID]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID)
}
sql += " ORDER BY m.ROWID ASC LIMIT ?"
bindings.append(limit)
return try withConnection { db in
var 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])
let 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 attachments = intValue(row[8]) ?? 0
let body = dataValue(row[9])
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
messages.append(
Message(
rowID: rowID,
chatID: resolvedChatID,
sender: sender,
text: resolvedText,
date: date,
isFromMe: isFromMe,
service: service,
handleID: handleID,
attachmentsCount: attachments
))
}
return messages
}
}
public func attachments(for messageID: Int64) throws -> [AttachmentMeta] {
let sql = """
SELECT a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker
FROM message_attachment_join maj
JOIN attachment a ON a.ROWID = maj.attachment_id
WHERE maj.message_id = ?
"""
return try withConnection { db in
var metas: [AttachmentMeta] = []
for row in try db.prepare(sql, messageID) {
let filename = stringValue(row[0])
let transferName = stringValue(row[1])
let uti = stringValue(row[2])
let mimeType = stringValue(row[3])
let totalBytes = int64Value(row[4]) ?? 0
let isSticker = boolValue(row[5])
let resolved = AttachmentResolver.resolve(filename)
metas.append(
AttachmentMeta(
filename: filename,
transferName: transferName,
uti: uti,
mimeType: mimeType,
totalBytes: totalBytes,
isSticker: isSticker,
originalPath: resolved.resolved,
missing: resolved.missing
))
}
return metas
}
}
public func maxRowID() throws -> Int64 {
return try withConnection { db in
let value = try db.scalar("SELECT MAX(ROWID) FROM message")
return int64Value(value) ?? 0
}
}
private func withConnection<T>(_ block: (Connection) throws -> T) throws -> T {
if DispatchQueue.getSpecific(key: queueKey) != nil {
return try block(connection)
}
return try queue.sync {
try block(connection)
}
}
private 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
}
private static func enhance(error: Error, path: String) -> Error {
let message = String(describing: error).lowercased()
if message.contains("out of memory (14)") || message.contains("authorization denied")
|| message.contains("unable to open database") || message.contains("cannot open")
{
return IMsgError.permissionDenied(path: path, underlying: error)
}
return error
}
private func appleDate(from value: Int64?) -> Date {
guard let value else { return Date(timeIntervalSince1970: MessageStore.appleEpochOffset) }
return Date(
timeIntervalSince1970: (Double(value) / 1_000_000_000) + MessageStore.appleEpochOffset)
}
private func stringValue(_ binding: Binding?) -> String {
return binding as? String ?? ""
}
private func int64Value(_ binding: Binding?) -> Int64? {
if let value = binding as? Int64 { return value }
if let value = binding as? Int { return Int64(value) }
if let value = binding as? Double { return Int64(value) }
return nil
}
private func intValue(_ binding: Binding?) -> Int? {
if let value = binding as? Int { return value }
if let value = binding as? Int64 { return Int(value) }
if let value = binding as? Double { return Int(value) }
return nil
}
private func boolValue(_ binding: Binding?) -> Bool {
if let value = binding as? Bool { return value }
if let value = intValue(binding) { return value != 0 }
return false
}
private func dataValue(_ binding: Binding?) -> Data {
if let blob = binding as? Blob {
return Data(blob.bytes)
}
return Data()
}
}