krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v5.0.3: 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}