import CloudKit import Foundation import Network import Observation extension Notification.Name { static let cloudSyncDidApplyChanges = Notification.Name("CloudSyncService.didApplyChanges") } enum CloudSyncStatus: String, Codable { case disabled case synced case syncing case offline case iCloudUnavailable case conflictResolved case error var title: String { switch self { case .disabled: return "Off" case .synced: return "Synced" case .syncing: return "Syncing" case .offline: return "Offline" case .iCloudUnavailable: return "iCloud unavailable" case .conflictResolved: return "Conflict resolved" case .error: return "Error" } } } enum CloudSyncTrigger: String { case launch case automatic case manual case `import` } enum ShareableEntity: Identifiable, Hashable { case trackedDomain(String) case workflow(UUID) var id: String { switch self { case .trackedDomain(let domain): return "tracked:\(domain)" case .workflow(let identifier): return "workflow:\(identifier.uuidString)" } } } private enum CloudRecordType { static let trackedDomain = "TrackedDomain" static let workflow = "DomainWorkflow" static let appSettings = "AppSettingsSnapshot" static let monitoringSettings = "MonitoringSettings" static let domainNote = "DomainNote" static let historyMetadata = "HistoryMetadata" static let tombstone = "SyncTombstone" } private enum CloudRecordKey { static let payload = "payload" static let updatedAt = "updatedAt" static let domain = "domain" static let identifier = "identifier" static let entityType = "entityType" static let deletedAt = "deletedAt" } private enum SyncEntityType: String, Codable { case trackedDomain case workflow } private struct SyncedAppSettings: Codable { var snapshot: AppSettingsSnapshot var updatedAt: Date } private struct SyncedMonitoringSettings: Codable { var settings: MonitoringSettings var updatedAt: Date } private struct SyncedDomainNote: Codable, Equatable { var domain: String var text: String var updatedAt: Date } private struct SyncedHistoryMetadata: Codable, Equatable { var domain: String var lastKnownAvailabilityRawValue: String? var lastChangeMessage: String? var lastChangeSeverityRawValue: Int? var certificateWarningLevelRawValue: String var certificateDaysRemaining: Int? var lastObservedAt: Date? var updatedAt: Date } private struct SyncTombstone: Codable, Equatable { var entityType: SyncEntityType var identifier: String var deletedAt: Date } private struct SyncPayload { var trackedDomains: [TrackedDomain] var workflows: [DomainWorkflow] var appSettings: SyncedAppSettings var monitoringSettings: SyncedMonitoringSettings var notesByDomain: [String: SyncedDomainNote] var historyMetadataByDomain: [String: SyncedHistoryMetadata] var tombstones: [SyncTombstone] } private enum SyncDatabaseScope { case privateDatabase case sharedDatabase } private struct SharedRecord { var record: CKRecord var value: Value } private struct MergedSyncPayload { var payload: SyncPayload var hadConflict: Bool var changedLocalData: Bool } @MainActor @Observable final class CloudSyncService { static let shared = CloudSyncService() var isEnabled: Bool var status: CloudSyncStatus var lastSyncDate: Date? var lastErrorMessage: String? var detailMessage: String private var container: CKContainer? private var privateDatabase: CKDatabase? private var sharedDatabase: CKDatabase? private let defaults: UserDefaults private let encoder: JSONEncoder private let decoder: JSONDecoder private let pathMonitor = NWPathMonitor() private let pathMonitorQueue = DispatchQueue(label: "DomainDig.CloudSync.PathMonitor") private var isNetworkAvailable = true private var scheduledSyncTask: Task? private var syncTask: Task? private enum StorageKey { static let isEnabled = "cloudSync.enabled" static let status = "cloudSync.status" static let lastSyncDate = "cloudSync.lastSyncDate" static let lastErrorMessage = "cloudSync.lastErrorMessage" static let detailMessage = "cloudSync.detailMessage" static let appSettingsUpdatedAt = "cloudSync.appSettingsUpdatedAt" static let monitoringSettingsUpdatedAt = "cloudSync.monitoringSettingsUpdatedAt" static let noteUpdatedAtByDomain = "cloudSync.noteUpdatedAtByDomain" static let tombstones = "cloudSync.tombstones" static let monitoringLocalActivationConfirmed = "cloudSync.monitoringLocalActivationConfirmed" } private init(defaults: UserDefaults = .standard) { self.defaults = defaults self.container = nil self.privateDatabase = nil self.sharedDatabase = nil let syncEnabled = defaults.bool(forKey: StorageKey.isEnabled) self.isEnabled = syncEnabled self.status = CloudSyncStatus(rawValue: defaults.string(forKey: StorageKey.status) ?? "") ?? (syncEnabled ? .synced : .disabled) self.lastSyncDate = defaults.object(forKey: StorageKey.lastSyncDate) as? Date self.lastErrorMessage = defaults.string(forKey: StorageKey.lastErrorMessage) self.detailMessage = defaults.string(forKey: StorageKey.detailMessage) ?? "DomainDig stores synced data in your private iCloud account." let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 self.encoder = encoder let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 self.decoder = decoder pathMonitor.pathUpdateHandler = { [weak self] path in Task { @MainActor [weak self] in guard let self else { return } self.isNetworkAvailable = path.status == .satisfied if !self.isNetworkAvailable, self.isEnabled, self.status == .syncing { self.setStatus(.offline, detail: "Waiting for a network connection.", error: nil) } } } pathMonitor.start(queue: pathMonitorQueue) } deinit { pathMonitor.cancel() } func setSyncEnabled(_ enabled: Bool) { isEnabled = enabled defaults.set(enabled, forKey: StorageKey.isEnabled) guard enabled else { scheduledSyncTask?.cancel() syncTask?.cancel() setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil) return } Task { await refreshAvailability() await syncNow(trigger: .manual) } } func refreshAvailability() async { guard isEnabled else { setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil) return } guard isNetworkAvailable else { setStatus(.offline, detail: "Waiting for a network connection.", error: nil) return } switch await accountStatus() { case .available: if status == .iCloudUnavailable || status == .offline || status == .disabled { setStatus(.synced, detail: syncSummaryDetail(), error: nil) } case .noAccount: setStatus(.iCloudUnavailable, detail: "Sign in to iCloud to sync DomainDig.", error: nil) case .restricted: setStatus(.iCloudUnavailable, detail: "iCloud access is restricted on this device.", error: nil) case .temporarilyUnavailable: setStatus(.iCloudUnavailable, detail: "iCloud is temporarily unavailable.", error: nil) case .unknown: setStatus(.iCloudUnavailable, detail: "DomainDig could not confirm iCloud availability.", error: nil) case .missingEntitlement: setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil) } } func scheduleSyncIfNeeded(trigger: CloudSyncTrigger = .automatic) { guard isEnabled else { return } scheduledSyncTask?.cancel() scheduledSyncTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 900_000_000) guard !Task.isCancelled else { return } await self?.syncNow(trigger: trigger) } } func markAppSettingsChanged() { defaults.set(Date(), forKey: StorageKey.appSettingsUpdatedAt) scheduleSyncIfNeeded() } func markMonitoringSettingsChanged(localActivationConfirmed: Bool = false) { defaults.set(Date(), forKey: StorageKey.monitoringSettingsUpdatedAt) if localActivationConfirmed { defaults.set(true, forKey: StorageKey.monitoringLocalActivationConfirmed) } scheduleSyncIfNeeded() } func markNoteChanged(for domain: String, updatedAt: Date = Date()) { let normalized = Self.normalizeDomain(domain) guard !normalized.isEmpty else { return } var noteDates = loadNoteUpdatedAtByDomain() noteDates[normalized] = updatedAt saveNoteUpdatedAtByDomain(noteDates) scheduleSyncIfNeeded() } func recordTrackedDomainDeletion(_ trackedDomain: TrackedDomain, deletedAt: Date = Date()) { let normalized = Self.normalizeDomain(trackedDomain.domain) guard !normalized.isEmpty else { return } saveTombstone(.init(entityType: .trackedDomain, identifier: normalized, deletedAt: deletedAt)) var noteDates = loadNoteUpdatedAtByDomain() noteDates.removeValue(forKey: normalized) saveNoteUpdatedAtByDomain(noteDates) scheduleSyncIfNeeded() } func recordWorkflowDeletion(_ workflow: DomainWorkflow, deletedAt: Date = Date()) { saveTombstone(.init(entityType: .workflow, identifier: workflow.id.uuidString, deletedAt: deletedAt)) scheduleSyncIfNeeded() } func recordTrackedDomainReset(_ trackedDomains: [TrackedDomain], deletedAt: Date = Date()) { for trackedDomain in trackedDomains { recordTrackedDomainDeletion(trackedDomain, deletedAt: deletedAt) } } func recordWorkflowReset(_ workflows: [DomainWorkflow], deletedAt: Date = Date()) { for workflow in workflows { recordWorkflowDeletion(workflow, deletedAt: deletedAt) } } func acceptShare(metadata: CKShare.Metadata) async throws { guard let container = cloudKitContainer() else { throw CloudSyncRuntimeError.missingEntitlement } if metadata.participantStatus == .pending { _ = try await container.accept(metadata) } await syncNow(trigger: .manual) } func existingShare(for entity: ShareableEntity) async throws -> CKShare? { let shareRecordName = try await shareRecordName(for: entity) guard let shareRecordName else { return nil } if let privateShare = try await fetchShare(recordName: shareRecordName, in: .privateDatabase) { return privateShare } return try await fetchShare(recordName: shareRecordName, in: .sharedDatabase) } func createShare(for entity: ShareableEntity) async throws -> CKShare { guard let database = cloudKitDatabase(in: .privateDatabase) else { throw CloudSyncRuntimeError.missingEntitlement } switch entity { case .trackedDomain(let domain): let normalized = Self.normalizeDomain(domain) let trackedDomain = DomainDataPortabilityService.loadTrackedDomains() .first(where: { Self.normalizeDomain($0.domain) == normalized }) guard let trackedDomain else { throw CloudSyncRuntimeError.missingLocalItem } let rootRecord = makeTrackedDomainRecord(trackedDomain) let share = CKShare(rootRecord: rootRecord) share[CKShare.SystemFieldKey.title] = normalized as CKRecordValue share.publicPermission = .none _ = try await database.modifyRecords( saving: [rootRecord, share], deleting: [], savePolicy: .allKeys, atomically: true ) await syncNow(trigger: .manual) return share case .workflow(let identifier): let workflow = DomainDataPortabilityService.loadWorkflows() .first(where: { $0.id == identifier }) guard let workflow else { throw CloudSyncRuntimeError.missingLocalItem } let rootRecord = makeWorkflowRecord(workflow) let share = CKShare(rootRecord: rootRecord) share[CKShare.SystemFieldKey.title] = workflow.name as CKRecordValue share.publicPermission = .none _ = try await database.modifyRecords( saving: [rootRecord, share], deleting: [], savePolicy: .allKeys, atomically: true ) await syncNow(trigger: .manual) return share } } func syncNow(trigger: CloudSyncTrigger = .manual) async { guard isEnabled else { setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil) return } if syncTask != nil { return } syncTask = Task { [weak self] in guard let self else { return } await self.performSync(trigger: trigger) await MainActor.run { self.syncTask = nil } } await syncTask?.value } private func performSync(trigger: CloudSyncTrigger) async { guard cloudKitDatabase(in: .privateDatabase) != nil else { setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil) return } guard isNetworkAvailable else { setStatus(.offline, detail: "Waiting for a network connection.", error: nil) return } switch await accountStatus() { case .available: break case .noAccount: setStatus(.iCloudUnavailable, detail: "Sign in to iCloud to sync DomainDig.", error: nil) return case .restricted: setStatus(.iCloudUnavailable, detail: "iCloud access is restricted on this device.", error: nil) return case .temporarilyUnavailable: setStatus(.iCloudUnavailable, detail: "iCloud is temporarily unavailable.", error: nil) return case .unknown: setStatus(.iCloudUnavailable, detail: "DomainDig could not confirm iCloud availability.", error: nil) return case .missingEntitlement: setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil) return } setStatus(.syncing, detail: trigger == .manual ? "Syncing now…" : "Syncing changes…", error: nil) do { let local = loadLocalPayload() let remote = try await fetchRemotePayload() let merged = merge(local: local, remote: remote) applyMergedPayload(merged.payload) try await pushMergedPayload(merged.payload) lastSyncDate = Date() defaults.set(lastSyncDate, forKey: StorageKey.lastSyncDate) if merged.changedLocalData { NotificationCenter.default.post(name: .cloudSyncDidApplyChanges, object: nil) } let detail = syncSummaryDetail() if merged.hadConflict { setStatus(.conflictResolved, detail: detail, error: nil) } else { setStatus(.synced, detail: detail, error: nil) } } catch { let mapped = mapSyncError(error) setStatus(mapped.status, detail: mapped.message, error: mapped.errorDetail) } } private func loadLocalPayload() -> SyncPayload { let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() let workflows = DomainDataPortabilityService.loadWorkflows() let appSettings = SyncedAppSettings( snapshot: DomainDataPortabilityService.loadAppSettings(), updatedAt: defaults.object(forKey: StorageKey.appSettingsUpdatedAt) as? Date ?? .distantPast ) let monitoringSettings = SyncedMonitoringSettings( settings: DomainDataPortabilityService.loadMonitoringSettings(), updatedAt: defaults.object(forKey: StorageKey.monitoringSettingsUpdatedAt) as? Date ?? .distantPast ) let noteDates = loadNoteUpdatedAtByDomain() let notesByDomain = Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedDomainNote)? in guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil } let normalized = Self.normalizeDomain(trackedDomain.domain) guard !normalized.isEmpty else { return nil } let noteText = trackedDomain.note ?? "" let updatedAt = noteDates[normalized] ?? trackedDomain.updatedAt return ( normalized, SyncedDomainNote( domain: normalized, text: noteText, updatedAt: updatedAt ) ) }) let historyMetadataByDomain = Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedHistoryMetadata)? in guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil } let normalized = Self.normalizeDomain(trackedDomain.domain) guard !normalized.isEmpty else { return nil } return ( normalized, SyncedHistoryMetadata( domain: normalized, lastKnownAvailabilityRawValue: trackedDomain.lastKnownAvailability?.rawValue, lastChangeMessage: trackedDomain.lastChangeSummary?.message, lastChangeSeverityRawValue: trackedDomain.lastChangeSeverity?.rawValue ?? trackedDomain.lastChangeSummary?.severity.rawValue, certificateWarningLevelRawValue: trackedDomain.certificateWarningLevel.rawValue, certificateDaysRemaining: trackedDomain.certificateDaysRemaining, lastObservedAt: trackedDomain.updatedAt, updatedAt: trackedDomain.updatedAt ) ) }) return SyncPayload( trackedDomains: trackedDomains, workflows: workflows, appSettings: appSettings, monitoringSettings: monitoringSettings, notesByDomain: notesByDomain, historyMetadataByDomain: historyMetadataByDomain, tombstones: loadTombstones() ) } private func fetchRemotePayload() async throws -> SyncPayload { let privateTrackedDomains = try await fetchTrackedDomainRecords(in: .privateDatabase) let sharedTrackedDomains = try await fetchTrackedDomainRecords(in: .sharedDatabase) let privateWorkflows = try await fetchWorkflowRecords(in: .privateDatabase) let sharedWorkflows = try await fetchWorkflowRecords(in: .sharedDatabase) let appSettingsRecords: [SyncedAppSettings] = try await fetchRecords( ofType: CloudRecordType.appSettings, in: .privateDatabase ) let monitoringSettingsRecords: [SyncedMonitoringSettings] = try await fetchRecords( ofType: CloudRecordType.monitoringSettings, in: .privateDatabase ) let domainNotes: [SyncedDomainNote] = try await fetchRecords( ofType: CloudRecordType.domainNote, in: .privateDatabase ) let historyMetadata: [SyncedHistoryMetadata] = try await fetchRecords( ofType: CloudRecordType.historyMetadata, in: .privateDatabase ) let tombstones: [SyncTombstone] = try await fetchRecords( ofType: CloudRecordType.tombstone, in: .privateDatabase ) let appSettings = appSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt }) ?? SyncedAppSettings(snapshot: DomainDataPortabilityService.loadAppSettings(), updatedAt: .distantPast) let monitoringSettings = monitoringSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt }) ?? SyncedMonitoringSettings(settings: DomainDataPortabilityService.loadMonitoringSettings(), updatedAt: .distantPast) return SyncPayload( trackedDomains: privateTrackedDomains + sharedTrackedDomains, workflows: privateWorkflows + sharedWorkflows, appSettings: appSettings, monitoringSettings: monitoringSettings, notesByDomain: Dictionary(uniqueKeysWithValues: domainNotes.map { (Self.normalizeDomain($0.domain), $0) }), historyMetadataByDomain: Dictionary(uniqueKeysWithValues: historyMetadata.map { (Self.normalizeDomain($0.domain), $0) }), tombstones: tombstones ) } private func pushMergedPayload(_ payload: SyncPayload) async throws { guard let privateDatabase = cloudKitDatabase(in: .privateDatabase) else { throw CloudSyncRuntimeError.missingEntitlement } var privateRecordsToSave: [CKRecord] = [] var sharedRecordsToSave: [CKRecord] = [] var recordIDsToDelete: [CKRecord.ID] = [] for trackedDomain in payload.trackedDomains { switch trackedDomain.collaboration?.scope ?? .privateDatabase { case .privateDatabase: privateRecordsToSave.append(makeTrackedDomainRecord(trackedDomain)) case .sharedDatabase: if trackedDomain.collaboration?.canEdit == true { sharedRecordsToSave.append(makeTrackedDomainRecord(trackedDomain)) } } } for workflow in payload.workflows { switch workflow.collaboration?.scope ?? .privateDatabase { case .privateDatabase: privateRecordsToSave.append(makeWorkflowRecord(workflow)) case .sharedDatabase: if workflow.collaboration?.canEdit == true { sharedRecordsToSave.append(makeWorkflowRecord(workflow)) } } } privateRecordsToSave.append(makeAppSettingsRecord(payload.appSettings)) privateRecordsToSave.append(makeMonitoringSettingsRecord(payload.monitoringSettings)) privateRecordsToSave.append(contentsOf: payload.notesByDomain.values.map(makeDomainNoteRecord)) privateRecordsToSave.append(contentsOf: payload.historyMetadataByDomain.values.map(makeHistoryMetadataRecord)) privateRecordsToSave.append(contentsOf: payload.tombstones.map(makeTombstoneRecord)) for tombstone in payload.tombstones { switch tombstone.entityType { case .trackedDomain: let identifier = tombstone.identifier recordIDsToDelete.append(CKRecord.ID(recordName: trackedDomainRecordName(for: identifier))) recordIDsToDelete.append(CKRecord.ID(recordName: domainNoteRecordName(for: identifier))) recordIDsToDelete.append(CKRecord.ID(recordName: historyMetadataRecordName(for: identifier))) case .workflow: recordIDsToDelete.append(CKRecord.ID(recordName: workflowRecordName(for: tombstone.identifier))) } } _ = try await privateDatabase.modifyRecords( saving: privateRecordsToSave, deleting: Array(Set(recordIDsToDelete)), savePolicy: .changedKeys, atomically: false ) if !sharedRecordsToSave.isEmpty, let sharedDatabase = cloudKitDatabase(in: .sharedDatabase) { _ = try await sharedDatabase.modifyRecords( saving: sharedRecordsToSave, deleting: [], savePolicy: .changedKeys, atomically: false ) } } private func applyMergedPayload(_ payload: SyncPayload) { DomainDataPortabilityService.saveTrackedDomains(payload.trackedDomains) DomainDataPortabilityService.saveWorkflows(payload.workflows) DomainDataPortabilityService.saveAppSettings(payload.appSettings.snapshot) DomainDataPortabilityService.saveMonitoringSettings(payload.monitoringSettings.settings) defaults.set(payload.appSettings.updatedAt, forKey: StorageKey.appSettingsUpdatedAt) defaults.set(payload.monitoringSettings.updatedAt, forKey: StorageKey.monitoringSettingsUpdatedAt) saveNoteUpdatedAtByDomain(Dictionary(uniqueKeysWithValues: payload.notesByDomain.map { ($0.key, $0.value.updatedAt) })) saveTombstones(payload.tombstones) } private func merge(local: SyncPayload, remote: SyncPayload) -> MergedSyncPayload { var hadConflict = false let tombstones = latestTombstones(local.tombstones + remote.tombstones) let trackedDomainsResult = mergeTrackedDomains(local.trackedDomains, remote.trackedDomains) hadConflict = hadConflict || trackedDomainsResult.hadConflict var trackedDomains = applyTrackedDomainTombstones(trackedDomainsResult.domains, tombstones: tombstones) let workflowsResult = mergeWorkflows(local.workflows, remote.workflows) hadConflict = hadConflict || workflowsResult.hadConflict let workflows = applyWorkflowTombstones(workflowsResult.workflows, tombstones: tombstones) let notesResult = mergeNotes(local.notesByDomain, remote.notesByDomain) hadConflict = hadConflict || notesResult.hadConflict trackedDomains = applyNotes(notesResult.notesByDomain, to: trackedDomains, tombstones: tombstones) let historyResult = mergeHistoryMetadata(local.historyMetadataByDomain, remote.historyMetadataByDomain) hadConflict = hadConflict || historyResult.hadConflict trackedDomains = applyHistoryMetadata(historyResult.historyMetadataByDomain, to: trackedDomains, tombstones: tombstones) let appSettingsResult = mergeAppSettings(local.appSettings, remote.appSettings) hadConflict = hadConflict || appSettingsResult.hadConflict let monitoringResult = mergeMonitoringSettings(local.monitoringSettings, remote.monitoringSettings) hadConflict = hadConflict || monitoringResult.hadConflict let mergedPayload = SyncPayload( trackedDomains: trackedDomains, workflows: workflows, appSettings: appSettingsResult.settings, monitoringSettings: monitoringResult.settings, notesByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedDomainNote)? in guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil } let normalized = Self.normalizeDomain(trackedDomain.domain) guard let note = notesResult.notesByDomain[normalized] else { return nil } return (normalized, note) }), historyMetadataByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedHistoryMetadata)? in guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil } let normalized = Self.normalizeDomain(trackedDomain.domain) guard let metadata = historyResult.historyMetadataByDomain[normalized] else { return nil } return (normalized, metadata) }), tombstones: tombstones ) let changedLocalData = mergedPayload.trackedDomains != local.trackedDomains || mergedPayload.workflows != local.workflows || !sameAppSettings(mergedPayload.appSettings.snapshot, local.appSettings.snapshot) || mergedPayload.monitoringSettings.settings != local.monitoringSettings.settings return MergedSyncPayload( payload: mergedPayload, hadConflict: hadConflict, changedLocalData: changedLocalData ) } private func mergeTrackedDomains(_ lhs: [TrackedDomain], _ rhs: [TrackedDomain]) -> (domains: [TrackedDomain], hadConflict: Bool) { var mergedByDomain = Dictionary(uniqueKeysWithValues: lhs.map { (Self.normalizeDomain($0.domain), Self.payloadTrackedDomain($0)) }) var hadConflict = false for trackedDomain in rhs.map(Self.payloadTrackedDomain) { let key = Self.normalizeDomain(trackedDomain.domain) guard !key.isEmpty else { continue } if let existing = mergedByDomain[key] { if existing != trackedDomain { hadConflict = true } mergedByDomain[key] = mergedTrackedDomain(existing, trackedDomain) } else { mergedByDomain[key] = trackedDomain } } let domains = mergedByDomain.values.sorted { if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending } return (domains, hadConflict) } private func mergeWorkflows(_ lhs: [DomainWorkflow], _ rhs: [DomainWorkflow]) -> (workflows: [DomainWorkflow], hadConflict: Bool) { var merged = Dictionary(uniqueKeysWithValues: lhs.map { ($0.id, normalizedWorkflow($0)) }) var hadConflict = false for workflow in rhs.map(normalizedWorkflow) { if let existing = merged[workflow.id] { if existing != workflow { hadConflict = true } let winner = preferredWorkflow(existing, workflow) merged[workflow.id] = DomainWorkflow( id: existing.id, name: winner.name, domains: deduplicated(existing.domains + workflow.domains), createdAt: min(existing.createdAt, workflow.createdAt), updatedAt: max(existing.updatedAt, workflow.updatedAt), notes: winner.notes ?? existing.notes ?? workflow.notes, collaboration: preferredCollaboration(existing.collaboration, workflow.collaboration) ) } else { merged[workflow.id] = workflow } } return ( merged.values.sorted { if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }, hadConflict ) } private func mergeNotes( _ lhs: [String: SyncedDomainNote], _ rhs: [String: SyncedDomainNote] ) -> (notesByDomain: [String: SyncedDomainNote], hadConflict: Bool) { var merged = lhs var hadConflict = false for (domain, remoteNote) in rhs { if let localNote = merged[domain], localNote != remoteNote { hadConflict = true merged[domain] = localNote.updatedAt >= remoteNote.updatedAt ? localNote : remoteNote } else { merged[domain] = remoteNote } } return (merged, hadConflict) } private func mergeHistoryMetadata( _ lhs: [String: SyncedHistoryMetadata], _ rhs: [String: SyncedHistoryMetadata] ) -> (historyMetadataByDomain: [String: SyncedHistoryMetadata], hadConflict: Bool) { var merged = lhs var hadConflict = false for (domain, remoteMetadata) in rhs { if let localMetadata = merged[domain], localMetadata != remoteMetadata { hadConflict = true merged[domain] = localMetadata.updatedAt >= remoteMetadata.updatedAt ? localMetadata : remoteMetadata } else { merged[domain] = remoteMetadata } } return (merged, hadConflict) } private func mergeAppSettings(_ lhs: SyncedAppSettings, _ rhs: SyncedAppSettings) -> (settings: SyncedAppSettings, hadConflict: Bool) { let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs let loser = winner.updatedAt == lhs.updatedAt ? rhs : lhs let merged = SyncedAppSettings( snapshot: AppSettingsSnapshot( recentSearches: deduplicated(winner.snapshot.recentSearches + loser.snapshot.recentSearches).prefix(20).map { $0 }, savedDomains: deduplicated(winner.snapshot.savedDomains + loser.snapshot.savedDomains), resolverURLString: winner.snapshot.resolverURLString, appDensityRawValue: winner.snapshot.appDensityRawValue ), updatedAt: max(lhs.updatedAt, rhs.updatedAt) ) return (merged, !sameAppSettings(lhs.snapshot, rhs.snapshot) || lhs.updatedAt != rhs.updatedAt) } private func mergeMonitoringSettings( _ lhs: SyncedMonitoringSettings, _ rhs: SyncedMonitoringSettings ) -> (settings: SyncedMonitoringSettings, hadConflict: Bool) { let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs var merged = winner if merged.settings.isEnabled && !defaults.bool(forKey: StorageKey.monitoringLocalActivationConfirmed) { merged.settings.isEnabled = false } if merged.settings.alertsEnabled { merged.settings.alertsEnabled = false } merged.updatedAt = max(lhs.updatedAt, rhs.updatedAt) return (merged, lhs.settings != rhs.settings || lhs.updatedAt != rhs.updatedAt) } private func applyTrackedDomainTombstones(_ trackedDomains: [TrackedDomain], tombstones: [SyncTombstone]) -> [TrackedDomain] { let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in guard tombstone.entityType == .trackedDomain else { return nil } return (tombstone.identifier, tombstone) }) return trackedDomains.filter { trackedDomain in let key = Self.normalizeDomain(trackedDomain.domain) guard let tombstone = tombstonesByDomain[key] else { return true } return trackedDomain.updatedAt > tombstone.deletedAt } } private func applyWorkflowTombstones(_ workflows: [DomainWorkflow], tombstones: [SyncTombstone]) -> [DomainWorkflow] { let tombstonesByIdentifier = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in guard tombstone.entityType == .workflow else { return nil } return (tombstone.identifier, tombstone) }) return workflows.filter { workflow in guard let tombstone = tombstonesByIdentifier[workflow.id.uuidString] else { return true } return workflow.updatedAt > tombstone.deletedAt } } private func applyNotes( _ notesByDomain: [String: SyncedDomainNote], to trackedDomains: [TrackedDomain], tombstones: [SyncTombstone] ) -> [TrackedDomain] { let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in guard tombstone.entityType == .trackedDomain else { return nil } return (tombstone.identifier, tombstone) }) return trackedDomains.map { trackedDomain in guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain } let normalized = Self.normalizeDomain(trackedDomain.domain) guard let note = notesByDomain[normalized] else { return trackedDomain } if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= note.updatedAt { return trackedDomain } var updated = trackedDomain updated.note = emptyToNil(note.text) return updated } } private func applyHistoryMetadata( _ historyMetadataByDomain: [String: SyncedHistoryMetadata], to trackedDomains: [TrackedDomain], tombstones: [SyncTombstone] ) -> [TrackedDomain] { let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in guard tombstone.entityType == .trackedDomain else { return nil } return (tombstone.identifier, tombstone) }) return trackedDomains.map { trackedDomain in guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain } let normalized = Self.normalizeDomain(trackedDomain.domain) guard let metadata = historyMetadataByDomain[normalized] else { return trackedDomain } if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= metadata.updatedAt { return trackedDomain } var updated = trackedDomain updated.lastKnownAvailability = metadata.lastKnownAvailabilityRawValue.flatMap(DomainAvailabilityStatus.init(rawValue:)) updated.lastChangeSeverity = metadata.lastChangeSeverityRawValue.flatMap(ChangeSeverity.init(rawValue:)) updated.certificateWarningLevel = CertificateWarningLevel(rawValue: metadata.certificateWarningLevelRawValue) ?? .none updated.certificateDaysRemaining = metadata.certificateDaysRemaining if let message = metadata.lastChangeMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { updated.lastChangeSummary = DomainChangeSummary( hasChanges: true, changedSections: [], message: message, severity: updated.lastChangeSeverity ?? .medium, impactClassification: .warning, generatedAt: metadata.lastObservedAt ?? metadata.updatedAt ) } return updated } } private func latestTombstones(_ tombstones: [SyncTombstone]) -> [SyncTombstone] { var latest: [String: SyncTombstone] = [:] for tombstone in tombstones { let key = "\(tombstone.entityType.rawValue):\(tombstone.identifier)" if let existing = latest[key] { latest[key] = existing.deletedAt >= tombstone.deletedAt ? existing : tombstone } else { latest[key] = tombstone } } return latest.values.sorted { $0.deletedAt > $1.deletedAt } } private func saveTombstone(_ tombstone: SyncTombstone) { var tombstones = loadTombstones() tombstones.append(tombstone) saveTombstones(latestTombstones(tombstones)) } private func loadTombstones() -> [SyncTombstone] { guard let data = defaults.data(forKey: StorageKey.tombstones), let tombstones = try? decoder.decode([SyncTombstone].self, from: data) else { return [] } return tombstones } private func saveTombstones(_ tombstones: [SyncTombstone]) { if let data = try? encoder.encode(Array(tombstones.prefix(200))) { defaults.set(data, forKey: StorageKey.tombstones) } } private func loadNoteUpdatedAtByDomain() -> [String: Date] { guard let data = defaults.data(forKey: StorageKey.noteUpdatedAtByDomain), let values = try? decoder.decode([String: Date].self, from: data) else { return [:] } return values } private func saveNoteUpdatedAtByDomain(_ values: [String: Date]) { if let data = try? encoder.encode(values) { defaults.set(data, forKey: StorageKey.noteUpdatedAtByDomain) } } private func setStatus(_ status: CloudSyncStatus, detail: String, error: String?) { self.status = status self.detailMessage = detail self.lastErrorMessage = error defaults.set(status.rawValue, forKey: StorageKey.status) defaults.set(detail, forKey: StorageKey.detailMessage) if let error { defaults.set(error, forKey: StorageKey.lastErrorMessage) } else { defaults.removeObject(forKey: StorageKey.lastErrorMessage) } } private func syncSummaryDetail() -> String { let dateLabel = lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced" return "Data stays in your private iCloud account. Last sync: \(dateLabel)." } private func accountStatus() async -> AvailabilityState { guard let container = cloudKitContainer() else { return .missingEntitlement } return await withCheckedContinuation { continuation in container.accountStatus { status, error in if let error { let ckError = error as? CKError if ckError?.code == .notAuthenticated { continuation.resume(returning: AvailabilityState.noAccount) return } continuation.resume(returning: AvailabilityState.unknown) return } switch status { case .available: continuation.resume(returning: AvailabilityState.available) case .noAccount: continuation.resume(returning: AvailabilityState.noAccount) case .restricted: continuation.resume(returning: AvailabilityState.restricted) case .temporarilyUnavailable: continuation.resume(returning: AvailabilityState.temporarilyUnavailable) case .couldNotDetermine: continuation.resume(returning: AvailabilityState.unknown) @unknown default: continuation.resume(returning: AvailabilityState.unknown) } } } } private func fetchRecords(ofType recordType: String, in scope: SyncDatabaseScope) async throws -> [T] { guard let database = cloudKitDatabase(in: scope) else { if scope == .sharedDatabase { return [] } throw CloudSyncRuntimeError.missingEntitlement } let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) var results: [T] = [] var cursor: CKQueryOperation.Cursor? repeat { let batch: (matchResults: [(CKRecord.ID, Result)], queryCursor: CKQueryOperation.Cursor?) if let cursor { batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200) } else { batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200) } for (_, result) in batch.matchResults { switch result { case .success(let record): guard let payload = record[CloudRecordKey.payload] as? Data else { continue } if let decoded = try? decoder.decode(T.self, from: payload) { results.append(decoded) } else { lastErrorMessage = "Some iCloud records were skipped because they could not be decoded." } case .failure: continue } } cursor = batch.queryCursor } while cursor != nil return results } private func fetchSharedRecords( ofType recordType: String, in scope: SyncDatabaseScope ) async throws -> [SharedRecord] { guard let database = cloudKitDatabase(in: scope) else { if scope == .sharedDatabase { return [] } throw CloudSyncRuntimeError.missingEntitlement } let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) var results: [SharedRecord] = [] var cursor: CKQueryOperation.Cursor? repeat { let batch: (matchResults: [(CKRecord.ID, Result)], queryCursor: CKQueryOperation.Cursor?) if let cursor { batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200) } else { batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200) } for (_, result) in batch.matchResults { switch result { case .success(let record): guard let payload = record[CloudRecordKey.payload] as? Data else { continue } if let decoded = try? decoder.decode(T.self, from: payload) { results.append(SharedRecord(record: record, value: decoded)) } else { lastErrorMessage = "Some iCloud records were skipped because they could not be decoded." } case .failure: continue } } cursor = batch.queryCursor } while cursor != nil return results } private func fetchTrackedDomainRecords(in scope: SyncDatabaseScope) async throws -> [TrackedDomain] { let records: [SharedRecord] = try await fetchSharedRecords(ofType: CloudRecordType.trackedDomain, in: scope) let shares = try await sharesByRecordID(for: records.map(\.record), in: scope) return records.map { entry in var trackedDomain = Self.payloadTrackedDomain(entry.value) trackedDomain.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope) return trackedDomain } } private func fetchWorkflowRecords(in scope: SyncDatabaseScope) async throws -> [DomainWorkflow] { let records: [SharedRecord] = try await fetchSharedRecords(ofType: CloudRecordType.workflow, in: scope) let shares = try await sharesByRecordID(for: records.map(\.record), in: scope) return records.map { entry in var workflow = normalizedWorkflow(entry.value) workflow.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope) return workflow } } private func sharesByRecordID( for records: [CKRecord], in scope: SyncDatabaseScope ) async throws -> [CKRecord.ID: CKShare] { guard let database = cloudKitDatabase(in: scope) else { return [:] } let shareReferences = records.reduce(into: [CKRecord.ID: CKRecord.Reference]()) { partialResult, record in if let reference = record.share { partialResult[record.recordID] = reference } } guard !shareReferences.isEmpty else { return [:] } let fetched = try await database.records(for: Array(Set(shareReferences.values.map(\.recordID)))) var sharesByRootRecordID: [CKRecord.ID: CKShare] = [:] for (rootRecordID, shareReference) in shareReferences { guard case .success(let shareRecord) = fetched[shareReference.recordID], let share = shareRecord as? CKShare else { continue } sharesByRootRecordID[rootRecordID] = share } return sharesByRootRecordID } private func collaborationMetadata( for record: CKRecord, share: CKShare?, scope: SyncDatabaseScope ) -> CollaborationMetadata { let ownership: CollaborationOwnership let permission: CollaborationPermission if let participant = share?.currentUserParticipant { ownership = participant.role == .owner ? .owner : .participant permission = participant.permission == .readOnly ? .readOnly : .editable } else { ownership = scope == .sharedDatabase ? .participant : .owner permission = scope == .sharedDatabase ? .readOnly : .editable } return CollaborationMetadata( scope: scope == .privateDatabase ? .privateDatabase : .sharedDatabase, ownership: ownership, permission: permission, shareRecordName: share?.recordID.recordName ) } private func fetchShare(recordName: String, in scope: SyncDatabaseScope) async throws -> CKShare? { guard let database = cloudKitDatabase(in: scope) else { return nil } let results = try await database.records(for: [CKRecord.ID(recordName: recordName)]) guard case .success(let shareRecord) = results[CKRecord.ID(recordName: recordName)] else { return nil } return shareRecord as? CKShare } private func shareRecordName(for entity: ShareableEntity) async throws -> String? { switch entity { case .trackedDomain(let domain): let normalized = Self.normalizeDomain(domain) return DomainDataPortabilityService.loadTrackedDomains() .first(where: { Self.normalizeDomain($0.domain) == normalized })? .collaboration?.shareRecordName case .workflow(let identifier): return DomainDataPortabilityService.loadWorkflows() .first(where: { $0.id == identifier })? .collaboration?.shareRecordName } } private func makeTrackedDomainRecord(_ trackedDomain: TrackedDomain) -> CKRecord { let normalized = Self.normalizeDomain(trackedDomain.domain) let sanitized = Self.payloadTrackedDomain(trackedDomain) return makeRecord( type: CloudRecordType.trackedDomain, recordName: trackedDomainRecordName(for: normalized), payload: sanitized, extraFields: [ CloudRecordKey.domain: normalized as CKRecordValue, CloudRecordKey.updatedAt: sanitized.updatedAt as CKRecordValue ] ) } private func makeWorkflowRecord(_ workflow: DomainWorkflow) -> CKRecord { let normalized = normalizedWorkflow(workflow) return makeRecord( type: CloudRecordType.workflow, recordName: workflowRecordName(for: normalized.id.uuidString), payload: normalized, extraFields: [ CloudRecordKey.identifier: normalized.id.uuidString as CKRecordValue, CloudRecordKey.updatedAt: normalized.updatedAt as CKRecordValue ] ) } private func makeAppSettingsRecord(_ settings: SyncedAppSettings) -> CKRecord { makeRecord( type: CloudRecordType.appSettings, recordName: "app-settings", payload: settings, extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue] ) } private func makeMonitoringSettingsRecord(_ settings: SyncedMonitoringSettings) -> CKRecord { makeRecord( type: CloudRecordType.monitoringSettings, recordName: "monitoring-settings", payload: settings, extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue] ) } private func makeDomainNoteRecord(_ note: SyncedDomainNote) -> CKRecord { makeRecord( type: CloudRecordType.domainNote, recordName: domainNoteRecordName(for: note.domain), payload: note, extraFields: [ CloudRecordKey.domain: note.domain as CKRecordValue, CloudRecordKey.updatedAt: note.updatedAt as CKRecordValue ] ) } private func makeHistoryMetadataRecord(_ metadata: SyncedHistoryMetadata) -> CKRecord { makeRecord( type: CloudRecordType.historyMetadata, recordName: historyMetadataRecordName(for: metadata.domain), payload: metadata, extraFields: [ CloudRecordKey.domain: metadata.domain as CKRecordValue, CloudRecordKey.updatedAt: metadata.updatedAt as CKRecordValue ] ) } private func makeTombstoneRecord(_ tombstone: SyncTombstone) -> CKRecord { makeRecord( type: CloudRecordType.tombstone, recordName: tombstoneRecordName(for: tombstone.entityType, identifier: tombstone.identifier), payload: tombstone, extraFields: [ CloudRecordKey.entityType: tombstone.entityType.rawValue as CKRecordValue, CloudRecordKey.identifier: tombstone.identifier as CKRecordValue, CloudRecordKey.deletedAt: tombstone.deletedAt as CKRecordValue ] ) } private func makeRecord( type: String, recordName: String, payload: T, extraFields: [String: CKRecordValue] ) -> CKRecord { let record = CKRecord(recordType: type, recordID: CKRecord.ID(recordName: recordName)) record[CloudRecordKey.payload] = (try? encoder.encode(payload)) as CKRecordValue? for (key, value) in extraFields { record[key] = value } return record } private func mapSyncError(_ error: Error) -> (status: CloudSyncStatus, message: String, errorDetail: String?) { if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingEntitlement { return ( .iCloudUnavailable, missingEntitlementMessage, nil ) } if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingLocalItem { return (.error, "The shared item is no longer available locally.", nil) } guard let ckError = error as? CKError else { return (.error, "Sync failed, but local data is still available.", error.localizedDescription) } switch ckError.code { case .networkUnavailable, .networkFailure, .serviceUnavailable: return (.offline, "Sync is waiting for a stable network connection.", ckError.localizedDescription) case .notAuthenticated: return (.iCloudUnavailable, "Sign in to iCloud to sync DomainDig.", ckError.localizedDescription) case .quotaExceeded: return (.error, "iCloud storage is full. DomainDig kept working locally.", ckError.localizedDescription) case .permissionFailure, .badContainer, .missingEntitlement: return (.iCloudUnavailable, "CloudKit access is unavailable for this build.", ckError.localizedDescription) case .partialFailure: return (.error, "Some iCloud records could not sync. Local data remains available.", ckError.localizedDescription) default: return (.error, "Sync failed, but local data is still available.", ckError.localizedDescription) } } private enum AvailabilityState { case available case noAccount case restricted case temporarilyUnavailable case unknown case missingEntitlement } private enum CloudSyncRuntimeError: Error, Equatable { case missingEntitlement case missingLocalItem } private static func normalizeDomain(_ value: String) -> String { value .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "https://", with: "") .replacingOccurrences(of: "http://", with: "") .components(separatedBy: "/") .first? .lowercased() ?? "" } private func cloudKitContainer() -> CKContainer? { if let container { return container } if isEntitlementConfigurationAvailable() { let container = CKContainer.default() self.container = container return container } return nil } private func cloudKitDatabase(in scope: SyncDatabaseScope) -> CKDatabase? { switch scope { case .privateDatabase: if let privateDatabase { return privateDatabase } case .sharedDatabase: if let sharedDatabase { return sharedDatabase } } guard let container = cloudKitContainer() else { return nil } switch scope { case .privateDatabase: let database = container.privateCloudDatabase self.privateDatabase = database return database case .sharedDatabase: let database = container.sharedCloudDatabase self.sharedDatabase = database return database } } private func isEntitlementConfigurationAvailable() -> Bool { if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.icloud-container-identifiers") != nil { return true } if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.ubiquity-container-identifiers") != nil { return true } if let entitlementsURL = Bundle.main.url(forResource: "archived-expanded-entitlements", withExtension: "xcent"), let data = try? Data(contentsOf: entitlementsURL), let contents = String(data: data, encoding: .utf8) { return contents.contains("com.apple.developer.icloud-services") && (contents.contains("CloudKit") || contents.contains("CloudKit-Anonymous")) } return false } private var missingEntitlementMessage: String { "This build does not have the CloudKit entitlement required for iCloud sync." } private static func payloadTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain { TrackedDomain( id: trackedDomain.id, domain: normalizeDomain(trackedDomain.domain), createdAt: trackedDomain.createdAt, updatedAt: trackedDomain.updatedAt, note: trackedDomain.note, isPinned: trackedDomain.isPinned, monitoringEnabled: trackedDomain.monitoringEnabled, lastKnownAvailability: trackedDomain.lastKnownAvailability, lastSnapshotID: nil, lastChangeSummary: nil, lastChangeSeverity: trackedDomain.lastChangeSeverity, certificateWarningLevel: trackedDomain.certificateWarningLevel, certificateDaysRemaining: trackedDomain.certificateDaysRemaining, lastMonitoredAt: trackedDomain.lastMonitoredAt, lastAlertAt: trackedDomain.lastAlertAt, collaboration: trackedDomain.collaboration ) } private func mergedTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain { let winner = preferredTrackedDomain(lhs, rhs) return TrackedDomain( id: lhs.id, domain: Self.normalizeDomain(lhs.domain.isEmpty ? rhs.domain : lhs.domain), createdAt: min(lhs.createdAt, rhs.createdAt), updatedAt: max(lhs.updatedAt, rhs.updatedAt), note: winner.note ?? lhs.note ?? rhs.note, isPinned: winner.isPinned, monitoringEnabled: winner.monitoringEnabled, lastKnownAvailability: winner.lastKnownAvailability ?? lhs.lastKnownAvailability ?? rhs.lastKnownAvailability, lastSnapshotID: nil, lastChangeSummary: nil, lastChangeSeverity: winner.lastChangeSeverity ?? lhs.lastChangeSeverity ?? rhs.lastChangeSeverity, certificateWarningLevel: higherCertificateWarningLevel(lhs.certificateWarningLevel, rhs.certificateWarningLevel), certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining, lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(), lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(), collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration) ) } private func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow { DomainWorkflow( id: workflow.id, name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines), domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }), createdAt: workflow.createdAt, updatedAt: workflow.updatedAt, notes: emptyToNil(workflow.notes ?? ""), collaboration: workflow.collaboration ) } private func preferredTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain { let lhsRank = collaborationRank(lhs.collaboration) let rhsRank = collaborationRank(rhs.collaboration) if lhsRank != rhsRank { return lhsRank > rhsRank ? lhs : rhs } return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs } private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow { let lhsRank = collaborationRank(lhs.collaboration) let rhsRank = collaborationRank(rhs.collaboration) if lhsRank != rhsRank { return lhsRank > rhsRank ? lhs : rhs } return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs } private func preferredCollaboration( _ lhs: CollaborationMetadata?, _ rhs: CollaborationMetadata? ) -> CollaborationMetadata? { guard let lhs else { return rhs } guard let rhs else { return lhs } let lhsRank = collaborationRank(lhs) let rhsRank = collaborationRank(rhs) if lhsRank != rhsRank { return lhsRank > rhsRank ? lhs : rhs } if lhs.shareRecordName != nil { return lhs } return rhs } private func collaborationRank(_ collaboration: CollaborationMetadata?) -> Int { guard let collaboration else { return 0 } switch collaboration.ownership { case .owner: return collaboration.isShared ? 3 : 2 case .participant: return collaboration.permission == .editable ? 1 : 0 } } private func emptyToNil(_ value: String) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private func sameAppSettings(_ lhs: AppSettingsSnapshot, _ rhs: AppSettingsSnapshot) -> Bool { lhs.recentSearches == rhs.recentSearches && lhs.savedDomains == rhs.savedDomains && lhs.resolverURLString == rhs.resolverURLString && lhs.appDensityRawValue == rhs.appDensityRawValue } private func deduplicated(_ values: [String]) -> [String] { var seen = Set() return values.filter { seen.insert($0).inserted } } private func higherCertificateWarningLevel( _ lhs: CertificateWarningLevel, _ rhs: CertificateWarningLevel ) -> CertificateWarningLevel { let rank: [CertificateWarningLevel: Int] = [ .none: 0, .warning: 1, .critical: 2 ] return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs } private func trackedDomainRecordName(for normalizedDomain: String) -> String { "tracked-domain:\(normalizedDomain)" } private func workflowRecordName(for identifier: String) -> String { "workflow:\(identifier)" } private func domainNoteRecordName(for normalizedDomain: String) -> String { "domain-note:\(normalizedDomain)" } private func historyMetadataRecordName(for normalizedDomain: String) -> String { "history-metadata:\(normalizedDomain)" } private func tombstoneRecordName(for entityType: SyncEntityType, identifier: String) -> String { "tombstone:\(entityType.rawValue):\(identifier)" } }