listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

PersistenceController.swift (4890B)


      1 import CoreData
      2 import Foundation
      3 
      4 private final class UpdatedAtMergePolicy: NSMergePolicy {
      5     private let fallbackPolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType)
      6 
      7     init() {
      8         super.init(merge: .mergeByPropertyStoreTrumpMergePolicyType)
      9     }
     10 
     11     override func resolve(mergeConflicts list: [Any]) throws {
     12         var fallbackConflicts: [Any] = []
     13 
     14         for item in list {
     15             guard
     16                 let conflict = item as? NSMergeConflict,
     17                 let item = conflict.sourceObject as? ItemEntity,
     18                 let objectSnapshot = conflict.objectSnapshot,
     19                 let persistedSnapshot = conflict.persistedSnapshot,
     20                 let storeUpdatedAt = persistedSnapshot["updatedAt"] as? Date
     21             else {
     22                 fallbackConflicts.append(item)
     23                 continue
     24             }
     25 
     26             let localUpdatedAt = (objectSnapshot["updatedAt"] as? Date) ?? item.updatedAt
     27 
     28             // Keep local in-memory values if they are newer or equal.
     29             guard storeUpdatedAt > localUpdatedAt else { continue }
     30 
     31             // Persisted values are newer; copy them onto the object to resolve conflict.
     32             // Use setPrimitiveValue to bypass KVC change tracking so willSave() does not
     33             // see these keys in changedValues() and overwrite updatedAt with Date().
     34             for (key, value) in persistedSnapshot {
     35                 if value is NSNull {
     36                     item.setPrimitiveValue(nil, forKey: key)
     37                 } else {
     38                     item.setPrimitiveValue(value, forKey: key)
     39                 }
     40             }
     41         }
     42 
     43         if !fallbackConflicts.isEmpty {
     44             try fallbackPolicy.resolve(mergeConflicts: fallbackConflicts)
     45         }
     46     }
     47 }
     48 
     49 @MainActor
     50 final class PersistenceController {
     51     static let shared = PersistenceController()
     52 
     53     private static let model: NSManagedObjectModel = {
     54         let bundle = Bundle(for: PersistenceController.self)
     55         guard let url = bundle.url(forResource: "Listless", withExtension: "momd"),
     56             let model = NSManagedObjectModel(contentsOf: url)
     57         else {
     58             fatalError("Failed to load Core Data model")
     59         }
     60         return model
     61     }()
     62 
     63     let container: NSPersistentContainer
     64     let syncMonitor: CloudKitSyncMonitor
     65 
     66     var viewContext: NSManagedObjectContext {
     67         container.viewContext
     68     }
     69 
     70     init(inMemory: Bool = false) {
     71         syncMonitor = CloudKitSyncMonitor()
     72 
     73         if inMemory {
     74             // Use a plain NSPersistentContainer (no CloudKit) with a unique
     75             // temporary store so each launch is fully isolated.
     76             let tempURL = FileManager.default.temporaryDirectory
     77                 .appendingPathComponent(UUID().uuidString)
     78                 .appendingPathExtension("sqlite")
     79             container = NSPersistentContainer(
     80                 name: "Listless", managedObjectModel: Self.model)
     81             container.persistentStoreDescriptions.first?.url = tempURL
     82         } else {
     83             container = NSPersistentCloudKitContainer(
     84                 name: "Listless", managedObjectModel: Self.model)
     85             // Configure CloudKit sync
     86             guard let description = container.persistentStoreDescriptions.first else {
     87                 fatalError("Failed to retrieve persistent store description")
     88             }
     89 
     90 #if DEBUG
     91             if let storeURL = description.url {
     92                 // Keep debug builds isolated from TestFlight/App Store local Core Data files.
     93                 description.url = storeURL.deletingLastPathComponent().appendingPathComponent(
     94                     "Listless-Debug.sqlite"
     95                 )
     96             }
     97 #endif
     98 
     99             description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
    100                 containerIdentifier: "iCloud.net.inqk.listless"
    101             )
    102 
    103             description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
    104             description.setOption(
    105                 true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
    106         }
    107 
    108         container.loadPersistentStores { storeDescription, error in
    109             if let error = error as NSError? {
    110                 fatalError("Unresolved error \(error), \(error.userInfo)")
    111             }
    112         }
    113 
    114         container.viewContext.automaticallyMergesChangesFromParent = true
    115         container.viewContext.mergePolicy = UpdatedAtMergePolicy()
    116 
    117         if !inMemory, let cloudContainer = container as? NSPersistentCloudKitContainer {
    118             syncMonitor.startMonitoring(container: cloudContainer)
    119         }
    120 
    121     }
    122 
    123     func save() throws {
    124         let context = container.viewContext
    125 
    126         guard context.hasChanges else { return }
    127 
    128         do {
    129             try context.save()
    130         } catch {
    131             throw ItemStoreError.saveFailed(error)
    132         }
    133     }
    134 }