diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/AppVersion.swift | 2 | ||||
| -rw-r--r-- | DomainDig/CloudSharingLifecycle.swift | 31 | ||||
| -rw-r--r-- | DomainDig/CloudSharingSheet.swift | 84 | ||||
| -rw-r--r-- | DomainDig/CloudSyncService.swift | 1588 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 59 | ||||
| -rw-r--r-- | DomainDig/DomainAvailabilityService.swift | 20 | ||||
| -rw-r--r-- | DomainDig/DomainDig.entitlements | 14 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 11 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 102 | ||||
| -rw-r--r-- | DomainDig/Info.plist | 2 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 75 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 56 | ||||
| -rw-r--r-- | DomainDig/WorkflowsView.swift | 31 |
13 files changed, 2033 insertions, 42 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index 346beb4..cc675b0 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { nonisolated static var current: String { - "3.4.0" + "3.5.0" } } diff --git a/DomainDig/CloudSharingLifecycle.swift b/DomainDig/CloudSharingLifecycle.swift new file mode 100644 index 0000000..fc5de80 --- /dev/null +++ b/DomainDig/CloudSharingLifecycle.swift @@ -0,0 +1,31 @@ +import CloudKit +import UIKit + +final class DomainDigAppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + configuration.delegateClass = DomainDigSceneDelegate.self + return configuration + } +} + +final class DomainDigSceneDelegate: NSObject, UIWindowSceneDelegate { + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let metadata = connectionOptions.cloudKitShareMetadata else { return } + accept(metadata: metadata) + } + + func windowScene(_ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) { + accept(metadata: cloudKitShareMetadata) + } + + private func accept(metadata: CKShare.Metadata) { + Task { + try? await CloudSyncService.shared.acceptShare(metadata: metadata) + } + } +} diff --git a/DomainDig/CloudSharingSheet.swift b/DomainDig/CloudSharingSheet.swift new file mode 100644 index 0000000..3d524ef --- /dev/null +++ b/DomainDig/CloudSharingSheet.swift @@ -0,0 +1,84 @@ +import CloudKit +import Foundation +import SwiftUI +import UIKit + +struct CloudSharingSheet: UIViewControllerRepresentable { + @Environment(\.dismiss) private var dismiss + + let entity: ShareableEntity + let title: String + + func makeCoordinator() -> Coordinator { + Coordinator(dismiss: dismiss, title: title) + } + + func makeUIViewController(context: Context) -> UIViewController { + UIViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + context.coordinator.presentIfNeeded(from: uiViewController, entity: entity) + } + + final class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate { + private let dismissAction: DismissAction + private let title: String + private let observer = CKSystemSharingUIObserver(container: .default()) + private var hasPresented = false + + init(dismiss: DismissAction, title: String) { + self.dismissAction = dismiss + self.title = title + super.init() + + observer.systemSharingUIDidSaveShareBlock = { _, _ in + Task { @MainActor in + await CloudSyncService.shared.syncNow(trigger: .manual) + } + } + + observer.systemSharingUIDidStopSharingBlock = { _, _ in + Task { @MainActor in + await CloudSyncService.shared.syncNow(trigger: .manual) + } + } + } + + func presentIfNeeded(from presenter: UIViewController, entity: ShareableEntity) { + guard !hasPresented else { return } + hasPresented = true + + let itemProvider = NSItemProvider() + let container = CKContainer.default() + + Task { @MainActor in + do { + if let existingShare = try await CloudSyncService.shared.existingShare(for: entity) { + itemProvider.registerCKShare(existingShare, container: container) + } else { + itemProvider.registerCKShare(container: container) { + try await CloudSyncService.shared.createShare(for: entity) + } + } + } catch { + dismissAction() + return + } + + let configuration = UIActivityItemsConfiguration(itemProviders: [itemProvider]) + let activityController = UIActivityViewController(activityItemsConfiguration: configuration) + activityController.completionWithItemsHandler = { [dismissAction] _, _, _, _ in + dismissAction() + } + activityController.popoverPresentationController?.sourceView = presenter.view + activityController.presentationController?.delegate = self + presenter.present(activityController, animated: true) + } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + dismissAction() + } + } +} 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<Value> { + 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<Void, Never>? + private var syncTask: Task<Void, Never>? + + 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<T: Decodable>(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<CKRecord, any Error>)], 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<T: Decodable>( + ofType recordType: String, + in scope: SyncDatabaseScope + ) async throws -> [SharedRecord<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: [SharedRecord<T>] = [] + var cursor: CKQueryOperation.Cursor? + + repeat { + let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], 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<TrackedDomain>] = 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<DomainWorkflow>] = 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<T: Encodable>( + 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<String>() + 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)" + } +} diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index e1d0a99..0386211 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -2421,6 +2421,7 @@ struct SettingsView: View { @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel @State private var purchaseService = PurchaseService.shared + @State private var cloudSyncService = CloudSyncService.shared @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString @AppStorage(AppDensity.userDefaultsKey) @@ -2480,6 +2481,43 @@ struct SettingsView: View { } } + Section("iCloud Sync") { + Toggle( + "Enable iCloud Sync", + isOn: Binding( + get: { cloudSyncService.isEnabled }, + set: { cloudSyncService.setSyncEnabled($0) } + ) + ) + + LabeledContent("Status", value: cloudSyncService.status.title) + LabeledContent( + "Last Sync", + value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced" + ) + + Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") { + Task { + await cloudSyncService.syncNow(trigger: .manual) + } + } + .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing) + + Text("iCloud Sync stores DomainDig data in your private iCloud account. DomainDig does not operate a sync server. Disabling sync keeps local data on this device.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + + Text(cloudSyncService.detailMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + + if let lastErrorMessage = cloudSyncService.lastErrorMessage { + Text(lastErrorMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.red) + } + } + Section("Monitoring") { Toggle( "Enable Background Monitoring", @@ -2713,7 +2751,7 @@ struct SettingsView: View { Section("About") { LabeledContent("Version", value: appVersion) - LabeledContent("Storage", value: "Local-only") + LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") } } @@ -2817,18 +2855,37 @@ struct SettingsView: View { Task { await viewModel.refreshUsageCredits() await viewModel.refreshMonitoringAuthorizationStatus() + await cloudSyncService.refreshAvailability() } } .onChange(of: resolverOption) { _, newValue in guard let presetURL = newValue.urlString else { storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: storedDensity + ) return } storedResolverURL = presetURL + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: storedDensity + ) } .onChange(of: customResolverURL) { _, newValue in guard resolverOption == .custom else { return } storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: storedDensity + ) + } + .onChange(of: storedDensity) { _, newValue in + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: newValue + ) } } diff --git a/DomainDig/DomainAvailabilityService.swift b/DomainDig/DomainAvailabilityService.swift index 59f6afa..b671fbc 100644 --- a/DomainDig/DomainAvailabilityService.swift +++ b/DomainDig/DomainAvailabilityService.swift @@ -10,17 +10,14 @@ struct DomainAvailabilityService { } if await checkViaRDAP(domain: normalizedDomain) == .registered { - debugLog("rdap", domain: normalizedDomain, status: .registered) return DomainAvailabilityResult(domain: normalizedDomain, status: .registered) } let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain) if fallbackStatus == .registered { - debugLog("dns-evidence", domain: normalizedDomain, details: "DNS exists but RDAP did not confirm registration") return DomainAvailabilityResult(domain: normalizedDomain, status: .unknown) } - debugLog("fallback", domain: normalizedDomain, status: fallbackStatus) return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus) } @@ -40,9 +37,6 @@ struct DomainAvailabilityService { private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? { let status = await RDAPService.registrationStatus(for: domain) - if status == nil { - debugLog("rdap-not-found", domain: domain, details: "Ignoring unavailable response from rdap.org") - } return status } @@ -53,7 +47,6 @@ struct DomainAvailabilityService { return .registered } } catch { - debugLog("dns-a-error", domain: domain, details: error.localizedDescription) } do { @@ -63,7 +56,6 @@ struct DomainAvailabilityService { } return .unknown } catch { - debugLog("dns-ns-error", domain: domain, details: error.localizedDescription) return .unknown } } @@ -99,16 +91,4 @@ struct DomainAvailabilityService { .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() } - - private static func debugLog(_ method: String, domain: String, status: DomainAvailabilityStatus) { - #if DEBUG - print("[Availability] \(domain) -> \(status.rawValue) via \(method)") - #endif - } - - private static func debugLog(_ method: String, domain: String, details: String) { - #if DEBUG - print("[Availability] \(domain) -> \(method): \(details)") - #endif - } } diff --git a/DomainDig/DomainDig.entitlements b/DomainDig/DomainDig.entitlements new file mode 100644 index 0000000..d792729 --- /dev/null +++ b/DomainDig/DomainDig.entitlements @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.developer.icloud-container-identifiers</key> + <array> + <string>iCloud.net.cleberg.DomainDig</string> + </array> + <key>com.apple.developer.icloud-services</key> + <array> + <string>CloudKit</string> + </array> +</dict> +</plist> diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index f7cd15e..4bb1b8f 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -9,10 +9,12 @@ import SwiftUI @main struct DomainDigApp: App { + @UIApplicationDelegateAdaptor(DomainDigAppDelegate.self) private var appDelegate @Environment(\.scenePhase) private var scenePhase @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue @State private var viewModel = DomainViewModel() @State private var purchaseService = PurchaseService.shared + @State private var cloudSyncService = CloudSyncService.shared init() { LocalNotificationService.shared.configureForegroundPresentation() @@ -25,9 +27,16 @@ struct DomainDigApp: App { .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) .task { let _ = purchaseService.currentTier + let _ = cloudSyncService.status await purchaseService.refreshEntitlements() viewModel.refreshMonitoringState() await viewModel.refreshMonitoringAuthorizationStatus() + await cloudSyncService.refreshAvailability() + cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) + viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + } + .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in + viewModel.refreshPersistedData() viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() } } @@ -36,7 +45,9 @@ struct DomainDigApp: App { viewModel.refreshMonitoringState() Task { await viewModel.refreshMonitoringAuthorizationStatus() + await cloudSyncService.refreshAvailability() } + cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() } } diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 527bf5e..02e09f7 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -607,12 +607,14 @@ final class DomainViewModel { savedDomains.append(searchedDomain) } DomainDataPortabilityService.saveSavedDomains(savedDomains) + CloudSyncService.shared.markAppSettingsChanged() refreshDataLifecycleSummary() } func removeSavedDomains(at offsets: IndexSet) { savedDomains.remove(atOffsets: offsets) DomainDataPortabilityService.saveSavedDomains(savedDomains) + CloudSyncService.shared.markAppSettingsChanged() refreshDataLifecycleSummary() } @@ -641,7 +643,12 @@ final class DomainViewModel { domain: normalizedDomain, createdAt: Date(), updatedAt: Date(), - lastKnownAvailability: availabilityStatus + lastKnownAvailability: availabilityStatus, + collaboration: CollaborationMetadata( + scope: .privateDatabase, + ownership: .owner, + permission: .editable + ) ), at: 0 ) @@ -666,7 +673,9 @@ final class DomainViewModel { } func deleteTrackedDomains(at offsets: IndexSet) { - let ids = offsets.map { sortedTrackedDomains[$0].id } + let removedDomains = offsets.map { sortedTrackedDomains[$0] } + let ids = removedDomains.map(\.id) + CloudSyncService.shared.recordTrackedDomainReset(removedDomains) trackedDomains.removeAll { ids.contains($0.id) } history.indices.forEach { index in if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) { @@ -679,6 +688,8 @@ final class DomainViewModel { } func deleteTrackedDomain(_ trackedDomain: TrackedDomain) { + guard canDelete(trackedDomain) else { return } + CloudSyncService.shared.recordTrackedDomainDeletion(trackedDomain) trackedDomains.removeAll { $0.id == trackedDomain.id } history.indices.forEach { index in if history[index].trackedDomainID == trackedDomain.id { @@ -691,14 +702,19 @@ final class DomainViewModel { } func togglePinned(for trackedDomain: TrackedDomain) { + guard canEdit(trackedDomain) else { return } guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } trackedDomains[index].isPinned.toggle() + trackedDomains[index].updatedAt = Date() persistTrackedDomains() } func updateNote(_ note: String, for trackedDomain: TrackedDomain) { + guard canEdit(trackedDomain) else { return } guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + trackedDomains[index].updatedAt = Date() + CloudSyncService.shared.markNoteChanged(for: trackedDomains[index].domain, updatedAt: trackedDomains[index].updatedAt) persistTrackedDomains() } @@ -725,6 +741,7 @@ final class DomainViewModel { } func clearWorkflows() { + CloudSyncService.shared.recordWorkflowReset(workflows) workflows.removeAll() latestWorkflowRunSummary = nil persistWorkflows() @@ -732,6 +749,7 @@ final class DomainViewModel { } func clearTrackedDomains() { + CloudSyncService.shared.recordTrackedDomainReset(trackedDomains) trackedDomains.removeAll() refreshingTrackedDomainID = nil persistTrackedDomains() @@ -742,6 +760,7 @@ final class DomainViewModel { func clearRecentSearches() { recentSearches.removeAll() DomainDataPortabilityService.saveRecentSearches([]) + CloudSyncService.shared.markAppSettingsChanged() refreshDataLifecycleSummary() } @@ -789,13 +808,13 @@ final class DomainViewModel { } monitoringSettings.isEnabled = isEnabled - persistMonitoringSettings() + persistMonitoringSettings(localActivationConfirmed: isEnabled) monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() } func setMonitoringScope(_ scope: MonitoringScope) { monitoringSettings.scope = scope - persistMonitoringSettings() + persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) } func setMonitoringFrequency(_ frequency: MonitoringFrequency) { @@ -804,7 +823,7 @@ final class DomainViewModel { return } monitoringSettings.frequency = frequency - persistMonitoringSettings() + persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() } @@ -815,7 +834,7 @@ final class DomainViewModel { return } monitoringSettings.alertFilter = filter - persistMonitoringSettings() + persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) } func setMonitoringAlertsEnabled(_ isEnabled: Bool) { @@ -825,7 +844,7 @@ final class DomainViewModel { return } monitoringSettings.alertsEnabled = isEnabled - persistMonitoringSettings() + persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) } func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) { @@ -836,24 +855,27 @@ final class DomainViewModel { } else { monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id } } - persistMonitoringSettings() + persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) } func toggleMonitoring(for trackedDomain: TrackedDomain) { + guard canEdit(trackedDomain) else { return } guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) return } guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } trackedDomains[index].monitoringEnabled.toggle() + trackedDomains[index].updatedAt = Date() MonitoringStorage.saveTrackedDomains(trackedDomains) + CloudSyncService.shared.scheduleSyncIfNeeded() sanitizeMonitoringSelection() } func requestMonitoringNotificationAuthorization() async { let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded() monitoringSettings.alertsEnabled = granted - persistMonitoringSettings() + persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) await refreshMonitoringAuthorizationStatus() } @@ -991,6 +1013,32 @@ final class DomainViewModel { } } + func canEdit(_ trackedDomain: TrackedDomain) -> Bool { + trackedDomain.collaboration?.canEdit ?? true + } + + func canDelete(_ trackedDomain: TrackedDomain) -> Bool { + trackedDomain.collaboration?.isOwner ?? true + } + + func collaborationLabel(for trackedDomain: TrackedDomain) -> String? { + guard let collaboration = trackedDomain.collaboration, collaboration.isShared else { return nil } + return "\(collaboration.ownership.title) • \(collaboration.permission.title)" + } + + func canEdit(_ workflow: DomainWorkflow) -> Bool { + workflow.collaboration?.canEdit ?? true + } + + func canDelete(_ workflow: DomainWorkflow) -> Bool { + workflow.collaboration?.isOwner ?? true + } + + func collaborationLabel(for workflow: DomainWorkflow) -> String? { + guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil } + return "\(collaboration.ownership.title) • \(collaboration.permission.title)" + } + @discardableResult func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? { let normalizedDomains = normalizedDomains(domains) @@ -1006,7 +1054,12 @@ final class DomainViewModel { domains: normalizedDomains, createdAt: Date(), updatedAt: Date(), - notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + collaboration: CollaborationMetadata( + scope: .privateDatabase, + ownership: .owner, + permission: .editable + ) ) workflows.insert(workflow, at: 0) persistWorkflows() @@ -1014,6 +1067,7 @@ final class DomainViewModel { } func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) { + guard canEdit(workflow) else { return } guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } let normalizedDomains = normalizedDomains(domains) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1027,6 +1081,8 @@ final class DomainViewModel { } func deleteWorkflow(_ workflow: DomainWorkflow) { + guard canDelete(workflow) else { return } + CloudSyncService.shared.recordWorkflowDeletion(workflow) workflows.removeAll { $0.id == workflow.id } if latestWorkflowRunSummary?.workflowID == workflow.id { latestWorkflowRunSummary = nil @@ -1039,6 +1095,7 @@ final class DomainViewModel { } func addDomains(_ domains: [String], to workflow: DomainWorkflow) { + guard canEdit(workflow) else { return } guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } let mergedDomains = normalizedDomains(workflows[index].domains + domains) guard mergedDomains != workflows[index].domains else { return } @@ -1048,6 +1105,7 @@ final class DomainViewModel { } func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) { + guard canEdit(workflow) else { return } guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } workflows[index].domains.remove(atOffsets: offsets) workflows[index].updatedAt = Date() @@ -1055,6 +1113,7 @@ final class DomainViewModel { } func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) { + guard canEdit(workflow) else { return } guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } workflows[index].domains.move(fromOffsets: offsets, toOffset: destination) workflows[index].updatedAt = Date() @@ -1349,9 +1408,25 @@ final class DomainViewModel { let result = try DomainDataPortabilityService.applyImport(preview, mode: mode) refreshPersistedData() portabilityStatusMessage = result.summary + CloudSyncService.shared.markAppSettingsChanged() + CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled) + CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .import) return result } + func persistCurrentAppSettings(resolverURLString: String, appDensityRawValue: String) { + DomainDataPortabilityService.saveAppSettings( + AppSettingsSnapshot( + recentSearches: recentSearches, + savedDomains: savedDomains, + resolverURLString: resolverURLString, + appDensityRawValue: appDensityRawValue + ) + ) + CloudSyncService.shared.markAppSettingsChanged() + refreshDataLifecycleSummary() + } + func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String { let reports = workflowReports(from: summary, changedOnly: changedOnly) let base = DomainReportExporter.batchText( @@ -2011,17 +2086,20 @@ final class DomainViewModel { private func persistTrackedDomains() { DomainDataPortabilityService.saveTrackedDomains(trackedDomains) + CloudSyncService.shared.scheduleSyncIfNeeded() refreshDataLifecycleSummary() } - private func persistMonitoringSettings() { + private func persistMonitoringSettings(localActivationConfirmed: Bool = false) { monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) MonitoringStorage.saveSettings(monitoringSettings) + CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed) } private func sanitizeMonitoringSelection() { monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) MonitoringStorage.saveSettings(monitoringSettings) + CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false) } private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { @@ -2150,6 +2228,7 @@ final class DomainViewModel { recentSearches = Array(recentSearches.prefix(Self.maxRecent)) } DomainDataPortabilityService.saveRecentSearches(recentSearches) + CloudSyncService.shared.markAppSettingsChanged() refreshDataLifecycleSummary() } @@ -2723,6 +2802,7 @@ final class DomainViewModel { private func persistWorkflows() { DomainDataPortabilityService.saveWorkflows(workflows) + CloudSyncService.shared.scheduleSyncIfNeeded() refreshDataLifecycleSummary() } diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist index a24a71e..5b0e7e6 100644 --- a/DomainDig/Info.plist +++ b/DomainDig/Info.plist @@ -10,6 +10,8 @@ <array> <string>net.cleberg.DomainDig.monitor.refresh</string> </array> + <key>CKSharingSupported</key> + <true/> <key>UIBackgroundModes</key> <array> <string>fetch</string> diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 7715b4a..25d1ba2 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -332,6 +332,7 @@ struct DomainWorkflow: Codable, Identifiable, Equatable { var createdAt: Date var updatedAt: Date var notes: String? + var collaboration: CollaborationMetadata? init( id: UUID = UUID(), @@ -339,7 +340,8 @@ struct DomainWorkflow: Codable, Identifiable, Equatable { domains: [String], createdAt: Date = Date(), updatedAt: Date = Date(), - notes: String? = nil + notes: String? = nil, + collaboration: CollaborationMetadata? = nil ) { self.id = id self.name = name @@ -347,6 +349,7 @@ struct DomainWorkflow: Codable, Identifiable, Equatable { self.createdAt = createdAt self.updatedAt = updatedAt self.notes = notes + self.collaboration = collaboration } } @@ -584,6 +587,70 @@ enum PremiumCapability: String, Codable { case advancedExports } +enum CollaborationPermission: String, Codable, Equatable { + case readOnly + case editable + + var title: String { + switch self { + case .readOnly: + return "Read only" + case .editable: + return "Editable" + } + } +} + +enum CollaborationOwnership: String, Codable, Equatable { + case owner + case participant + + var title: String { + switch self { + case .owner: + return "Owned by you" + case .participant: + return "Shared with you" + } + } +} + +enum CollaborationScope: String, Codable, Equatable { + case privateDatabase + case sharedDatabase +} + +struct CollaborationMetadata: Codable, Equatable { + var scope: CollaborationScope + var ownership: CollaborationOwnership + var permission: CollaborationPermission + var shareRecordName: String? + + init( + scope: CollaborationScope, + ownership: CollaborationOwnership, + permission: CollaborationPermission, + shareRecordName: String? = nil + ) { + self.scope = scope + self.ownership = ownership + self.permission = permission + self.shareRecordName = shareRecordName + } + + var isShared: Bool { + shareRecordName != nil || scope == .sharedDatabase + } + + var canEdit: Bool { + permission == .editable + } + + var isOwner: Bool { + ownership == .owner + } +} + struct TrackedDomain: Codable, Identifiable, Equatable { let id: UUID var domain: String @@ -600,6 +667,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable { var certificateDaysRemaining: Int? var lastMonitoredAt: Date? var lastAlertAt: Date? + var collaboration: CollaborationMetadata? init( id: UUID = UUID(), @@ -616,7 +684,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable { certificateWarningLevel: CertificateWarningLevel = .none, certificateDaysRemaining: Int? = nil, lastMonitoredAt: Date? = nil, - lastAlertAt: Date? = nil + lastAlertAt: Date? = nil, + collaboration: CollaborationMetadata? = nil ) { self.id = id self.domain = domain @@ -633,6 +702,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable { self.certificateDaysRemaining = certificateDaysRemaining self.lastMonitoredAt = lastMonitoredAt self.lastAlertAt = lastAlertAt + self.collaboration = collaboration } init(from decoder: Decoder) throws { @@ -652,6 +722,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable { certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining) lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt) lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt) + collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration) } } diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index 968914f..1a38282 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -193,10 +193,12 @@ struct WatchlistView: View { } .tint(.cyan) - Button(role: .destructive) { - viewModel.deleteTrackedDomain(trackedDomain) - } label: { - Label("Delete", systemImage: "trash") + if viewModel.canDelete(trackedDomain) { + Button(role: .destructive) { + viewModel.deleteTrackedDomain(trackedDomain) + } label: { + Label("Delete", systemImage: "trash") + } } } .contextMenu { @@ -219,11 +221,21 @@ struct WatchlistView: View { } label: { Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") } + .disabled(!viewModel.canEdit(trackedDomain)) - Button(role: .destructive) { - viewModel.deleteTrackedDomain(trackedDomain) + Button { + // The system sharing UI manages participants and permissions. } label: { - Label("Delete", systemImage: "trash") + Label(trackedDomain.collaboration?.isShared == true ? "Shared" : "Private", systemImage: "person.2") + } + .disabled(true) + + if viewModel.canDelete(trackedDomain) { + Button(role: .destructive) { + viewModel.deleteTrackedDomain(trackedDomain) + } label: { + Label("Delete", systemImage: "trash") + } } } .listRowBackground(Color(.systemGray6).opacity(0.5)) @@ -287,6 +299,12 @@ struct WatchlistRowView: View { .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) + if let collaboration = trackedDomain.collaboration, collaboration.isShared { + Text("\(collaboration.ownership.title) • \(collaboration.permission.title)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off") if let lastMonitoredAt = trackedDomain.lastMonitoredAt { @@ -340,6 +358,17 @@ struct WatchlistRowView: View { @ViewBuilder private var indicatorRow: some View { HStack(spacing: 8) { + if trackedDomain.collaboration?.isShared == true { + AppStatusBadgeView( + model: .init( + title: "Shared", + systemImage: "person.2.fill", + foregroundColor: .cyan, + backgroundColor: .cyan.opacity(0.16) + ) + ) + } + AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary)) if trackedDomain.certificateWarningLevel != .none { @@ -369,6 +398,7 @@ struct TrackedDomainDetailView: View { @State private var noteDraft = "" @State private var isEditingNote = false @State private var showRerunOptions = false + @State private var shareEntity: ShareableEntity? private var liveTrackedDomain: TrackedDomain { viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain @@ -410,6 +440,7 @@ struct TrackedDomainDetailView: View { } label: { Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin") } + .disabled(!viewModel.canEdit(liveTrackedDomain)) Button { viewModel.toggleMonitoring(for: liveTrackedDomain) @@ -419,6 +450,7 @@ struct TrackedDomainDetailView: View { systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell" ) } + .disabled(!viewModel.canEdit(liveTrackedDomain)) Button { noteDraft = liveTrackedDomain.note ?? "" @@ -426,6 +458,13 @@ struct TrackedDomainDetailView: View { } label: { Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text") } + .disabled(!viewModel.canEdit(liveTrackedDomain)) + + Button { + shareEntity = .trackedDomain(liveTrackedDomain.domain) + } label: { + Label(liveTrackedDomain.collaboration?.isShared == true ? "Manage Share" : "Share Domain", systemImage: "person.2") + } } .listRowBackground(Color(.systemGray6).opacity(0.5)) @@ -518,5 +557,8 @@ struct TrackedDomainDetailView: View { } } } + .sheet(item: $shareEntity) { entity in + CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain) + } } } diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift index 5025a0b..06ae96b 100644 --- a/DomainDig/WorkflowsView.swift +++ b/DomainDig/WorkflowsView.swift @@ -129,6 +129,11 @@ private struct WorkflowRowView: View { var body: some View { VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { HStack(alignment: .firstTextBaseline, spacing: 8) { + if workflow.collaboration?.isShared == true { + Image(systemName: "person.2.fill") + .font(.caption2) + .foregroundStyle(.cyan) + } Text(workflow.name) .font(appDensity.font(.callout, design: .default, weight: .semibold)) .foregroundStyle(.primary) @@ -143,6 +148,12 @@ private struct WorkflowRowView: View { .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) + if let collaboration = workflow.collaboration, collaboration.isShared { + Text("\(collaboration.ownership.title) • \(collaboration.permission.title)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + if let notes = workflow.notes, !notes.isEmpty { Text(notes) .font(appDensity.font(.caption)) @@ -163,6 +174,7 @@ struct WorkflowDetailView: View { @State private var showingEditor = false @State private var draftDomain = "" @State private var includeAllExports = false + @State private var shareEntity: ShareableEntity? private var workflow: DomainWorkflow? { viewModel.workflow(withID: workflowID) @@ -186,6 +198,11 @@ struct WorkflowDetailView: View { .font(appDensity.font(.caption)) .foregroundStyle(.secondary) } + if let collaboration = workflow.collaboration, collaboration.isShared { + Text("\(collaboration.ownership.title) • \(collaboration.permission.title)") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } } Section("Actions") { @@ -207,6 +224,13 @@ struct WorkflowDetailView: View { } label: { Label("Edit Workflow", systemImage: "pencil") } + .disabled(!viewModel.canEdit(workflow)) + + Button { + shareEntity = .workflow(workflow.id) + } label: { + Label(workflow.collaboration?.isShared == true ? "Manage Share" : "Share Workflow", systemImage: "person.2") + } if latestSummary != nil { Toggle("Export all domains", isOn: $includeAllExports) @@ -263,6 +287,7 @@ struct WorkflowDetailView: View { viewModel.addDomains([draftDomain], to: workflow) draftDomain = "" } + .disabled(!viewModel.canEdit(workflow)) } } @@ -288,9 +313,11 @@ struct WorkflowDetailView: View { .buttonStyle(.plain) } .onDelete { offsets in + guard viewModel.canEdit(workflow) else { return } viewModel.removeWorkflowDomains(at: offsets, from: workflow) } .onMove { offsets, destination in + guard viewModel.canEdit(workflow) else { return } viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow) } } @@ -302,6 +329,7 @@ struct WorkflowDetailView: View { .toolbar { if !workflow.domains.isEmpty { EditButton() + .disabled(!viewModel.canEdit(workflow)) } } .sheet(isPresented: $showingEditor) { @@ -310,6 +338,9 @@ struct WorkflowDetailView: View { .sheet(item: workflowSummaryBinding) { summary in WorkflowRunSummaryView(viewModel: viewModel, summary: summary) } + .sheet(item: $shareEntity) { entity in + CloudSharingSheet(entity: entity, title: workflow.name) + } } else { Text("Workflow not found") .font(appDensity.font(.callout)) |
