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