@expo-kit/cloudkit 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -2
- package/app.plugin.js +1 -0
- package/build/codec.d.ts +6 -0
- package/build/codec.d.ts.map +1 -0
- package/build/codec.js +34 -0
- package/build/codec.js.map +1 -0
- package/build/index.d.ts +7 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +9 -0
- package/build/index.js.map +1 -0
- package/build/native.d.ts +86 -0
- package/build/native.d.ts.map +1 -0
- package/build/native.js +7 -0
- package/build/native.js.map +1 -0
- package/build/schema.d.ts +98 -0
- package/build/schema.d.ts.map +1 -0
- package/build/schema.js +83 -0
- package/build/schema.js.map +1 -0
- package/build/sync.d.ts +78 -0
- package/build/sync.d.ts.map +1 -0
- package/build/sync.js +236 -0
- package/build/sync.js.map +1 -0
- package/build/use-cloud-sync.d.ts +5 -0
- package/build/use-cloud-sync.d.ts.map +1 -0
- package/build/use-cloud-sync.js +6 -0
- package/build/use-cloud-sync.js.map +1 -0
- package/expo-module.config.json +7 -0
- package/ios/CloudSyncEngineObject.swift +62 -0
- package/ios/ExpoKitCloudKit.podspec +28 -0
- package/ios/ExpoKitCloudKitModule.swift +50 -0
- package/ios/LocalStore.swift +351 -0
- package/ios/PushSubscriber.swift +14 -0
- package/ios/RecordMapper.swift +237 -0
- package/ios/Records.swift +60 -0
- package/ios/SchemaModel.swift +68 -0
- package/ios/SyncCore.swift +586 -0
- package/package.json +61 -4
- package/plugin/build/index.d.ts +7 -0
- package/plugin/build/index.js +68 -0
- package/src/codec.ts +36 -0
- package/src/index.ts +26 -0
- package/src/native.ts +83 -0
- package/src/schema.ts +169 -0
- package/src/sync.ts +322 -0
- package/src/use-cloud-sync.ts +9 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// Plain Swift model of the app's schema. No Expo types here, so it can be tested on its own.
|
|
4
|
+
|
|
5
|
+
enum FieldKind: String, CaseIterable {
|
|
6
|
+
case string, int, double, bool, date, stringList, intList, doubleList, reference, asset
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
struct FieldModel: Equatable {
|
|
10
|
+
let name: String
|
|
11
|
+
let kind: FieldKind
|
|
12
|
+
let optional: Bool
|
|
13
|
+
/// For references: the record type it links to.
|
|
14
|
+
let target: String?
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
struct TypeModel: Equatable {
|
|
18
|
+
let name: String
|
|
19
|
+
let fields: [String: FieldModel]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct SchemaModel: Equatable {
|
|
23
|
+
let types: [String: TypeModel]
|
|
24
|
+
|
|
25
|
+
func type(_ name: String) throws -> TypeModel {
|
|
26
|
+
guard let type = types[name] else { throw CloudKitSyncError.unknownType(name) }
|
|
27
|
+
return type
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
enum CloudKitSyncError: Error, LocalizedError, Equatable {
|
|
32
|
+
case unknownType(String)
|
|
33
|
+
case invalidSchema(String)
|
|
34
|
+
case invalidID(String)
|
|
35
|
+
case invalidValue(String)
|
|
36
|
+
case notStarted
|
|
37
|
+
case storage(String)
|
|
38
|
+
|
|
39
|
+
var errorDescription: String? {
|
|
40
|
+
switch self {
|
|
41
|
+
case .unknownType(let name): return "\"\(name)\" isn't a record type in the schema."
|
|
42
|
+
case .invalidSchema(let message), .invalidID(let message), .invalidValue(let message), .storage(let message):
|
|
43
|
+
return message
|
|
44
|
+
case .notStarted: return "The sync engine isn't started. Call start() first."
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// The ID of a record in iCloud is "<Type>.<id>": CloudKit IDs must be unique across all types in a zone.
|
|
50
|
+
enum RecordName {
|
|
51
|
+
static func make(type: String, id: String) -> String { "\(type).\(id)" }
|
|
52
|
+
|
|
53
|
+
/// Splits "<Type>.<id>". Type names can't contain dots, so the first dot is the separator.
|
|
54
|
+
static func parse(_ recordName: String) -> (type: String, id: String)? {
|
|
55
|
+
guard let dot = recordName.firstIndex(of: ".") else { return nil }
|
|
56
|
+
let type = String(recordName[..<dot])
|
|
57
|
+
let id = String(recordName[recordName.index(after: dot)...])
|
|
58
|
+
return type.isEmpty || id.isEmpty ? nil : (type, id)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static func validate(type: String, id: String) throws {
|
|
62
|
+
if id.isEmpty { throw CloudKitSyncError.invalidID("\(type): the id is empty.") }
|
|
63
|
+
if !id.allSatisfy(\.isASCII) { throw CloudKitSyncError.invalidID("\(type).\(id): ids can only use ASCII characters.") }
|
|
64
|
+
if make(type: type, id: id).utf8.count > 255 {
|
|
65
|
+
throw CloudKitSyncError.invalidID("\(type).\(id): the id is too long (type + id must be at most 254 characters).")
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import CloudKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// How a conflict between this device and iCloud is settled.
|
|
5
|
+
enum ConflictPolicy: String {
|
|
6
|
+
/// The edit with the later `ekModifiedAt` wins. A delete from iCloud wins over a local edit.
|
|
7
|
+
case newest
|
|
8
|
+
/// iCloud's version always wins.
|
|
9
|
+
case server
|
|
10
|
+
/// This device's version always wins, also over a delete from iCloud.
|
|
11
|
+
case device
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// What the engine reports to JS.
|
|
15
|
+
protocol SyncCoreDelegate: AnyObject {
|
|
16
|
+
func syncCore(_ core: SyncCore, didChangeStatus status: [String: Any])
|
|
17
|
+
func syncCoreHasNewChanges(_ core: SyncCore)
|
|
18
|
+
func syncCore(_ core: SyncCore, didChangeAccount change: [String: Any])
|
|
19
|
+
func syncCore(_ core: SyncCore, didFail error: Error, context: String)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// All sync logic around CKSyncEngine, without Expo types. The Expo shared object wraps it.
|
|
23
|
+
///
|
|
24
|
+
/// Flow: local changes go to the outbox, then to CKSyncEngine as pending changes.
|
|
25
|
+
/// CKSyncEngine asks for records in `nextRecordZoneChangeBatch`, built from the outbox.
|
|
26
|
+
/// Changes from iCloud go to the inbox, and the app takes them with `pendingChanges()` + `ack()`.
|
|
27
|
+
final class SyncCore: CKSyncEngineDelegate, @unchecked Sendable {
|
|
28
|
+
private enum MetaKey {
|
|
29
|
+
static let engineState = "engineState"
|
|
30
|
+
static let initialFetchDone = "initialFetchDone"
|
|
31
|
+
static let zoneCreated = "zoneCreated"
|
|
32
|
+
static let lastSyncedAt = "lastSyncedAt"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let mapper: RecordMapper
|
|
36
|
+
let store: LocalStore
|
|
37
|
+
let policy: ConflictPolicy
|
|
38
|
+
weak var delegate: SyncCoreDelegate?
|
|
39
|
+
|
|
40
|
+
private let container: CKContainer
|
|
41
|
+
private let zoneID: CKRecordZone.ID
|
|
42
|
+
private let assetsDirectory: URL
|
|
43
|
+
private var engine: CKSyncEngine?
|
|
44
|
+
private let lock = NSRecursiveLock()
|
|
45
|
+
/// Outbox sequence of each record being uploaded right now.
|
|
46
|
+
private var inFlight: [String: Int64] = [:]
|
|
47
|
+
private var fetching = false
|
|
48
|
+
private var sending = false
|
|
49
|
+
private var lastError: String?
|
|
50
|
+
private var accountStatus = "unknown"
|
|
51
|
+
|
|
52
|
+
init(container: CKContainer, zoneName: String, schema: SchemaModel, policy: ConflictPolicy, store: LocalStore, assetsDirectory: URL) {
|
|
53
|
+
self.container = container
|
|
54
|
+
self.zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
|
|
55
|
+
self.mapper = RecordMapper(schema: schema, zoneID: zoneID)
|
|
56
|
+
self.policy = policy
|
|
57
|
+
self.store = store
|
|
58
|
+
self.assetsDirectory = assetsDirectory
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// MARK: Lifecycle
|
|
62
|
+
|
|
63
|
+
private var accountObserver: NSObjectProtocol?
|
|
64
|
+
|
|
65
|
+
/// Starts CKSyncEngine from the saved state. Downloads first; local changes upload only after that.
|
|
66
|
+
func start() async {
|
|
67
|
+
if accountObserver == nil {
|
|
68
|
+
// CKSyncEngine reports changes while it runs. This also covers signing in after a sign-out,
|
|
69
|
+
// when no engine is running.
|
|
70
|
+
accountObserver = NotificationCenter.default.addObserver(forName: .CKAccountChanged, object: nil, queue: nil) { [weak self] _ in
|
|
71
|
+
guard let self else { return }
|
|
72
|
+
Task { await self.accountDidChange() }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if withLock({ engine }) != nil {
|
|
76
|
+
await syncNow() // Already running.
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
await refreshAccountStatus()
|
|
80
|
+
guard accountStatus == "available" else {
|
|
81
|
+
publishStatus()
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
startEngine()
|
|
85
|
+
await syncNow()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private func startEngine() {
|
|
89
|
+
let saved = (try? store.meta(MetaKey.engineState)).flatMap {
|
|
90
|
+
try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: $0)
|
|
91
|
+
}
|
|
92
|
+
var configuration = CKSyncEngine.Configuration(
|
|
93
|
+
database: container.privateCloudDatabase,
|
|
94
|
+
stateSerialization: saved,
|
|
95
|
+
delegate: self
|
|
96
|
+
)
|
|
97
|
+
configuration.automaticallySync = true
|
|
98
|
+
let engine = CKSyncEngine(configuration)
|
|
99
|
+
withLock { self.engine = engine }
|
|
100
|
+
|
|
101
|
+
if (try? store.flag(MetaKey.zoneCreated)) != true {
|
|
102
|
+
engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
|
|
103
|
+
}
|
|
104
|
+
// Local changes wait in the outbox until the first download has finished,
|
|
105
|
+
// so a fresh install never overwrites what's already in iCloud.
|
|
106
|
+
if (try? store.flag(MetaKey.initialFetchDone)) == true {
|
|
107
|
+
registerOutbox(engine)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private func accountDidChange() async {
|
|
112
|
+
await refreshAccountStatus()
|
|
113
|
+
if accountStatus == "available", withLock({ engine }) == nil {
|
|
114
|
+
startEngine()
|
|
115
|
+
await syncNow()
|
|
116
|
+
}
|
|
117
|
+
publishStatus()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
deinit {
|
|
121
|
+
if let accountObserver { NotificationCenter.default.removeObserver(accountObserver) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func stop() async {
|
|
125
|
+
await detachEngine()?.cancelOperations()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Downloads, then uploads. Safe to call any time, e.g. pull to refresh or app to the front.
|
|
129
|
+
func syncNow() async {
|
|
130
|
+
if withLock({ engine }) == nil {
|
|
131
|
+
await refreshAccountStatus()
|
|
132
|
+
guard accountStatus == "available" else { publishStatus(); return }
|
|
133
|
+
startEngine()
|
|
134
|
+
}
|
|
135
|
+
guard let engine = withLock({ self.engine }) else { return }
|
|
136
|
+
do {
|
|
137
|
+
try await engine.fetchChanges()
|
|
138
|
+
if (try? store.flag(MetaKey.initialFetchDone)) != true {
|
|
139
|
+
try store.setFlag(MetaKey.initialFetchDone, true)
|
|
140
|
+
registerOutbox(engine)
|
|
141
|
+
}
|
|
142
|
+
try await engine.sendChanges()
|
|
143
|
+
try store.setMeta(MetaKey.lastSyncedAt, String(Date().timeIntervalSince1970 * 1000).data(using: .utf8))
|
|
144
|
+
withLock { lastError = nil }
|
|
145
|
+
} catch {
|
|
146
|
+
handle(error, context: "syncNow")
|
|
147
|
+
}
|
|
148
|
+
publishStatus()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// MARK: Local changes
|
|
152
|
+
|
|
153
|
+
func save(type: String, id: String, fields: [String: Any]) throws {
|
|
154
|
+
let normalized = try mapper.normalize(type: type, id: id, fields: fields)
|
|
155
|
+
try store.enqueue(type: type, id: id, operation: .save, fields: normalized, modifiedAt: Date())
|
|
156
|
+
addPending(.saveRecord(recordID(type: type, id: id)))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func delete(type: String, id: String) throws {
|
|
160
|
+
_ = try mapper.schema.type(type)
|
|
161
|
+
try RecordName.validate(type: type, id: id)
|
|
162
|
+
try store.enqueue(type: type, id: id, operation: .delete, fields: [:], modifiedAt: Date())
|
|
163
|
+
removeAssets(type: type, id: id)
|
|
164
|
+
addPending(.deleteRecord(recordID(type: type, id: id)))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private func addPending(_ change: CKSyncEngine.PendingRecordZoneChange) {
|
|
168
|
+
guard let engine = withLock({ self.engine }), (try? store.flag(MetaKey.initialFetchDone)) == true else {
|
|
169
|
+
publishStatus()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
engine.state.add(pendingRecordZoneChanges: [change])
|
|
173
|
+
publishStatus()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private func registerOutbox(_ engine: CKSyncEngine) {
|
|
177
|
+
guard let entries = try? store.outboxEntries(), !entries.isEmpty else { return }
|
|
178
|
+
engine.state.add(pendingRecordZoneChanges: entries.map { entry in
|
|
179
|
+
let id = CKRecord.ID(recordName: entry.recordName, zoneID: zoneID)
|
|
180
|
+
return entry.operation == .save ? .saveRecord(id) : .deleteRecord(id)
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// MARK: Changes for the app
|
|
185
|
+
|
|
186
|
+
func pendingChanges(limit: Int) throws -> [[String: Any]] {
|
|
187
|
+
try store.inbox(limit: limit).map { entry in
|
|
188
|
+
[
|
|
189
|
+
"sequence": entry.sequence,
|
|
190
|
+
"type": entry.type,
|
|
191
|
+
"id": entry.id,
|
|
192
|
+
"operation": entry.operation.rawValue,
|
|
193
|
+
"fields": entry.fields as [String: Any],
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func ack(_ sequences: [Int64]) throws {
|
|
199
|
+
try store.ackInbox(sequences)
|
|
200
|
+
publishStatus()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// MARK: Status
|
|
204
|
+
|
|
205
|
+
func status() -> [String: Any] {
|
|
206
|
+
let counts = (try? store.counts()) ?? (outbox: 0, inbox: 0)
|
|
207
|
+
let lastSyncedAt = (try? store.meta(MetaKey.lastSyncedAt)).flatMap { $0 }.flatMap { Double(String(decoding: $0, as: UTF8.self)) }
|
|
208
|
+
return withLock {
|
|
209
|
+
[
|
|
210
|
+
"account": accountStatus,
|
|
211
|
+
"syncing": fetching || sending,
|
|
212
|
+
"initialSyncDone": (try? store.flag(MetaKey.initialFetchDone)) == true,
|
|
213
|
+
"pendingUploads": counts.outbox,
|
|
214
|
+
"pendingChanges": counts.inbox,
|
|
215
|
+
"lastSyncedAt": lastSyncedAt as Any? ?? NSNull(),
|
|
216
|
+
"error": lastError as Any? ?? NSNull(),
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private func publishStatus() {
|
|
222
|
+
delegate?.syncCore(self, didChangeStatus: status())
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func refreshAccountStatus() async {
|
|
226
|
+
let status: String
|
|
227
|
+
do {
|
|
228
|
+
switch try await container.accountStatus() {
|
|
229
|
+
case .available: status = "available"
|
|
230
|
+
case .noAccount: status = "noAccount"
|
|
231
|
+
case .restricted: status = "restricted"
|
|
232
|
+
case .temporarilyUnavailable: status = "temporarilyUnavailable"
|
|
233
|
+
case .couldNotDetermine: status = "couldNotDetermine"
|
|
234
|
+
@unknown default: status = "couldNotDetermine"
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
status = "couldNotDetermine"
|
|
238
|
+
}
|
|
239
|
+
withLock { accountStatus = status }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// MARK: CKSyncEngineDelegate
|
|
243
|
+
|
|
244
|
+
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
|
|
245
|
+
// An engine that was replaced (account change, iCloud data deleted) must not
|
|
246
|
+
// write its old state over the fresh one.
|
|
247
|
+
guard withLock({ engine }) === syncEngine else { return }
|
|
248
|
+
switch event {
|
|
249
|
+
case .stateUpdate(let update):
|
|
250
|
+
if let data = try? JSONEncoder().encode(update.stateSerialization) {
|
|
251
|
+
try? store.setMeta(MetaKey.engineState, data)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
case .accountChange(let change):
|
|
255
|
+
await handleAccountChange(change)
|
|
256
|
+
|
|
257
|
+
case .fetchedDatabaseChanges(let changes):
|
|
258
|
+
if changes.deletions.contains(where: { $0.zoneID == zoneID }) {
|
|
259
|
+
await handleZoneDeleted()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case .fetchedRecordZoneChanges(let changes):
|
|
263
|
+
handleFetched(changes)
|
|
264
|
+
|
|
265
|
+
case .sentDatabaseChanges(let changes):
|
|
266
|
+
if changes.savedZones.contains(where: { $0.zoneID == zoneID }) {
|
|
267
|
+
try? store.setFlag(MetaKey.zoneCreated, true)
|
|
268
|
+
}
|
|
269
|
+
for failure in changes.failedZoneSaves where failure.zone.zoneID == zoneID {
|
|
270
|
+
handle(failure.error, context: "saveZone")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case .sentRecordZoneChanges(let changes):
|
|
274
|
+
handleSent(changes, engine: syncEngine)
|
|
275
|
+
|
|
276
|
+
case .willFetchChanges:
|
|
277
|
+
withLock { fetching = true }
|
|
278
|
+
publishStatus()
|
|
279
|
+
case .didFetchChanges:
|
|
280
|
+
withLock { fetching = false }
|
|
281
|
+
publishStatus()
|
|
282
|
+
case .willSendChanges:
|
|
283
|
+
withLock { sending = true }
|
|
284
|
+
publishStatus()
|
|
285
|
+
case .didSendChanges:
|
|
286
|
+
withLock { sending = false }
|
|
287
|
+
publishStatus()
|
|
288
|
+
|
|
289
|
+
case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges:
|
|
290
|
+
break
|
|
291
|
+
@unknown default:
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? {
|
|
297
|
+
let changes = syncEngine.state.pendingRecordZoneChanges.filter { context.options.scope.contains($0) }
|
|
298
|
+
guard !changes.isEmpty else { return nil }
|
|
299
|
+
return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { [weak self] recordID in
|
|
300
|
+
self?.record(for: recordID, engine: syncEngine)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/// Builds the record to upload from the outbox. Starts from the saved system fields,
|
|
305
|
+
/// so iCloud sees it as an edit of the version it has, not a new record.
|
|
306
|
+
private func record(for recordID: CKRecord.ID, engine: CKSyncEngine) -> CKRecord? {
|
|
307
|
+
guard let entry = try? store.outboxEntry(recordID.recordName), entry.operation == .save else {
|
|
308
|
+
engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
309
|
+
return nil
|
|
310
|
+
}
|
|
311
|
+
let record = existingRecord(recordID) ?? CKRecord(recordType: entry.type, recordID: recordID)
|
|
312
|
+
do {
|
|
313
|
+
try mapper.apply(fields: entry.fields, modifiedAt: entry.modifiedAt, to: record)
|
|
314
|
+
} catch {
|
|
315
|
+
// The data can't be uploaded (e.g. a file is gone). Keep it out of the loop and tell the app.
|
|
316
|
+
engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
317
|
+
try? store.discardOutbox(recordID.recordName)
|
|
318
|
+
handle(error, context: "buildRecord \(recordID.recordName)")
|
|
319
|
+
return nil
|
|
320
|
+
}
|
|
321
|
+
withLock { inFlight[recordID.recordName] = entry.sequence }
|
|
322
|
+
return record
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// MARK: Sent changes
|
|
326
|
+
|
|
327
|
+
private func handleSent(_ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine) {
|
|
328
|
+
for record in changes.savedRecords {
|
|
329
|
+
let name = record.recordID.recordName
|
|
330
|
+
saveSystemFields(record)
|
|
331
|
+
let sequence = withLock { inFlight.removeValue(forKey: name) }
|
|
332
|
+
if let sequence, (try? store.removeOutbox(name, sequence: sequence)) == false,
|
|
333
|
+
let newer = try? store.outboxEntry(name) {
|
|
334
|
+
// Changed again while uploading: upload the newer version too.
|
|
335
|
+
engine.state.add(pendingRecordZoneChanges: [newer.operation == .save ? .saveRecord(record.recordID) : .deleteRecord(record.recordID)])
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for recordID in changes.deletedRecordIDs {
|
|
340
|
+
finishDelete(recordID)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (recordID, error) in changes.failedRecordDeletes {
|
|
344
|
+
if error.code == .unknownItem {
|
|
345
|
+
finishDelete(recordID) // Already gone in iCloud.
|
|
346
|
+
} else if !isRetried(error) {
|
|
347
|
+
handle(error, context: "delete \(recordID.recordName)")
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for failure in changes.failedRecordSaves {
|
|
352
|
+
let recordID = failure.record.recordID
|
|
353
|
+
let name = recordID.recordName
|
|
354
|
+
withLock { _ = inFlight.removeValue(forKey: name) }
|
|
355
|
+
switch failure.error.code {
|
|
356
|
+
case .serverRecordChanged:
|
|
357
|
+
guard let serverRecord = failure.error.serverRecord else { break }
|
|
358
|
+
resolveConflict(local: try? store.outboxEntry(name), server: serverRecord, engine: engine)
|
|
359
|
+
case .zoneNotFound:
|
|
360
|
+
// The zone is gone (e.g. deleted from another device). Create it again, then retry.
|
|
361
|
+
try? store.setFlag(MetaKey.zoneCreated, false)
|
|
362
|
+
try? store.setSystemFields(name, nil)
|
|
363
|
+
engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
|
|
364
|
+
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
365
|
+
case .unknownItem:
|
|
366
|
+
// Deleted in iCloud since we last saw it. Upload it as a new record.
|
|
367
|
+
try? store.setSystemFields(name, nil)
|
|
368
|
+
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
369
|
+
case .quotaExceeded:
|
|
370
|
+
handle(failure.error, context: "iCloud storage is full")
|
|
371
|
+
default:
|
|
372
|
+
if !isRetried(failure.error) { handle(failure.error, context: "save \(name)") }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
publishStatus()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private func finishDelete(_ recordID: CKRecord.ID) {
|
|
379
|
+
let name = recordID.recordName
|
|
380
|
+
try? store.setSystemFields(name, nil)
|
|
381
|
+
if let entry = try? store.outboxEntry(name), entry.operation == .delete {
|
|
382
|
+
_ = try? store.removeOutbox(name, sequence: entry.sequence)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/// iCloud has a different version than the one this edit started from.
|
|
387
|
+
private func resolveConflict(local: LocalStore.OutboxEntry?, server: CKRecord, engine: CKSyncEngine) {
|
|
388
|
+
let name = server.recordID.recordName
|
|
389
|
+
saveSystemFields(server)
|
|
390
|
+
guard let local, local.operation == .save else {
|
|
391
|
+
if deliver(server) { delegate?.syncCoreHasNewChanges(self) }
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
if localWins(local: local, serverModifiedAt: server[RecordMapper.modifiedAtKey] as? Date) {
|
|
395
|
+
// Upload again, now based on the server's version (keeps its unknown fields).
|
|
396
|
+
engine.state.add(pendingRecordZoneChanges: [.saveRecord(server.recordID)])
|
|
397
|
+
} else {
|
|
398
|
+
try? store.discardOutbox(name)
|
|
399
|
+
engine.state.remove(pendingRecordZoneChanges: [.saveRecord(server.recordID)])
|
|
400
|
+
if deliver(server) { delegate?.syncCoreHasNewChanges(self) }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private func localWins(local: LocalStore.OutboxEntry, serverModifiedAt: Date?) -> Bool {
|
|
405
|
+
switch policy {
|
|
406
|
+
case .device: return true
|
|
407
|
+
case .server: return false
|
|
408
|
+
case .newest:
|
|
409
|
+
guard let serverModifiedAt else { return true }
|
|
410
|
+
return local.modifiedAt >= serverModifiedAt
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// MARK: Fetched changes
|
|
415
|
+
|
|
416
|
+
private func handleFetched(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) {
|
|
417
|
+
var delivered = false
|
|
418
|
+
for modification in changes.modifications {
|
|
419
|
+
let record = modification.record
|
|
420
|
+
guard record.recordID.zoneID == zoneID else { continue }
|
|
421
|
+
saveSystemFields(record)
|
|
422
|
+
if let local = try? store.outboxEntry(record.recordID.recordName), local.operation == .save {
|
|
423
|
+
// A local edit is waiting for the same record.
|
|
424
|
+
if localWins(local: local, serverModifiedAt: record[RecordMapper.modifiedAtKey] as? Date) { continue }
|
|
425
|
+
try? store.discardOutbox(record.recordID.recordName)
|
|
426
|
+
engine?.state.remove(pendingRecordZoneChanges: [.saveRecord(record.recordID)])
|
|
427
|
+
}
|
|
428
|
+
delivered = deliver(record) || delivered
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for deletion in changes.deletions {
|
|
432
|
+
let recordID = deletion.recordID
|
|
433
|
+
guard recordID.zoneID == zoneID, let name = RecordName.parse(recordID.recordName) else { continue }
|
|
434
|
+
try? store.setSystemFields(recordID.recordName, nil)
|
|
435
|
+
if let local = try? store.outboxEntry(recordID.recordName), local.operation == .save, policy == .device {
|
|
436
|
+
continue // This device's edit wins: it will be uploaded as a new record.
|
|
437
|
+
}
|
|
438
|
+
try? store.discardOutbox(recordID.recordName)
|
|
439
|
+
engine?.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
440
|
+
guard mapper.schema.types[name.type] != nil else { continue }
|
|
441
|
+
removeAssets(type: name.type, id: name.id)
|
|
442
|
+
do {
|
|
443
|
+
try store.addInbox(type: name.type, id: name.id, operation: .delete, fields: [:])
|
|
444
|
+
delivered = true
|
|
445
|
+
} catch {
|
|
446
|
+
handle(error, context: "inbox delete \(recordID.recordName)")
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if delivered { delegate?.syncCoreHasNewChanges(self) }
|
|
451
|
+
publishStatus()
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/// Puts a record from iCloud in the inbox for the app. Returns false for unknown types.
|
|
455
|
+
@discardableResult
|
|
456
|
+
private func deliver(_ record: CKRecord) -> Bool {
|
|
457
|
+
do {
|
|
458
|
+
guard let read = try mapper.read(record, saveAsset: saveAsset) else { return false }
|
|
459
|
+
try store.addInbox(type: read.type, id: read.id, operation: .save, fields: read.fields)
|
|
460
|
+
return true
|
|
461
|
+
} catch {
|
|
462
|
+
handle(error, context: "inbox save \(record.recordID.recordName)")
|
|
463
|
+
return false
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/// Copies a downloaded file out of CloudKit's cache to a permanent place, keeping its extension.
|
|
468
|
+
private func saveAsset(_ target: RecordMapper.AssetTarget) throws -> String? {
|
|
469
|
+
guard let source = target.asset.fileURL else { return nil }
|
|
470
|
+
let fileManager = FileManager.default
|
|
471
|
+
let directory = assetDirectory(type: target.type, id: target.id)
|
|
472
|
+
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
473
|
+
// Remove earlier copies of this field (the extension may have changed).
|
|
474
|
+
for old in (try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
|
|
475
|
+
where old.deletingPathExtension().lastPathComponent == target.field {
|
|
476
|
+
try? fileManager.removeItem(at: old)
|
|
477
|
+
}
|
|
478
|
+
var destination = directory.appendingPathComponent(target.field)
|
|
479
|
+
if !target.fileExtension.isEmpty { destination.appendPathExtension(target.fileExtension) }
|
|
480
|
+
try fileManager.copyItem(at: source, to: destination)
|
|
481
|
+
return destination.absoluteString
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private func assetDirectory(type: String, id: String) -> URL {
|
|
485
|
+
assetsDirectory.appendingPathComponent(type).appendingPathComponent(id)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/// Downloaded files of a deleted record aren't needed anymore.
|
|
489
|
+
private func removeAssets(type: String, id: String) {
|
|
490
|
+
try? FileManager.default.removeItem(at: assetDirectory(type: type, id: id))
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// MARK: Account and zone
|
|
494
|
+
|
|
495
|
+
private func handleAccountChange(_ change: CKSyncEngine.Event.AccountChange) async {
|
|
496
|
+
let kind: String
|
|
497
|
+
switch change.changeType {
|
|
498
|
+
case .signIn: kind = "signIn"
|
|
499
|
+
case .signOut: kind = "signOut"
|
|
500
|
+
case .switchAccounts: kind = "switchAccounts"
|
|
501
|
+
@unknown default: kind = "unknown"
|
|
502
|
+
}
|
|
503
|
+
var replaced: CKSyncEngine?
|
|
504
|
+
if kind == "signOut" || kind == "switchAccounts" {
|
|
505
|
+
// The data in iCloud belongs to another account now. Forget everything about it.
|
|
506
|
+
// The app decides what to do with its local data.
|
|
507
|
+
replaced = detachEngine()
|
|
508
|
+
try? store.reset(keepOutbox: false)
|
|
509
|
+
}
|
|
510
|
+
await refreshAccountStatus()
|
|
511
|
+
delegate?.syncCore(self, didChangeAccount: ["change": kind, "account": accountStatus])
|
|
512
|
+
if let replaced {
|
|
513
|
+
// Restart outside this callback: cancelling an engine from inside its own event can deadlock.
|
|
514
|
+
Task { await self.restart(replacing: replaced) }
|
|
515
|
+
}
|
|
516
|
+
publishStatus()
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/// Takes the engine out of use right away, so none of its later events are handled.
|
|
520
|
+
private func detachEngine() -> CKSyncEngine? {
|
|
521
|
+
withLock {
|
|
522
|
+
let current = engine
|
|
523
|
+
engine = nil
|
|
524
|
+
inFlight.removeAll()
|
|
525
|
+
return current
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/// Cancels the replaced engine (its saved state was cleared) and starts a fresh one if signed in.
|
|
530
|
+
private func restart(replacing old: CKSyncEngine) async {
|
|
531
|
+
await old.cancelOperations()
|
|
532
|
+
await refreshAccountStatus()
|
|
533
|
+
if accountStatus == "available" {
|
|
534
|
+
startEngine()
|
|
535
|
+
await syncNow()
|
|
536
|
+
}
|
|
537
|
+
publishStatus()
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private func handleZoneDeleted() async {
|
|
541
|
+
// The user deleted this app's iCloud data (e.g. in Settings). Start fresh; the app decides
|
|
542
|
+
// whether to upload its local data again with importAll().
|
|
543
|
+
guard let replaced = detachEngine() else { return }
|
|
544
|
+
try? store.reset(keepOutbox: false)
|
|
545
|
+
delegate?.syncCore(self, didChangeAccount: ["change": "cloudDataDeleted", "account": accountStatus])
|
|
546
|
+
Task { await self.restart(replacing: replaced) }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// MARK: Helpers
|
|
550
|
+
|
|
551
|
+
private func recordID(type: String, id: String) -> CKRecord.ID {
|
|
552
|
+
CKRecord.ID(recordName: RecordName.make(type: type, id: id), zoneID: zoneID)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private func existingRecord(_ recordID: CKRecord.ID) -> CKRecord? {
|
|
556
|
+
guard let data = try? store.systemFields(recordID.recordName),
|
|
557
|
+
let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil }
|
|
558
|
+
coder.requiresSecureCoding = true
|
|
559
|
+
defer { coder.finishDecoding() }
|
|
560
|
+
return CKRecord(coder: coder)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private func saveSystemFields(_ record: CKRecord) {
|
|
564
|
+
let coder = NSKeyedArchiver(requiringSecureCoding: true)
|
|
565
|
+
record.encodeSystemFields(with: coder)
|
|
566
|
+
coder.finishEncoding()
|
|
567
|
+
try? store.setSystemFields(record.recordID.recordName, coder.encodedData)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/// Errors CKSyncEngine retries by itself.
|
|
571
|
+
private func isRetried(_ error: CKError) -> Bool {
|
|
572
|
+
[.networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled, .requestRateLimited]
|
|
573
|
+
.contains(error.code)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private func handle(_ error: Error, context: String) {
|
|
577
|
+
withLock { lastError = "\(context): \(error.localizedDescription)" }
|
|
578
|
+
delegate?.syncCore(self, didFail: error, context: context)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private func withLock<T>(_ body: () -> T) -> T {
|
|
582
|
+
lock.lock()
|
|
583
|
+
defer { lock.unlock() }
|
|
584
|
+
return body()
|
|
585
|
+
}
|
|
586
|
+
}
|