diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index 91b0eb7..55525ae 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -9,7 +9,7 @@ 3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks; AES-GCM выполняется CryptoKit. 4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live - сообщения и сохраняет snapshot локально. + сообщения и сохраняет snapshot локально через SwiftData. 5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS. 6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot, текстовые ответы возвращаются немедленно или через гарантированную очередь, @@ -18,6 +18,17 @@ XMPP, отправляет запись через тот же XEP-0363/OMEMO-пайплайн и возвращает часам отдельный результат фактической отправки. +## Локальное хранение + +История чатов сохраняется в SwiftData: `ChatArchive` работает поверх `@Model`- +записей `ConversationRecord`, `MessageRecord` и `ArchiveMetadataRecord` +(`Sources/Shared/Persistence/`). Доменные типы `Conversation`/`ChatMessage` +остаются value-типами в памяти, а SwiftData — только слой долговременного +хранения. Контейнер один на приложение и фильтрует записи по `accountJID`. +Вложенные Codable-значения (реакции, call-метаданные, MAM-чекпоинты) хранятся +как `Data`-атрибуты. При первом запуске после обновления старый JSON-снапшот +(`Luma/Accounts/.json`) однократно переносится в SwiftData и удаляется. + ## Контакты `RosterModule` Martin передаёт initial roster и последующие roster-push в @@ -205,6 +216,6 @@ PushKit/CallKit и серверной APNs-инфраструктуры. - APNs provider + XEP-0357 registration; - современный `urn:xmpp:omemo:2` adapter и миграция устройств; - MIX и реакции; -- SQLite/SQLCipher вместо JSON snapshot; +- шифрование SQLite-хранилища (SQLCipher-уровня) поверх SwiftData; - PushKit/CallKit для фоновых входящих звонков и групповые звонки; - share extension и notification service extension. diff --git a/Sources/Shared/Persistence/ChatArchive.swift b/Sources/Shared/Persistence/ChatArchive.swift index f40698c..dbc1442 100644 --- a/Sources/Shared/Persistence/ChatArchive.swift +++ b/Sources/Shared/Persistence/ChatArchive.swift @@ -1,6 +1,14 @@ import Foundation +import SwiftData -actor ChatArchive { +/// Durable per-account chat history. +/// +/// The in-memory domain model stays the value types `Conversation` and +/// `ChatMessage`; this type is only the persistence boundary. Storage is +/// SwiftData (`@Model` records in `ArchiveRecords.swift`) instead of the +/// former per-account JSON snapshot file. A one-shot migration reads the legacy +/// JSON file on first launch and seeds the SwiftData store. +final class ChatArchive { struct Snapshot: Codable, Sendable { static let currentSchemaVersion = 6 @@ -73,47 +81,148 @@ actor ChatArchive { static let empty = Snapshot(conversations: [], messages: []) } - private let fileURL: URL - private let encoder: JSONEncoder - private let decoder: JSONDecoder + private let accountJID: String + private let legacyFileURL: URL init(accountJID: String) { + self.accountJID = accountJID.lowercased() let fileManager = FileManager.default let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fileManager.temporaryDirectory let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true) - try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json") - - encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - encoder.dateEncodingStrategy = .millisecondsSince1970 - decoder = JSONDecoder() - decoder.dateDecodingStrategy = .millisecondsSince1970 + legacyFileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json") } - func load() -> Snapshot { - guard let data = try? Data(contentsOf: fileURL), - let snapshot = try? decoder.decode(Snapshot.self, from: data) else { + func load() async -> Snapshot { + let jid = accountJID + let legacyURL = legacyFileURL + return await MainActor.run { + let context = LumaPersistence.archiveContainer.mainContext + + guard + let conversations = try? context.fetch(FetchDescriptor()), + let messages = try? context.fetch(FetchDescriptor()), + let metadata = try? context.fetch(FetchDescriptor()) + else { + return Snapshot.empty + } + + let accountConversations = conversations.filter { $0.accountJID == jid } + let accountMessages = messages.filter { $0.accountJID == jid } + let accountMetadata = metadata.filter { $0.accountJID == jid } + + if !accountConversations.isEmpty || !accountMessages.isEmpty || !accountMetadata.isEmpty { + return Self.snapshot( + conversations: accountConversations, + messages: accountMessages, + metadata: accountMetadata.first + ) + } + + // The SwiftData store is empty for this account: migrate the legacy + // JSON snapshot once, if it still exists. + if let legacy = Self.decodeLegacySnapshot(from: legacyURL) { + try? Self.replaceAll(with: legacy, accountJID: jid, context: context) + try? context.save() + try? FileManager.default.removeItem(at: legacyURL) + return legacy + } + return .empty } - return snapshot } - func save(_ snapshot: Snapshot) throws { - let data = try encoder.encode(snapshot) - try data.write(to: fileURL, options: [.atomic]) -#if os(iOS) - try? FileManager.default.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: fileURL.path + func save(_ snapshot: Snapshot) async throws { + let jid = accountJID + let legacyURL = legacyFileURL + try await MainActor.run { + let context = LumaPersistence.archiveContainer.mainContext + try Self.replaceAll(with: snapshot, accountJID: jid, context: context) + try context.save() + // The legacy file is stale once SwiftData owns the history. + try? FileManager.default.removeItem(at: legacyURL) + } + } + + func erase() async throws { + let jid = accountJID + let legacyURL = legacyFileURL + try await MainActor.run { + let context = LumaPersistence.archiveContainer.mainContext + + for record in try context.fetch(FetchDescriptor()) + where record.accountJID == jid { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where record.accountJID == jid { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where record.accountJID == jid { + context.delete(record) + } + try context.save() + try? FileManager.default.removeItem(at: legacyURL) + } + } + + // MARK: - Helpers + + private static func snapshot( + conversations: [ConversationRecord], + messages: [MessageRecord], + metadata: ArchiveMetadataRecord? + ) -> Snapshot { + Snapshot( + conversations: conversations.map { $0.value }, + messages: messages.map { $0.value }, + locallyDeletedMessageIDs: metadata.flatMap { + ArchiveBlobCodec.decode([String].self, from: $0.locallyDeletedMessageIDsData).map { Set($0) } + } ?? [], + rosterContactJIDs: metadata.flatMap { + ArchiveBlobCodec.decode([String].self, from: $0.rosterContactJIDsData).map { Set($0) } + } ?? [], + lastSuccessfulMAMSync: metadata?.lastSuccessfulMAMSync, + lastSuccessfulMAMCursor: metadata?.lastSuccessfulMAMCursor, + mamCheckpoints: metadata.flatMap { + ArchiveBlobCodec.decode([MAMArchiveKey: MAMArchiveCheckpoint].self, from: $0.mamCheckpointsData) + } ?? [:] ) -#endif } - func erase() throws { - guard FileManager.default.fileExists(atPath: fileURL.path) else { return } - try FileManager.default.removeItem(at: fileURL) + private static func replaceAll( + with snapshot: Snapshot, + accountJID: String, + context: ModelContext + ) throws { + for record in try context.fetch(FetchDescriptor()) + where record.accountJID == accountJID { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where record.accountJID == accountJID { + context.delete(record) + } + for record in try context.fetch(FetchDescriptor()) + where record.accountJID == accountJID { + context.delete(record) + } + + for conversation in snapshot.conversations { + context.insert(ConversationRecord(accountJID: accountJID, conversation: conversation)) + } + for message in snapshot.messages { + context.insert(MessageRecord(accountJID: accountJID, message: message)) + } + context.insert(ArchiveMetadataRecord(accountJID: accountJID, snapshot: snapshot)) + } + + private static func decodeLegacySnapshot(from url: URL) -> Snapshot? { + guard let data = try? Data(contentsOf: url) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + return try? decoder.decode(Snapshot.self, from: data) } private static func stableHash(_ value: String) -> String {