krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.6.0: 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}