← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


Listless/Sync/PersistenceController.swift (4890B)

1import CoreData
2import Foundation
3
4private 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
50final 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}