krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.8.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 `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 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
70private 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
79private enum SyncEntityType: String, Codable {
80 case trackedDomain
81 case workflow
82}
83
84private struct SyncedAppSettings: Codable {
85 var snapshot: AppSettingsSnapshot
86 var updatedAt: Date
87}
88
89private struct SyncedMonitoringSettings: Codable {
90 var settings: MonitoringSettings
91 var updatedAt: Date
92}
93
94private struct SyncedDomainNote: Codable, Equatable {
95 var domain: String
96 var text: String
97 var updatedAt: Date
98}
99
100private 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
111private struct SyncTombstone: Codable, Equatable {
112 var entityType: SyncEntityType
113 var identifier: String
114 var deletedAt: Date
115}
116
117private 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
127private enum SyncDatabaseScope {
128 case privateDatabase
129 case sharedDatabase
130}
131
132private struct SharedRecord<Value> {
133 var record: CKRecord
134 var value: Value
135}
136
137private struct MergedSyncPayload {
138 var payload: SyncPayload
139 var hadConflict: Bool
140 var changedLocalData: Bool
141}
142
143@MainActor
144@Observable
145final 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 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 guard let container = cloudKitContainer() else {
340 throw CloudSyncRuntimeError.missingEntitlement
341 }
342
343 if metadata.participantStatus == .pending {
344 _ = try await container.accept(metadata)
345 }
346
347 await syncNow(trigger: .manual)
348 }
349
350 func existingShare(for entity: ShareableEntity) async throws -> CKShare? {
351 let shareRecordName = try await shareRecordName(for: entity)
352 guard let shareRecordName else { return nil }
353 if let privateShare = try await fetchShare(recordName: shareRecordName, in: .privateDatabase) {
354 return privateShare
355 }
356 return try await fetchShare(recordName: shareRecordName, in: .sharedDatabase)
357 }
358
359 func createShare(for entity: ShareableEntity) async throws -> CKShare {
360 guard let database = cloudKitDatabase(in: .privateDatabase) else {
361 throw CloudSyncRuntimeError.missingEntitlement
362 }
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 privateTrackedDomains = try await fetchTrackedDomainRecords(in: .privateDatabase)
549 let sharedTrackedDomains = try await fetchTrackedDomainRecords(in: .sharedDatabase)
550 let privateWorkflows = try await fetchWorkflowRecords(in: .privateDatabase)
551 let sharedWorkflows = try await fetchWorkflowRecords(in: .sharedDatabase)
552
553 let appSettingsRecords: [SyncedAppSettings] = try await fetchRecords(
554 ofType: CloudRecordType.appSettings,
555 in: .privateDatabase
556 )
557 let monitoringSettingsRecords: [SyncedMonitoringSettings] = try await fetchRecords(
558 ofType: CloudRecordType.monitoringSettings,
559 in: .privateDatabase
560 )
561 let domainNotes: [SyncedDomainNote] = try await fetchRecords(
562 ofType: CloudRecordType.domainNote,
563 in: .privateDatabase
564 )
565 let historyMetadata: [SyncedHistoryMetadata] = try await fetchRecords(
566 ofType: CloudRecordType.historyMetadata,
567 in: .privateDatabase
568 )
569 let tombstones: [SyncTombstone] = try await fetchRecords(
570 ofType: CloudRecordType.tombstone,
571 in: .privateDatabase
572 )
573
574 let appSettings = appSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt })
575 ?? SyncedAppSettings(snapshot: DomainDataPortabilityService.loadAppSettings(), updatedAt: .distantPast)
576 let monitoringSettings = monitoringSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt })
577 ?? SyncedMonitoringSettings(settings: DomainDataPortabilityService.loadMonitoringSettings(), updatedAt: .distantPast)
578
579 return SyncPayload(
580 trackedDomains: privateTrackedDomains + sharedTrackedDomains,
581 workflows: privateWorkflows + sharedWorkflows,
582 appSettings: appSettings,
583 monitoringSettings: monitoringSettings,
584 notesByDomain: Dictionary(uniqueKeysWithValues: domainNotes.map { (Self.normalizeDomain($0.domain), $0) }),
585 historyMetadataByDomain: Dictionary(uniqueKeysWithValues: historyMetadata.map { (Self.normalizeDomain($0.domain), $0) }),
586 tombstones: tombstones
587 )
588 }
589
590 private func pushMergedPayload(_ payload: SyncPayload) async throws {
591 guard let privateDatabase = cloudKitDatabase(in: .privateDatabase) else {
592 throw CloudSyncRuntimeError.missingEntitlement
593 }
594
595 var privateRecordsToSave: [CKRecord] = []
596 var sharedRecordsToSave: [CKRecord] = []
597 var recordIDsToDelete: [CKRecord.ID] = []
598
599 for trackedDomain in payload.trackedDomains {
600 switch trackedDomain.collaboration?.scope ?? .privateDatabase {
601 case .privateDatabase:
602 privateRecordsToSave.append(makeTrackedDomainRecord(trackedDomain))
603 case .sharedDatabase:
604 if trackedDomain.collaboration?.canEdit == true {
605 sharedRecordsToSave.append(makeTrackedDomainRecord(trackedDomain))
606 }
607 }
608 }
609
610 for workflow in payload.workflows {
611 switch workflow.collaboration?.scope ?? .privateDatabase {
612 case .privateDatabase:
613 privateRecordsToSave.append(makeWorkflowRecord(workflow))
614 case .sharedDatabase:
615 if workflow.collaboration?.canEdit == true {
616 sharedRecordsToSave.append(makeWorkflowRecord(workflow))
617 }
618 }
619 }
620
621 privateRecordsToSave.append(makeAppSettingsRecord(payload.appSettings))
622 privateRecordsToSave.append(makeMonitoringSettingsRecord(payload.monitoringSettings))
623 privateRecordsToSave.append(contentsOf: payload.notesByDomain.values.map(makeDomainNoteRecord))
624 privateRecordsToSave.append(contentsOf: payload.historyMetadataByDomain.values.map(makeHistoryMetadataRecord))
625 privateRecordsToSave.append(contentsOf: payload.tombstones.map(makeTombstoneRecord))
626
627 for tombstone in payload.tombstones {
628 switch tombstone.entityType {
629 case .trackedDomain:
630 let identifier = tombstone.identifier
631 recordIDsToDelete.append(CKRecord.ID(recordName: trackedDomainRecordName(for: identifier)))
632 recordIDsToDelete.append(CKRecord.ID(recordName: domainNoteRecordName(for: identifier)))
633 recordIDsToDelete.append(CKRecord.ID(recordName: historyMetadataRecordName(for: identifier)))
634 case .workflow:
635 recordIDsToDelete.append(CKRecord.ID(recordName: workflowRecordName(for: tombstone.identifier)))
636 }
637 }
638
639 _ = try await privateDatabase.modifyRecords(
640 saving: privateRecordsToSave,
641 deleting: Array(Set(recordIDsToDelete)),
642 savePolicy: .changedKeys,
643 atomically: false
644 )
645
646 if !sharedRecordsToSave.isEmpty, let sharedDatabase = cloudKitDatabase(in: .sharedDatabase) {
647 _ = try await sharedDatabase.modifyRecords(
648 saving: sharedRecordsToSave,
649 deleting: [],
650 savePolicy: .changedKeys,
651 atomically: false
652 )
653 }
654 }
655
656 private func applyMergedPayload(_ payload: SyncPayload) {
657 DomainDataPortabilityService.saveTrackedDomains(payload.trackedDomains)
658 DomainDataPortabilityService.saveWorkflows(payload.workflows)
659 DomainDataPortabilityService.saveAppSettings(payload.appSettings.snapshot)
660 DomainDataPortabilityService.saveMonitoringSettings(payload.monitoringSettings.settings)
661
662 defaults.set(payload.appSettings.updatedAt, forKey: StorageKey.appSettingsUpdatedAt)
663 defaults.set(payload.monitoringSettings.updatedAt, forKey: StorageKey.monitoringSettingsUpdatedAt)
664 saveNoteUpdatedAtByDomain(Dictionary(uniqueKeysWithValues: payload.notesByDomain.map { ($0.key, $0.value.updatedAt) }))
665 saveTombstones(payload.tombstones)
666 }
667
668 private func merge(local: SyncPayload, remote: SyncPayload) -> MergedSyncPayload {
669 var hadConflict = false
670
671 let tombstones = latestTombstones(local.tombstones + remote.tombstones)
672
673 let trackedDomainsResult = mergeTrackedDomains(local.trackedDomains, remote.trackedDomains)
674 hadConflict = hadConflict || trackedDomainsResult.hadConflict
675 var trackedDomains = applyTrackedDomainTombstones(trackedDomainsResult.domains, tombstones: tombstones)
676
677 let workflowsResult = mergeWorkflows(local.workflows, remote.workflows)
678 hadConflict = hadConflict || workflowsResult.hadConflict
679 let workflows = applyWorkflowTombstones(workflowsResult.workflows, tombstones: tombstones)
680
681 let notesResult = mergeNotes(local.notesByDomain, remote.notesByDomain)
682 hadConflict = hadConflict || notesResult.hadConflict
683 trackedDomains = applyNotes(notesResult.notesByDomain, to: trackedDomains, tombstones: tombstones)
684
685 let historyResult = mergeHistoryMetadata(local.historyMetadataByDomain, remote.historyMetadataByDomain)
686 hadConflict = hadConflict || historyResult.hadConflict
687 trackedDomains = applyHistoryMetadata(historyResult.historyMetadataByDomain, to: trackedDomains, tombstones: tombstones)
688
689 let appSettingsResult = mergeAppSettings(local.appSettings, remote.appSettings)
690 hadConflict = hadConflict || appSettingsResult.hadConflict
691
692 let monitoringResult = mergeMonitoringSettings(local.monitoringSettings, remote.monitoringSettings)
693 hadConflict = hadConflict || monitoringResult.hadConflict
694
695 let mergedPayload = SyncPayload(
696 trackedDomains: trackedDomains,
697 workflows: workflows,
698 appSettings: appSettingsResult.settings,
699 monitoringSettings: monitoringResult.settings,
700 notesByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedDomainNote)? in
701 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
702 let normalized = Self.normalizeDomain(trackedDomain.domain)
703 guard let note = notesResult.notesByDomain[normalized] else { return nil }
704 return (normalized, note)
705 }),
706 historyMetadataByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedHistoryMetadata)? in
707 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
708 let normalized = Self.normalizeDomain(trackedDomain.domain)
709 guard let metadata = historyResult.historyMetadataByDomain[normalized] else { return nil }
710 return (normalized, metadata)
711 }),
712 tombstones: tombstones
713 )
714
715 let changedLocalData =
716 mergedPayload.trackedDomains != local.trackedDomains
717 || mergedPayload.workflows != local.workflows
718 || !sameAppSettings(mergedPayload.appSettings.snapshot, local.appSettings.snapshot)
719 || mergedPayload.monitoringSettings.settings != local.monitoringSettings.settings
720
721 return MergedSyncPayload(
722 payload: mergedPayload,
723 hadConflict: hadConflict,
724 changedLocalData: changedLocalData
725 )
726 }
727
728 private func mergeTrackedDomains(_ lhs: [TrackedDomain], _ rhs: [TrackedDomain]) -> (domains: [TrackedDomain], hadConflict: Bool) {
729 var mergedByDomain = Dictionary(uniqueKeysWithValues: lhs.map { (Self.normalizeDomain($0.domain), Self.payloadTrackedDomain($0)) })
730 var hadConflict = false
731
732 for trackedDomain in rhs.map(Self.payloadTrackedDomain) {
733 let key = Self.normalizeDomain(trackedDomain.domain)
734 guard !key.isEmpty else { continue }
735
736 if let existing = mergedByDomain[key] {
737 if existing != trackedDomain {
738 hadConflict = true
739 }
740 mergedByDomain[key] = mergedTrackedDomain(existing, trackedDomain)
741 } else {
742 mergedByDomain[key] = trackedDomain
743 }
744 }
745
746 let domains = mergedByDomain.values.sorted {
747 if $0.updatedAt != $1.updatedAt {
748 return $0.updatedAt > $1.updatedAt
749 }
750 return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
751 }
752
753 return (domains, hadConflict)
754 }
755
756 private func mergeWorkflows(_ lhs: [DomainWorkflow], _ rhs: [DomainWorkflow]) -> (workflows: [DomainWorkflow], hadConflict: Bool) {
757 var merged = Dictionary(uniqueKeysWithValues: lhs.map { ($0.id, normalizedWorkflow($0)) })
758 var hadConflict = false
759
760 for workflow in rhs.map(normalizedWorkflow) {
761 if let existing = merged[workflow.id] {
762 if existing != workflow {
763 hadConflict = true
764 }
765 let winner = preferredWorkflow(existing, workflow)
766 merged[workflow.id] = DomainWorkflow(
767 id: existing.id,
768 name: winner.name,
769 domains: deduplicated(existing.domains + workflow.domains),
770 createdAt: min(existing.createdAt, workflow.createdAt),
771 updatedAt: max(existing.updatedAt, workflow.updatedAt),
772 notes: winner.notes ?? existing.notes ?? workflow.notes,
773 collaboration: preferredCollaboration(existing.collaboration, workflow.collaboration)
774 )
775 } else {
776 merged[workflow.id] = workflow
777 }
778 }
779
780 return (
781 merged.values.sorted {
782 if $0.updatedAt != $1.updatedAt {
783 return $0.updatedAt > $1.updatedAt
784 }
785 return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
786 },
787 hadConflict
788 )
789 }
790
791 private func mergeNotes(
792 _ lhs: [String: SyncedDomainNote],
793 _ rhs: [String: SyncedDomainNote]
794 ) -> (notesByDomain: [String: SyncedDomainNote], hadConflict: Bool) {
795 var merged = lhs
796 var hadConflict = false
797
798 for (domain, remoteNote) in rhs {
799 if let localNote = merged[domain], localNote != remoteNote {
800 hadConflict = true
801 merged[domain] = localNote.updatedAt >= remoteNote.updatedAt ? localNote : remoteNote
802 } else {
803 merged[domain] = remoteNote
804 }
805 }
806
807 return (merged, hadConflict)
808 }
809
810 private func mergeHistoryMetadata(
811 _ lhs: [String: SyncedHistoryMetadata],
812 _ rhs: [String: SyncedHistoryMetadata]
813 ) -> (historyMetadataByDomain: [String: SyncedHistoryMetadata], hadConflict: Bool) {
814 var merged = lhs
815 var hadConflict = false
816
817 for (domain, remoteMetadata) in rhs {
818 if let localMetadata = merged[domain], localMetadata != remoteMetadata {
819 hadConflict = true
820 merged[domain] = localMetadata.updatedAt >= remoteMetadata.updatedAt ? localMetadata : remoteMetadata
821 } else {
822 merged[domain] = remoteMetadata
823 }
824 }
825
826 return (merged, hadConflict)
827 }
828
829 private func mergeAppSettings(_ lhs: SyncedAppSettings, _ rhs: SyncedAppSettings) -> (settings: SyncedAppSettings, hadConflict: Bool) {
830 let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
831 let loser = winner.updatedAt == lhs.updatedAt ? rhs : lhs
832
833 let merged = SyncedAppSettings(
834 snapshot: AppSettingsSnapshot(
835 recentSearches: deduplicated(winner.snapshot.recentSearches + loser.snapshot.recentSearches).prefix(20).map { $0 },
836 savedDomains: deduplicated(winner.snapshot.savedDomains + loser.snapshot.savedDomains),
837 resolverURLString: winner.snapshot.resolverURLString,
838 appDensityRawValue: winner.snapshot.appDensityRawValue
839 ),
840 updatedAt: max(lhs.updatedAt, rhs.updatedAt)
841 )
842
843 return (merged, !sameAppSettings(lhs.snapshot, rhs.snapshot) || lhs.updatedAt != rhs.updatedAt)
844 }
845
846 private func mergeMonitoringSettings(
847 _ lhs: SyncedMonitoringSettings,
848 _ rhs: SyncedMonitoringSettings
849 ) -> (settings: SyncedMonitoringSettings, hadConflict: Bool) {
850 let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
851 var merged = winner
852
853 if merged.settings.isEnabled && !defaults.bool(forKey: StorageKey.monitoringLocalActivationConfirmed) {
854 merged.settings.isEnabled = false
855 }
856
857 if merged.settings.alertsEnabled {
858 merged.settings.alertsEnabled = false
859 }
860
861 merged.updatedAt = max(lhs.updatedAt, rhs.updatedAt)
862 return (merged, lhs.settings != rhs.settings || lhs.updatedAt != rhs.updatedAt)
863 }
864
865 private func applyTrackedDomainTombstones(_ trackedDomains: [TrackedDomain], tombstones: [SyncTombstone]) -> [TrackedDomain] {
866 let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
867 guard tombstone.entityType == .trackedDomain else { return nil }
868 return (tombstone.identifier, tombstone)
869 })
870
871 return trackedDomains.filter { trackedDomain in
872 let key = Self.normalizeDomain(trackedDomain.domain)
873 guard let tombstone = tombstonesByDomain[key] else { return true }
874 return trackedDomain.updatedAt > tombstone.deletedAt
875 }
876 }
877
878 private func applyWorkflowTombstones(_ workflows: [DomainWorkflow], tombstones: [SyncTombstone]) -> [DomainWorkflow] {
879 let tombstonesByIdentifier = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
880 guard tombstone.entityType == .workflow else { return nil }
881 return (tombstone.identifier, tombstone)
882 })
883
884 return workflows.filter { workflow in
885 guard let tombstone = tombstonesByIdentifier[workflow.id.uuidString] else { return true }
886 return workflow.updatedAt > tombstone.deletedAt
887 }
888 }
889
890 private func applyNotes(
891 _ notesByDomain: [String: SyncedDomainNote],
892 to trackedDomains: [TrackedDomain],
893 tombstones: [SyncTombstone]
894 ) -> [TrackedDomain] {
895 let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
896 guard tombstone.entityType == .trackedDomain else { return nil }
897 return (tombstone.identifier, tombstone)
898 })
899
900 return trackedDomains.map { trackedDomain in
901 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain }
902 let normalized = Self.normalizeDomain(trackedDomain.domain)
903 guard let note = notesByDomain[normalized] else { return trackedDomain }
904 if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= note.updatedAt {
905 return trackedDomain
906 }
907
908 var updated = trackedDomain
909 updated.note = emptyToNil(note.text)
910 return updated
911 }
912 }
913
914 private func applyHistoryMetadata(
915 _ historyMetadataByDomain: [String: SyncedHistoryMetadata],
916 to trackedDomains: [TrackedDomain],
917 tombstones: [SyncTombstone]
918 ) -> [TrackedDomain] {
919 let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
920 guard tombstone.entityType == .trackedDomain else { return nil }
921 return (tombstone.identifier, tombstone)
922 })
923
924 return trackedDomains.map { trackedDomain in
925 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain }
926 let normalized = Self.normalizeDomain(trackedDomain.domain)
927 guard let metadata = historyMetadataByDomain[normalized] else { return trackedDomain }
928 if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= metadata.updatedAt {
929 return trackedDomain
930 }
931
932 var updated = trackedDomain
933 updated.lastKnownAvailability = metadata.lastKnownAvailabilityRawValue.flatMap(DomainAvailabilityStatus.init(rawValue:))
934 updated.lastChangeSeverity = metadata.lastChangeSeverityRawValue.flatMap(ChangeSeverity.init(rawValue:))
935 updated.certificateWarningLevel = CertificateWarningLevel(rawValue: metadata.certificateWarningLevelRawValue) ?? .none
936 updated.certificateDaysRemaining = metadata.certificateDaysRemaining
937
938 if let message = metadata.lastChangeMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
939 updated.lastChangeSummary = DomainChangeSummary(
940 hasChanges: true,
941 changedSections: [],
942 message: message,
943 severity: updated.lastChangeSeverity ?? .medium,
944 impactClassification: .warning,
945 generatedAt: metadata.lastObservedAt ?? metadata.updatedAt
946 )
947 }
948
949 return updated
950 }
951 }
952
953 private func latestTombstones(_ tombstones: [SyncTombstone]) -> [SyncTombstone] {
954 var latest: [String: SyncTombstone] = [:]
955
956 for tombstone in tombstones {
957 let key = "\(tombstone.entityType.rawValue):\(tombstone.identifier)"
958 if let existing = latest[key] {
959 latest[key] = existing.deletedAt >= tombstone.deletedAt ? existing : tombstone
960 } else {
961 latest[key] = tombstone
962 }
963 }
964
965 return latest.values.sorted { $0.deletedAt > $1.deletedAt }
966 }
967
968 private func saveTombstone(_ tombstone: SyncTombstone) {
969 var tombstones = loadTombstones()
970 tombstones.append(tombstone)
971 saveTombstones(latestTombstones(tombstones))
972 }
973
974 private func loadTombstones() -> [SyncTombstone] {
975 guard let data = defaults.data(forKey: StorageKey.tombstones),
976 let tombstones = try? decoder.decode([SyncTombstone].self, from: data) else {
977 return []
978 }
979 return tombstones
980 }
981
982 private func saveTombstones(_ tombstones: [SyncTombstone]) {
983 if let data = try? encoder.encode(Array(tombstones.prefix(200))) {
984 defaults.set(data, forKey: StorageKey.tombstones)
985 }
986 }
987
988 private func loadNoteUpdatedAtByDomain() -> [String: Date] {
989 guard let data = defaults.data(forKey: StorageKey.noteUpdatedAtByDomain),
990 let values = try? decoder.decode([String: Date].self, from: data) else {
991 return [:]
992 }
993 return values
994 }
995
996 private func saveNoteUpdatedAtByDomain(_ values: [String: Date]) {
997 if let data = try? encoder.encode(values) {
998 defaults.set(data, forKey: StorageKey.noteUpdatedAtByDomain)
999 }
1000 }
1001
1002 private func setStatus(_ status: CloudSyncStatus, detail: String, error: String?) {
1003 self.status = status
1004 self.detailMessage = detail
1005 self.lastErrorMessage = error
1006
1007 defaults.set(status.rawValue, forKey: StorageKey.status)
1008 defaults.set(detail, forKey: StorageKey.detailMessage)
1009 if let error {
1010 defaults.set(error, forKey: StorageKey.lastErrorMessage)
1011 } else {
1012 defaults.removeObject(forKey: StorageKey.lastErrorMessage)
1013 }
1014 }
1015
1016 private func syncSummaryDetail() -> String {
1017 let dateLabel = lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
1018 return "Data stays in your private iCloud account. Last sync: \(dateLabel)."
1019 }
1020
1021 private func accountStatus() async -> AvailabilityState {
1022 guard let container = cloudKitContainer() else {
1023 return .missingEntitlement
1024 }
1025
1026 return await withCheckedContinuation { continuation in
1027 container.accountStatus { status, error in
1028 if let error {
1029 let ckError = error as? CKError
1030 if ckError?.code == .notAuthenticated {
1031 continuation.resume(returning: AvailabilityState.noAccount)
1032 return
1033 }
1034 continuation.resume(returning: AvailabilityState.unknown)
1035 return
1036 }
1037
1038 switch status {
1039 case .available:
1040 continuation.resume(returning: AvailabilityState.available)
1041 case .noAccount:
1042 continuation.resume(returning: AvailabilityState.noAccount)
1043 case .restricted:
1044 continuation.resume(returning: AvailabilityState.restricted)
1045 case .temporarilyUnavailable:
1046 continuation.resume(returning: AvailabilityState.temporarilyUnavailable)
1047 case .couldNotDetermine:
1048 continuation.resume(returning: AvailabilityState.unknown)
1049 @unknown default:
1050 continuation.resume(returning: AvailabilityState.unknown)
1051 }
1052 }
1053 }
1054 }
1055
1056 private func fetchRecords<T: Decodable>(ofType recordType: String, in scope: SyncDatabaseScope) async throws -> [T] {
1057 guard let database = cloudKitDatabase(in: scope) else {
1058 if scope == .sharedDatabase {
1059 return []
1060 }
1061 throw CloudSyncRuntimeError.missingEntitlement
1062 }
1063
1064 let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
1065 var results: [T] = []
1066 var cursor: CKQueryOperation.Cursor?
1067
1068 repeat {
1069 let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?)
1070 if let cursor {
1071 batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200)
1072 } else {
1073 batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200)
1074 }
1075
1076 for (_, result) in batch.matchResults {
1077 switch result {
1078 case .success(let record):
1079 guard let payload = record[CloudRecordKey.payload] as? Data else { continue }
1080 if let decoded = try? decoder.decode(T.self, from: payload) {
1081 results.append(decoded)
1082 } else {
1083 lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
1084 }
1085 case .failure:
1086 continue
1087 }
1088 }
1089
1090 cursor = batch.queryCursor
1091 } while cursor != nil
1092
1093 return results
1094 }
1095
1096 private func fetchSharedRecords<T: Decodable>(
1097 ofType recordType: String,
1098 in scope: SyncDatabaseScope
1099 ) async throws -> [SharedRecord<T>] {
1100 guard let database = cloudKitDatabase(in: scope) else {
1101 if scope == .sharedDatabase {
1102 return []
1103 }
1104 throw CloudSyncRuntimeError.missingEntitlement
1105 }
1106
1107 let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
1108 var results: [SharedRecord<T>] = []
1109 var cursor: CKQueryOperation.Cursor?
1110
1111 repeat {
1112 let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?)
1113 if let cursor {
1114 batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200)
1115 } else {
1116 batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200)
1117 }
1118
1119 for (_, result) in batch.matchResults {
1120 switch result {
1121 case .success(let record):
1122 guard let payload = record[CloudRecordKey.payload] as? Data else { continue }
1123 if let decoded = try? decoder.decode(T.self, from: payload) {
1124 results.append(SharedRecord(record: record, value: decoded))
1125 } else {
1126 lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
1127 }
1128 case .failure:
1129 continue
1130 }
1131 }
1132
1133 cursor = batch.queryCursor
1134 } while cursor != nil
1135
1136 return results
1137 }
1138
1139 private func fetchTrackedDomainRecords(in scope: SyncDatabaseScope) async throws -> [TrackedDomain] {
1140 let records: [SharedRecord<TrackedDomain>] = try await fetchSharedRecords(ofType: CloudRecordType.trackedDomain, in: scope)
1141 let shares = try await sharesByRecordID(for: records.map(\.record), in: scope)
1142
1143 return records.map { entry in
1144 var trackedDomain = Self.payloadTrackedDomain(entry.value)
1145 trackedDomain.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope)
1146 return trackedDomain
1147 }
1148 }
1149
1150 private func fetchWorkflowRecords(in scope: SyncDatabaseScope) async throws -> [DomainWorkflow] {
1151 let records: [SharedRecord<DomainWorkflow>] = try await fetchSharedRecords(ofType: CloudRecordType.workflow, in: scope)
1152 let shares = try await sharesByRecordID(for: records.map(\.record), in: scope)
1153
1154 return records.map { entry in
1155 var workflow = normalizedWorkflow(entry.value)
1156 workflow.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope)
1157 return workflow
1158 }
1159 }
1160
1161 private func sharesByRecordID(
1162 for records: [CKRecord],
1163 in scope: SyncDatabaseScope
1164 ) async throws -> [CKRecord.ID: CKShare] {
1165 guard let database = cloudKitDatabase(in: scope) else { return [:] }
1166
1167 let shareReferences = records.reduce(into: [CKRecord.ID: CKRecord.Reference]()) { partialResult, record in
1168 if let reference = record.share {
1169 partialResult[record.recordID] = reference
1170 }
1171 }
1172 guard !shareReferences.isEmpty else { return [:] }
1173
1174 let fetched = try await database.records(for: Array(Set(shareReferences.values.map(\.recordID))))
1175 var sharesByRootRecordID: [CKRecord.ID: CKShare] = [:]
1176
1177 for (rootRecordID, shareReference) in shareReferences {
1178 guard case .success(let shareRecord) = fetched[shareReference.recordID],
1179 let share = shareRecord as? CKShare else {
1180 continue
1181 }
1182 sharesByRootRecordID[rootRecordID] = share
1183 }
1184
1185 return sharesByRootRecordID
1186 }
1187
1188 private func collaborationMetadata(
1189 for record: CKRecord,
1190 share: CKShare?,
1191 scope: SyncDatabaseScope
1192 ) -> CollaborationMetadata {
1193 let ownership: CollaborationOwnership
1194 let permission: CollaborationPermission
1195
1196 if let participant = share?.currentUserParticipant {
1197 ownership = participant.role == .owner ? .owner : .participant
1198 permission = participant.permission == .readOnly ? .readOnly : .editable
1199 } else {
1200 ownership = scope == .sharedDatabase ? .participant : .owner
1201 permission = scope == .sharedDatabase ? .readOnly : .editable
1202 }
1203
1204 return CollaborationMetadata(
1205 scope: scope == .privateDatabase ? .privateDatabase : .sharedDatabase,
1206 ownership: ownership,
1207 permission: permission,
1208 shareRecordName: share?.recordID.recordName
1209 )
1210 }
1211
1212 private func fetchShare(recordName: String, in scope: SyncDatabaseScope) async throws -> CKShare? {
1213 guard let database = cloudKitDatabase(in: scope) else { return nil }
1214 let results = try await database.records(for: [CKRecord.ID(recordName: recordName)])
1215 guard case .success(let shareRecord) = results[CKRecord.ID(recordName: recordName)] else {
1216 return nil
1217 }
1218 return shareRecord as? CKShare
1219 }
1220
1221 private func shareRecordName(for entity: ShareableEntity) async throws -> String? {
1222 switch entity {
1223 case .trackedDomain(let domain):
1224 let normalized = Self.normalizeDomain(domain)
1225 return DomainDataPortabilityService.loadTrackedDomains()
1226 .first(where: { Self.normalizeDomain($0.domain) == normalized })?
1227 .collaboration?.shareRecordName
1228 case .workflow(let identifier):
1229 return DomainDataPortabilityService.loadWorkflows()
1230 .first(where: { $0.id == identifier })?
1231 .collaboration?.shareRecordName
1232 }
1233 }
1234
1235 private func makeTrackedDomainRecord(_ trackedDomain: TrackedDomain) -> CKRecord {
1236 let normalized = Self.normalizeDomain(trackedDomain.domain)
1237 let sanitized = Self.payloadTrackedDomain(trackedDomain)
1238 return makeRecord(
1239 type: CloudRecordType.trackedDomain,
1240 recordName: trackedDomainRecordName(for: normalized),
1241 payload: sanitized,
1242 extraFields: [
1243 CloudRecordKey.domain: normalized as CKRecordValue,
1244 CloudRecordKey.updatedAt: sanitized.updatedAt as CKRecordValue
1245 ]
1246 )
1247 }
1248
1249 private func makeWorkflowRecord(_ workflow: DomainWorkflow) -> CKRecord {
1250 let normalized = normalizedWorkflow(workflow)
1251 return makeRecord(
1252 type: CloudRecordType.workflow,
1253 recordName: workflowRecordName(for: normalized.id.uuidString),
1254 payload: normalized,
1255 extraFields: [
1256 CloudRecordKey.identifier: normalized.id.uuidString as CKRecordValue,
1257 CloudRecordKey.updatedAt: normalized.updatedAt as CKRecordValue
1258 ]
1259 )
1260 }
1261
1262 private func makeAppSettingsRecord(_ settings: SyncedAppSettings) -> CKRecord {
1263 makeRecord(
1264 type: CloudRecordType.appSettings,
1265 recordName: "app-settings",
1266 payload: settings,
1267 extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue]
1268 )
1269 }
1270
1271 private func makeMonitoringSettingsRecord(_ settings: SyncedMonitoringSettings) -> CKRecord {
1272 makeRecord(
1273 type: CloudRecordType.monitoringSettings,
1274 recordName: "monitoring-settings",
1275 payload: settings,
1276 extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue]
1277 )
1278 }
1279
1280 private func makeDomainNoteRecord(_ note: SyncedDomainNote) -> CKRecord {
1281 makeRecord(
1282 type: CloudRecordType.domainNote,
1283 recordName: domainNoteRecordName(for: note.domain),
1284 payload: note,
1285 extraFields: [
1286 CloudRecordKey.domain: note.domain as CKRecordValue,
1287 CloudRecordKey.updatedAt: note.updatedAt as CKRecordValue
1288 ]
1289 )
1290 }
1291
1292 private func makeHistoryMetadataRecord(_ metadata: SyncedHistoryMetadata) -> CKRecord {
1293 makeRecord(
1294 type: CloudRecordType.historyMetadata,
1295 recordName: historyMetadataRecordName(for: metadata.domain),
1296 payload: metadata,
1297 extraFields: [
1298 CloudRecordKey.domain: metadata.domain as CKRecordValue,
1299 CloudRecordKey.updatedAt: metadata.updatedAt as CKRecordValue
1300 ]
1301 )
1302 }
1303
1304 private func makeTombstoneRecord(_ tombstone: SyncTombstone) -> CKRecord {
1305 makeRecord(
1306 type: CloudRecordType.tombstone,
1307 recordName: tombstoneRecordName(for: tombstone.entityType, identifier: tombstone.identifier),
1308 payload: tombstone,
1309 extraFields: [
1310 CloudRecordKey.entityType: tombstone.entityType.rawValue as CKRecordValue,
1311 CloudRecordKey.identifier: tombstone.identifier as CKRecordValue,
1312 CloudRecordKey.deletedAt: tombstone.deletedAt as CKRecordValue
1313 ]
1314 )
1315 }
1316
1317 private func makeRecord<T: Encodable>(
1318 type: String,
1319 recordName: String,
1320 payload: T,
1321 extraFields: [String: CKRecordValue]
1322 ) -> CKRecord {
1323 let record = CKRecord(recordType: type, recordID: CKRecord.ID(recordName: recordName))
1324 record[CloudRecordKey.payload] = (try? encoder.encode(payload)) as CKRecordValue?
1325 for (key, value) in extraFields {
1326 record[key] = value
1327 }
1328 return record
1329 }
1330
1331 private func mapSyncError(_ error: Error) -> (status: CloudSyncStatus, message: String, errorDetail: String?) {
1332 if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingEntitlement {
1333 return (
1334 .iCloudUnavailable,
1335 missingEntitlementMessage,
1336 nil
1337 )
1338 }
1339
1340 if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingLocalItem {
1341 return (.error, "The shared item is no longer available locally.", nil)
1342 }
1343
1344 guard let ckError = error as? CKError else {
1345 return (.error, "Sync failed, but local data is still available.", error.localizedDescription)
1346 }
1347
1348 switch ckError.code {
1349 case .networkUnavailable, .networkFailure, .serviceUnavailable:
1350 return (.offline, "Sync is waiting for a stable network connection.", ckError.localizedDescription)
1351 case .notAuthenticated:
1352 return (.iCloudUnavailable, "Sign in to iCloud to sync DomainDig.", ckError.localizedDescription)
1353 case .quotaExceeded:
1354 return (.error, "iCloud storage is full. DomainDig kept working locally.", ckError.localizedDescription)
1355 case .permissionFailure, .badContainer, .missingEntitlement:
1356 return (.iCloudUnavailable, "CloudKit access is unavailable for this build.", ckError.localizedDescription)
1357 case .partialFailure:
1358 return (.error, "Some iCloud records could not sync. Local data remains available.", ckError.localizedDescription)
1359 default:
1360 return (.error, "Sync failed, but local data is still available.", ckError.localizedDescription)
1361 }
1362 }
1363
1364 private enum AvailabilityState {
1365 case available
1366 case noAccount
1367 case restricted
1368 case temporarilyUnavailable
1369 case unknown
1370 case missingEntitlement
1371 }
1372
1373 private enum CloudSyncRuntimeError: Error, Equatable {
1374 case missingEntitlement
1375 case missingLocalItem
1376 }
1377
1378 private static func normalizeDomain(_ value: String) -> String {
1379 value
1380 .trimmingCharacters(in: .whitespacesAndNewlines)
1381 .replacingOccurrences(of: "https://", with: "")
1382 .replacingOccurrences(of: "http://", with: "")
1383 .components(separatedBy: "/")
1384 .first?
1385 .lowercased() ?? ""
1386 }
1387
1388 private func cloudKitContainer() -> CKContainer? {
1389 if let container {
1390 return container
1391 }
1392
1393 if isEntitlementConfigurationAvailable() {
1394 let container = CKContainer.default()
1395 self.container = container
1396 return container
1397 }
1398
1399 return nil
1400 }
1401
1402 private func cloudKitDatabase(in scope: SyncDatabaseScope) -> CKDatabase? {
1403 switch scope {
1404 case .privateDatabase:
1405 if let privateDatabase {
1406 return privateDatabase
1407 }
1408 case .sharedDatabase:
1409 if let sharedDatabase {
1410 return sharedDatabase
1411 }
1412 }
1413
1414 guard let container = cloudKitContainer() else {
1415 return nil
1416 }
1417
1418 switch scope {
1419 case .privateDatabase:
1420 let database = container.privateCloudDatabase
1421 self.privateDatabase = database
1422 return database
1423 case .sharedDatabase:
1424 let database = container.sharedCloudDatabase
1425 self.sharedDatabase = database
1426 return database
1427 }
1428 }
1429
1430 private func isEntitlementConfigurationAvailable() -> Bool {
1431 if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.icloud-container-identifiers") != nil {
1432 return true
1433 }
1434
1435 if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.ubiquity-container-identifiers") != nil {
1436 return true
1437 }
1438
1439 if let entitlementsURL = Bundle.main.url(forResource: "archived-expanded-entitlements", withExtension: "xcent"),
1440 let data = try? Data(contentsOf: entitlementsURL),
1441 let contents = String(data: data, encoding: .utf8) {
1442 return contents.contains("com.apple.developer.icloud-services")
1443 && (contents.contains("CloudKit") || contents.contains("CloudKit-Anonymous"))
1444 }
1445
1446 return false
1447 }
1448
1449 private var missingEntitlementMessage: String {
1450 "This build does not have the CloudKit entitlement required for iCloud sync."
1451 }
1452
1453 private static func payloadTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain {
1454 TrackedDomain(
1455 id: trackedDomain.id,
1456 domain: normalizeDomain(trackedDomain.domain),
1457 createdAt: trackedDomain.createdAt,
1458 updatedAt: trackedDomain.updatedAt,
1459 note: trackedDomain.note,
1460 isPinned: trackedDomain.isPinned,
1461 monitoringEnabled: trackedDomain.monitoringEnabled,
1462 lastKnownAvailability: trackedDomain.lastKnownAvailability,
1463 lastSnapshotID: nil,
1464 lastChangeSummary: nil,
1465 lastChangeSeverity: trackedDomain.lastChangeSeverity,
1466 certificateWarningLevel: trackedDomain.certificateWarningLevel,
1467 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
1468 lastMonitoredAt: trackedDomain.lastMonitoredAt,
1469 lastAlertAt: trackedDomain.lastAlertAt,
1470 monitoringState: trackedDomain.monitoringState,
1471 pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts,
1472 collaboration: trackedDomain.collaboration
1473 )
1474 }
1475
1476 private func mergedTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain {
1477 let winner = preferredTrackedDomain(lhs, rhs)
1478
1479 return TrackedDomain(
1480 id: lhs.id,
1481 domain: Self.normalizeDomain(lhs.domain.isEmpty ? rhs.domain : lhs.domain),
1482 createdAt: min(lhs.createdAt, rhs.createdAt),
1483 updatedAt: max(lhs.updatedAt, rhs.updatedAt),
1484 note: winner.note ?? lhs.note ?? rhs.note,
1485 isPinned: winner.isPinned,
1486 monitoringEnabled: winner.monitoringEnabled,
1487 lastKnownAvailability: winner.lastKnownAvailability ?? lhs.lastKnownAvailability ?? rhs.lastKnownAvailability,
1488 lastSnapshotID: nil,
1489 lastChangeSummary: nil,
1490 lastChangeSeverity: winner.lastChangeSeverity ?? lhs.lastChangeSeverity ?? rhs.lastChangeSeverity,
1491 certificateWarningLevel: higherCertificateWarningLevel(lhs.certificateWarningLevel, rhs.certificateWarningLevel),
1492 certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining,
1493 lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(),
1494 lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(),
1495 monitoringState: preferredMonitoringState(lhs.monitoringState, rhs.monitoringState),
1496 pendingMonitoringAlerts: deduplicatedPendingAlerts(lhs.pendingMonitoringAlerts + rhs.pendingMonitoringAlerts),
1497 collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration)
1498 )
1499 }
1500
1501 private func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
1502 DomainWorkflow(
1503 id: workflow.id,
1504 name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines),
1505 domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }),
1506 createdAt: workflow.createdAt,
1507 updatedAt: workflow.updatedAt,
1508 notes: emptyToNil(workflow.notes ?? ""),
1509 collaboration: workflow.collaboration
1510 )
1511 }
1512
1513 private func preferredTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain {
1514 let lhsRank = collaborationRank(lhs.collaboration)
1515 let rhsRank = collaborationRank(rhs.collaboration)
1516 if lhsRank != rhsRank {
1517 return lhsRank > rhsRank ? lhs : rhs
1518 }
1519 return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
1520 }
1521
1522 private func preferredMonitoringState(_ lhs: MonitoringState, _ rhs: MonitoringState) -> MonitoringState {
1523 let lhsDate = [lhs.lastCheck, lhs.lastAlertDate, lhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1524 let rhsDate = [rhs.lastCheck, rhs.lastAlertDate, rhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1525 return lhsDate >= rhsDate ? lhs : rhs
1526 }
1527
1528 private func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] {
1529 var uniqueByHash: [String: MonitoringPendingAlert] = [:]
1530 for alert in alerts {
1531 if let existing = uniqueByHash[alert.changeHash] {
1532 uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert
1533 } else {
1534 uniqueByHash[alert.changeHash] = alert
1535 }
1536 }
1537 return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt }
1538 }
1539
1540 private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow {
1541 let lhsRank = collaborationRank(lhs.collaboration)
1542 let rhsRank = collaborationRank(rhs.collaboration)
1543 if lhsRank != rhsRank {
1544 return lhsRank > rhsRank ? lhs : rhs
1545 }
1546 return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
1547 }
1548
1549 private func preferredCollaboration(
1550 _ lhs: CollaborationMetadata?,
1551 _ rhs: CollaborationMetadata?
1552 ) -> CollaborationMetadata? {
1553 guard let lhs else { return rhs }
1554 guard let rhs else { return lhs }
1555 let lhsRank = collaborationRank(lhs)
1556 let rhsRank = collaborationRank(rhs)
1557 if lhsRank != rhsRank {
1558 return lhsRank > rhsRank ? lhs : rhs
1559 }
1560 if lhs.shareRecordName != nil {
1561 return lhs
1562 }
1563 return rhs
1564 }
1565
1566 private func collaborationRank(_ collaboration: CollaborationMetadata?) -> Int {
1567 guard let collaboration else { return 0 }
1568 switch collaboration.ownership {
1569 case .owner:
1570 return collaboration.isShared ? 3 : 2
1571 case .participant:
1572 return collaboration.permission == .editable ? 1 : 0
1573 }
1574 }
1575
1576 private func emptyToNil(_ value: String) -> String? {
1577 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1578 return trimmed.isEmpty ? nil : trimmed
1579 }
1580
1581 private func sameAppSettings(_ lhs: AppSettingsSnapshot, _ rhs: AppSettingsSnapshot) -> Bool {
1582 lhs.recentSearches == rhs.recentSearches
1583 && lhs.savedDomains == rhs.savedDomains
1584 && lhs.resolverURLString == rhs.resolverURLString
1585 && lhs.appDensityRawValue == rhs.appDensityRawValue
1586 }
1587
1588 private func deduplicated(_ values: [String]) -> [String] {
1589 var seen = Set<String>()
1590 return values.filter { seen.insert($0).inserted }
1591 }
1592
1593 private func higherCertificateWarningLevel(
1594 _ lhs: CertificateWarningLevel,
1595 _ rhs: CertificateWarningLevel
1596 ) -> CertificateWarningLevel {
1597 let rank: [CertificateWarningLevel: Int] = [
1598 .none: 0,
1599 .warning: 1,
1600 .critical: 2
1601 ]
1602 return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs
1603 }
1604
1605 private func trackedDomainRecordName(for normalizedDomain: String) -> String {
1606 "tracked-domain:\(normalizedDomain)"
1607 }
1608
1609 private func workflowRecordName(for identifier: String) -> String {
1610 "workflow:\(identifier)"
1611 }
1612
1613 private func domainNoteRecordName(for normalizedDomain: String) -> String {
1614 "domain-note:\(normalizedDomain)"
1615 }
1616
1617 private func historyMetadataRecordName(for normalizedDomain: String) -> String {
1618 "history-metadata:\(normalizedDomain)"
1619 }
1620
1621 private func tombstoneRecordName(for entityType: SyncEntityType, identifier: String) -> String {
1622 "tombstone:\(entityType.rawValue):\(identifier)"
1623 }
1624}