From b35f1b432fc24fbab1fa05593c9a0bca7e4d95a6 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 23 Apr 2026 23:12:34 -0500 Subject: feat(v3.6.0): add iCloud sharing for workflows and tracked domains - implement CloudKit sharing (CKShare) for TrackedDomain and DomainWorkflow - support read-only and editable permissions - add Share actions and shared state indicators in UI - handle shared record ownership and participant roles - implement deterministic conflict resolution to prevent duplication - ensure compatibility with existing iCloud sync layer - maintain local-first behavior with graceful offline handling - remove debug logging for domain availability/rdap messages notes: - no custom backend or accounts introduced - sharing is Apple ecosystem only (iCloud-based) - large history data remains local-only --- DomainDig/CloudSyncService.swift | 1588 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1588 insertions(+) create mode 100644 DomainDig/CloudSyncService.swift (limited to 'DomainDig/CloudSyncService.swift') diff --git a/DomainDig/CloudSyncService.swift b/DomainDig/CloudSyncService.swift new file mode 100644 index 0000000..9947e08 --- /dev/null +++ b/DomainDig/CloudSyncService.swift @@ -0,0 +1,1588 @@ +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)" + } +} -- cgit v1.2.3