aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-23 23:12:34 -0500
committerChristian Cleberg <[email protected]>2026-04-23 23:12:34 -0500
commitb35f1b432fc24fbab1fa05593c9a0bca7e4d95a6 (patch)
tree099cea2cc4324331a719ed2cad8c0815033b5471
parentf045a5d339a796f40a9e958436ebc6f5314bdab8 (diff)
downloaddomain-dig-b35f1b432fc24fbab1fa05593c9a0bca7e4d95a6.tar.gz
domain-dig-b35f1b432fc24fbab1fa05593c9a0bca7e4d95a6.tar.bz2
domain-dig-b35f1b432fc24fbab1fa05593c9a0bca7e4d95a6.zip
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
-rw-r--r--DomainDig.xcodeproj/project.pbxproj15
-rw-r--r--DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist4
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/CloudSharingLifecycle.swift31
-rw-r--r--DomainDig/CloudSharingSheet.swift84
-rw-r--r--DomainDig/CloudSyncService.swift1588
-rw-r--r--DomainDig/ContentView.swift59
-rw-r--r--DomainDig/DomainAvailabilityService.swift20
-rw-r--r--DomainDig/DomainDig.entitlements14
-rw-r--r--DomainDig/DomainDigApp.swift11
-rw-r--r--DomainDig/DomainViewModel.swift102
-rw-r--r--DomainDig/Info.plist2
-rw-r--r--DomainDig/Models.swift75
-rw-r--r--DomainDig/WatchlistView.swift56
-rw-r--r--DomainDig/WorkflowsView.swift31
-rw-r--r--DomainDigCLI.swift2
16 files changed, 2048 insertions, 48 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 22c4dfc..1ccc6bc 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -169,6 +169,11 @@
TargetAttributes = {
8B7800682F6090E300933221 = {
CreatedOnToolsVersion = 26.3;
+ SystemCapabilities = {
+ com.apple.iCloud = {
+ enabled = 1;
+ };
+ };
};
};
};
@@ -359,8 +364,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 26;
+ CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -377,7 +383,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.4.0;
+ MARKETING_VERSION = 3.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -395,8 +401,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 26;
+ CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -413,7 +420,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.4.0;
+ MARKETING_VERSION = 3.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
index 06d0111..9e08675 100644
--- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,12 +7,12 @@
<key>DomainDig.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
- <integer>1</integer>
+ <integer>0</integer>
</dict>
<key>DomainDigCLI.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
- <integer>0</integer>
+ <integer>1</integer>
</dict>
</dict>
</dict>
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))
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
index 8c433d8..bb9dd80 100644
--- a/DomainDigCLI.swift
+++ b/DomainDigCLI.swift
@@ -428,6 +428,8 @@ struct DomainDigCLI {
domaindig backup export [path]
domaindig backup import <path> [--replace]
domaindig backup validate <path> [--json]
+
+ note: the CLI remains local-only and does not sync with iCloud yet.
"""
}
}