Files
Luma/Sources/Shared/Persistence/ArchiveStore.swift
T
wt 2884810ec4
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
finish SwiftData migration with @Query views
- Drive chat list, timeline and forward picker from SwiftData @Query
  instead of AppModel's in-memory arrays; inject the per-account
  ModelContext from RootView with an in-memory fallback.
- Physically delete locally deleted messages (context.delete) and
  purge rows marked deleted by older builds on store open, so @Query
  views never resurrect them.
- Delete the legacy JSON snapshot only after a successful import save
  and restore the completeUntilFirstUserAuthentication data protection
  attribute on the store file.
- Mirror the old ChatArchive tolerant decoding in LegacyArchiveImporter
  so snapshots from older schema versions (missing reactions,
  isGroupMessage, roster fields) still import.
- Cover the new behaviour in ArchiveStoreTests and guard it with new
  verify.sh invariants; update AGENTS.md, ARCHITECTURE.md and
  SECURITY.md.
2026-08-29 00:57:08 +07:00

185 lines
7.2 KiB
Swift

import Foundation
import SwiftData
/// SwiftData-backed replacement for the legacy JSON `ChatArchive`. Owns the
/// per-account `ModelContainer`/`ModelContext`, imports the old JSON snapshot
/// once, and exposes load/save/erase.
@MainActor
final class ArchiveStore {
struct Loaded {
var conversations: [Conversation]
var messages: [ChatMessage]
var locallyDeletedMessageIDs: Set<String>
var rosterContactJIDs: Set<String>
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
}
let container: ModelContainer
let context: ModelContext
private let accountJID: String
private let storeURL: URL
private let legacyJSONURL: URL
private(set) var metadata: ArchiveMetadata?
init(accountJID: String) throws {
self.accountJID = accountJID
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)
let hash = Self.stableHash(accountJID)
storeURL = directory.appendingPathComponent("\(hash).store")
legacyJSONURL = directory.appendingPathComponent("\(hash).json")
let schema = Schema([
Conversation.self,
ChatMessage.self,
ArchiveMetadata.self,
])
let configuration = ModelConfiguration(schema: schema, url: storeURL)
container = try ModelContainer(for: schema, configurations: [configuration])
context = ModelContext(container)
context.autosaveEnabled = false
applyDataProtection()
migrateLegacyJSONIfNeeded()
purgeLocallyDeletedMessages()
}
func load() -> Loaded {
let conversationSort = [SortDescriptor(\Conversation.lastActivity, order: .reverse)]
let conversations = (try? context.fetch(FetchDescriptor<Conversation>(sortBy: conversationSort))) ?? []
let messages = (try? context.fetch(FetchDescriptor<ChatMessage>())) ?? []
let metadata = fetchMetadata()
let mamCheckpoints = Dictionary(
metadata.mamCheckpoints.map { ($0.key, $0.checkpoint) },
uniquingKeysWith: { first, _ in first }
)
return Loaded(
conversations: conversations,
messages: messages,
locallyDeletedMessageIDs: Set(metadata.locallyDeletedMessageIDs),
rosterContactJIDs: Set(metadata.rosterContactJIDs),
lastSuccessfulMAMSync: metadata.lastSuccessfulMAMSync,
lastSuccessfulMAMCursor: metadata.lastSuccessfulMAMCursor,
mamCheckpoints: mamCheckpoints
)
}
/// Persists the passed metadata plus all pending model mutations.
func save(
locallyDeletedMessageIDs: Set<String>,
rosterContactJIDs: Set<String>,
lastSuccessfulMAMSync: Date?,
lastSuccessfulMAMCursor: String?,
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
) throws {
let metadata = fetchMetadata()
metadata.locallyDeletedMessageIDs = Array(locallyDeletedMessageIDs)
metadata.rosterContactJIDs = Array(rosterContactJIDs)
metadata.lastSuccessfulMAMSync = lastSuccessfulMAMSync
metadata.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(lastSuccessfulMAMCursor)
metadata.mamCheckpoints = mamCheckpoints.map {
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
}
try context.save()
applyDataProtection()
}
func erase() throws {
try context.delete(model: Conversation.self)
try context.delete(model: ChatMessage.self)
try context.delete(model: ArchiveMetadata.self)
try context.save()
}
private func fetchMetadata() -> ArchiveMetadata {
if let metadata { return metadata }
let existing = try? context.fetch(FetchDescriptor<ArchiveMetadata>()).first
let metadata = existing ?? ArchiveMetadata(accountJID: accountJID)
context.insert(metadata)
self.metadata = metadata
return metadata
}
private func migrateLegacyJSONIfNeeded() {
guard FileManager.default.fileExists(atPath: legacyJSONURL.path) else { return }
guard let imported = try? LegacyArchiveImporter.importIfNeeded(from: legacyJSONURL) else {
return
}
for conversation in imported.conversations {
context.insert(conversation)
}
for message in imported.messages {
context.insert(message)
}
let metadata = fetchMetadata()
metadata.locallyDeletedMessageIDs = imported.locallyDeletedMessageIDs
metadata.rosterContactJIDs = imported.rosterContactJIDs
metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync
metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor
metadata.mamCheckpoints = imported.mamCheckpoints
do {
try context.save()
} catch {
// The imported rows stay pending in memory so this session keeps
// the history, but the legacy JSON remains on disk as the only
// durable copy until a save actually succeeds.
return
}
applyDataProtection()
try? FileManager.default.removeItem(at: legacyJSONURL)
}
/// Removes rows that older builds only marked as locally deleted (the
/// legacy snapshot filtered them at load time). `@Query`-based views read
/// the store directly, so these rows must physically disappear.
func purgeLocallyDeletedMessages() {
let metadata = fetchMetadata()
let deletedKeys = Set(metadata.locallyDeletedMessageIDs)
guard !deletedKeys.isEmpty else { return }
let messages = (try? context.fetch(FetchDescriptor<ChatMessage>())) ?? []
var removedAny = false
for message in messages {
let key = Self.deletionKey(
messageID: message.clientID,
conversationID: message.conversationID
)
guard deletedKeys.contains(key) else { continue }
context.delete(message)
removedAny = true
}
if removedAny {
try? context.save()
applyDataProtection()
}
}
/// Key format shared with `AppModel.localDeletionKey`:
/// `<conversationID>\u{1F}<messageID>`.
static func deletionKey(messageID: String, conversationID: String) -> String {
conversationID.lowercased() + "\u{1F}" + messageID
}
private func applyDataProtection() {
#if os(iOS)
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: storeURL.path
)
#endif
}
static func stableHash(_ value: String) -> String {
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
return String(hash, radix: 16, uppercase: false)
}
}