| @@ -0,0 +1,1588 @@ |
| |
1 | import CloudKit |
| |
2 | import Foundation |
| |
3 | import Network |
| |
4 | import Observation |
| |
5 | |
| |
6 | extension Notification.Name { |
| |
7 | static let cloudSyncDidApplyChanges = Notification.Name("CloudSyncService.didApplyChanges") |
| |
8 | } |
| |
9 | |
| |
10 | enum CloudSyncStatus: String, Codable { |
| |
11 | case disabled |
| |
12 | case synced |
| |
13 | case syncing |
| |
14 | case offline |
| |
15 | case iCloudUnavailable |
| |
16 | case conflictResolved |
| |
17 | case error |
| |
18 | |
| |
19 | var title: String { |
| |
20 | switch self { |
| |
21 | case .disabled: |
| |
22 | return "Off" |
| |
23 | case .synced: |
| |
24 | return "Synced" |
| |
25 | case .syncing: |
| |
26 | return "Syncing" |
| |
27 | case .offline: |
| |
28 | return "Offline" |
| |
29 | case .iCloudUnavailable: |
| |
30 | return "iCloud unavailable" |
| |
31 | case .conflictResolved: |
| |
32 | return "Conflict resolved" |
| |
33 | case .error: |
| |
34 | return "Error" |
| |
35 | } |
| |
36 | } |
| |
37 | } |
| |
38 | |
| |
39 | enum CloudSyncTrigger: String { |
| |
40 | case launch |
| |
41 | case automatic |
| |
42 | case manual |
| |
43 | case `import` |
| |
44 | } |
| |
45 | |
| |
46 | enum ShareableEntity: Identifiable, Hashable { |
| |
47 | case trackedDomain(String) |
| |
48 | case workflow(UUID) |
| |
49 | |
| |
50 | var id: String { |
| |
51 | switch self { |
| |
52 | case .trackedDomain(let domain): |
| |
53 | return "tracked:\(domain)" |
| |
54 | case .workflow(let identifier): |
| |
55 | return "workflow:\(identifier.uuidString)" |
| |
56 | } |
| |
57 | } |
| |
58 | } |
| |
59 | |
| |
60 | private enum CloudRecordType { |
| |
61 | static let trackedDomain = "TrackedDomain" |
| |
62 | static let workflow = "DomainWorkflow" |
| |
63 | static let appSettings = "AppSettingsSnapshot" |
| |
64 | static let monitoringSettings = "MonitoringSettings" |
| |
65 | static let domainNote = "DomainNote" |
| |
66 | static let historyMetadata = "HistoryMetadata" |
| |
67 | static let tombstone = "SyncTombstone" |
| |
68 | } |
| |
69 | |
| |
70 | private enum CloudRecordKey { |
| |
71 | static let payload = "payload" |
| |
72 | static let updatedAt = "updatedAt" |
| |
73 | static let domain = "domain" |
| |
74 | static let identifier = "identifier" |
| |
75 | static let entityType = "entityType" |
| |
76 | static let deletedAt = "deletedAt" |
| |
77 | } |
| |
78 | |
| |
79 | private enum SyncEntityType: String, Codable { |
| |
80 | case trackedDomain |
| |
81 | case workflow |
| |
82 | } |
| |
83 | |
| |
84 | private struct SyncedAppSettings: Codable { |
| |
85 | var snapshot: AppSettingsSnapshot |
| |
86 | var updatedAt: Date |
| |
87 | } |
| |
88 | |
| |
89 | private struct SyncedMonitoringSettings: Codable { |
| |
90 | var settings: MonitoringSettings |
| |
91 | var updatedAt: Date |
| |
92 | } |
| |
93 | |
| |
94 | private struct SyncedDomainNote: Codable, Equatable { |
| |
95 | var domain: String |
| |
96 | var text: String |
| |
97 | var updatedAt: Date |
| |
98 | } |
| |
99 | |
| |
100 | private struct SyncedHistoryMetadata: Codable, Equatable { |
| |
101 | var domain: String |
| |
102 | var lastKnownAvailabilityRawValue: String? |
| |
103 | var lastChangeMessage: String? |
| |
104 | var lastChangeSeverityRawValue: Int? |
| |
105 | var certificateWarningLevelRawValue: String |
| |
106 | var certificateDaysRemaining: Int? |
| |
107 | var lastObservedAt: Date? |
| |
108 | var updatedAt: Date |
| |
109 | } |
| |
110 | |
| |
111 | private struct SyncTombstone: Codable, Equatable { |
| |
112 | var entityType: SyncEntityType |
| |
113 | var identifier: String |
| |
114 | var deletedAt: Date |
| |
115 | } |
| |
116 | |
| |
117 | private struct SyncPayload { |
| |
118 | var trackedDomains: [TrackedDomain] |
| |
119 | var workflows: [DomainWorkflow] |
| |
120 | var appSettings: SyncedAppSettings |
| |
121 | var monitoringSettings: SyncedMonitoringSettings |
| |
122 | var notesByDomain: [String: SyncedDomainNote] |
| |
123 | var historyMetadataByDomain: [String: SyncedHistoryMetadata] |
| |
124 | var tombstones: [SyncTombstone] |
| |
125 | } |
| |
126 | |
| |
127 | private enum SyncDatabaseScope { |
| |
128 | case privateDatabase |
| |
129 | case sharedDatabase |
| |
130 | } |
| |
131 | |
| |
132 | private struct SharedRecord<Value> { |
| |
133 | var record: CKRecord |
| |
134 | var value: Value |
| |
135 | } |
| |
136 | |
| |
137 | private struct MergedSyncPayload { |
| |
138 | var payload: SyncPayload |
| |
139 | var hadConflict: Bool |
| |
140 | var changedLocalData: Bool |
| |
141 | } |
| |
142 | |
| |
143 | @MainActor |
| |
144 | @Observable |
| |
145 | final class CloudSyncService { |
| |
146 | static let shared = CloudSyncService() |
| |
147 | |
| |
148 | var isEnabled: Bool |
| |
149 | var status: CloudSyncStatus |
| |
150 | var lastSyncDate: Date? |
| |
151 | var lastErrorMessage: String? |
| |
152 | var detailMessage: String |
| |
153 | |
| |
154 | private var container: CKContainer? |
| |
155 | private var privateDatabase: CKDatabase? |
| |
156 | private var sharedDatabase: CKDatabase? |
| |
157 | private let defaults: UserDefaults |
| |
158 | private let encoder: JSONEncoder |
| |
159 | private let decoder: JSONDecoder |
| |
160 | private let pathMonitor = NWPathMonitor() |
| |
161 | private let pathMonitorQueue = DispatchQueue(label: "DomainDig.CloudSync.PathMonitor") |
| |
162 | |
| |
163 | private var isNetworkAvailable = true |
| |
164 | private var scheduledSyncTask: Task<Void, Never>? |
| |
165 | private var syncTask: Task<Void, Never>? |
| |
166 | |
| |
167 | private enum StorageKey { |
| |
168 | static let isEnabled = "cloudSync.enabled" |
| |
169 | static let status = "cloudSync.status" |
| |
170 | static let lastSyncDate = "cloudSync.lastSyncDate" |
| |
171 | static let lastErrorMessage = "cloudSync.lastErrorMessage" |
| |
172 | static let detailMessage = "cloudSync.detailMessage" |
| |
173 | static let appSettingsUpdatedAt = "cloudSync.appSettingsUpdatedAt" |
| |
174 | static let monitoringSettingsUpdatedAt = "cloudSync.monitoringSettingsUpdatedAt" |
| |
175 | static let noteUpdatedAtByDomain = "cloudSync.noteUpdatedAtByDomain" |
| |
176 | static let tombstones = "cloudSync.tombstones" |
| |
177 | static let monitoringLocalActivationConfirmed = "cloudSync.monitoringLocalActivationConfirmed" |
| |
178 | } |
| |
179 | |
| |
180 | private init(defaults: UserDefaults = .standard) { |
| |
181 | self.defaults = defaults |
| |
182 | self.container = nil |
| |
183 | self.privateDatabase = nil |
| |
184 | self.sharedDatabase = nil |
| |
185 | let syncEnabled = defaults.bool(forKey: StorageKey.isEnabled) |
| |
186 | self.isEnabled = syncEnabled |
| |
187 | self.status = CloudSyncStatus(rawValue: defaults.string(forKey: StorageKey.status) ?? "") ?? (syncEnabled ? .synced : .disabled) |
| |
188 | self.lastSyncDate = defaults.object(forKey: StorageKey.lastSyncDate) as? Date |
| |
189 | self.lastErrorMessage = defaults.string(forKey: StorageKey.lastErrorMessage) |
| |
190 | self.detailMessage = defaults.string(forKey: StorageKey.detailMessage) ?? "DomainDig stores synced data in your private iCloud account." |
| |
191 | |
| |
192 | let encoder = JSONEncoder() |
| |
193 | encoder.dateEncodingStrategy = .iso8601 |
| |
194 | self.encoder = encoder |
| |
195 | |
| |
196 | let decoder = JSONDecoder() |
| |
197 | decoder.dateDecodingStrategy = .iso8601 |
| |
198 | self.decoder = decoder |
| |
199 | |
| |
200 | pathMonitor.pathUpdateHandler = { [weak self] path in |
| |
201 | Task { @MainActor [weak self] in |
| |
202 | guard let self else { return } |
| |
203 | self.isNetworkAvailable = path.status == .satisfied |
| |
204 | if !self.isNetworkAvailable, self.isEnabled, self.status == .syncing { |
| |
205 | self.setStatus(.offline, detail: "Waiting for a network connection.", error: nil) |
| |
206 | } |
| |
207 | } |
| |
208 | } |
| |
209 | pathMonitor.start(queue: pathMonitorQueue) |
| |
210 | } |
| |
211 | |
| |
212 | deinit { |
| |
213 | pathMonitor.cancel() |
| |
214 | } |
| |
215 | |
| |
216 | func setSyncEnabled(_ enabled: Bool) { |
| |
217 | isEnabled = enabled |
| |
218 | defaults.set(enabled, forKey: StorageKey.isEnabled) |
| |
219 | |
| |
220 | guard enabled else { |
| |
221 | scheduledSyncTask?.cancel() |
| |
222 | syncTask?.cancel() |
| |
223 | setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil) |
| |
224 | return |
| |
225 | } |
| |
226 | |
| |
227 | Task { |
| |
228 | await refreshAvailability() |
| |
229 | await syncNow(trigger: .manual) |
| |
230 | } |
| |
231 | } |
| |
232 | |
| |
233 | func refreshAvailability() async { |
| |
234 | guard isEnabled else { |
| |
235 | setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil) |
| |
236 | return |
| |
237 | } |
| |
238 | |
| |
239 | guard isNetworkAvailable else { |
| |
240 | setStatus(.offline, detail: "Waiting for a network connection.", error: nil) |
| |
241 | return |
| |
242 | } |
| |
243 | |
| |
244 | switch await accountStatus() { |
| |
245 | case .available: |
| |
246 | if status == .iCloudUnavailable || status == .offline || status == .disabled { |
| |
247 | setStatus(.synced, detail: syncSummaryDetail(), error: nil) |
| |
248 | } |
| |
249 | case .noAccount: |
| |
250 | setStatus(.iCloudUnavailable, detail: "Sign in to iCloud to sync DomainDig.", error: nil) |
| |
251 | case .restricted: |
| |
252 | setStatus(.iCloudUnavailable, detail: "iCloud access is restricted on this device.", error: nil) |
| |
253 | case .temporarilyUnavailable: |
| |
254 | setStatus(.iCloudUnavailable, detail: "iCloud is temporarily unavailable.", error: nil) |
| |
255 | case .unknown: |
| |
256 | setStatus(.iCloudUnavailable, detail: "DomainDig could not confirm iCloud availability.", error: nil) |
| |
257 | case .missingEntitlement: |
| |
258 | setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil) |
| |
259 | } |
| |
260 | } |
| |
261 | |
| |
262 | func scheduleSyncIfNeeded(trigger: CloudSyncTrigger = .automatic) { |
| |
263 | guard isEnabled else { return } |
| |
264 | scheduledSyncTask?.cancel() |
| |
265 | scheduledSyncTask = Task { [weak self] in |
| |
266 | try? await Task.sleep(nanoseconds: 900_000_000) |
| |
267 | guard !Task.isCancelled else { return } |
| |
268 | await self?.syncNow(trigger: trigger) |
| |
269 | } |
| |
270 | } |
| |
271 | |
| |
272 | func markAppSettingsChanged() { |
| |
273 | defaults.set(Date(), forKey: StorageKey.appSettingsUpdatedAt) |
| |
274 | scheduleSyncIfNeeded() |
| |
275 | } |
| |
276 | |
| |
277 | func markMonitoringSettingsChanged(localActivationConfirmed: Bool = false) { |
| |
278 | defaults.set(Date(), forKey: StorageKey.monitoringSettingsUpdatedAt) |
| |
279 | if localActivationConfirmed { |
| |
280 | defaults.set(true, forKey: StorageKey.monitoringLocalActivationConfirmed) |
| |
281 | } |
| |
282 | scheduleSyncIfNeeded() |
| |
283 | } |
| |
284 | |
| |
285 | func markNoteChanged(for domain: String, updatedAt: Date = Date()) { |
| |
286 | let normalized = Self.normalizeDomain(domain) |
| |
287 | guard !normalized.isEmpty else { return } |
| |
288 | |
| |
289 | var noteDates = loadNoteUpdatedAtByDomain() |
| |
290 | noteDates[normalized] = updatedAt |
| |
291 | saveNoteUpdatedAtByDomain(noteDates) |
| |
292 | scheduleSyncIfNeeded() |
| |
293 | } |
| |
294 | |
| |
295 | func recordTrackedDomainDeletion(_ trackedDomain: TrackedDomain, deletedAt: Date = Date()) { |
| |
296 | let normalized = Self.normalizeDomain(trackedDomain.domain) |
| |
297 | guard !normalized.isEmpty else { return } |
| |
298 | saveTombstone(.init(entityType: .trackedDomain, identifier: normalized, deletedAt: deletedAt)) |
| |
299 | |
| |
300 | var noteDates = loadNoteUpdatedAtByDomain() |
| |
301 | noteDates.removeValue(forKey: normalized) |
| |
302 | saveNoteUpdatedAtByDomain(noteDates) |
| |
303 | |
| |
304 | scheduleSyncIfNeeded() |
| |
305 | } |
| |
306 | |
| |
307 | func recordWorkflowDeletion(_ workflow: DomainWorkflow, deletedAt: Date = Date()) { |
| |
308 | saveTombstone(.init(entityType: .workflow, identifier: workflow.id.uuidString, deletedAt: deletedAt)) |
| |
309 | scheduleSyncIfNeeded() |
| |
310 | } |
| |
311 | |
| |
312 | func recordTrackedDomainReset(_ trackedDomains: [TrackedDomain], deletedAt: Date = Date()) { |
| |
313 | for trackedDomain in trackedDomains { |
| |
314 | recordTrackedDomainDeletion(trackedDomain, deletedAt: deletedAt) |
| |
315 | } |
| |
316 | } |
| |
317 | |
| |
318 | func recordWorkflowReset(_ workflows: [DomainWorkflow], deletedAt: Date = Date()) { |
| |
319 | for workflow in workflows { |
| |
320 | recordWorkflowDeletion(workflow, deletedAt: deletedAt) |
| |
321 | } |
| |
322 | } |
| |
323 | |
| |
324 | func acceptShare(metadata: CKShare.Metadata) async throws { |
| |
325 | guard let container = cloudKitContainer() else { |
| |
326 | throw CloudSyncRuntimeError.missingEntitlement |
| |
327 | } |
| |
328 | |
| |
329 | if metadata.participantStatus == .pending { |
| |
330 | _ = try await container.accept(metadata) |
| |
331 | } |
| |
332 | |
| |
333 | await syncNow(trigger: .manual) |
| |
334 | } |
| |
335 | |
| |
336 | func existingShare(for entity: ShareableEntity) async throws -> CKShare? { |
| |
337 | let shareRecordName = try await shareRecordName(for: entity) |
| |
338 | guard let shareRecordName else { return nil } |
| |
339 | if let privateShare = try await fetchShare(recordName: shareRecordName, in: .privateDatabase) { |
| |
340 | return privateShare |
| |
341 | } |
| |
342 | return try await fetchShare(recordName: shareRecordName, in: .sharedDatabase) |
| |
343 | } |
| |
344 | |
| |
345 | func createShare(for entity: ShareableEntity) async throws -> CKShare { |
| |
346 | guard let database = cloudKitDatabase(in: .privateDatabase) else { |
| |
347 | throw CloudSyncRuntimeError.missingEntitlement |
| |
348 | } |
| |
349 | |
| |
350 | switch entity { |
| |
351 | case .trackedDomain(let domain): |
| |
352 | let normalized = Self.normalizeDomain(domain) |
| |
353 | let trackedDomain = DomainDataPortabilityService.loadTrackedDomains() |
| |
354 | .first(where: { Self.normalizeDomain($0.domain) == normalized }) |
| |
355 | guard let trackedDomain else { |
| |
356 | throw CloudSyncRuntimeError.missingLocalItem |
| |
357 | } |
| |
358 | |
| |
359 | let rootRecord = makeTrackedDomainRecord(trackedDomain) |
| |
360 | let share = CKShare(rootRecord: rootRecord) |
| |
361 | share[CKShare.SystemFieldKey.title] = normalized as CKRecordValue |
| |
362 | share.publicPermission = .none |
| |
363 | _ = try await database.modifyRecords( |
| |
364 | saving: [rootRecord, share], |
| |
365 | deleting: [], |
| |
366 | savePolicy: .allKeys, |
| |
367 | atomically: true |
| |
368 | ) |
| |
369 | await syncNow(trigger: .manual) |
| |
370 | return share |
| |
371 | |
| |
372 | case .workflow(let identifier): |
| |
373 | let workflow = DomainDataPortabilityService.loadWorkflows() |
| |
374 | .first(where: { $0.id == identifier }) |
| |
375 | guard let workflow else { |
| |
376 | throw CloudSyncRuntimeError.missingLocalItem |
| |
377 | } |
| |
378 | |
| |
379 | let rootRecord = makeWorkflowRecord(workflow) |
| |
380 | let share = CKShare(rootRecord: rootRecord) |
| |
381 | share[CKShare.SystemFieldKey.title] = workflow.name as CKRecordValue |
| |
382 | share.publicPermission = .none |
| |
383 | _ = try await database.modifyRecords( |
| |
384 | saving: [rootRecord, share], |
| |
385 | deleting: [], |
| |
386 | savePolicy: .allKeys, |
| |
387 | atomically: true |
| |
388 | ) |
| |
389 | await syncNow(trigger: .manual) |
| |
390 | return share |
| |
391 | } |
| |
392 | } |
| |
393 | |
| |
394 | func syncNow(trigger: CloudSyncTrigger = .manual) async { |
| |
395 | guard isEnabled else { |
| |
396 | setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil) |
| |
397 | return |
| |
398 | } |
| |
399 | |
| |
400 | if syncTask != nil { |
| |
401 | return |
| |
402 | } |
| |
403 | |
| |
404 | syncTask = Task { [weak self] in |
| |
405 | guard let self else { return } |
| |
406 | await self.performSync(trigger: trigger) |
| |
407 | await MainActor.run { |
| |
408 | self.syncTask = nil |
| |
409 | } |
| |
410 | } |
| |
411 | |
| |
412 | await syncTask?.value |
| |
413 | } |
| |
414 | |
| |
415 | private func performSync(trigger: CloudSyncTrigger) async { |
| |
416 | guard cloudKitDatabase(in: .privateDatabase) != nil else { |
| |
417 | setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil) |
| |
418 | return |
| |
419 | } |
| |
420 | |
| |
421 | guard isNetworkAvailable else { |
| |
422 | setStatus(.offline, detail: "Waiting for a network connection.", error: nil) |
| |
423 | return |
| |
424 | } |
| |
425 | |
| |
426 | switch await accountStatus() { |
| |
427 | case .available: |
| |
428 | break |
| |
429 | case .noAccount: |
| |
430 | setStatus(.iCloudUnavailable, detail: "Sign in to iCloud to sync DomainDig.", error: nil) |
| |
431 | return |
| |
432 | case .restricted: |
| |
433 | setStatus(.iCloudUnavailable, detail: "iCloud access is restricted on this device.", error: nil) |
| |
434 | return |
| |
435 | case .temporarilyUnavailable: |
| |
436 | setStatus(.iCloudUnavailable, detail: "iCloud is temporarily unavailable.", error: nil) |
| |
437 | return |
| |
438 | case .unknown: |
| |
439 | setStatus(.iCloudUnavailable, detail: "DomainDig could not confirm iCloud availability.", error: nil) |
| |
440 | return |
| |
441 | case .missingEntitlement: |
| |
442 | setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil) |
| |
443 | return |
| |
444 | } |
| |
445 | |
| |
446 | setStatus(.syncing, detail: trigger == .manual ? "Syncing now…" : "Syncing changes…", error: nil) |
| |
447 | |
| |
448 | do { |
| |
449 | let local = loadLocalPayload() |
| |
450 | let remote = try await fetchRemotePayload() |
| |
451 | let merged = merge(local: local, remote: remote) |
| |
452 | applyMergedPayload(merged.payload) |
| |
453 | try await pushMergedPayload(merged.payload) |
| |
454 | |
| |
455 | lastSyncDate = Date() |
| |
456 | defaults.set(lastSyncDate, forKey: StorageKey.lastSyncDate) |
| |
457 | |
| |
458 | if merged.changedLocalData { |
| |
459 | NotificationCenter.default.post(name: .cloudSyncDidApplyChanges, object: nil) |
| |
460 | } |
| |
461 | |
| |
462 | let detail = syncSummaryDetail() |
| |
463 | if merged.hadConflict { |
| |
464 | setStatus(.conflictResolved, detail: detail, error: nil) |
| |
465 | } else { |
| |
466 | setStatus(.synced, detail: detail, error: nil) |
| |
467 | } |
| |
468 | } catch { |
| |
469 | let mapped = mapSyncError(error) |
| |
470 | setStatus(mapped.status, detail: mapped.message, error: mapped.errorDetail) |
| |
471 | } |
| |
472 | } |
| |
473 | |
| |
474 | private func loadLocalPayload() -> SyncPayload { |
| |
475 | let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() |
| |
476 | let workflows = DomainDataPortabilityService.loadWorkflows() |
| |
477 | let appSettings = SyncedAppSettings( |
| |
478 | snapshot: DomainDataPortabilityService.loadAppSettings(), |
| |
479 | updatedAt: defaults.object(forKey: StorageKey.appSettingsUpdatedAt) as? Date ?? .distantPast |
| |
480 | ) |
| |
481 | let monitoringSettings = SyncedMonitoringSettings( |
| |
482 | settings: DomainDataPortabilityService.loadMonitoringSettings(), |
| |
483 | updatedAt: defaults.object(forKey: StorageKey.monitoringSettingsUpdatedAt) as? Date ?? .distantPast |
| |
484 | ) |
| |
485 | |
| |
486 | let noteDates = loadNoteUpdatedAtByDomain() |
| |
487 | let notesByDomain = Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedDomainNote)? in |
| |
488 | guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil } |
| |
489 | let normalized = Self.normalizeDomain(trackedDomain.domain) |
| |
490 | guard !normalized.isEmpty else { return nil } |
| |
491 | let noteText = trackedDomain.note ?? "" |
| |
492 | let updatedAt = noteDates[normalized] ?? trackedDomain.updatedAt |
| |
493 | return ( |
| |
494 | normalized, |
| |
495 | SyncedDomainNote( |
| |
496 | domain: normalized, |
| |
497 | text: noteText, |
| |
498 | updatedAt: updatedAt |
| |
499 | ) |
| |
500 | ) |
| |
501 | }) |
| |
502 | |
| |
503 | let historyMetadataByDomain = Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedHistoryMetadata)? in |
| |
504 | guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil } |
| |
505 | let normalized = Self.normalizeDomain(trackedDomain.domain) |
| |
506 | guard !normalized.isEmpty else { return nil } |
| |
507 | return ( |
| |
508 | normalized, |
| |
509 | SyncedHistoryMetadata( |
| |
510 | domain: normalized, |
| |
511 | lastKnownAvailabilityRawValue: trackedDomain.lastKnownAvailability?.rawValue, |
| |
512 | lastChangeMessage: trackedDomain.lastChangeSummary?.message, |
| |
513 | lastChangeSeverityRawValue: trackedDomain.lastChangeSeverity?.rawValue ?? trackedDomain.lastChangeSummary?.severity.rawValue, |
| |
514 | certificateWarningLevelRawValue: trackedDomain.certificateWarningLevel.rawValue, |
| |
515 | certificateDaysRemaining: trackedDomain.certificateDaysRemaining, |
| |
516 | lastObservedAt: trackedDomain.updatedAt, |
| |
517 | updatedAt: trackedDomain.updatedAt |
| |
518 | ) |
| |
519 | ) |
| |
520 | }) |
| |
521 | |
| |
522 | return SyncPayload( |
| |
523 | trackedDomains: trackedDomains, |
| |
524 | workflows: workflows, |
| |
525 | appSettings: appSettings, |
| |
526 | monitoringSettings: monitoringSettings, |
| |
527 | notesByDomain: notesByDomain, |
| |
528 | historyMetadataByDomain: historyMetadataByDomain, |
| |
529 | tombstones: loadTombstones() |
| |
530 | ) |
| |
531 | } |
| |
532 | |
| |
533 | private func fetchRemotePayload() async throws -> SyncPayload { |
| |
534 | let privateTrackedDomains = try await fetchTrackedDomainRecords(in: .privateDatabase) |
| |
535 | let sharedTrackedDomains = try await fetchTrackedDomainRecords(in: .sharedDatabase) |
| |
536 | let privateWorkflows = try await fetchWorkflowRecords(in: .privateDatabase) |
| |
537 | let sharedWorkflows = try await fetchWorkflowRecords(in: .sharedDatabase) |
| |
538 | |
| |
539 | let appSettingsRecords: [SyncedAppSettings] = try await fetchRecords( |
| |
540 | ofType: CloudRecordType.appSettings, |
| |
541 | in: .privateDatabase |
| |
542 | ) |
| |
543 | let monitoringSettingsRecords: [SyncedMonitoringSettings] = try await fetchRecords( |
| |
544 | ofType: CloudRecordType.monitoringSettings, |
| |
545 | in: .privateDatabase |
| |
546 | ) |
| |
547 | let domainNotes: [SyncedDomainNote] = try await fetchRecords( |
| |
548 | ofType: CloudRecordType.domainNote, |
| |
549 | in: .privateDatabase |
| |
550 | ) |
| |
551 | let historyMetadata: [SyncedHistoryMetadata] = try await fetchRecords( |
| |
552 | ofType: CloudRecordType.historyMetadata, |
| |
553 | in: .privateDatabase |
| |
554 | ) |
| |
555 | let tombstones: [SyncTombstone] = try await fetchRecords( |
| |
556 | ofType: CloudRecordType.tombstone, |
| |
557 | in: .privateDatabase |
| |
558 | ) |
| |
559 | |
| |
560 | let appSettings = appSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt }) |
| |
561 | ?? SyncedAppSettings(snapshot: DomainDataPortabilityService.loadAppSettings(), updatedAt: .distantPast) |
| |
562 | let monitoringSettings = monitoringSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt }) |
| |
563 | ?? SyncedMonitoringSettings(settings: DomainDataPortabilityService.loadMonitoringSettings(), updatedAt: .distantPast) |
| |
564 | |
| |
565 | return SyncPayload( |
| |
566 | trackedDomains: privateTrackedDomains + sharedTrackedDomains, |
| |
567 | workflows: privateWorkflows + sharedWorkflows, |
| |
568 | appSettings: appSettings, |
| |
569 | monitoringSettings: monitoringSettings, |
| |
570 | notesByDomain: Dictionary(uniqueKeysWithValues: domainNotes.map { (Self.normalizeDomain($0.domain), $0) }), |
| |
571 | historyMetadataByDomain: Dictionary(uniqueKeysWithValues: historyMetadata.map { (Self.normalizeDomain($0.domain), $0) }), |
| |
572 | tombstones: tombstones |
| |
573 | ) |
| |
574 | } |
| |
575 | |
| |
576 | private func pushMergedPayload(_ payload: SyncPayload) async throws { |
| |
577 | guard let privateDatabase = cloudKitDatabase(in: .privateDatabase) else { |
| |
578 | throw CloudSyncRuntimeError.missingEntitlement |
| |
579 | } |
| |
580 | |
| |
581 | var privateRecordsToSave: [CKRecord] = [] |
| |
582 | var sharedRecordsToSave: [CKRecord] = [] |
| |
583 | var recordIDsToDelete: [CKRecord.ID] = [] |
| |
584 | |
| |
585 | for trackedDomain in payload.trackedDomains { |
| |
586 | switch trackedDomain.collaboration?.scope ?? .privateDatabase { |
| |
587 | case .privateDatabase: |
| |
588 | privateRecordsToSave.append(makeTrackedDomainRecord(trackedDomain)) |
| |
589 | case .sharedDatabase: |
| |
590 | if trackedDomain.collaboration?.canEdit == true { |
| |
591 | sharedRecordsToSave.append(makeTrackedDomainRecord(trackedDomain)) |
| |
592 | } |
| |
593 | } |
| |
594 | } |
| |
595 | |
| |
596 | for workflow in payload.workflows { |
| |
597 | switch workflow.collaboration?.scope ?? .privateDatabase { |
| |
598 | case .privateDatabase: |
| |
599 | privateRecordsToSave.append(makeWorkflowRecord(workflow)) |
| |
600 | case .sharedDatabase: |
| |
601 | if workflow.collaboration?.canEdit == true { |
| |
602 | sharedRecordsToSave.append(makeWorkflowRecord(workflow)) |
| |
603 | } |
| |
604 | } |
| |
605 | } |
| |
606 | |
| |
607 | privateRecordsToSave.append(makeAppSettingsRecord(payload.appSettings)) |
| |
608 | privateRecordsToSave.append(makeMonitoringSettingsRecord(payload.monitoringSettings)) |
| |
609 | privateRecordsToSave.append(contentsOf: payload.notesByDomain.values.map(makeDomainNoteRecord)) |
| |
610 | privateRecordsToSave.append(contentsOf: payload.historyMetadataByDomain.values.map(makeHistoryMetadataRecord)) |
| |
611 | privateRecordsToSave.append(contentsOf: payload.tombstones.map(makeTombstoneRecord)) |
| |
612 | |
| |
613 | for tombstone in payload.tombstones { |
| |
614 | switch tombstone.entityType { |
| |
615 | case .trackedDomain: |
| |
616 | let identifier = tombstone.identifier |
| |
617 | recordIDsToDelete.append(CKRecord.ID(recordName: trackedDomainRecordName(for: identifier))) |
| |
618 | recordIDsToDelete.append(CKRecord.ID(recordName: domainNoteRecordName(for: identifier))) |
| |
619 | recordIDsToDelete.append(CKRecord.ID(recordName: historyMetadataRecordName(for: identifier))) |
| |
620 | case .workflow: |
| |
621 | recordIDsToDelete.append(CKRecord.ID(recordName: workflowRecordName(for: tombstone.identifier))) |
| |
622 | } |
| |
623 | } |
| |
624 | |
| |
625 | _ = try await privateDatabase.modifyRecords( |
| |
626 | saving: privateRecordsToSave, |
| |
627 | deleting: Array(Set(recordIDsToDelete)), |
| |
628 | savePolicy: .changedKeys, |
| |
629 | atomically: false |
| |
630 | ) |
| |
631 | |
| |
632 | if !sharedRecordsToSave.isEmpty, let sharedDatabase = cloudKitDatabase(in: .sharedDatabase) { |
| |
633 | _ = try await sharedDatabase.modifyRecords( |
| |
634 | saving: sharedRecordsToSave, |
| |
635 | deleting: [], |
| |
636 | savePolicy: .changedKeys, |
| |
637 | atomically: false |
| |
638 | ) |
| |
639 | } |
| |
640 | } |
| |
641 | |
| |
642 | private func applyMergedPayload(_ payload: SyncPayload) { |
| |
643 | DomainDataPortabilityService.saveTrackedDomains(payload.trackedDomains) |
| |
644 | DomainDataPortabilityService.saveWorkflows(payload.workflows) |
| |
645 | DomainDataPortabilityService.saveAppSettings(payload.appSettings.snapshot) |
| |
646 | DomainDataPortabilityService.saveMonitoringSettings(payload.monitoringSettings.settings) |
| |
647 | |
| |
648 | defaults.set(payload.appSettings.updatedAt, forKey: StorageKey.appSettingsUpdatedAt) |
| |
649 | defaults.set(payload.monitoringSettings.updatedAt, forKey: StorageKey.monitoringSettingsUpdatedAt) |
| |
650 | saveNoteUpdatedAtByDomain(Dictionary(uniqueKeysWithValues: payload.notesByDomain.map { ($0.key, $0.value.updatedAt) })) |
| |
651 | saveTombstones(payload.tombstones) |
| |
652 | } |
| |
653 | |
| |
654 | private func merge(local: SyncPayload, remote: SyncPayload) -> MergedSyncPayload { |
| |
655 | var hadConflict = false |
| |
656 | |
| |
657 | let tombstones = latestTombstones(local.tombstones + remote.tombstones) |
| |
658 | |
| |
659 | let trackedDomainsResult = mergeTrackedDomains(local.trackedDomains, remote.trackedDomains) |
| |
660 | hadConflict = hadConflict || trackedDomainsResult.hadConflict |
| |
661 | var trackedDomains = applyTrackedDomainTombstones(trackedDomainsResult.domains, tombstones: tombstones) |
| |
662 | |
| |
663 | let workflowsResult = mergeWorkflows(local.workflows, remote.workflows) |
| |
664 | hadConflict = hadConflict || workflowsResult.hadConflict |
| |
665 | let workflows = applyWorkflowTombstones(workflowsResult.workflows, tombstones: tombstones) |
| |
666 | |
| |
667 | let notesResult = mergeNotes(local.notesByDomain, remote.notesByDomain) |
| |
668 | hadConflict = hadConflict || notesResult.hadConflict |
| |
669 | trackedDomains = applyNotes(notesResult.notesByDomain, to: trackedDomains, tombstones: tombstones) |
| |
670 | |
| |
671 | let historyResult = mergeHistoryMetadata(local.historyMetadataByDomain, remote.historyMetadataByDomain) |
| |
672 | hadConflict = hadConflict || historyResult.hadConflict |
| |
673 | trackedDomains = applyHistoryMetadata(historyResult.historyMetadataByDomain, to: trackedDomains, tombstones: tombstones) |
| |
674 | |
| |
675 | let appSettingsResult = mergeAppSettings(local.appSettings, remote.appSettings) |
| |
676 | hadConflict = hadConflict || appSettingsResult.hadConflict |
| |
677 | |
| |
678 | let monitoringResult = mergeMonitoringSettings(local.monitoringSettings, remote.monitoringSettings) |
| |
679 | hadConflict = hadConflict || monitoringResult.hadConflict |
| |
680 | |
| |
681 | let mergedPayload = SyncPayload( |
| |
682 | trackedDomains: trackedDomains, |
| |
683 | workflows: workflows, |
| |
684 | appSettings: appSettingsResult.settings, |
| |
685 | monitoringSettings: monitoringResult.settings, |
| |
686 | notesByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedDomainNote)? in |
| |
687 | guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil } |
| |
688 | let normalized = Self.normalizeDomain(trackedDomain.domain) |
| |
689 | guard let note = notesResult.notesByDomain[normalized] else { return nil } |
| |
690 | return (normalized, note) |
| |
691 | }), |
| |
692 | historyMetadataByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedHistoryMetadata)? in |
| |
693 | guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil } |
| |
694 | let normalized = Self.normalizeDomain(trackedDomain.domain) |
| |
695 | guard let metadata = historyResult.historyMetadataByDomain[normalized] else { return nil } |
| |
696 | return (normalized, metadata) |
| |
697 | }), |
| |
698 | tombstones: tombstones |
| |
699 | ) |
| |
700 | |
| |
701 | let changedLocalData = |
| |
702 | mergedPayload.trackedDomains != local.trackedDomains |
| |
703 | || mergedPayload.workflows != local.workflows |
| |
704 | || !sameAppSettings(mergedPayload.appSettings.snapshot, local.appSettings.snapshot) |
| |
705 | || mergedPayload.monitoringSettings.settings != local.monitoringSettings.settings |
| |
706 | |
| |
707 | return MergedSyncPayload( |
| |
708 | payload: mergedPayload, |
| |
709 | hadConflict: hadConflict, |
| |
710 | changedLocalData: changedLocalData |
| |
711 | ) |
| |
712 | } |
| |
713 | |
| |
714 | private func mergeTrackedDomains(_ lhs: [TrackedDomain], _ rhs: [TrackedDomain]) -> (domains: [TrackedDomain], hadConflict: Bool) { |
| |
715 | var mergedByDomain = Dictionary(uniqueKeysWithValues: lhs.map { (Self.normalizeDomain($0.domain), Self.payloadTrackedDomain($0)) }) |
| |
716 | var hadConflict = false |
| |
717 | |
| |
718 | for trackedDomain in rhs.map(Self.payloadTrackedDomain) { |
| |
719 | let key = Self.normalizeDomain(trackedDomain.domain) |
| |
720 | guard !key.isEmpty else { continue } |
| |
721 | |
| |
722 | if let existing = mergedByDomain[key] { |
| |
723 | if existing != trackedDomain { |
| |
724 | hadConflict = true |
| |
725 | } |
| |
726 | mergedByDomain[key] = mergedTrackedDomain(existing, trackedDomain) |
| |
727 | } else { |
| |
728 | mergedByDomain[key] = trackedDomain |
| |
729 | } |
| |
730 | } |
| |
731 | |
| |
732 | let domains = mergedByDomain.values.sorted { |
| |
733 | if $0.updatedAt != $1.updatedAt { |
| |
734 | return $0.updatedAt > $1.updatedAt |
| |
735 | } |
| |
736 | return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending |
| |
737 | } |
| |
738 | |
| |
739 | return (domains, hadConflict) |
| |
740 | } |
| |
741 | |
| |
742 | private func mergeWorkflows(_ lhs: [DomainWorkflow], _ rhs: [DomainWorkflow]) -> (workflows: [DomainWorkflow], hadConflict: Bool) { |
| |
743 | var merged = Dictionary(uniqueKeysWithValues: lhs.map { ($0.id, normalizedWorkflow($0)) }) |
| |
744 | var hadConflict = false |
| |
745 | |
| |
746 | for workflow in rhs.map(normalizedWorkflow) { |
| |
747 | if let existing = merged[workflow.id] { |
| |
748 | if existing != workflow { |
| |
749 | hadConflict = true |
| |
750 | } |
| |
751 | let winner = preferredWorkflow(existing, workflow) |
| |
752 | merged[workflow.id] = DomainWorkflow( |
| |
753 | id: existing.id, |
| |
754 | name: winner.name, |
| |
755 | domains: deduplicated(existing.domains + workflow.domains), |
| |
756 | createdAt: min(existing.createdAt, workflow.createdAt), |
| |
757 | updatedAt: max(existing.updatedAt, workflow.updatedAt), |
| |
758 | notes: winner.notes ?? existing.notes ?? workflow.notes, |
| |
759 | collaboration: preferredCollaboration(existing.collaboration, workflow.collaboration) |
| |
760 | ) |
| |
761 | } else { |
| |
762 | merged[workflow.id] = workflow |
| |
763 | } |
| |
764 | } |
| |
765 | |
| |
766 | return ( |
| |
767 | merged.values.sorted { |
| |
768 | if $0.updatedAt != $1.updatedAt { |
| |
769 | return $0.updatedAt > $1.updatedAt |
| |
770 | } |
| |
771 | return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending |
| |
772 | }, |
| |
773 | hadConflict |
| |
774 | ) |
| |
775 | } |
| |
776 | |
| |
777 | private func mergeNotes( |
| |
778 | _ lhs: [String: SyncedDomainNote], |
| |
779 | _ rhs: [String: SyncedDomainNote] |
| |
780 | ) -> (notesByDomain: [String: SyncedDomainNote], hadConflict: Bool) { |
| |
781 | var merged = lhs |
| |
782 | var hadConflict = false |
| |
783 | |
| |
784 | for (domain, remoteNote) in rhs { |
| |
785 | if let localNote = merged[domain], localNote != remoteNote { |
| |
786 | hadConflict = true |
| |
787 | merged[domain] = localNote.updatedAt >= remoteNote.updatedAt ? localNote : remoteNote |
| |
788 | } else { |
| |
789 | merged[domain] = remoteNote |
| |
790 | } |
| |
791 | } |
| |
792 | |
| |
793 | return (merged, hadConflict) |
| |
794 | } |
| |
795 | |
| |
796 | private func mergeHistoryMetadata( |
| |
797 | _ lhs: [String: SyncedHistoryMetadata], |
| |
798 | _ rhs: [String: SyncedHistoryMetadata] |
| |
799 | ) -> (historyMetadataByDomain: [String: SyncedHistoryMetadata], hadConflict: Bool) { |
| |
800 | var merged = lhs |
| |
801 | var hadConflict = false |
| |
802 | |
| |
803 | for (domain, remoteMetadata) in rhs { |
| |
804 | if let localMetadata = merged[domain], localMetadata != remoteMetadata { |
| |
805 | hadConflict = true |
| |
806 | merged[domain] = localMetadata.updatedAt >= remoteMetadata.updatedAt ? localMetadata : remoteMetadata |
| |
807 | } else { |
| |
808 | merged[domain] = remoteMetadata |
| |
809 | } |
| |
810 | } |
| |
811 | |
| |
812 | return (merged, hadConflict) |
| |
813 | } |
| |
814 | |
| |
815 | private func mergeAppSettings(_ lhs: SyncedAppSettings, _ rhs: SyncedAppSettings) -> (settings: SyncedAppSettings, hadConflict: Bool) { |
| |
816 | let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs |
| |
817 | let loser = winner.updatedAt == lhs.updatedAt ? rhs : lhs |
| |
818 | |
| |
819 | let merged = SyncedAppSettings( |
| |
820 | snapshot: AppSettingsSnapshot( |
| |
821 | recentSearches: deduplicated(winner.snapshot.recentSearches + loser.snapshot.recentSearches).prefix(20).map { $0 }, |
| |
822 | savedDomains: deduplicated(winner.snapshot.savedDomains + loser.snapshot.savedDomains), |
| |
823 | resolverURLString: winner.snapshot.resolverURLString, |
| |
824 | appDensityRawValue: winner.snapshot.appDensityRawValue |
| |
825 | ), |
| |
826 | updatedAt: max(lhs.updatedAt, rhs.updatedAt) |
| |
827 | ) |
| |
828 | |
| |
829 | return (merged, !sameAppSettings(lhs.snapshot, rhs.snapshot) || lhs.updatedAt != rhs.updatedAt) |
| |
830 | } |
| |
831 | |
| |
832 | private func mergeMonitoringSettings( |
| |
833 | _ lhs: SyncedMonitoringSettings, |
| |
834 | _ rhs: SyncedMonitoringSettings |
| |
835 | ) -> (settings: SyncedMonitoringSettings, hadConflict: Bool) { |
| |
836 | let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs |
| |
837 | var merged = winner |
| |
838 | |
| |
839 | if merged.settings.isEnabled && !defaults.bool(forKey: StorageKey.monitoringLocalActivationConfirmed) { |
| |
840 | merged.settings.isEnabled = false |
| |
841 | } |
| |
842 | |
| |
843 | if merged.settings.alertsEnabled { |
| |
844 | merged.settings.alertsEnabled = false |
| |
845 | } |
| |
846 | |
| |
847 | merged.updatedAt = max(lhs.updatedAt, rhs.updatedAt) |
| |
848 | return (merged, lhs.settings != rhs.settings || lhs.updatedAt != rhs.updatedAt) |
| |
849 | } |
| |
850 | |
| |
851 | private func applyTrackedDomainTombstones(_ trackedDomains: [TrackedDomain], tombstones: [SyncTombstone]) -> [TrackedDomain] { |
| |
852 | let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in |
| |
853 | guard tombstone.entityType == .trackedDomain else { return nil } |
| |
854 | return (tombstone.identifier, tombstone) |
| |
855 | }) |
| |
856 | |
| |
857 | return trackedDomains.filter { trackedDomain in |
| |
858 | let key = Self.normalizeDomain(trackedDomain.domain) |
| |
859 | guard let tombstone = tombstonesByDomain[key] else { return true } |
| |
860 | return trackedDomain.updatedAt > tombstone.deletedAt |
| |
861 | } |
| |
862 | } |
| |
863 | |
| |
864 | private func applyWorkflowTombstones(_ workflows: [DomainWorkflow], tombstones: [SyncTombstone]) -> [DomainWorkflow] { |
| |
865 | let tombstonesByIdentifier = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in |
| |
866 | guard tombstone.entityType == .workflow else { return nil } |
| |
867 | return (tombstone.identifier, tombstone) |
| |
868 | }) |
| |
869 | |
| |
870 | return workflows.filter { workflow in |
| |
871 | guard let tombstone = tombstonesByIdentifier[workflow.id.uuidString] else { return true } |
| |
872 | return workflow.updatedAt > tombstone.deletedAt |
| |
873 | } |
| |
874 | } |
| |
875 | |
| |
876 | private func applyNotes( |
| |
877 | _ notesByDomain: [String: SyncedDomainNote], |
| |
878 | to trackedDomains: [TrackedDomain], |
| |
879 | tombstones: [SyncTombstone] |
| |
880 | ) -> [TrackedDomain] { |
| |
881 | let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in |
| |
882 | guard tombstone.entityType == .trackedDomain else { return nil } |
| |
883 | return (tombstone.identifier, tombstone) |
| |
884 | }) |
| |
885 | |
| |
886 | return trackedDomains.map { trackedDomain in |
| |
887 | guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain } |
| |
888 | let normalized = Self.normalizeDomain(trackedDomain.domain) |
| |
889 | guard let note = notesByDomain[normalized] else { return trackedDomain } |
| |
890 | if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= note.updatedAt { |
| |
891 | return trackedDomain |
| |
892 | } |
| |
893 | |
| |
894 | var updated = trackedDomain |
| |
895 | updated.note = emptyToNil(note.text) |
| |
896 | return updated |
| |
897 | } |
| |
898 | } |
| |
899 | |
| |
900 | private func applyHistoryMetadata( |
| |
901 | _ historyMetadataByDomain: [String: SyncedHistoryMetadata], |
| |
902 | to trackedDomains: [TrackedDomain], |
| |
903 | tombstones: [SyncTombstone] |
| |
904 | ) -> [TrackedDomain] { |
| |
905 | let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in |
| |
906 | guard tombstone.entityType == .trackedDomain else { return nil } |
| |
907 | return (tombstone.identifier, tombstone) |
| |
908 | }) |
| |
909 | |
| |
910 | return trackedDomains.map { trackedDomain in |
| |
911 | guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain } |
| |
912 | let normalized = Self.normalizeDomain(trackedDomain.domain) |
| |
913 | guard let metadata = historyMetadataByDomain[normalized] else { return trackedDomain } |
| |
914 | if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= metadata.updatedAt { |
| |
915 | return trackedDomain |
| |
916 | } |
| |
917 | |
| |
918 | var updated = trackedDomain |
| |
919 | updated.lastKnownAvailability = metadata.lastKnownAvailabilityRawValue.flatMap(DomainAvailabilityStatus.init(rawValue:)) |
| |
920 | updated.lastChangeSeverity = metadata.lastChangeSeverityRawValue.flatMap(ChangeSeverity.init(rawValue:)) |
| |
921 | updated.certificateWarningLevel = CertificateWarningLevel(rawValue: metadata.certificateWarningLevelRawValue) ?? .none |
| |
922 | updated.certificateDaysRemaining = metadata.certificateDaysRemaining |
| |
923 | |
| |
924 | if let message = metadata.lastChangeMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { |
| |
925 | updated.lastChangeSummary = DomainChangeSummary( |
| |
926 | hasChanges: true, |
| |
927 | changedSections: [], |
| |
928 | message: message, |
| |
929 | severity: updated.lastChangeSeverity ?? .medium, |
| |
930 | impactClassification: .warning, |
| |
931 | generatedAt: metadata.lastObservedAt ?? metadata.updatedAt |
| |
932 | ) |
| |
933 | } |
| |
934 | |
| |
935 | return updated |
| |
936 | } |
| |
937 | } |
| |
938 | |
| |
939 | private func latestTombstones(_ tombstones: [SyncTombstone]) -> [SyncTombstone] { |
| |
940 | var latest: [String: SyncTombstone] = [:] |
| |
941 | |
| |
942 | for tombstone in tombstones { |
| |
943 | let key = "\(tombstone.entityType.rawValue):\(tombstone.identifier)" |
| |
944 | if let existing = latest[key] { |
| |
945 | latest[key] = existing.deletedAt >= tombstone.deletedAt ? existing : tombstone |
| |
946 | } else { |
| |
947 | latest[key] = tombstone |
| |
948 | } |
| |
949 | } |
| |
950 | |
| |
951 | return latest.values.sorted { $0.deletedAt > $1.deletedAt } |
| |
952 | } |
| |
953 | |
| |
954 | private func saveTombstone(_ tombstone: SyncTombstone) { |
| |
955 | var tombstones = loadTombstones() |
| |
956 | tombstones.append(tombstone) |
| |
957 | saveTombstones(latestTombstones(tombstones)) |
| |
958 | } |
| |
959 | |
| |
960 | private func loadTombstones() -> [SyncTombstone] { |
| |
961 | guard let data = defaults.data(forKey: StorageKey.tombstones), |
| |
962 | let tombstones = try? decoder.decode([SyncTombstone].self, from: data) else { |
| |
963 | return [] |
| |
964 | } |
| |
965 | return tombstones |
| |
966 | } |
| |
967 | |
| |
968 | private func saveTombstones(_ tombstones: [SyncTombstone]) { |
| |
969 | if let data = try? encoder.encode(Array(tombstones.prefix(200))) { |
| |
970 | defaults.set(data, forKey: StorageKey.tombstones) |
| |
971 | } |
| |
972 | } |
| |
973 | |
| |
974 | private func loadNoteUpdatedAtByDomain() -> [String: Date] { |
| |
975 | guard let data = defaults.data(forKey: StorageKey.noteUpdatedAtByDomain), |
| |
976 | let values = try? decoder.decode([String: Date].self, from: data) else { |
| |
977 | return [:] |
| |
978 | } |
| |
979 | return values |
| |
980 | } |
| |
981 | |
| |
982 | private func saveNoteUpdatedAtByDomain(_ values: [String: Date]) { |
| |
983 | if let data = try? encoder.encode(values) { |
| |
984 | defaults.set(data, forKey: StorageKey.noteUpdatedAtByDomain) |
| |
985 | } |
| |
986 | } |
| |
987 | |
| |
988 | private func setStatus(_ status: CloudSyncStatus, detail: String, error: String?) { |
| |
989 | self.status = status |
| |
990 | self.detailMessage = detail |
| |
991 | self.lastErrorMessage = error |
| |
992 | |
| |
993 | defaults.set(status.rawValue, forKey: StorageKey.status) |
| |
994 | defaults.set(detail, forKey: StorageKey.detailMessage) |
| |
995 | if let error { |
| |
996 | defaults.set(error, forKey: StorageKey.lastErrorMessage) |
| |
997 | } else { |
| |
998 | defaults.removeObject(forKey: StorageKey.lastErrorMessage) |
| |
999 | } |
| |
1000 | } |
| |
1001 | |
| |
1002 | private func syncSummaryDetail() -> String { |
| |
1003 | let dateLabel = lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced" |
| |
1004 | return "Data stays in your private iCloud account. Last sync: \(dateLabel)." |
| |
1005 | } |
| |
1006 | |
| |
1007 | private func accountStatus() async -> AvailabilityState { |
| |
1008 | guard let container = cloudKitContainer() else { |
| |
1009 | return .missingEntitlement |
| |
1010 | } |
| |
1011 | |
| |
1012 | return await withCheckedContinuation { continuation in |
| |
1013 | container.accountStatus { status, error in |
| |
1014 | if let error { |
| |
1015 | let ckError = error as? CKError |
| |
1016 | if ckError?.code == .notAuthenticated { |
| |
1017 | continuation.resume(returning: AvailabilityState.noAccount) |
| |
1018 | return |
| |
1019 | } |
| |
1020 | continuation.resume(returning: AvailabilityState.unknown) |
| |
1021 | return |
| |
1022 | } |
| |
1023 | |
| |
1024 | switch status { |
| |
1025 | case .available: |
| |
1026 | continuation.resume(returning: AvailabilityState.available) |
| |
1027 | case .noAccount: |
| |
1028 | continuation.resume(returning: AvailabilityState.noAccount) |
| |
1029 | case .restricted: |
| |
1030 | continuation.resume(returning: AvailabilityState.restricted) |
| |
1031 | case .temporarilyUnavailable: |
| |
1032 | continuation.resume(returning: AvailabilityState.temporarilyUnavailable) |
| |
1033 | case .couldNotDetermine: |
| |
1034 | continuation.resume(returning: AvailabilityState.unknown) |
| |
1035 | @unknown default: |
| |
1036 | continuation.resume(returning: AvailabilityState.unknown) |
| |
1037 | } |
| |
1038 | } |
| |
1039 | } |
| |
1040 | } |
| |
1041 | |
| |
1042 | private func fetchRecords<T: Decodable>(ofType recordType: String, in scope: SyncDatabaseScope) async throws -> [T] { |
| |
1043 | guard let database = cloudKitDatabase(in: scope) else { |
| |
1044 | if scope == .sharedDatabase { |
| |
1045 | return [] |
| |
1046 | } |
| |
1047 | throw CloudSyncRuntimeError.missingEntitlement |
| |
1048 | } |
| |
1049 | |
| |
1050 | let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) |
| |
1051 | var results: [T] = [] |
| |
1052 | var cursor: CKQueryOperation.Cursor? |
| |
1053 | |
| |
1054 | repeat { |
| |
1055 | let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?) |
| |
1056 | if let cursor { |
| |
1057 | batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200) |
| |
1058 | } else { |
| |
1059 | batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200) |
| |
1060 | } |
| |
1061 | |
| |
1062 | for (_, result) in batch.matchResults { |
| |
1063 | switch result { |
| |
1064 | case .success(let record): |
| |
1065 | guard let payload = record[CloudRecordKey.payload] as? Data else { continue } |
| |
1066 | if let decoded = try? decoder.decode(T.self, from: payload) { |
| |
1067 | results.append(decoded) |
| |
1068 | } else { |
| |
1069 | lastErrorMessage = "Some iCloud records were skipped because they could not be decoded." |
| |
1070 | } |
| |
1071 | case .failure: |
| |
1072 | continue |
| |
1073 | } |
| |
1074 | } |
| |
1075 | |
| |
1076 | cursor = batch.queryCursor |
| |
1077 | } while cursor != nil |
| |
1078 | |
| |
1079 | return results |
| |
1080 | } |
| |
1081 | |
| |
1082 | private func fetchSharedRecords<T: Decodable>( |
| |
1083 | ofType recordType: String, |
| |
1084 | in scope: SyncDatabaseScope |
| |
1085 | ) async throws -> [SharedRecord<T>] { |
| |
1086 | guard let database = cloudKitDatabase(in: scope) else { |
| |
1087 | if scope == .sharedDatabase { |
| |
1088 | return [] |
| |
1089 | } |
| |
1090 | throw CloudSyncRuntimeError.missingEntitlement |
| |
1091 | } |
| |
1092 | |
| |
1093 | let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) |
| |
1094 | var results: [SharedRecord<T>] = [] |
| |
1095 | var cursor: CKQueryOperation.Cursor? |
| |
1096 | |
| |
1097 | repeat { |
| |
1098 | let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?) |
| |
1099 | if let cursor { |
| |
1100 | batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200) |
| |
1101 | } else { |
| |
1102 | batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200) |
| |
1103 | } |
| |
1104 | |
| |
1105 | for (_, result) in batch.matchResults { |
| |
1106 | switch result { |
| |
1107 | case .success(let record): |
| |
1108 | guard let payload = record[CloudRecordKey.payload] as? Data else { continue } |
| |
1109 | if let decoded = try? decoder.decode(T.self, from: payload) { |
| |
1110 | results.append(SharedRecord(record: record, value: decoded)) |
| |
1111 | } else { |
| |
1112 | lastErrorMessage = "Some iCloud records were skipped because they could not be decoded." |
| |
1113 | } |
| |
1114 | case .failure: |
| |
1115 | continue |
| |
1116 | } |
| |
1117 | } |
| |
1118 | |
| |
1119 | cursor = batch.queryCursor |
| |
1120 | } while cursor != nil |
| |
1121 | |
| |
1122 | return results |
| |
1123 | } |
| |
1124 | |
| |
1125 | private func fetchTrackedDomainRecords(in scope: SyncDatabaseScope) async throws -> [TrackedDomain] { |
| |
1126 | let records: [SharedRecord<TrackedDomain>] = try await fetchSharedRecords(ofType: CloudRecordType.trackedDomain, in: scope) |
| |
1127 | let shares = try await sharesByRecordID(for: records.map(\.record), in: scope) |
| |
1128 | |
| |
1129 | return records.map { entry in |
| |
1130 | var trackedDomain = Self.payloadTrackedDomain(entry.value) |
| |
1131 | trackedDomain.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope) |
| |
1132 | return trackedDomain |
| |
1133 | } |
| |
1134 | } |
| |
1135 | |
| |
1136 | private func fetchWorkflowRecords(in scope: SyncDatabaseScope) async throws -> [DomainWorkflow] { |
| |
1137 | let records: [SharedRecord<DomainWorkflow>] = try await fetchSharedRecords(ofType: CloudRecordType.workflow, in: scope) |
| |
1138 | let shares = try await sharesByRecordID(for: records.map(\.record), in: scope) |
| |
1139 | |
| |
1140 | return records.map { entry in |
| |
1141 | var workflow = normalizedWorkflow(entry.value) |
| |
1142 | workflow.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope) |
| |
1143 | return workflow |
| |
1144 | } |
| |
1145 | } |
| |
1146 | |
| |
1147 | private func sharesByRecordID( |
| |
1148 | for records: [CKRecord], |
| |
1149 | in scope: SyncDatabaseScope |
| |
1150 | ) async throws -> [CKRecord.ID: CKShare] { |
| |
1151 | guard let database = cloudKitDatabase(in: scope) else { return [:] } |
| |
1152 | |
| |
1153 | let shareReferences = records.reduce(into: [CKRecord.ID: CKRecord.Reference]()) { partialResult, record in |
| |
1154 | if let reference = record.share { |
| |
1155 | partialResult[record.recordID] = reference |
| |
1156 | } |
| |
1157 | } |
| |
1158 | guard !shareReferences.isEmpty else { return [:] } |
| |
1159 | |
| |
1160 | let fetched = try await database.records(for: Array(Set(shareReferences.values.map(\.recordID)))) |
| |
1161 | var sharesByRootRecordID: [CKRecord.ID: CKShare] = [:] |
| |
1162 | |
| |
1163 | for (rootRecordID, shareReference) in shareReferences { |
| |
1164 | guard case .success(let shareRecord) = fetched[shareReference.recordID], |
| |
1165 | let share = shareRecord as? CKShare else { |
| |
1166 | continue |
| |
1167 | } |
| |
1168 | sharesByRootRecordID[rootRecordID] = share |
| |
1169 | } |
| |
1170 | |
| |
1171 | return sharesByRootRecordID |
| |
1172 | } |
| |
1173 | |
| |
1174 | private func collaborationMetadata( |
| |
1175 | for record: CKRecord, |
| |
1176 | share: CKShare?, |
| |
1177 | scope: SyncDatabaseScope |
| |
1178 | ) -> CollaborationMetadata { |
| |
1179 | let ownership: CollaborationOwnership |
| |
1180 | let permission: CollaborationPermission |
| |
1181 | |
| |
1182 | if let participant = share?.currentUserParticipant { |
| |
1183 | ownership = participant.role == .owner ? .owner : .participant |
| |
1184 | permission = participant.permission == .readOnly ? .readOnly : .editable |
| |
1185 | } else { |
| |
1186 | ownership = scope == .sharedDatabase ? .participant : .owner |
| |
1187 | permission = scope == .sharedDatabase ? .readOnly : .editable |
| |
1188 | } |
| |
1189 | |
| |
1190 | return CollaborationMetadata( |
| |
1191 | scope: scope == .privateDatabase ? .privateDatabase : .sharedDatabase, |
| |
1192 | ownership: ownership, |
| |
1193 | permission: permission, |
| |
1194 | shareRecordName: share?.recordID.recordName |
| |
1195 | ) |
| |
1196 | } |
| |
1197 | |
| |
1198 | private func fetchShare(recordName: String, in scope: SyncDatabaseScope) async throws -> CKShare? { |
| |
1199 | guard let database = cloudKitDatabase(in: scope) else { return nil } |
| |
1200 | let results = try await database.records(for: [CKRecord.ID(recordName: recordName)]) |
| |
1201 | guard case .success(let shareRecord) = results[CKRecord.ID(recordName: recordName)] else { |
| |
1202 | return nil |
| |
1203 | } |
| |
1204 | return shareRecord as? CKShare |
| |
1205 | } |
| |
1206 | |
| |
1207 | private func shareRecordName(for entity: ShareableEntity) async throws -> String? { |
| |
1208 | switch entity { |
| |
1209 | case .trackedDomain(let domain): |
| |
1210 | let normalized = Self.normalizeDomain(domain) |
| |
1211 | return DomainDataPortabilityService.loadTrackedDomains() |
| |
1212 | .first(where: { Self.normalizeDomain($0.domain) == normalized })? |
| |
1213 | .collaboration?.shareRecordName |
| |
1214 | case .workflow(let identifier): |
| |
1215 | return DomainDataPortabilityService.loadWorkflows() |
| |
1216 | .first(where: { $0.id == identifier })? |
| |
1217 | .collaboration?.shareRecordName |
| |
1218 | } |
| |
1219 | } |
| |
1220 | |
| |
1221 | private func makeTrackedDomainRecord(_ trackedDomain: TrackedDomain) -> CKRecord { |
| |
1222 | let normalized = Self.normalizeDomain(trackedDomain.domain) |
| |
1223 | let sanitized = Self.payloadTrackedDomain(trackedDomain) |
| |
1224 | return makeRecord( |
| |
1225 | type: CloudRecordType.trackedDomain, |
| |
1226 | recordName: trackedDomainRecordName(for: normalized), |
| |
1227 | payload: sanitized, |
| |
1228 | extraFields: [ |
| |
1229 | CloudRecordKey.domain: normalized as CKRecordValue, |
| |
1230 | CloudRecordKey.updatedAt: sanitized.updatedAt as CKRecordValue |
| |
1231 | ] |
| |
1232 | ) |
| |
1233 | } |
| |
1234 | |
| |
1235 | private func makeWorkflowRecord(_ workflow: DomainWorkflow) -> CKRecord { |
| |
1236 | let normalized = normalizedWorkflow(workflow) |
| |
1237 | return makeRecord( |
| |
1238 | type: CloudRecordType.workflow, |
| |
1239 | recordName: workflowRecordName(for: normalized.id.uuidString), |
| |
1240 | payload: normalized, |
| |
1241 | extraFields: [ |
| |
1242 | CloudRecordKey.identifier: normalized.id.uuidString as CKRecordValue, |
| |
1243 | CloudRecordKey.updatedAt: normalized.updatedAt as CKRecordValue |
| |
1244 | ] |
| |
1245 | ) |
| |
1246 | } |
| |
1247 | |
| |
1248 | private func makeAppSettingsRecord(_ settings: SyncedAppSettings) -> CKRecord { |
| |
1249 | makeRecord( |
| |
1250 | type: CloudRecordType.appSettings, |
| |
1251 | recordName: "app-settings", |
| |
1252 | payload: settings, |
| |
1253 | extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue] |
| |
1254 | ) |
| |
1255 | } |
| |
1256 | |
| |
1257 | private func makeMonitoringSettingsRecord(_ settings: SyncedMonitoringSettings) -> CKRecord { |
| |
1258 | makeRecord( |
| |
1259 | type: CloudRecordType.monitoringSettings, |
| |
1260 | recordName: "monitoring-settings", |
| |
1261 | payload: settings, |
| |
1262 | extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue] |
| |
1263 | ) |
| |
1264 | } |
| |
1265 | |
| |
1266 | private func makeDomainNoteRecord(_ note: SyncedDomainNote) -> CKRecord { |
| |
1267 | makeRecord( |
| |
1268 | type: CloudRecordType.domainNote, |
| |
1269 | recordName: domainNoteRecordName(for: note.domain), |
| |
1270 | payload: note, |
| |
1271 | extraFields: [ |
| |
1272 | CloudRecordKey.domain: note.domain as CKRecordValue, |
| |
1273 | CloudRecordKey.updatedAt: note.updatedAt as CKRecordValue |
| |
1274 | ] |
| |
1275 | ) |
| |
1276 | } |
| |
1277 | |
| |
1278 | private func makeHistoryMetadataRecord(_ metadata: SyncedHistoryMetadata) -> CKRecord { |
| |
1279 | makeRecord( |
| |
1280 | type: CloudRecordType.historyMetadata, |
| |
1281 | recordName: historyMetadataRecordName(for: metadata.domain), |
| |
1282 | payload: metadata, |
| |
1283 | extraFields: [ |
| |
1284 | CloudRecordKey.domain: metadata.domain as CKRecordValue, |
| |
1285 | CloudRecordKey.updatedAt: metadata.updatedAt as CKRecordValue |
| |
1286 | ] |
| |
1287 | ) |
| |
1288 | } |
| |
1289 | |
| |
1290 | private func makeTombstoneRecord(_ tombstone: SyncTombstone) -> CKRecord { |
| |
1291 | makeRecord( |
| |
1292 | type: CloudRecordType.tombstone, |
| |
1293 | recordName: tombstoneRecordName(for: tombstone.entityType, identifier: tombstone.identifier), |
| |
1294 | payload: tombstone, |
| |
1295 | extraFields: [ |
| |
1296 | CloudRecordKey.entityType: tombstone.entityType.rawValue as CKRecordValue, |
| |
1297 | CloudRecordKey.identifier: tombstone.identifier as CKRecordValue, |
| |
1298 | CloudRecordKey.deletedAt: tombstone.deletedAt as CKRecordValue |
| |
1299 | ] |
| |
1300 | ) |
| |
1301 | } |
| |
1302 | |
| |
1303 | private func makeRecord<T: Encodable>( |
| |
1304 | type: String, |
| |
1305 | recordName: String, |
| |
1306 | payload: T, |
| |
1307 | extraFields: [String: CKRecordValue] |
| |
1308 | ) -> CKRecord { |
| |
1309 | let record = CKRecord(recordType: type, recordID: CKRecord.ID(recordName: recordName)) |
| |
1310 | record[CloudRecordKey.payload] = (try? encoder.encode(payload)) as CKRecordValue? |
| |
1311 | for (key, value) in extraFields { |
| |
1312 | record[key] = value |
| |
1313 | } |
| |
1314 | return record |
| |
1315 | } |
| |
1316 | |
| |
1317 | private func mapSyncError(_ error: Error) -> (status: CloudSyncStatus, message: String, errorDetail: String?) { |
| |
1318 | if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingEntitlement { |
| |
1319 | return ( |
| |
1320 | .iCloudUnavailable, |
| |
1321 | missingEntitlementMessage, |
| |
1322 | nil |
| |
1323 | ) |
| |
1324 | } |
| |
1325 | |
| |
1326 | if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingLocalItem { |
| |
1327 | return (.error, "The shared item is no longer available locally.", nil) |
| |
1328 | } |
| |
1329 | |
| |
1330 | guard let ckError = error as? CKError else { |
| |
1331 | return (.error, "Sync failed, but local data is still available.", error.localizedDescription) |
| |
1332 | } |
| |
1333 | |
| |
1334 | switch ckError.code { |
| |
1335 | case .networkUnavailable, .networkFailure, .serviceUnavailable: |
| |
1336 | return (.offline, "Sync is waiting for a stable network connection.", ckError.localizedDescription) |
| |
1337 | case .notAuthenticated: |
| |
1338 | return (.iCloudUnavailable, "Sign in to iCloud to sync DomainDig.", ckError.localizedDescription) |
| |
1339 | case .quotaExceeded: |
| |
1340 | return (.error, "iCloud storage is full. DomainDig kept working locally.", ckError.localizedDescription) |
| |
1341 | case .permissionFailure, .badContainer, .missingEntitlement: |
| |
1342 | return (.iCloudUnavailable, "CloudKit access is unavailable for this build.", ckError.localizedDescription) |
| |
1343 | case .partialFailure: |
| |
1344 | return (.error, "Some iCloud records could not sync. Local data remains available.", ckError.localizedDescription) |
| |
1345 | default: |
| |
1346 | return (.error, "Sync failed, but local data is still available.", ckError.localizedDescription) |
| |
1347 | } |
| |
1348 | } |
| |
1349 | |
| |
1350 | private enum AvailabilityState { |
| |
1351 | case available |
| |
1352 | case noAccount |
| |
1353 | case restricted |
| |
1354 | case temporarilyUnavailable |
| |
1355 | case unknown |
| |
1356 | case missingEntitlement |
| |
1357 | } |
| |
1358 | |
| |
1359 | private enum CloudSyncRuntimeError: Error, Equatable { |
| |
1360 | case missingEntitlement |
| |
1361 | case missingLocalItem |
| |
1362 | } |
| |
1363 | |
| |
1364 | private static func normalizeDomain(_ value: String) -> String { |
| |
1365 | value |
| |
1366 | .trimmingCharacters(in: .whitespacesAndNewlines) |
| |
1367 | .replacingOccurrences(of: "https://", with: "") |
| |
1368 | .replacingOccurrences(of: "http://", with: "") |
| |
1369 | .components(separatedBy: "/") |
| |
1370 | .first? |
| |
1371 | .lowercased() ?? "" |
| |
1372 | } |
| |
1373 | |
| |
1374 | private func cloudKitContainer() -> CKContainer? { |
| |
1375 | if let container { |
| |
1376 | return container |
| |
1377 | } |
| |
1378 | |
| |
1379 | if isEntitlementConfigurationAvailable() { |
| |
1380 | let container = CKContainer.default() |
| |
1381 | self.container = container |
| |
1382 | return container |
| |
1383 | } |
| |
1384 | |
| |
1385 | return nil |
| |
1386 | } |
| |
1387 | |
| |
1388 | private func cloudKitDatabase(in scope: SyncDatabaseScope) -> CKDatabase? { |
| |
1389 | switch scope { |
| |
1390 | case .privateDatabase: |
| |
1391 | if let privateDatabase { |
| |
1392 | return privateDatabase |
| |
1393 | } |
| |
1394 | case .sharedDatabase: |
| |
1395 | if let sharedDatabase { |
| |
1396 | return sharedDatabase |
| |
1397 | } |
| |
1398 | } |
| |
1399 | |
| |
1400 | guard let container = cloudKitContainer() else { |
| |
1401 | return nil |
| |
1402 | } |
| |
1403 | |
| |
1404 | switch scope { |
| |
1405 | case .privateDatabase: |
| |
1406 | let database = container.privateCloudDatabase |
| |
1407 | self.privateDatabase = database |
| |
1408 | return database |
| |
1409 | case .sharedDatabase: |
| |
1410 | let database = container.sharedCloudDatabase |
| |
1411 | self.sharedDatabase = database |
| |
1412 | return database |
| |
1413 | } |
| |
1414 | } |
| |
1415 | |
| |
1416 | private func isEntitlementConfigurationAvailable() -> Bool { |
| |
1417 | if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.icloud-container-identifiers") != nil { |
| |
1418 | return true |
| |
1419 | } |
| |
1420 | |
| |
1421 | if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.ubiquity-container-identifiers") != nil { |
| |
1422 | return true |
| |
1423 | } |
| |
1424 | |
| |
1425 | if let entitlementsURL = Bundle.main.url(forResource: "archived-expanded-entitlements", withExtension: "xcent"), |
| |
1426 | let data = try? Data(contentsOf: entitlementsURL), |
| |
1427 | let contents = String(data: data, encoding: .utf8) { |
| |
1428 | return contents.contains("com.apple.developer.icloud-services") |
| |
1429 | && (contents.contains("CloudKit") || contents.contains("CloudKit-Anonymous")) |
| |
1430 | } |
| |
1431 | |
| |
1432 | return false |
| |
1433 | } |
| |
1434 | |
| |
1435 | private var missingEntitlementMessage: String { |
| |
1436 | "This build does not have the CloudKit entitlement required for iCloud sync." |
| |
1437 | } |
| |
1438 | |
| |
1439 | private static func payloadTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain { |
| |
1440 | TrackedDomain( |
| |
1441 | id: trackedDomain.id, |
| |
1442 | domain: normalizeDomain(trackedDomain.domain), |
| |
1443 | createdAt: trackedDomain.createdAt, |
| |
1444 | updatedAt: trackedDomain.updatedAt, |
| |
1445 | note: trackedDomain.note, |
| |
1446 | isPinned: trackedDomain.isPinned, |
| |
1447 | monitoringEnabled: trackedDomain.monitoringEnabled, |
| |
1448 | lastKnownAvailability: trackedDomain.lastKnownAvailability, |
| |
1449 | lastSnapshotID: nil, |
| |
1450 | lastChangeSummary: nil, |
| |
1451 | lastChangeSeverity: trackedDomain.lastChangeSeverity, |
| |
1452 | certificateWarningLevel: trackedDomain.certificateWarningLevel, |
| |
1453 | certificateDaysRemaining: trackedDomain.certificateDaysRemaining, |
| |
1454 | lastMonitoredAt: trackedDomain.lastMonitoredAt, |
| |
1455 | lastAlertAt: trackedDomain.lastAlertAt, |
| |
1456 | collaboration: trackedDomain.collaboration |
| |
1457 | ) |
| |
1458 | } |
| |
1459 | |
| |
1460 | private func mergedTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain { |
| |
1461 | let winner = preferredTrackedDomain(lhs, rhs) |
| |
1462 | |
| |
1463 | return TrackedDomain( |
| |
1464 | id: lhs.id, |
| |
1465 | domain: Self.normalizeDomain(lhs.domain.isEmpty ? rhs.domain : lhs.domain), |
| |
1466 | createdAt: min(lhs.createdAt, rhs.createdAt), |
| |
1467 | updatedAt: max(lhs.updatedAt, rhs.updatedAt), |
| |
1468 | note: winner.note ?? lhs.note ?? rhs.note, |
| |
1469 | isPinned: winner.isPinned, |
| |
1470 | monitoringEnabled: winner.monitoringEnabled, |
| |
1471 | lastKnownAvailability: winner.lastKnownAvailability ?? lhs.lastKnownAvailability ?? rhs.lastKnownAvailability, |
| |
1472 | lastSnapshotID: nil, |
| |
1473 | lastChangeSummary: nil, |
| |
1474 | lastChangeSeverity: winner.lastChangeSeverity ?? lhs.lastChangeSeverity ?? rhs.lastChangeSeverity, |
| |
1475 | certificateWarningLevel: higherCertificateWarningLevel(lhs.certificateWarningLevel, rhs.certificateWarningLevel), |
| |
1476 | certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining, |
| |
1477 | lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(), |
| |
1478 | lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(), |
| |
1479 | collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration) |
| |
1480 | ) |
| |
1481 | } |
| |
1482 | |
| |
1483 | private func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow { |
| |
1484 | DomainWorkflow( |
| |
1485 | id: workflow.id, |
| |
1486 | name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines), |
| |
1487 | domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }), |
| |
1488 | createdAt: workflow.createdAt, |
| |
1489 | updatedAt: workflow.updatedAt, |
| |
1490 | notes: emptyToNil(workflow.notes ?? ""), |
| |
1491 | collaboration: workflow.collaboration |
| |
1492 | ) |
| |
1493 | } |
| |
1494 | |
| |
1495 | private func preferredTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain { |
| |
1496 | let lhsRank = collaborationRank(lhs.collaboration) |
| |
1497 | let rhsRank = collaborationRank(rhs.collaboration) |
| |
1498 | if lhsRank != rhsRank { |
| |
1499 | return lhsRank > rhsRank ? lhs : rhs |
| |
1500 | } |
| |
1501 | return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs |
| |
1502 | } |
| |
1503 | |
| |
1504 | private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow { |
| |
1505 | let lhsRank = collaborationRank(lhs.collaboration) |
| |
1506 | let rhsRank = collaborationRank(rhs.collaboration) |
| |
1507 | if lhsRank != rhsRank { |
| |
1508 | return lhsRank > rhsRank ? lhs : rhs |
| |
1509 | } |
| |
1510 | return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs |
| |
1511 | } |
| |
1512 | |
| |
1513 | private func preferredCollaboration( |
| |
1514 | _ lhs: CollaborationMetadata?, |
| |
1515 | _ rhs: CollaborationMetadata? |
| |
1516 | ) -> CollaborationMetadata? { |
| |
1517 | guard let lhs else { return rhs } |
| |
1518 | guard let rhs else { return lhs } |
| |
1519 | let lhsRank = collaborationRank(lhs) |
| |
1520 | let rhsRank = collaborationRank(rhs) |
| |
1521 | if lhsRank != rhsRank { |
| |
1522 | return lhsRank > rhsRank ? lhs : rhs |
| |
1523 | } |
| |
1524 | if lhs.shareRecordName != nil { |
| |
1525 | return lhs |
| |
1526 | } |
| |
1527 | return rhs |
| |
1528 | } |
| |
1529 | |
| |
1530 | private func collaborationRank(_ collaboration: CollaborationMetadata?) -> Int { |
| |
1531 | guard let collaboration else { return 0 } |
| |
1532 | switch collaboration.ownership { |
| |
1533 | case .owner: |
| |
1534 | return collaboration.isShared ? 3 : 2 |
| |
1535 | case .participant: |
| |
1536 | return collaboration.permission == .editable ? 1 : 0 |
| |
1537 | } |
| |
1538 | } |
| |
1539 | |
| |
1540 | private func emptyToNil(_ value: String) -> String? { |
| |
1541 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| |
1542 | return trimmed.isEmpty ? nil : trimmed |
| |
1543 | } |
| |
1544 | |
| |
1545 | private func sameAppSettings(_ lhs: AppSettingsSnapshot, _ rhs: AppSettingsSnapshot) -> Bool { |
| |
1546 | lhs.recentSearches == rhs.recentSearches |
| |
1547 | && lhs.savedDomains == rhs.savedDomains |
| |
1548 | && lhs.resolverURLString == rhs.resolverURLString |
| |
1549 | && lhs.appDensityRawValue == rhs.appDensityRawValue |
| |
1550 | } |
| |
1551 | |
| |
1552 | private func deduplicated(_ values: [String]) -> [String] { |
| |
1553 | var seen = Set<String>() |
| |
1554 | return values.filter { seen.insert($0).inserted } |
| |
1555 | } |
| |
1556 | |
| |
1557 | private func higherCertificateWarningLevel( |
| |
1558 | _ lhs: CertificateWarningLevel, |
| |
1559 | _ rhs: CertificateWarningLevel |
| |
1560 | ) -> CertificateWarningLevel { |
| |
1561 | let rank: [CertificateWarningLevel: Int] = [ |
| |
1562 | .none: 0, |
| |
1563 | .warning: 1, |
| |
1564 | .critical: 2 |
| |
1565 | ] |
| |
1566 | return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs |
| |
1567 | } |
| |
1568 | |
| |
1569 | private func trackedDomainRecordName(for normalizedDomain: String) -> String { |
| |
1570 | "tracked-domain:\(normalizedDomain)" |
| |
1571 | } |
| |
1572 | |
| |
1573 | private func workflowRecordName(for identifier: String) -> String { |
| |
1574 | "workflow:\(identifier)" |
| |
1575 | } |
| |
1576 | |
| |
1577 | private func domainNoteRecordName(for normalizedDomain: String) -> String { |
| |
1578 | "domain-note:\(normalizedDomain)" |
| |
1579 | } |
| |
1580 | |
| |
1581 | private func historyMetadataRecordName(for normalizedDomain: String) -> String { |
| |
1582 | "history-metadata:\(normalizedDomain)" |
| |
1583 | } |
| |
1584 | |
| |
1585 | private func tombstoneRecordName(for entityType: SyncEntityType, identifier: String) -> String { |
| |
1586 | "tombstone:\(entityType.rawValue):\(identifier)" |
| |
1587 | } |
| |
1588 | } |