Commit b35f1b432f

b35f1b432fc24fbab1fa05593c9a0bca7e4d95a6

parent: f045a5d339

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-24 04:12 UTC

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

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +11 −4
@@ -169,6 +169,11 @@
169169 TargetAttributes = {
170170 8B7800682F6090E300933221 = {
171171 CreatedOnToolsVersion = 26.3;
172 SystemCapabilities = {
173 com.apple.iCloud = {
174 enabled = 1;
175 };
176 };
172177 };
173178 };
174179 };
@@ -359,8 +364,9 @@
359364 buildSettings = {
360365 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
361366 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
367 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
362368 CODE_SIGN_STYLE = Automatic;
363 CURRENT_PROJECT_VERSION = 26;
369 CURRENT_PROJECT_VERSION = 28;
364370 DEVELOPMENT_TEAM = ZCNAX3VL9D;
365371 ENABLE_PREVIEWS = YES;
366372 GENERATE_INFOPLIST_FILE = YES;
@@ -377,7 +383,7 @@
377383 "$(inherited)",
378384 "@executable_path/Frameworks",
379385 );
380 MARKETING_VERSION = 3.4.0;
386 MARKETING_VERSION = 3.6.0;
381387 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
382388 PRODUCT_NAME = "$(TARGET_NAME)";
383389 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -395,8 +401,9 @@
395401 buildSettings = {
396402 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
397403 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
404 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
398405 CODE_SIGN_STYLE = Automatic;
399 CURRENT_PROJECT_VERSION = 26;
406 CURRENT_PROJECT_VERSION = 28;
400407 DEVELOPMENT_TEAM = ZCNAX3VL9D;
401408 ENABLE_PREVIEWS = YES;
402409 GENERATE_INFOPLIST_FILE = YES;
@@ -413,7 +420,7 @@
413420 "$(inherited)",
414421 "@executable_path/Frameworks",
415422 );
416 MARKETING_VERSION = 3.4.0;
423 MARKETING_VERSION = 3.6.0;
417424 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
418425 PRODUCT_NAME = "$(TARGET_NAME)";
419426 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +2 −2
@@ -7,12 +7,12 @@
77 <key>DomainDig.xcscheme_^#shared#^_</key>
88 <dict>
99 <key>orderHint</key>
10 <integer>1</integer>
10 <integer>0</integer>
1111 </dict>
1212 <key>DomainDigCLI.xcscheme_^#shared#^_</key>
1313 <dict>
1414 <key>orderHint</key>
15 <integer>0</integer>
15 <integer>1</integer>
1616 </dict>
1717 </dict>
1818</dict>
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
22
33enum AppVersion {
44 nonisolated static var current: String {
5 "3.4.0"
5 "3.5.0"
66 }
77}
DomainDig/CloudSharingLifecycle.swift added +31
@@ -0,0 +1,31 @@
1import CloudKit
2import UIKit
3
4final class DomainDigAppDelegate: NSObject, UIApplicationDelegate {
5 func application(
6 _ application: UIApplication,
7 configurationForConnecting connectingSceneSession: UISceneSession,
8 options: UIScene.ConnectionOptions
9 ) -> UISceneConfiguration {
10 let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
11 configuration.delegateClass = DomainDigSceneDelegate.self
12 return configuration
13 }
14}
15
16final class DomainDigSceneDelegate: NSObject, UIWindowSceneDelegate {
17 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
18 guard let metadata = connectionOptions.cloudKitShareMetadata else { return }
19 accept(metadata: metadata)
20 }
21
22 func windowScene(_ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
23 accept(metadata: cloudKitShareMetadata)
24 }
25
26 private func accept(metadata: CKShare.Metadata) {
27 Task {
28 try? await CloudSyncService.shared.acceptShare(metadata: metadata)
29 }
30 }
31}
DomainDig/CloudSharingSheet.swift added +84
@@ -0,0 +1,84 @@
1import CloudKit
2import Foundation
3import SwiftUI
4import UIKit
5
6struct CloudSharingSheet: UIViewControllerRepresentable {
7 @Environment(\.dismiss) private var dismiss
8
9 let entity: ShareableEntity
10 let title: String
11
12 func makeCoordinator() -> Coordinator {
13 Coordinator(dismiss: dismiss, title: title)
14 }
15
16 func makeUIViewController(context: Context) -> UIViewController {
17 UIViewController()
18 }
19
20 func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
21 context.coordinator.presentIfNeeded(from: uiViewController, entity: entity)
22 }
23
24 final class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
25 private let dismissAction: DismissAction
26 private let title: String
27 private let observer = CKSystemSharingUIObserver(container: .default())
28 private var hasPresented = false
29
30 init(dismiss: DismissAction, title: String) {
31 self.dismissAction = dismiss
32 self.title = title
33 super.init()
34
35 observer.systemSharingUIDidSaveShareBlock = { _, _ in
36 Task { @MainActor in
37 await CloudSyncService.shared.syncNow(trigger: .manual)
38 }
39 }
40
41 observer.systemSharingUIDidStopSharingBlock = { _, _ in
42 Task { @MainActor in
43 await CloudSyncService.shared.syncNow(trigger: .manual)
44 }
45 }
46 }
47
48 func presentIfNeeded(from presenter: UIViewController, entity: ShareableEntity) {
49 guard !hasPresented else { return }
50 hasPresented = true
51
52 let itemProvider = NSItemProvider()
53 let container = CKContainer.default()
54
55 Task { @MainActor in
56 do {
57 if let existingShare = try await CloudSyncService.shared.existingShare(for: entity) {
58 itemProvider.registerCKShare(existingShare, container: container)
59 } else {
60 itemProvider.registerCKShare(container: container) {
61 try await CloudSyncService.shared.createShare(for: entity)
62 }
63 }
64 } catch {
65 dismissAction()
66 return
67 }
68
69 let configuration = UIActivityItemsConfiguration(itemProviders: [itemProvider])
70 let activityController = UIActivityViewController(activityItemsConfiguration: configuration)
71 activityController.completionWithItemsHandler = { [dismissAction] _, _, _, _ in
72 dismissAction()
73 }
74 activityController.popoverPresentationController?.sourceView = presenter.view
75 activityController.presentationController?.delegate = self
76 presenter.present(activityController, animated: true)
77 }
78 }
79
80 func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
81 dismissAction()
82 }
83 }
84}
DomainDig/CloudSyncService.swift added +1588
@@ -0,0 +1,1588 @@
1import CloudKit
2import Foundation
3import Network
4import Observation
5
6extension Notification.Name {
7 static let cloudSyncDidApplyChanges = Notification.Name("CloudSyncService.didApplyChanges")
8}
9
10enum CloudSyncStatus: String, Codable {
11 case disabled
12 case synced
13 case syncing
14 case offline
15 case iCloudUnavailable
16 case conflictResolved
17 case error
18
19 var title: String {
20 switch self {
21 case .disabled:
22 return "Off"
23 case .synced:
24 return "Synced"
25 case .syncing:
26 return "Syncing"
27 case .offline:
28 return "Offline"
29 case .iCloudUnavailable:
30 return "iCloud unavailable"
31 case .conflictResolved:
32 return "Conflict resolved"
33 case .error:
34 return "Error"
35 }
36 }
37}
38
39enum CloudSyncTrigger: String {
40 case launch
41 case automatic
42 case manual
43 case `import`
44}
45
46enum ShareableEntity: Identifiable, Hashable {
47 case trackedDomain(String)
48 case workflow(UUID)
49
50 var id: String {
51 switch self {
52 case .trackedDomain(let domain):
53 return "tracked:\(domain)"
54 case .workflow(let identifier):
55 return "workflow:\(identifier.uuidString)"
56 }
57 }
58}
59
60private enum CloudRecordType {
61 static let trackedDomain = "TrackedDomain"
62 static let workflow = "DomainWorkflow"
63 static let appSettings = "AppSettingsSnapshot"
64 static let monitoringSettings = "MonitoringSettings"
65 static let domainNote = "DomainNote"
66 static let historyMetadata = "HistoryMetadata"
67 static let tombstone = "SyncTombstone"
68}
69
70private enum CloudRecordKey {
71 static let payload = "payload"
72 static let updatedAt = "updatedAt"
73 static let domain = "domain"
74 static let identifier = "identifier"
75 static let entityType = "entityType"
76 static let deletedAt = "deletedAt"
77}
78
79private enum SyncEntityType: String, Codable {
80 case trackedDomain
81 case workflow
82}
83
84private struct SyncedAppSettings: Codable {
85 var snapshot: AppSettingsSnapshot
86 var updatedAt: Date
87}
88
89private struct SyncedMonitoringSettings: Codable {
90 var settings: MonitoringSettings
91 var updatedAt: Date
92}
93
94private struct SyncedDomainNote: Codable, Equatable {
95 var domain: String
96 var text: String
97 var updatedAt: Date
98}
99
100private struct SyncedHistoryMetadata: Codable, Equatable {
101 var domain: String
102 var lastKnownAvailabilityRawValue: String?
103 var lastChangeMessage: String?
104 var lastChangeSeverityRawValue: Int?
105 var certificateWarningLevelRawValue: String
106 var certificateDaysRemaining: Int?
107 var lastObservedAt: Date?
108 var updatedAt: Date
109}
110
111private struct SyncTombstone: Codable, Equatable {
112 var entityType: SyncEntityType
113 var identifier: String
114 var deletedAt: Date
115}
116
117private struct SyncPayload {
118 var trackedDomains: [TrackedDomain]
119 var workflows: [DomainWorkflow]
120 var appSettings: SyncedAppSettings
121 var monitoringSettings: SyncedMonitoringSettings
122 var notesByDomain: [String: SyncedDomainNote]
123 var historyMetadataByDomain: [String: SyncedHistoryMetadata]
124 var tombstones: [SyncTombstone]
125}
126
127private enum SyncDatabaseScope {
128 case privateDatabase
129 case sharedDatabase
130}
131
132private struct SharedRecord<Value> {
133 var record: CKRecord
134 var value: Value
135}
136
137private struct MergedSyncPayload {
138 var payload: SyncPayload
139 var hadConflict: Bool
140 var changedLocalData: Bool
141}
142
143@MainActor
144@Observable
145final class CloudSyncService {
146 static let shared = CloudSyncService()
147
148 var isEnabled: Bool
149 var status: CloudSyncStatus
150 var lastSyncDate: Date?
151 var lastErrorMessage: String?
152 var detailMessage: String
153
154 private var container: CKContainer?
155 private var privateDatabase: CKDatabase?
156 private var sharedDatabase: CKDatabase?
157 private let defaults: UserDefaults
158 private let encoder: JSONEncoder
159 private let decoder: JSONDecoder
160 private let pathMonitor = NWPathMonitor()
161 private let pathMonitorQueue = DispatchQueue(label: "DomainDig.CloudSync.PathMonitor")
162
163 private var isNetworkAvailable = true
164 private var scheduledSyncTask: Task<Void, Never>?
165 private var syncTask: Task<Void, Never>?
166
167 private enum StorageKey {
168 static let isEnabled = "cloudSync.enabled"
169 static let status = "cloudSync.status"
170 static let lastSyncDate = "cloudSync.lastSyncDate"
171 static let lastErrorMessage = "cloudSync.lastErrorMessage"
172 static let detailMessage = "cloudSync.detailMessage"
173 static let appSettingsUpdatedAt = "cloudSync.appSettingsUpdatedAt"
174 static let monitoringSettingsUpdatedAt = "cloudSync.monitoringSettingsUpdatedAt"
175 static let noteUpdatedAtByDomain = "cloudSync.noteUpdatedAtByDomain"
176 static let tombstones = "cloudSync.tombstones"
177 static let monitoringLocalActivationConfirmed = "cloudSync.monitoringLocalActivationConfirmed"
178 }
179
180 private init(defaults: UserDefaults = .standard) {
181 self.defaults = defaults
182 self.container = nil
183 self.privateDatabase = nil
184 self.sharedDatabase = nil
185 let syncEnabled = defaults.bool(forKey: StorageKey.isEnabled)
186 self.isEnabled = syncEnabled
187 self.status = CloudSyncStatus(rawValue: defaults.string(forKey: StorageKey.status) ?? "") ?? (syncEnabled ? .synced : .disabled)
188 self.lastSyncDate = defaults.object(forKey: StorageKey.lastSyncDate) as? Date
189 self.lastErrorMessage = defaults.string(forKey: StorageKey.lastErrorMessage)
190 self.detailMessage = defaults.string(forKey: StorageKey.detailMessage) ?? "DomainDig stores synced data in your private iCloud account."
191
192 let encoder = JSONEncoder()
193 encoder.dateEncodingStrategy = .iso8601
194 self.encoder = encoder
195
196 let decoder = JSONDecoder()
197 decoder.dateDecodingStrategy = .iso8601
198 self.decoder = decoder
199
200 pathMonitor.pathUpdateHandler = { [weak self] path in
201 Task { @MainActor [weak self] in
202 guard let self else { return }
203 self.isNetworkAvailable = path.status == .satisfied
204 if !self.isNetworkAvailable, self.isEnabled, self.status == .syncing {
205 self.setStatus(.offline, detail: "Waiting for a network connection.", error: nil)
206 }
207 }
208 }
209 pathMonitor.start(queue: pathMonitorQueue)
210 }
211
212 deinit {
213 pathMonitor.cancel()
214 }
215
216 func setSyncEnabled(_ enabled: Bool) {
217 isEnabled = enabled
218 defaults.set(enabled, forKey: StorageKey.isEnabled)
219
220 guard enabled else {
221 scheduledSyncTask?.cancel()
222 syncTask?.cancel()
223 setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil)
224 return
225 }
226
227 Task {
228 await refreshAvailability()
229 await syncNow(trigger: .manual)
230 }
231 }
232
233 func refreshAvailability() async {
234 guard isEnabled else {
235 setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil)
236 return
237 }
238
239 guard isNetworkAvailable else {
240 setStatus(.offline, detail: "Waiting for a network connection.", error: nil)
241 return
242 }
243
244 switch await accountStatus() {
245 case .available:
246 if status == .iCloudUnavailable || status == .offline || status == .disabled {
247 setStatus(.synced, detail: syncSummaryDetail(), error: nil)
248 }
249 case .noAccount:
250 setStatus(.iCloudUnavailable, detail: "Sign in to iCloud to sync DomainDig.", error: nil)
251 case .restricted:
252 setStatus(.iCloudUnavailable, detail: "iCloud access is restricted on this device.", error: nil)
253 case .temporarilyUnavailable:
254 setStatus(.iCloudUnavailable, detail: "iCloud is temporarily unavailable.", error: nil)
255 case .unknown:
256 setStatus(.iCloudUnavailable, detail: "DomainDig could not confirm iCloud availability.", error: nil)
257 case .missingEntitlement:
258 setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil)
259 }
260 }
261
262 func scheduleSyncIfNeeded(trigger: CloudSyncTrigger = .automatic) {
263 guard isEnabled else { return }
264 scheduledSyncTask?.cancel()
265 scheduledSyncTask = Task { [weak self] in
266 try? await Task.sleep(nanoseconds: 900_000_000)
267 guard !Task.isCancelled else { return }
268 await self?.syncNow(trigger: trigger)
269 }
270 }
271
272 func markAppSettingsChanged() {
273 defaults.set(Date(), forKey: StorageKey.appSettingsUpdatedAt)
274 scheduleSyncIfNeeded()
275 }
276
277 func markMonitoringSettingsChanged(localActivationConfirmed: Bool = false) {
278 defaults.set(Date(), forKey: StorageKey.monitoringSettingsUpdatedAt)
279 if localActivationConfirmed {
280 defaults.set(true, forKey: StorageKey.monitoringLocalActivationConfirmed)
281 }
282 scheduleSyncIfNeeded()
283 }
284
285 func markNoteChanged(for domain: String, updatedAt: Date = Date()) {
286 let normalized = Self.normalizeDomain(domain)
287 guard !normalized.isEmpty else { return }
288
289 var noteDates = loadNoteUpdatedAtByDomain()
290 noteDates[normalized] = updatedAt
291 saveNoteUpdatedAtByDomain(noteDates)
292 scheduleSyncIfNeeded()
293 }
294
295 func recordTrackedDomainDeletion(_ trackedDomain: TrackedDomain, deletedAt: Date = Date()) {
296 let normalized = Self.normalizeDomain(trackedDomain.domain)
297 guard !normalized.isEmpty else { return }
298 saveTombstone(.init(entityType: .trackedDomain, identifier: normalized, deletedAt: deletedAt))
299
300 var noteDates = loadNoteUpdatedAtByDomain()
301 noteDates.removeValue(forKey: normalized)
302 saveNoteUpdatedAtByDomain(noteDates)
303
304 scheduleSyncIfNeeded()
305 }
306
307 func recordWorkflowDeletion(_ workflow: DomainWorkflow, deletedAt: Date = Date()) {
308 saveTombstone(.init(entityType: .workflow, identifier: workflow.id.uuidString, deletedAt: deletedAt))
309 scheduleSyncIfNeeded()
310 }
311
312 func recordTrackedDomainReset(_ trackedDomains: [TrackedDomain], deletedAt: Date = Date()) {
313 for trackedDomain in trackedDomains {
314 recordTrackedDomainDeletion(trackedDomain, deletedAt: deletedAt)
315 }
316 }
317
318 func recordWorkflowReset(_ workflows: [DomainWorkflow], deletedAt: Date = Date()) {
319 for workflow in workflows {
320 recordWorkflowDeletion(workflow, deletedAt: deletedAt)
321 }
322 }
323
324 func acceptShare(metadata: CKShare.Metadata) async throws {
325 guard let container = cloudKitContainer() else {
326 throw CloudSyncRuntimeError.missingEntitlement
327 }
328
329 if metadata.participantStatus == .pending {
330 _ = try await container.accept(metadata)
331 }
332
333 await syncNow(trigger: .manual)
334 }
335
336 func existingShare(for entity: ShareableEntity) async throws -> CKShare? {
337 let shareRecordName = try await shareRecordName(for: entity)
338 guard let shareRecordName else { return nil }
339 if let privateShare = try await fetchShare(recordName: shareRecordName, in: .privateDatabase) {
340 return privateShare
341 }
342 return try await fetchShare(recordName: shareRecordName, in: .sharedDatabase)
343 }
344
345 func createShare(for entity: ShareableEntity) async throws -> CKShare {
346 guard let database = cloudKitDatabase(in: .privateDatabase) else {
347 throw CloudSyncRuntimeError.missingEntitlement
348 }
349
350 switch entity {
351 case .trackedDomain(let domain):
352 let normalized = Self.normalizeDomain(domain)
353 let trackedDomain = DomainDataPortabilityService.loadTrackedDomains()
354 .first(where: { Self.normalizeDomain($0.domain) == normalized })
355 guard let trackedDomain else {
356 throw CloudSyncRuntimeError.missingLocalItem
357 }
358
359 let rootRecord = makeTrackedDomainRecord(trackedDomain)
360 let share = CKShare(rootRecord: rootRecord)
361 share[CKShare.SystemFieldKey.title] = normalized as CKRecordValue
362 share.publicPermission = .none
363 _ = try await database.modifyRecords(
364 saving: [rootRecord, share],
365 deleting: [],
366 savePolicy: .allKeys,
367 atomically: true
368 )
369 await syncNow(trigger: .manual)
370 return share
371
372 case .workflow(let identifier):
373 let workflow = DomainDataPortabilityService.loadWorkflows()
374 .first(where: { $0.id == identifier })
375 guard let workflow else {
376 throw CloudSyncRuntimeError.missingLocalItem
377 }
378
379 let rootRecord = makeWorkflowRecord(workflow)
380 let share = CKShare(rootRecord: rootRecord)
381 share[CKShare.SystemFieldKey.title] = workflow.name as CKRecordValue
382 share.publicPermission = .none
383 _ = try await database.modifyRecords(
384 saving: [rootRecord, share],
385 deleting: [],
386 savePolicy: .allKeys,
387 atomically: true
388 )
389 await syncNow(trigger: .manual)
390 return share
391 }
392 }
393
394 func syncNow(trigger: CloudSyncTrigger = .manual) async {
395 guard isEnabled else {
396 setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil)
397 return
398 }
399
400 if syncTask != nil {
401 return
402 }
403
404 syncTask = Task { [weak self] in
405 guard let self else { return }
406 await self.performSync(trigger: trigger)
407 await MainActor.run {
408 self.syncTask = nil
409 }
410 }
411
412 await syncTask?.value
413 }
414
415 private func performSync(trigger: CloudSyncTrigger) async {
416 guard cloudKitDatabase(in: .privateDatabase) != nil else {
417 setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil)
418 return
419 }
420
421 guard isNetworkAvailable else {
422 setStatus(.offline, detail: "Waiting for a network connection.", error: nil)
423 return
424 }
425
426 switch await accountStatus() {
427 case .available:
428 break
429 case .noAccount:
430 setStatus(.iCloudUnavailable, detail: "Sign in to iCloud to sync DomainDig.", error: nil)
431 return
432 case .restricted:
433 setStatus(.iCloudUnavailable, detail: "iCloud access is restricted on this device.", error: nil)
434 return
435 case .temporarilyUnavailable:
436 setStatus(.iCloudUnavailable, detail: "iCloud is temporarily unavailable.", error: nil)
437 return
438 case .unknown:
439 setStatus(.iCloudUnavailable, detail: "DomainDig could not confirm iCloud availability.", error: nil)
440 return
441 case .missingEntitlement:
442 setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil)
443 return
444 }
445
446 setStatus(.syncing, detail: trigger == .manual ? "Syncing now…" : "Syncing changes…", error: nil)
447
448 do {
449 let local = loadLocalPayload()
450 let remote = try await fetchRemotePayload()
451 let merged = merge(local: local, remote: remote)
452 applyMergedPayload(merged.payload)
453 try await pushMergedPayload(merged.payload)
454
455 lastSyncDate = Date()
456 defaults.set(lastSyncDate, forKey: StorageKey.lastSyncDate)
457
458 if merged.changedLocalData {
459 NotificationCenter.default.post(name: .cloudSyncDidApplyChanges, object: nil)
460 }
461
462 let detail = syncSummaryDetail()
463 if merged.hadConflict {
464 setStatus(.conflictResolved, detail: detail, error: nil)
465 } else {
466 setStatus(.synced, detail: detail, error: nil)
467 }
468 } catch {
469 let mapped = mapSyncError(error)
470 setStatus(mapped.status, detail: mapped.message, error: mapped.errorDetail)
471 }
472 }
473
474 private func loadLocalPayload() -> SyncPayload {
475 let trackedDomains = DomainDataPortabilityService.loadTrackedDomains()
476 let workflows = DomainDataPortabilityService.loadWorkflows()
477 let appSettings = SyncedAppSettings(
478 snapshot: DomainDataPortabilityService.loadAppSettings(),
479 updatedAt: defaults.object(forKey: StorageKey.appSettingsUpdatedAt) as? Date ?? .distantPast
480 )
481 let monitoringSettings = SyncedMonitoringSettings(
482 settings: DomainDataPortabilityService.loadMonitoringSettings(),
483 updatedAt: defaults.object(forKey: StorageKey.monitoringSettingsUpdatedAt) as? Date ?? .distantPast
484 )
485
486 let noteDates = loadNoteUpdatedAtByDomain()
487 let notesByDomain = Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedDomainNote)? in
488 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
489 let normalized = Self.normalizeDomain(trackedDomain.domain)
490 guard !normalized.isEmpty else { return nil }
491 let noteText = trackedDomain.note ?? ""
492 let updatedAt = noteDates[normalized] ?? trackedDomain.updatedAt
493 return (
494 normalized,
495 SyncedDomainNote(
496 domain: normalized,
497 text: noteText,
498 updatedAt: updatedAt
499 )
500 )
501 })
502
503 let historyMetadataByDomain = Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedHistoryMetadata)? in
504 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
505 let normalized = Self.normalizeDomain(trackedDomain.domain)
506 guard !normalized.isEmpty else { return nil }
507 return (
508 normalized,
509 SyncedHistoryMetadata(
510 domain: normalized,
511 lastKnownAvailabilityRawValue: trackedDomain.lastKnownAvailability?.rawValue,
512 lastChangeMessage: trackedDomain.lastChangeSummary?.message,
513 lastChangeSeverityRawValue: trackedDomain.lastChangeSeverity?.rawValue ?? trackedDomain.lastChangeSummary?.severity.rawValue,
514 certificateWarningLevelRawValue: trackedDomain.certificateWarningLevel.rawValue,
515 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
516 lastObservedAt: trackedDomain.updatedAt,
517 updatedAt: trackedDomain.updatedAt
518 )
519 )
520 })
521
522 return SyncPayload(
523 trackedDomains: trackedDomains,
524 workflows: workflows,
525 appSettings: appSettings,
526 monitoringSettings: monitoringSettings,
527 notesByDomain: notesByDomain,
528 historyMetadataByDomain: historyMetadataByDomain,
529 tombstones: loadTombstones()
530 )
531 }
532
533 private func fetchRemotePayload() async throws -> SyncPayload {
534 let privateTrackedDomains = try await fetchTrackedDomainRecords(in: .privateDatabase)
535 let sharedTrackedDomains = try await fetchTrackedDomainRecords(in: .sharedDatabase)
536 let privateWorkflows = try await fetchWorkflowRecords(in: .privateDatabase)
537 let sharedWorkflows = try await fetchWorkflowRecords(in: .sharedDatabase)
538
539 let appSettingsRecords: [SyncedAppSettings] = try await fetchRecords(
540 ofType: CloudRecordType.appSettings,
541 in: .privateDatabase
542 )
543 let monitoringSettingsRecords: [SyncedMonitoringSettings] = try await fetchRecords(
544 ofType: CloudRecordType.monitoringSettings,
545 in: .privateDatabase
546 )
547 let domainNotes: [SyncedDomainNote] = try await fetchRecords(
548 ofType: CloudRecordType.domainNote,
549 in: .privateDatabase
550 )
551 let historyMetadata: [SyncedHistoryMetadata] = try await fetchRecords(
552 ofType: CloudRecordType.historyMetadata,
553 in: .privateDatabase
554 )
555 let tombstones: [SyncTombstone] = try await fetchRecords(
556 ofType: CloudRecordType.tombstone,
557 in: .privateDatabase
558 )
559
560 let appSettings = appSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt })
561 ?? SyncedAppSettings(snapshot: DomainDataPortabilityService.loadAppSettings(), updatedAt: .distantPast)
562 let monitoringSettings = monitoringSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt })
563 ?? SyncedMonitoringSettings(settings: DomainDataPortabilityService.loadMonitoringSettings(), updatedAt: .distantPast)
564
565 return SyncPayload(
566 trackedDomains: privateTrackedDomains + sharedTrackedDomains,
567 workflows: privateWorkflows + sharedWorkflows,
568 appSettings: appSettings,
569 monitoringSettings: monitoringSettings,
570 notesByDomain: Dictionary(uniqueKeysWithValues: domainNotes.map { (Self.normalizeDomain($0.domain), $0) }),
571 historyMetadataByDomain: Dictionary(uniqueKeysWithValues: historyMetadata.map { (Self.normalizeDomain($0.domain), $0) }),
572 tombstones: tombstones
573 )
574 }
575
576 private func pushMergedPayload(_ payload: SyncPayload) async throws {
577 guard let privateDatabase = cloudKitDatabase(in: .privateDatabase) else {
578 throw CloudSyncRuntimeError.missingEntitlement
579 }
580
581 var privateRecordsToSave: [CKRecord] = []
582 var sharedRecordsToSave: [CKRecord] = []
583 var recordIDsToDelete: [CKRecord.ID] = []
584
585 for trackedDomain in payload.trackedDomains {
586 switch trackedDomain.collaboration?.scope ?? .privateDatabase {
587 case .privateDatabase:
588 privateRecordsToSave.append(makeTrackedDomainRecord(trackedDomain))
589 case .sharedDatabase:
590 if trackedDomain.collaboration?.canEdit == true {
591 sharedRecordsToSave.append(makeTrackedDomainRecord(trackedDomain))
592 }
593 }
594 }
595
596 for workflow in payload.workflows {
597 switch workflow.collaboration?.scope ?? .privateDatabase {
598 case .privateDatabase:
599 privateRecordsToSave.append(makeWorkflowRecord(workflow))
600 case .sharedDatabase:
601 if workflow.collaboration?.canEdit == true {
602 sharedRecordsToSave.append(makeWorkflowRecord(workflow))
603 }
604 }
605 }
606
607 privateRecordsToSave.append(makeAppSettingsRecord(payload.appSettings))
608 privateRecordsToSave.append(makeMonitoringSettingsRecord(payload.monitoringSettings))
609 privateRecordsToSave.append(contentsOf: payload.notesByDomain.values.map(makeDomainNoteRecord))
610 privateRecordsToSave.append(contentsOf: payload.historyMetadataByDomain.values.map(makeHistoryMetadataRecord))
611 privateRecordsToSave.append(contentsOf: payload.tombstones.map(makeTombstoneRecord))
612
613 for tombstone in payload.tombstones {
614 switch tombstone.entityType {
615 case .trackedDomain:
616 let identifier = tombstone.identifier
617 recordIDsToDelete.append(CKRecord.ID(recordName: trackedDomainRecordName(for: identifier)))
618 recordIDsToDelete.append(CKRecord.ID(recordName: domainNoteRecordName(for: identifier)))
619 recordIDsToDelete.append(CKRecord.ID(recordName: historyMetadataRecordName(for: identifier)))
620 case .workflow:
621 recordIDsToDelete.append(CKRecord.ID(recordName: workflowRecordName(for: tombstone.identifier)))
622 }
623 }
624
625 _ = try await privateDatabase.modifyRecords(
626 saving: privateRecordsToSave,
627 deleting: Array(Set(recordIDsToDelete)),
628 savePolicy: .changedKeys,
629 atomically: false
630 )
631
632 if !sharedRecordsToSave.isEmpty, let sharedDatabase = cloudKitDatabase(in: .sharedDatabase) {
633 _ = try await sharedDatabase.modifyRecords(
634 saving: sharedRecordsToSave,
635 deleting: [],
636 savePolicy: .changedKeys,
637 atomically: false
638 )
639 }
640 }
641
642 private func applyMergedPayload(_ payload: SyncPayload) {
643 DomainDataPortabilityService.saveTrackedDomains(payload.trackedDomains)
644 DomainDataPortabilityService.saveWorkflows(payload.workflows)
645 DomainDataPortabilityService.saveAppSettings(payload.appSettings.snapshot)
646 DomainDataPortabilityService.saveMonitoringSettings(payload.monitoringSettings.settings)
647
648 defaults.set(payload.appSettings.updatedAt, forKey: StorageKey.appSettingsUpdatedAt)
649 defaults.set(payload.monitoringSettings.updatedAt, forKey: StorageKey.monitoringSettingsUpdatedAt)
650 saveNoteUpdatedAtByDomain(Dictionary(uniqueKeysWithValues: payload.notesByDomain.map { ($0.key, $0.value.updatedAt) }))
651 saveTombstones(payload.tombstones)
652 }
653
654 private func merge(local: SyncPayload, remote: SyncPayload) -> MergedSyncPayload {
655 var hadConflict = false
656
657 let tombstones = latestTombstones(local.tombstones + remote.tombstones)
658
659 let trackedDomainsResult = mergeTrackedDomains(local.trackedDomains, remote.trackedDomains)
660 hadConflict = hadConflict || trackedDomainsResult.hadConflict
661 var trackedDomains = applyTrackedDomainTombstones(trackedDomainsResult.domains, tombstones: tombstones)
662
663 let workflowsResult = mergeWorkflows(local.workflows, remote.workflows)
664 hadConflict = hadConflict || workflowsResult.hadConflict
665 let workflows = applyWorkflowTombstones(workflowsResult.workflows, tombstones: tombstones)
666
667 let notesResult = mergeNotes(local.notesByDomain, remote.notesByDomain)
668 hadConflict = hadConflict || notesResult.hadConflict
669 trackedDomains = applyNotes(notesResult.notesByDomain, to: trackedDomains, tombstones: tombstones)
670
671 let historyResult = mergeHistoryMetadata(local.historyMetadataByDomain, remote.historyMetadataByDomain)
672 hadConflict = hadConflict || historyResult.hadConflict
673 trackedDomains = applyHistoryMetadata(historyResult.historyMetadataByDomain, to: trackedDomains, tombstones: tombstones)
674
675 let appSettingsResult = mergeAppSettings(local.appSettings, remote.appSettings)
676 hadConflict = hadConflict || appSettingsResult.hadConflict
677
678 let monitoringResult = mergeMonitoringSettings(local.monitoringSettings, remote.monitoringSettings)
679 hadConflict = hadConflict || monitoringResult.hadConflict
680
681 let mergedPayload = SyncPayload(
682 trackedDomains: trackedDomains,
683 workflows: workflows,
684 appSettings: appSettingsResult.settings,
685 monitoringSettings: monitoringResult.settings,
686 notesByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedDomainNote)? in
687 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
688 let normalized = Self.normalizeDomain(trackedDomain.domain)
689 guard let note = notesResult.notesByDomain[normalized] else { return nil }
690 return (normalized, note)
691 }),
692 historyMetadataByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedHistoryMetadata)? in
693 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
694 let normalized = Self.normalizeDomain(trackedDomain.domain)
695 guard let metadata = historyResult.historyMetadataByDomain[normalized] else { return nil }
696 return (normalized, metadata)
697 }),
698 tombstones: tombstones
699 )
700
701 let changedLocalData =
702 mergedPayload.trackedDomains != local.trackedDomains
703 || mergedPayload.workflows != local.workflows
704 || !sameAppSettings(mergedPayload.appSettings.snapshot, local.appSettings.snapshot)
705 || mergedPayload.monitoringSettings.settings != local.monitoringSettings.settings
706
707 return MergedSyncPayload(
708 payload: mergedPayload,
709 hadConflict: hadConflict,
710 changedLocalData: changedLocalData
711 )
712 }
713
714 private func mergeTrackedDomains(_ lhs: [TrackedDomain], _ rhs: [TrackedDomain]) -> (domains: [TrackedDomain], hadConflict: Bool) {
715 var mergedByDomain = Dictionary(uniqueKeysWithValues: lhs.map { (Self.normalizeDomain($0.domain), Self.payloadTrackedDomain($0)) })
716 var hadConflict = false
717
718 for trackedDomain in rhs.map(Self.payloadTrackedDomain) {
719 let key = Self.normalizeDomain(trackedDomain.domain)
720 guard !key.isEmpty else { continue }
721
722 if let existing = mergedByDomain[key] {
723 if existing != trackedDomain {
724 hadConflict = true
725 }
726 mergedByDomain[key] = mergedTrackedDomain(existing, trackedDomain)
727 } else {
728 mergedByDomain[key] = trackedDomain
729 }
730 }
731
732 let domains = mergedByDomain.values.sorted {
733 if $0.updatedAt != $1.updatedAt {
734 return $0.updatedAt > $1.updatedAt
735 }
736 return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
737 }
738
739 return (domains, hadConflict)
740 }
741
742 private func mergeWorkflows(_ lhs: [DomainWorkflow], _ rhs: [DomainWorkflow]) -> (workflows: [DomainWorkflow], hadConflict: Bool) {
743 var merged = Dictionary(uniqueKeysWithValues: lhs.map { ($0.id, normalizedWorkflow($0)) })
744 var hadConflict = false
745
746 for workflow in rhs.map(normalizedWorkflow) {
747 if let existing = merged[workflow.id] {
748 if existing != workflow {
749 hadConflict = true
750 }
751 let winner = preferredWorkflow(existing, workflow)
752 merged[workflow.id] = DomainWorkflow(
753 id: existing.id,
754 name: winner.name,
755 domains: deduplicated(existing.domains + workflow.domains),
756 createdAt: min(existing.createdAt, workflow.createdAt),
757 updatedAt: max(existing.updatedAt, workflow.updatedAt),
758 notes: winner.notes ?? existing.notes ?? workflow.notes,
759 collaboration: preferredCollaboration(existing.collaboration, workflow.collaboration)
760 )
761 } else {
762 merged[workflow.id] = workflow
763 }
764 }
765
766 return (
767 merged.values.sorted {
768 if $0.updatedAt != $1.updatedAt {
769 return $0.updatedAt > $1.updatedAt
770 }
771 return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
772 },
773 hadConflict
774 )
775 }
776
777 private func mergeNotes(
778 _ lhs: [String: SyncedDomainNote],
779 _ rhs: [String: SyncedDomainNote]
780 ) -> (notesByDomain: [String: SyncedDomainNote], hadConflict: Bool) {
781 var merged = lhs
782 var hadConflict = false
783
784 for (domain, remoteNote) in rhs {
785 if let localNote = merged[domain], localNote != remoteNote {
786 hadConflict = true
787 merged[domain] = localNote.updatedAt >= remoteNote.updatedAt ? localNote : remoteNote
788 } else {
789 merged[domain] = remoteNote
790 }
791 }
792
793 return (merged, hadConflict)
794 }
795
796 private func mergeHistoryMetadata(
797 _ lhs: [String: SyncedHistoryMetadata],
798 _ rhs: [String: SyncedHistoryMetadata]
799 ) -> (historyMetadataByDomain: [String: SyncedHistoryMetadata], hadConflict: Bool) {
800 var merged = lhs
801 var hadConflict = false
802
803 for (domain, remoteMetadata) in rhs {
804 if let localMetadata = merged[domain], localMetadata != remoteMetadata {
805 hadConflict = true
806 merged[domain] = localMetadata.updatedAt >= remoteMetadata.updatedAt ? localMetadata : remoteMetadata
807 } else {
808 merged[domain] = remoteMetadata
809 }
810 }
811
812 return (merged, hadConflict)
813 }
814
815 private func mergeAppSettings(_ lhs: SyncedAppSettings, _ rhs: SyncedAppSettings) -> (settings: SyncedAppSettings, hadConflict: Bool) {
816 let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
817 let loser = winner.updatedAt == lhs.updatedAt ? rhs : lhs
818
819 let merged = SyncedAppSettings(
820 snapshot: AppSettingsSnapshot(
821 recentSearches: deduplicated(winner.snapshot.recentSearches + loser.snapshot.recentSearches).prefix(20).map { $0 },
822 savedDomains: deduplicated(winner.snapshot.savedDomains + loser.snapshot.savedDomains),
823 resolverURLString: winner.snapshot.resolverURLString,
824 appDensityRawValue: winner.snapshot.appDensityRawValue
825 ),
826 updatedAt: max(lhs.updatedAt, rhs.updatedAt)
827 )
828
829 return (merged, !sameAppSettings(lhs.snapshot, rhs.snapshot) || lhs.updatedAt != rhs.updatedAt)
830 }
831
832 private func mergeMonitoringSettings(
833 _ lhs: SyncedMonitoringSettings,
834 _ rhs: SyncedMonitoringSettings
835 ) -> (settings: SyncedMonitoringSettings, hadConflict: Bool) {
836 let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
837 var merged = winner
838
839 if merged.settings.isEnabled && !defaults.bool(forKey: StorageKey.monitoringLocalActivationConfirmed) {
840 merged.settings.isEnabled = false
841 }
842
843 if merged.settings.alertsEnabled {
844 merged.settings.alertsEnabled = false
845 }
846
847 merged.updatedAt = max(lhs.updatedAt, rhs.updatedAt)
848 return (merged, lhs.settings != rhs.settings || lhs.updatedAt != rhs.updatedAt)
849 }
850
851 private func applyTrackedDomainTombstones(_ trackedDomains: [TrackedDomain], tombstones: [SyncTombstone]) -> [TrackedDomain] {
852 let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
853 guard tombstone.entityType == .trackedDomain else { return nil }
854 return (tombstone.identifier, tombstone)
855 })
856
857 return trackedDomains.filter { trackedDomain in
858 let key = Self.normalizeDomain(trackedDomain.domain)
859 guard let tombstone = tombstonesByDomain[key] else { return true }
860 return trackedDomain.updatedAt > tombstone.deletedAt
861 }
862 }
863
864 private func applyWorkflowTombstones(_ workflows: [DomainWorkflow], tombstones: [SyncTombstone]) -> [DomainWorkflow] {
865 let tombstonesByIdentifier = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
866 guard tombstone.entityType == .workflow else { return nil }
867 return (tombstone.identifier, tombstone)
868 })
869
870 return workflows.filter { workflow in
871 guard let tombstone = tombstonesByIdentifier[workflow.id.uuidString] else { return true }
872 return workflow.updatedAt > tombstone.deletedAt
873 }
874 }
875
876 private func applyNotes(
877 _ notesByDomain: [String: SyncedDomainNote],
878 to trackedDomains: [TrackedDomain],
879 tombstones: [SyncTombstone]
880 ) -> [TrackedDomain] {
881 let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
882 guard tombstone.entityType == .trackedDomain else { return nil }
883 return (tombstone.identifier, tombstone)
884 })
885
886 return trackedDomains.map { trackedDomain in
887 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain }
888 let normalized = Self.normalizeDomain(trackedDomain.domain)
889 guard let note = notesByDomain[normalized] else { return trackedDomain }
890 if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= note.updatedAt {
891 return trackedDomain
892 }
893
894 var updated = trackedDomain
895 updated.note = emptyToNil(note.text)
896 return updated
897 }
898 }
899
900 private func applyHistoryMetadata(
901 _ historyMetadataByDomain: [String: SyncedHistoryMetadata],
902 to trackedDomains: [TrackedDomain],
903 tombstones: [SyncTombstone]
904 ) -> [TrackedDomain] {
905 let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
906 guard tombstone.entityType == .trackedDomain else { return nil }
907 return (tombstone.identifier, tombstone)
908 })
909
910 return trackedDomains.map { trackedDomain in
911 guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain }
912 let normalized = Self.normalizeDomain(trackedDomain.domain)
913 guard let metadata = historyMetadataByDomain[normalized] else { return trackedDomain }
914 if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= metadata.updatedAt {
915 return trackedDomain
916 }
917
918 var updated = trackedDomain
919 updated.lastKnownAvailability = metadata.lastKnownAvailabilityRawValue.flatMap(DomainAvailabilityStatus.init(rawValue:))
920 updated.lastChangeSeverity = metadata.lastChangeSeverityRawValue.flatMap(ChangeSeverity.init(rawValue:))
921 updated.certificateWarningLevel = CertificateWarningLevel(rawValue: metadata.certificateWarningLevelRawValue) ?? .none
922 updated.certificateDaysRemaining = metadata.certificateDaysRemaining
923
924 if let message = metadata.lastChangeMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
925 updated.lastChangeSummary = DomainChangeSummary(
926 hasChanges: true,
927 changedSections: [],
928 message: message,
929 severity: updated.lastChangeSeverity ?? .medium,
930 impactClassification: .warning,
931 generatedAt: metadata.lastObservedAt ?? metadata.updatedAt
932 )
933 }
934
935 return updated
936 }
937 }
938
939 private func latestTombstones(_ tombstones: [SyncTombstone]) -> [SyncTombstone] {
940 var latest: [String: SyncTombstone] = [:]
941
942 for tombstone in tombstones {
943 let key = "\(tombstone.entityType.rawValue):\(tombstone.identifier)"
944 if let existing = latest[key] {
945 latest[key] = existing.deletedAt >= tombstone.deletedAt ? existing : tombstone
946 } else {
947 latest[key] = tombstone
948 }
949 }
950
951 return latest.values.sorted { $0.deletedAt > $1.deletedAt }
952 }
953
954 private func saveTombstone(_ tombstone: SyncTombstone) {
955 var tombstones = loadTombstones()
956 tombstones.append(tombstone)
957 saveTombstones(latestTombstones(tombstones))
958 }
959
960 private func loadTombstones() -> [SyncTombstone] {
961 guard let data = defaults.data(forKey: StorageKey.tombstones),
962 let tombstones = try? decoder.decode([SyncTombstone].self, from: data) else {
963 return []
964 }
965 return tombstones
966 }
967
968 private func saveTombstones(_ tombstones: [SyncTombstone]) {
969 if let data = try? encoder.encode(Array(tombstones.prefix(200))) {
970 defaults.set(data, forKey: StorageKey.tombstones)
971 }
972 }
973
974 private func loadNoteUpdatedAtByDomain() -> [String: Date] {
975 guard let data = defaults.data(forKey: StorageKey.noteUpdatedAtByDomain),
976 let values = try? decoder.decode([String: Date].self, from: data) else {
977 return [:]
978 }
979 return values
980 }
981
982 private func saveNoteUpdatedAtByDomain(_ values: [String: Date]) {
983 if let data = try? encoder.encode(values) {
984 defaults.set(data, forKey: StorageKey.noteUpdatedAtByDomain)
985 }
986 }
987
988 private func setStatus(_ status: CloudSyncStatus, detail: String, error: String?) {
989 self.status = status
990 self.detailMessage = detail
991 self.lastErrorMessage = error
992
993 defaults.set(status.rawValue, forKey: StorageKey.status)
994 defaults.set(detail, forKey: StorageKey.detailMessage)
995 if let error {
996 defaults.set(error, forKey: StorageKey.lastErrorMessage)
997 } else {
998 defaults.removeObject(forKey: StorageKey.lastErrorMessage)
999 }
1000 }
1001
1002 private func syncSummaryDetail() -> String {
1003 let dateLabel = lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
1004 return "Data stays in your private iCloud account. Last sync: \(dateLabel)."
1005 }
1006
1007 private func accountStatus() async -> AvailabilityState {
1008 guard let container = cloudKitContainer() else {
1009 return .missingEntitlement
1010 }
1011
1012 return await withCheckedContinuation { continuation in
1013 container.accountStatus { status, error in
1014 if let error {
1015 let ckError = error as? CKError
1016 if ckError?.code == .notAuthenticated {
1017 continuation.resume(returning: AvailabilityState.noAccount)
1018 return
1019 }
1020 continuation.resume(returning: AvailabilityState.unknown)
1021 return
1022 }
1023
1024 switch status {
1025 case .available:
1026 continuation.resume(returning: AvailabilityState.available)
1027 case .noAccount:
1028 continuation.resume(returning: AvailabilityState.noAccount)
1029 case .restricted:
1030 continuation.resume(returning: AvailabilityState.restricted)
1031 case .temporarilyUnavailable:
1032 continuation.resume(returning: AvailabilityState.temporarilyUnavailable)
1033 case .couldNotDetermine:
1034 continuation.resume(returning: AvailabilityState.unknown)
1035 @unknown default:
1036 continuation.resume(returning: AvailabilityState.unknown)
1037 }
1038 }
1039 }
1040 }
1041
1042 private func fetchRecords<T: Decodable>(ofType recordType: String, in scope: SyncDatabaseScope) async throws -> [T] {
1043 guard let database = cloudKitDatabase(in: scope) else {
1044 if scope == .sharedDatabase {
1045 return []
1046 }
1047 throw CloudSyncRuntimeError.missingEntitlement
1048 }
1049
1050 let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
1051 var results: [T] = []
1052 var cursor: CKQueryOperation.Cursor?
1053
1054 repeat {
1055 let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?)
1056 if let cursor {
1057 batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200)
1058 } else {
1059 batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200)
1060 }
1061
1062 for (_, result) in batch.matchResults {
1063 switch result {
1064 case .success(let record):
1065 guard let payload = record[CloudRecordKey.payload] as? Data else { continue }
1066 if let decoded = try? decoder.decode(T.self, from: payload) {
1067 results.append(decoded)
1068 } else {
1069 lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
1070 }
1071 case .failure:
1072 continue
1073 }
1074 }
1075
1076 cursor = batch.queryCursor
1077 } while cursor != nil
1078
1079 return results
1080 }
1081
1082 private func fetchSharedRecords<T: Decodable>(
1083 ofType recordType: String,
1084 in scope: SyncDatabaseScope
1085 ) async throws -> [SharedRecord<T>] {
1086 guard let database = cloudKitDatabase(in: scope) else {
1087 if scope == .sharedDatabase {
1088 return []
1089 }
1090 throw CloudSyncRuntimeError.missingEntitlement
1091 }
1092
1093 let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
1094 var results: [SharedRecord<T>] = []
1095 var cursor: CKQueryOperation.Cursor?
1096
1097 repeat {
1098 let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?)
1099 if let cursor {
1100 batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200)
1101 } else {
1102 batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200)
1103 }
1104
1105 for (_, result) in batch.matchResults {
1106 switch result {
1107 case .success(let record):
1108 guard let payload = record[CloudRecordKey.payload] as? Data else { continue }
1109 if let decoded = try? decoder.decode(T.self, from: payload) {
1110 results.append(SharedRecord(record: record, value: decoded))
1111 } else {
1112 lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
1113 }
1114 case .failure:
1115 continue
1116 }
1117 }
1118
1119 cursor = batch.queryCursor
1120 } while cursor != nil
1121
1122 return results
1123 }
1124
1125 private func fetchTrackedDomainRecords(in scope: SyncDatabaseScope) async throws -> [TrackedDomain] {
1126 let records: [SharedRecord<TrackedDomain>] = try await fetchSharedRecords(ofType: CloudRecordType.trackedDomain, in: scope)
1127 let shares = try await sharesByRecordID(for: records.map(\.record), in: scope)
1128
1129 return records.map { entry in
1130 var trackedDomain = Self.payloadTrackedDomain(entry.value)
1131 trackedDomain.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope)
1132 return trackedDomain
1133 }
1134 }
1135
1136 private func fetchWorkflowRecords(in scope: SyncDatabaseScope) async throws -> [DomainWorkflow] {
1137 let records: [SharedRecord<DomainWorkflow>] = try await fetchSharedRecords(ofType: CloudRecordType.workflow, in: scope)
1138 let shares = try await sharesByRecordID(for: records.map(\.record), in: scope)
1139
1140 return records.map { entry in
1141 var workflow = normalizedWorkflow(entry.value)
1142 workflow.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope)
1143 return workflow
1144 }
1145 }
1146
1147 private func sharesByRecordID(
1148 for records: [CKRecord],
1149 in scope: SyncDatabaseScope
1150 ) async throws -> [CKRecord.ID: CKShare] {
1151 guard let database = cloudKitDatabase(in: scope) else { return [:] }
1152
1153 let shareReferences = records.reduce(into: [CKRecord.ID: CKRecord.Reference]()) { partialResult, record in
1154 if let reference = record.share {
1155 partialResult[record.recordID] = reference
1156 }
1157 }
1158 guard !shareReferences.isEmpty else { return [:] }
1159
1160 let fetched = try await database.records(for: Array(Set(shareReferences.values.map(\.recordID))))
1161 var sharesByRootRecordID: [CKRecord.ID: CKShare] = [:]
1162
1163 for (rootRecordID, shareReference) in shareReferences {
1164 guard case .success(let shareRecord) = fetched[shareReference.recordID],
1165 let share = shareRecord as? CKShare else {
1166 continue
1167 }
1168 sharesByRootRecordID[rootRecordID] = share
1169 }
1170
1171 return sharesByRootRecordID
1172 }
1173
1174 private func collaborationMetadata(
1175 for record: CKRecord,
1176 share: CKShare?,
1177 scope: SyncDatabaseScope
1178 ) -> CollaborationMetadata {
1179 let ownership: CollaborationOwnership
1180 let permission: CollaborationPermission
1181
1182 if let participant = share?.currentUserParticipant {
1183 ownership = participant.role == .owner ? .owner : .participant
1184 permission = participant.permission == .readOnly ? .readOnly : .editable
1185 } else {
1186 ownership = scope == .sharedDatabase ? .participant : .owner
1187 permission = scope == .sharedDatabase ? .readOnly : .editable
1188 }
1189
1190 return CollaborationMetadata(
1191 scope: scope == .privateDatabase ? .privateDatabase : .sharedDatabase,
1192 ownership: ownership,
1193 permission: permission,
1194 shareRecordName: share?.recordID.recordName
1195 )
1196 }
1197
1198 private func fetchShare(recordName: String, in scope: SyncDatabaseScope) async throws -> CKShare? {
1199 guard let database = cloudKitDatabase(in: scope) else { return nil }
1200 let results = try await database.records(for: [CKRecord.ID(recordName: recordName)])
1201 guard case .success(let shareRecord) = results[CKRecord.ID(recordName: recordName)] else {
1202 return nil
1203 }
1204 return shareRecord as? CKShare
1205 }
1206
1207 private func shareRecordName(for entity: ShareableEntity) async throws -> String? {
1208 switch entity {
1209 case .trackedDomain(let domain):
1210 let normalized = Self.normalizeDomain(domain)
1211 return DomainDataPortabilityService.loadTrackedDomains()
1212 .first(where: { Self.normalizeDomain($0.domain) == normalized })?
1213 .collaboration?.shareRecordName
1214 case .workflow(let identifier):
1215 return DomainDataPortabilityService.loadWorkflows()
1216 .first(where: { $0.id == identifier })?
1217 .collaboration?.shareRecordName
1218 }
1219 }
1220
1221 private func makeTrackedDomainRecord(_ trackedDomain: TrackedDomain) -> CKRecord {
1222 let normalized = Self.normalizeDomain(trackedDomain.domain)
1223 let sanitized = Self.payloadTrackedDomain(trackedDomain)
1224 return makeRecord(
1225 type: CloudRecordType.trackedDomain,
1226 recordName: trackedDomainRecordName(for: normalized),
1227 payload: sanitized,
1228 extraFields: [
1229 CloudRecordKey.domain: normalized as CKRecordValue,
1230 CloudRecordKey.updatedAt: sanitized.updatedAt as CKRecordValue
1231 ]
1232 )
1233 }
1234
1235 private func makeWorkflowRecord(_ workflow: DomainWorkflow) -> CKRecord {
1236 let normalized = normalizedWorkflow(workflow)
1237 return makeRecord(
1238 type: CloudRecordType.workflow,
1239 recordName: workflowRecordName(for: normalized.id.uuidString),
1240 payload: normalized,
1241 extraFields: [
1242 CloudRecordKey.identifier: normalized.id.uuidString as CKRecordValue,
1243 CloudRecordKey.updatedAt: normalized.updatedAt as CKRecordValue
1244 ]
1245 )
1246 }
1247
1248 private func makeAppSettingsRecord(_ settings: SyncedAppSettings) -> CKRecord {
1249 makeRecord(
1250 type: CloudRecordType.appSettings,
1251 recordName: "app-settings",
1252 payload: settings,
1253 extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue]
1254 )
1255 }
1256
1257 private func makeMonitoringSettingsRecord(_ settings: SyncedMonitoringSettings) -> CKRecord {
1258 makeRecord(
1259 type: CloudRecordType.monitoringSettings,
1260 recordName: "monitoring-settings",
1261 payload: settings,
1262 extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue]
1263 )
1264 }
1265
1266 private func makeDomainNoteRecord(_ note: SyncedDomainNote) -> CKRecord {
1267 makeRecord(
1268 type: CloudRecordType.domainNote,
1269 recordName: domainNoteRecordName(for: note.domain),
1270 payload: note,
1271 extraFields: [
1272 CloudRecordKey.domain: note.domain as CKRecordValue,
1273 CloudRecordKey.updatedAt: note.updatedAt as CKRecordValue
1274 ]
1275 )
1276 }
1277
1278 private func makeHistoryMetadataRecord(_ metadata: SyncedHistoryMetadata) -> CKRecord {
1279 makeRecord(
1280 type: CloudRecordType.historyMetadata,
1281 recordName: historyMetadataRecordName(for: metadata.domain),
1282 payload: metadata,
1283 extraFields: [
1284 CloudRecordKey.domain: metadata.domain as CKRecordValue,
1285 CloudRecordKey.updatedAt: metadata.updatedAt as CKRecordValue
1286 ]
1287 )
1288 }
1289
1290 private func makeTombstoneRecord(_ tombstone: SyncTombstone) -> CKRecord {
1291 makeRecord(
1292 type: CloudRecordType.tombstone,
1293 recordName: tombstoneRecordName(for: tombstone.entityType, identifier: tombstone.identifier),
1294 payload: tombstone,
1295 extraFields: [
1296 CloudRecordKey.entityType: tombstone.entityType.rawValue as CKRecordValue,
1297 CloudRecordKey.identifier: tombstone.identifier as CKRecordValue,
1298 CloudRecordKey.deletedAt: tombstone.deletedAt as CKRecordValue
1299 ]
1300 )
1301 }
1302
1303 private func makeRecord<T: Encodable>(
1304 type: String,
1305 recordName: String,
1306 payload: T,
1307 extraFields: [String: CKRecordValue]
1308 ) -> CKRecord {
1309 let record = CKRecord(recordType: type, recordID: CKRecord.ID(recordName: recordName))
1310 record[CloudRecordKey.payload] = (try? encoder.encode(payload)) as CKRecordValue?
1311 for (key, value) in extraFields {
1312 record[key] = value
1313 }
1314 return record
1315 }
1316
1317 private func mapSyncError(_ error: Error) -> (status: CloudSyncStatus, message: String, errorDetail: String?) {
1318 if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingEntitlement {
1319 return (
1320 .iCloudUnavailable,
1321 missingEntitlementMessage,
1322 nil
1323 )
1324 }
1325
1326 if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingLocalItem {
1327 return (.error, "The shared item is no longer available locally.", nil)
1328 }
1329
1330 guard let ckError = error as? CKError else {
1331 return (.error, "Sync failed, but local data is still available.", error.localizedDescription)
1332 }
1333
1334 switch ckError.code {
1335 case .networkUnavailable, .networkFailure, .serviceUnavailable:
1336 return (.offline, "Sync is waiting for a stable network connection.", ckError.localizedDescription)
1337 case .notAuthenticated:
1338 return (.iCloudUnavailable, "Sign in to iCloud to sync DomainDig.", ckError.localizedDescription)
1339 case .quotaExceeded:
1340 return (.error, "iCloud storage is full. DomainDig kept working locally.", ckError.localizedDescription)
1341 case .permissionFailure, .badContainer, .missingEntitlement:
1342 return (.iCloudUnavailable, "CloudKit access is unavailable for this build.", ckError.localizedDescription)
1343 case .partialFailure:
1344 return (.error, "Some iCloud records could not sync. Local data remains available.", ckError.localizedDescription)
1345 default:
1346 return (.error, "Sync failed, but local data is still available.", ckError.localizedDescription)
1347 }
1348 }
1349
1350 private enum AvailabilityState {
1351 case available
1352 case noAccount
1353 case restricted
1354 case temporarilyUnavailable
1355 case unknown
1356 case missingEntitlement
1357 }
1358
1359 private enum CloudSyncRuntimeError: Error, Equatable {
1360 case missingEntitlement
1361 case missingLocalItem
1362 }
1363
1364 private static func normalizeDomain(_ value: String) -> String {
1365 value
1366 .trimmingCharacters(in: .whitespacesAndNewlines)
1367 .replacingOccurrences(of: "https://", with: "")
1368 .replacingOccurrences(of: "http://", with: "")
1369 .components(separatedBy: "/")
1370 .first?
1371 .lowercased() ?? ""
1372 }
1373
1374 private func cloudKitContainer() -> CKContainer? {
1375 if let container {
1376 return container
1377 }
1378
1379 if isEntitlementConfigurationAvailable() {
1380 let container = CKContainer.default()
1381 self.container = container
1382 return container
1383 }
1384
1385 return nil
1386 }
1387
1388 private func cloudKitDatabase(in scope: SyncDatabaseScope) -> CKDatabase? {
1389 switch scope {
1390 case .privateDatabase:
1391 if let privateDatabase {
1392 return privateDatabase
1393 }
1394 case .sharedDatabase:
1395 if let sharedDatabase {
1396 return sharedDatabase
1397 }
1398 }
1399
1400 guard let container = cloudKitContainer() else {
1401 return nil
1402 }
1403
1404 switch scope {
1405 case .privateDatabase:
1406 let database = container.privateCloudDatabase
1407 self.privateDatabase = database
1408 return database
1409 case .sharedDatabase:
1410 let database = container.sharedCloudDatabase
1411 self.sharedDatabase = database
1412 return database
1413 }
1414 }
1415
1416 private func isEntitlementConfigurationAvailable() -> Bool {
1417 if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.icloud-container-identifiers") != nil {
1418 return true
1419 }
1420
1421 if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.ubiquity-container-identifiers") != nil {
1422 return true
1423 }
1424
1425 if let entitlementsURL = Bundle.main.url(forResource: "archived-expanded-entitlements", withExtension: "xcent"),
1426 let data = try? Data(contentsOf: entitlementsURL),
1427 let contents = String(data: data, encoding: .utf8) {
1428 return contents.contains("com.apple.developer.icloud-services")
1429 && (contents.contains("CloudKit") || contents.contains("CloudKit-Anonymous"))
1430 }
1431
1432 return false
1433 }
1434
1435 private var missingEntitlementMessage: String {
1436 "This build does not have the CloudKit entitlement required for iCloud sync."
1437 }
1438
1439 private static func payloadTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain {
1440 TrackedDomain(
1441 id: trackedDomain.id,
1442 domain: normalizeDomain(trackedDomain.domain),
1443 createdAt: trackedDomain.createdAt,
1444 updatedAt: trackedDomain.updatedAt,
1445 note: trackedDomain.note,
1446 isPinned: trackedDomain.isPinned,
1447 monitoringEnabled: trackedDomain.monitoringEnabled,
1448 lastKnownAvailability: trackedDomain.lastKnownAvailability,
1449 lastSnapshotID: nil,
1450 lastChangeSummary: nil,
1451 lastChangeSeverity: trackedDomain.lastChangeSeverity,
1452 certificateWarningLevel: trackedDomain.certificateWarningLevel,
1453 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
1454 lastMonitoredAt: trackedDomain.lastMonitoredAt,
1455 lastAlertAt: trackedDomain.lastAlertAt,
1456 collaboration: trackedDomain.collaboration
1457 )
1458 }
1459
1460 private func mergedTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain {
1461 let winner = preferredTrackedDomain(lhs, rhs)
1462
1463 return TrackedDomain(
1464 id: lhs.id,
1465 domain: Self.normalizeDomain(lhs.domain.isEmpty ? rhs.domain : lhs.domain),
1466 createdAt: min(lhs.createdAt, rhs.createdAt),
1467 updatedAt: max(lhs.updatedAt, rhs.updatedAt),
1468 note: winner.note ?? lhs.note ?? rhs.note,
1469 isPinned: winner.isPinned,
1470 monitoringEnabled: winner.monitoringEnabled,
1471 lastKnownAvailability: winner.lastKnownAvailability ?? lhs.lastKnownAvailability ?? rhs.lastKnownAvailability,
1472 lastSnapshotID: nil,
1473 lastChangeSummary: nil,
1474 lastChangeSeverity: winner.lastChangeSeverity ?? lhs.lastChangeSeverity ?? rhs.lastChangeSeverity,
1475 certificateWarningLevel: higherCertificateWarningLevel(lhs.certificateWarningLevel, rhs.certificateWarningLevel),
1476 certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining,
1477 lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(),
1478 lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(),
1479 collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration)
1480 )
1481 }
1482
1483 private func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
1484 DomainWorkflow(
1485 id: workflow.id,
1486 name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines),
1487 domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }),
1488 createdAt: workflow.createdAt,
1489 updatedAt: workflow.updatedAt,
1490 notes: emptyToNil(workflow.notes ?? ""),
1491 collaboration: workflow.collaboration
1492 )
1493 }
1494
1495 private func preferredTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain {
1496 let lhsRank = collaborationRank(lhs.collaboration)
1497 let rhsRank = collaborationRank(rhs.collaboration)
1498 if lhsRank != rhsRank {
1499 return lhsRank > rhsRank ? lhs : rhs
1500 }
1501 return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
1502 }
1503
1504 private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow {
1505 let lhsRank = collaborationRank(lhs.collaboration)
1506 let rhsRank = collaborationRank(rhs.collaboration)
1507 if lhsRank != rhsRank {
1508 return lhsRank > rhsRank ? lhs : rhs
1509 }
1510 return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
1511 }
1512
1513 private func preferredCollaboration(
1514 _ lhs: CollaborationMetadata?,
1515 _ rhs: CollaborationMetadata?
1516 ) -> CollaborationMetadata? {
1517 guard let lhs else { return rhs }
1518 guard let rhs else { return lhs }
1519 let lhsRank = collaborationRank(lhs)
1520 let rhsRank = collaborationRank(rhs)
1521 if lhsRank != rhsRank {
1522 return lhsRank > rhsRank ? lhs : rhs
1523 }
1524 if lhs.shareRecordName != nil {
1525 return lhs
1526 }
1527 return rhs
1528 }
1529
1530 private func collaborationRank(_ collaboration: CollaborationMetadata?) -> Int {
1531 guard let collaboration else { return 0 }
1532 switch collaboration.ownership {
1533 case .owner:
1534 return collaboration.isShared ? 3 : 2
1535 case .participant:
1536 return collaboration.permission == .editable ? 1 : 0
1537 }
1538 }
1539
1540 private func emptyToNil(_ value: String) -> String? {
1541 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1542 return trimmed.isEmpty ? nil : trimmed
1543 }
1544
1545 private func sameAppSettings(_ lhs: AppSettingsSnapshot, _ rhs: AppSettingsSnapshot) -> Bool {
1546 lhs.recentSearches == rhs.recentSearches
1547 && lhs.savedDomains == rhs.savedDomains
1548 && lhs.resolverURLString == rhs.resolverURLString
1549 && lhs.appDensityRawValue == rhs.appDensityRawValue
1550 }
1551
1552 private func deduplicated(_ values: [String]) -> [String] {
1553 var seen = Set<String>()
1554 return values.filter { seen.insert($0).inserted }
1555 }
1556
1557 private func higherCertificateWarningLevel(
1558 _ lhs: CertificateWarningLevel,
1559 _ rhs: CertificateWarningLevel
1560 ) -> CertificateWarningLevel {
1561 let rank: [CertificateWarningLevel: Int] = [
1562 .none: 0,
1563 .warning: 1,
1564 .critical: 2
1565 ]
1566 return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs
1567 }
1568
1569 private func trackedDomainRecordName(for normalizedDomain: String) -> String {
1570 "tracked-domain:\(normalizedDomain)"
1571 }
1572
1573 private func workflowRecordName(for identifier: String) -> String {
1574 "workflow:\(identifier)"
1575 }
1576
1577 private func domainNoteRecordName(for normalizedDomain: String) -> String {
1578 "domain-note:\(normalizedDomain)"
1579 }
1580
1581 private func historyMetadataRecordName(for normalizedDomain: String) -> String {
1582 "history-metadata:\(normalizedDomain)"
1583 }
1584
1585 private func tombstoneRecordName(for entityType: SyncEntityType, identifier: String) -> String {
1586 "tombstone:\(entityType.rawValue):\(identifier)"
1587 }
1588}
DomainDig/ContentView.swift +58 −1
@@ -2421,6 +2421,7 @@ struct SettingsView: View {
24212421 @Environment(\.appDensity) private var appDensity
24222422 @Bindable var viewModel: DomainViewModel
24232423 @State private var purchaseService = PurchaseService.shared
2424 @State private var cloudSyncService = CloudSyncService.shared
24242425 @AppStorage(DNSResolverOption.userDefaultsKey)
24252426 private var storedResolverURL = DNSResolverOption.defaultURLString
24262427 @AppStorage(AppDensity.userDefaultsKey)
@@ -2480,6 +2481,43 @@ struct SettingsView: View {
24802481 }
24812482 }
24822483
2484 Section("iCloud Sync") {
2485 Toggle(
2486 "Enable iCloud Sync",
2487 isOn: Binding(
2488 get: { cloudSyncService.isEnabled },
2489 set: { cloudSyncService.setSyncEnabled($0) }
2490 )
2491 )
2492
2493 LabeledContent("Status", value: cloudSyncService.status.title)
2494 LabeledContent(
2495 "Last Sync",
2496 value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
2497 )
2498
2499 Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") {
2500 Task {
2501 await cloudSyncService.syncNow(trigger: .manual)
2502 }
2503 }
2504 .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing)
2505
2506 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.")
2507 .font(appDensity.font(.caption, design: .default))
2508 .foregroundStyle(.secondary)
2509
2510 Text(cloudSyncService.detailMessage)
2511 .font(appDensity.font(.caption, design: .default))
2512 .foregroundStyle(.secondary)
2513
2514 if let lastErrorMessage = cloudSyncService.lastErrorMessage {
2515 Text(lastErrorMessage)
2516 .font(appDensity.font(.caption, design: .default))
2517 .foregroundStyle(.red)
2518 }
2519 }
2520
24832521 Section("Monitoring") {
24842522 Toggle(
24852523 "Enable Background Monitoring",
@@ -2713,7 +2751,7 @@ struct SettingsView: View {
27132751
27142752 Section("About") {
27152753 LabeledContent("Version", value: appVersion)
2716 LabeledContent("Storage", value: "Local-only")
2754 LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
27172755 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
27182756 }
27192757 }
@@ -2817,18 +2855,37 @@ struct SettingsView: View {
28172855 Task {
28182856 await viewModel.refreshUsageCredits()
28192857 await viewModel.refreshMonitoringAuthorizationStatus()
2858 await cloudSyncService.refreshAvailability()
28202859 }
28212860 }
28222861 .onChange(of: resolverOption) { _, newValue in
28232862 guard let presetURL = newValue.urlString else {
28242863 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2864 viewModel.persistCurrentAppSettings(
2865 resolverURLString: storedResolverURL,
2866 appDensityRawValue: storedDensity
2867 )
28252868 return
28262869 }
28272870 storedResolverURL = presetURL
2871 viewModel.persistCurrentAppSettings(
2872 resolverURLString: storedResolverURL,
2873 appDensityRawValue: storedDensity
2874 )
28282875 }
28292876 .onChange(of: customResolverURL) { _, newValue in
28302877 guard resolverOption == .custom else { return }
28312878 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
2879 viewModel.persistCurrentAppSettings(
2880 resolverURLString: storedResolverURL,
2881 appDensityRawValue: storedDensity
2882 )
2883 }
2884 .onChange(of: storedDensity) { _, newValue in
2885 viewModel.persistCurrentAppSettings(
2886 resolverURLString: storedResolverURL,
2887 appDensityRawValue: newValue
2888 )
28322889 }
28332890 }
28342891
DomainDig/DomainAvailabilityService.swift −20
@@ -10,17 +10,14 @@ struct DomainAvailabilityService {
1010 }
1111
1212 if await checkViaRDAP(domain: normalizedDomain) == .registered {
13 debugLog("rdap", domain: normalizedDomain, status: .registered)
1413 return DomainAvailabilityResult(domain: normalizedDomain, status: .registered)
1514 }
1615
1716 let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain)
1817 if fallbackStatus == .registered {
19 debugLog("dns-evidence", domain: normalizedDomain, details: "DNS exists but RDAP did not confirm registration")
2018 return DomainAvailabilityResult(domain: normalizedDomain, status: .unknown)
2119 }
2220
23 debugLog("fallback", domain: normalizedDomain, status: fallbackStatus)
2421 return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus)
2522 }
2623
@@ -40,9 +37,6 @@ struct DomainAvailabilityService {
4037
4138 private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? {
4239 let status = await RDAPService.registrationStatus(for: domain)
43 if status == nil {
44 debugLog("rdap-not-found", domain: domain, details: "Ignoring unavailable response from rdap.org")
45 }
4640 return status
4741 }
4842
@@ -53,7 +47,6 @@ struct DomainAvailabilityService {
5347 return .registered
5448 }
5549 } catch {
56 debugLog("dns-a-error", domain: domain, details: error.localizedDescription)
5750 }
5851
5952 do {
@@ -63,7 +56,6 @@ struct DomainAvailabilityService {
6356 }
6457 return .unknown
6558 } catch {
66 debugLog("dns-ns-error", domain: domain, details: error.localizedDescription)
6759 return .unknown
6860 }
6961 }
@@ -99,16 +91,4 @@ struct DomainAvailabilityService {
9991 .trimmingCharacters(in: .whitespacesAndNewlines)
10092 .lowercased()
10193 }
102
103 private static func debugLog(_ method: String, domain: String, status: DomainAvailabilityStatus) {
104 #if DEBUG
105 print("[Availability] \(domain) -> \(status.rawValue) via \(method)")
106 #endif
107 }
108
109 private static func debugLog(_ method: String, domain: String, details: String) {
110 #if DEBUG
111 print("[Availability] \(domain) -> \(method): \(details)")
112 #endif
113 }
11494}
DomainDig/DomainDig.entitlements added +14
@@ -0,0 +1,14 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>com.apple.developer.icloud-container-identifiers</key>
6 <array>
7 <string>iCloud.net.cleberg.DomainDig</string>
8 </array>
9 <key>com.apple.developer.icloud-services</key>
10 <array>
11 <string>CloudKit</string>
12 </array>
13</dict>
14</plist>
DomainDig/DomainDigApp.swift +11
@@ -9,10 +9,12 @@ import SwiftUI
99
1010@main
1111struct DomainDigApp: App {
12 @UIApplicationDelegateAdaptor(DomainDigAppDelegate.self) private var appDelegate
1213 @Environment(\.scenePhase) private var scenePhase
1314 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
1415 @State private var viewModel = DomainViewModel()
1516 @State private var purchaseService = PurchaseService.shared
17 @State private var cloudSyncService = CloudSyncService.shared
1618
1719 init() {
1820 LocalNotificationService.shared.configureForegroundPresentation()
@@ -25,9 +27,16 @@ struct DomainDigApp: App {
2527 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
2628 .task {
2729 let _ = purchaseService.currentTier
30 let _ = cloudSyncService.status
2831 await purchaseService.refreshEntitlements()
2932 viewModel.refreshMonitoringState()
3033 await viewModel.refreshMonitoringAuthorizationStatus()
34 await cloudSyncService.refreshAvailability()
35 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
36 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
37 }
38 .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in
39 viewModel.refreshPersistedData()
3140 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
3241 }
3342 }
@@ -36,7 +45,9 @@ struct DomainDigApp: App {
3645 viewModel.refreshMonitoringState()
3746 Task {
3847 await viewModel.refreshMonitoringAuthorizationStatus()
48 await cloudSyncService.refreshAvailability()
3949 }
50 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
4051 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
4152 }
4253 }
DomainDig/DomainViewModel.swift +91 −11
@@ -607,12 +607,14 @@ final class DomainViewModel {
607607 savedDomains.append(searchedDomain)
608608 }
609609 DomainDataPortabilityService.saveSavedDomains(savedDomains)
610 CloudSyncService.shared.markAppSettingsChanged()
610611 refreshDataLifecycleSummary()
611612 }
612613
613614 func removeSavedDomains(at offsets: IndexSet) {
614615 savedDomains.remove(atOffsets: offsets)
615616 DomainDataPortabilityService.saveSavedDomains(savedDomains)
617 CloudSyncService.shared.markAppSettingsChanged()
616618 refreshDataLifecycleSummary()
617619 }
618620
@@ -641,7 +643,12 @@ final class DomainViewModel {
641643 domain: normalizedDomain,
642644 createdAt: Date(),
643645 updatedAt: Date(),
644 lastKnownAvailability: availabilityStatus
646 lastKnownAvailability: availabilityStatus,
647 collaboration: CollaborationMetadata(
648 scope: .privateDatabase,
649 ownership: .owner,
650 permission: .editable
651 )
645652 ),
646653 at: 0
647654 )
@@ -666,7 +673,9 @@ final class DomainViewModel {
666673 }
667674
668675 func deleteTrackedDomains(at offsets: IndexSet) {
669 let ids = offsets.map { sortedTrackedDomains[$0].id }
676 let removedDomains = offsets.map { sortedTrackedDomains[$0] }
677 let ids = removedDomains.map(\.id)
678 CloudSyncService.shared.recordTrackedDomainReset(removedDomains)
670679 trackedDomains.removeAll { ids.contains($0.id) }
671680 history.indices.forEach { index in
672681 if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
@@ -679,6 +688,8 @@ final class DomainViewModel {
679688 }
680689
681690 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
691 guard canDelete(trackedDomain) else { return }
692 CloudSyncService.shared.recordTrackedDomainDeletion(trackedDomain)
682693 trackedDomains.removeAll { $0.id == trackedDomain.id }
683694 history.indices.forEach { index in
684695 if history[index].trackedDomainID == trackedDomain.id {
@@ -691,14 +702,19 @@ final class DomainViewModel {
691702 }
692703
693704 func togglePinned(for trackedDomain: TrackedDomain) {
705 guard canEdit(trackedDomain) else { return }
694706 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
695707 trackedDomains[index].isPinned.toggle()
708 trackedDomains[index].updatedAt = Date()
696709 persistTrackedDomains()
697710 }
698711
699712 func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
713 guard canEdit(trackedDomain) else { return }
700714 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
701715 trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
716 trackedDomains[index].updatedAt = Date()
717 CloudSyncService.shared.markNoteChanged(for: trackedDomains[index].domain, updatedAt: trackedDomains[index].updatedAt)
702718 persistTrackedDomains()
703719 }
704720
@@ -725,6 +741,7 @@ final class DomainViewModel {
725741 }
726742
727743 func clearWorkflows() {
744 CloudSyncService.shared.recordWorkflowReset(workflows)
728745 workflows.removeAll()
729746 latestWorkflowRunSummary = nil
730747 persistWorkflows()
@@ -732,6 +749,7 @@ final class DomainViewModel {
732749 }
733750
734751 func clearTrackedDomains() {
752 CloudSyncService.shared.recordTrackedDomainReset(trackedDomains)
735753 trackedDomains.removeAll()
736754 refreshingTrackedDomainID = nil
737755 persistTrackedDomains()
@@ -742,6 +760,7 @@ final class DomainViewModel {
742760 func clearRecentSearches() {
743761 recentSearches.removeAll()
744762 DomainDataPortabilityService.saveRecentSearches([])
763 CloudSyncService.shared.markAppSettingsChanged()
745764 refreshDataLifecycleSummary()
746765 }
747766
@@ -789,13 +808,13 @@ final class DomainViewModel {
789808 }
790809
791810 monitoringSettings.isEnabled = isEnabled
792 persistMonitoringSettings()
811 persistMonitoringSettings(localActivationConfirmed: isEnabled)
793812 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
794813 }
795814
796815 func setMonitoringScope(_ scope: MonitoringScope) {
797816 monitoringSettings.scope = scope
798 persistMonitoringSettings()
817 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
799818 }
800819
801820 func setMonitoringFrequency(_ frequency: MonitoringFrequency) {
@@ -804,7 +823,7 @@ final class DomainViewModel {
804823 return
805824 }
806825 monitoringSettings.frequency = frequency
807 persistMonitoringSettings()
826 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
808827 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
809828 }
810829
@@ -815,7 +834,7 @@ final class DomainViewModel {
815834 return
816835 }
817836 monitoringSettings.alertFilter = filter
818 persistMonitoringSettings()
837 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
819838 }
820839
821840 func setMonitoringAlertsEnabled(_ isEnabled: Bool) {
@@ -825,7 +844,7 @@ final class DomainViewModel {
825844 return
826845 }
827846 monitoringSettings.alertsEnabled = isEnabled
828 persistMonitoringSettings()
847 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
829848 }
830849
831850 func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) {
@@ -836,24 +855,27 @@ final class DomainViewModel {
836855 } else {
837856 monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id }
838857 }
839 persistMonitoringSettings()
858 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
840859 }
841860
842861 func toggleMonitoring(for trackedDomain: TrackedDomain) {
862 guard canEdit(trackedDomain) else { return }
843863 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
844864 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
845865 return
846866 }
847867 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
848868 trackedDomains[index].monitoringEnabled.toggle()
869 trackedDomains[index].updatedAt = Date()
849870 MonitoringStorage.saveTrackedDomains(trackedDomains)
871 CloudSyncService.shared.scheduleSyncIfNeeded()
850872 sanitizeMonitoringSelection()
851873 }
852874
853875 func requestMonitoringNotificationAuthorization() async {
854876 let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
855877 monitoringSettings.alertsEnabled = granted
856 persistMonitoringSettings()
878 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
857879 await refreshMonitoringAuthorizationStatus()
858880 }
859881
@@ -991,6 +1013,32 @@ final class DomainViewModel {
9911013 }
9921014 }
9931015
1016 func canEdit(_ trackedDomain: TrackedDomain) -> Bool {
1017 trackedDomain.collaboration?.canEdit ?? true
1018 }
1019
1020 func canDelete(_ trackedDomain: TrackedDomain) -> Bool {
1021 trackedDomain.collaboration?.isOwner ?? true
1022 }
1023
1024 func collaborationLabel(for trackedDomain: TrackedDomain) -> String? {
1025 guard let collaboration = trackedDomain.collaboration, collaboration.isShared else { return nil }
1026 return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
1027 }
1028
1029 func canEdit(_ workflow: DomainWorkflow) -> Bool {
1030 workflow.collaboration?.canEdit ?? true
1031 }
1032
1033 func canDelete(_ workflow: DomainWorkflow) -> Bool {
1034 workflow.collaboration?.isOwner ?? true
1035 }
1036
1037 func collaborationLabel(for workflow: DomainWorkflow) -> String? {
1038 guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil }
1039 return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
1040 }
1041
9941042 @discardableResult
9951043 func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
9961044 let normalizedDomains = normalizedDomains(domains)
@@ -1006,7 +1054,12 @@ final class DomainViewModel {
10061054 domains: normalizedDomains,
10071055 createdAt: Date(),
10081056 updatedAt: Date(),
1009 notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
1057 notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
1058 collaboration: CollaborationMetadata(
1059 scope: .privateDatabase,
1060 ownership: .owner,
1061 permission: .editable
1062 )
10101063 )
10111064 workflows.insert(workflow, at: 0)
10121065 persistWorkflows()
@@ -1014,6 +1067,7 @@ final class DomainViewModel {
10141067 }
10151068
10161069 func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
1070 guard canEdit(workflow) else { return }
10171071 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
10181072 let normalizedDomains = normalizedDomains(domains)
10191073 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1027,6 +1081,8 @@ final class DomainViewModel {
10271081 }
10281082
10291083 func deleteWorkflow(_ workflow: DomainWorkflow) {
1084 guard canDelete(workflow) else { return }
1085 CloudSyncService.shared.recordWorkflowDeletion(workflow)
10301086 workflows.removeAll { $0.id == workflow.id }
10311087 if latestWorkflowRunSummary?.workflowID == workflow.id {
10321088 latestWorkflowRunSummary = nil
@@ -1039,6 +1095,7 @@ final class DomainViewModel {
10391095 }
10401096
10411097 func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
1098 guard canEdit(workflow) else { return }
10421099 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
10431100 let mergedDomains = normalizedDomains(workflows[index].domains + domains)
10441101 guard mergedDomains != workflows[index].domains else { return }
@@ -1048,6 +1105,7 @@ final class DomainViewModel {
10481105 }
10491106
10501107 func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
1108 guard canEdit(workflow) else { return }
10511109 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
10521110 workflows[index].domains.remove(atOffsets: offsets)
10531111 workflows[index].updatedAt = Date()
@@ -1055,6 +1113,7 @@ final class DomainViewModel {
10551113 }
10561114
10571115 func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
1116 guard canEdit(workflow) else { return }
10581117 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
10591118 workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
10601119 workflows[index].updatedAt = Date()
@@ -1349,9 +1408,25 @@ final class DomainViewModel {
13491408 let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
13501409 refreshPersistedData()
13511410 portabilityStatusMessage = result.summary
1411 CloudSyncService.shared.markAppSettingsChanged()
1412 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled)
1413 CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .import)
13521414 return result
13531415 }
13541416
1417 func persistCurrentAppSettings(resolverURLString: String, appDensityRawValue: String) {
1418 DomainDataPortabilityService.saveAppSettings(
1419 AppSettingsSnapshot(
1420 recentSearches: recentSearches,
1421 savedDomains: savedDomains,
1422 resolverURLString: resolverURLString,
1423 appDensityRawValue: appDensityRawValue
1424 )
1425 )
1426 CloudSyncService.shared.markAppSettingsChanged()
1427 refreshDataLifecycleSummary()
1428 }
1429
13551430 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
13561431 let reports = workflowReports(from: summary, changedOnly: changedOnly)
13571432 let base = DomainReportExporter.batchText(
@@ -2011,17 +2086,20 @@ final class DomainViewModel {
20112086
20122087 private func persistTrackedDomains() {
20132088 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2089 CloudSyncService.shared.scheduleSyncIfNeeded()
20142090 refreshDataLifecycleSummary()
20152091 }
20162092
2017 private func persistMonitoringSettings() {
2093 private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
20182094 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
20192095 MonitoringStorage.saveSettings(monitoringSettings)
2096 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed)
20202097 }
20212098
20222099 private func sanitizeMonitoringSelection() {
20232100 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
20242101 MonitoringStorage.saveSettings(monitoringSettings)
2102 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)
20252103 }
20262104
20272105 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
@@ -2150,6 +2228,7 @@ final class DomainViewModel {
21502228 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
21512229 }
21522230 DomainDataPortabilityService.saveRecentSearches(recentSearches)
2231 CloudSyncService.shared.markAppSettingsChanged()
21532232 refreshDataLifecycleSummary()
21542233 }
21552234
@@ -2723,6 +2802,7 @@ final class DomainViewModel {
27232802
27242803 private func persistWorkflows() {
27252804 DomainDataPortabilityService.saveWorkflows(workflows)
2805 CloudSyncService.shared.scheduleSyncIfNeeded()
27262806 refreshDataLifecycleSummary()
27272807 }
27282808
DomainDig/Info.plist +2
@@ -10,6 +10,8 @@
1010 <array>
1111 <string>net.cleberg.DomainDig.monitor.refresh</string>
1212 </array>
13 <key>CKSharingSupported</key>
14 <true/>
1315 <key>UIBackgroundModes</key>
1416 <array>
1517 <string>fetch</string>
DomainDig/Models.swift +73 −2
@@ -332,6 +332,7 @@ struct DomainWorkflow: Codable, Identifiable, Equatable {
332332 var createdAt: Date
333333 var updatedAt: Date
334334 var notes: String?
335 var collaboration: CollaborationMetadata?
335336
336337 init(
337338 id: UUID = UUID(),
@@ -339,7 +340,8 @@ struct DomainWorkflow: Codable, Identifiable, Equatable {
339340 domains: [String],
340341 createdAt: Date = Date(),
341342 updatedAt: Date = Date(),
342 notes: String? = nil
343 notes: String? = nil,
344 collaboration: CollaborationMetadata? = nil
343345 ) {
344346 self.id = id
345347 self.name = name
@@ -347,6 +349,7 @@ struct DomainWorkflow: Codable, Identifiable, Equatable {
347349 self.createdAt = createdAt
348350 self.updatedAt = updatedAt
349351 self.notes = notes
352 self.collaboration = collaboration
350353 }
351354}
352355
@@ -584,6 +587,70 @@ enum PremiumCapability: String, Codable {
584587 case advancedExports
585588}
586589
590enum CollaborationPermission: String, Codable, Equatable {
591 case readOnly
592 case editable
593
594 var title: String {
595 switch self {
596 case .readOnly:
597 return "Read only"
598 case .editable:
599 return "Editable"
600 }
601 }
602}
603
604enum CollaborationOwnership: String, Codable, Equatable {
605 case owner
606 case participant
607
608 var title: String {
609 switch self {
610 case .owner:
611 return "Owned by you"
612 case .participant:
613 return "Shared with you"
614 }
615 }
616}
617
618enum CollaborationScope: String, Codable, Equatable {
619 case privateDatabase
620 case sharedDatabase
621}
622
623struct CollaborationMetadata: Codable, Equatable {
624 var scope: CollaborationScope
625 var ownership: CollaborationOwnership
626 var permission: CollaborationPermission
627 var shareRecordName: String?
628
629 init(
630 scope: CollaborationScope,
631 ownership: CollaborationOwnership,
632 permission: CollaborationPermission,
633 shareRecordName: String? = nil
634 ) {
635 self.scope = scope
636 self.ownership = ownership
637 self.permission = permission
638 self.shareRecordName = shareRecordName
639 }
640
641 var isShared: Bool {
642 shareRecordName != nil || scope == .sharedDatabase
643 }
644
645 var canEdit: Bool {
646 permission == .editable
647 }
648
649 var isOwner: Bool {
650 ownership == .owner
651 }
652}
653
587654struct TrackedDomain: Codable, Identifiable, Equatable {
588655 let id: UUID
589656 var domain: String
@@ -600,6 +667,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
600667 var certificateDaysRemaining: Int?
601668 var lastMonitoredAt: Date?
602669 var lastAlertAt: Date?
670 var collaboration: CollaborationMetadata?
603671
604672 init(
605673 id: UUID = UUID(),
@@ -616,7 +684,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
616684 certificateWarningLevel: CertificateWarningLevel = .none,
617685 certificateDaysRemaining: Int? = nil,
618686 lastMonitoredAt: Date? = nil,
619 lastAlertAt: Date? = nil
687 lastAlertAt: Date? = nil,
688 collaboration: CollaborationMetadata? = nil
620689 ) {
621690 self.id = id
622691 self.domain = domain
@@ -633,6 +702,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
633702 self.certificateDaysRemaining = certificateDaysRemaining
634703 self.lastMonitoredAt = lastMonitoredAt
635704 self.lastAlertAt = lastAlertAt
705 self.collaboration = collaboration
636706 }
637707
638708 init(from decoder: Decoder) throws {
@@ -652,6 +722,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
652722 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
653723 lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
654724 lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt)
725 collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
655726 }
656727}
657728
DomainDig/WatchlistView.swift +49 −7
@@ -193,10 +193,12 @@ struct WatchlistView: View {
193193 }
194194 .tint(.cyan)
195195
196 Button(role: .destructive) {
197 viewModel.deleteTrackedDomain(trackedDomain)
198 } label: {
199 Label("Delete", systemImage: "trash")
196 if viewModel.canDelete(trackedDomain) {
197 Button(role: .destructive) {
198 viewModel.deleteTrackedDomain(trackedDomain)
199 } label: {
200 Label("Delete", systemImage: "trash")
201 }
200202 }
201203 }
202204 .contextMenu {
@@ -219,11 +221,21 @@ struct WatchlistView: View {
219221 } label: {
220222 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
221223 }
224 .disabled(!viewModel.canEdit(trackedDomain))
222225
223 Button(role: .destructive) {
224 viewModel.deleteTrackedDomain(trackedDomain)
226 Button {
227 // The system sharing UI manages participants and permissions.
225228 } label: {
226 Label("Delete", systemImage: "trash")
229 Label(trackedDomain.collaboration?.isShared == true ? "Shared" : "Private", systemImage: "person.2")
230 }
231 .disabled(true)
232
233 if viewModel.canDelete(trackedDomain) {
234 Button(role: .destructive) {
235 viewModel.deleteTrackedDomain(trackedDomain)
236 } label: {
237 Label("Delete", systemImage: "trash")
238 }
227239 }
228240 }
229241 .listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -287,6 +299,12 @@ struct WatchlistRowView: View {
287299 .font(appDensity.font(.caption2))
288300 .foregroundStyle(.secondary)
289301
302 if let collaboration = trackedDomain.collaboration, collaboration.isShared {
303 Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
304 .font(appDensity.font(.caption2))
305 .foregroundStyle(.secondary)
306 }
307
290308 HStack(spacing: 8) {
291309 Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
292310 if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
@@ -340,6 +358,17 @@ struct WatchlistRowView: View {
340358 @ViewBuilder
341359 private var indicatorRow: some View {
342360 HStack(spacing: 8) {
361 if trackedDomain.collaboration?.isShared == true {
362 AppStatusBadgeView(
363 model: .init(
364 title: "Shared",
365 systemImage: "person.2.fill",
366 foregroundColor: .cyan,
367 backgroundColor: .cyan.opacity(0.16)
368 )
369 )
370 }
371
343372 AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary))
344373
345374 if trackedDomain.certificateWarningLevel != .none {
@@ -369,6 +398,7 @@ struct TrackedDomainDetailView: View {
369398 @State private var noteDraft = ""
370399 @State private var isEditingNote = false
371400 @State private var showRerunOptions = false
401 @State private var shareEntity: ShareableEntity?
372402
373403 private var liveTrackedDomain: TrackedDomain {
374404 viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
@@ -410,6 +440,7 @@ struct TrackedDomainDetailView: View {
410440 } label: {
411441 Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
412442 }
443 .disabled(!viewModel.canEdit(liveTrackedDomain))
413444
414445 Button {
415446 viewModel.toggleMonitoring(for: liveTrackedDomain)
@@ -419,6 +450,7 @@ struct TrackedDomainDetailView: View {
419450 systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell"
420451 )
421452 }
453 .disabled(!viewModel.canEdit(liveTrackedDomain))
422454
423455 Button {
424456 noteDraft = liveTrackedDomain.note ?? ""
@@ -426,6 +458,13 @@ struct TrackedDomainDetailView: View {
426458 } label: {
427459 Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
428460 }
461 .disabled(!viewModel.canEdit(liveTrackedDomain))
462
463 Button {
464 shareEntity = .trackedDomain(liveTrackedDomain.domain)
465 } label: {
466 Label(liveTrackedDomain.collaboration?.isShared == true ? "Manage Share" : "Share Domain", systemImage: "person.2")
467 }
429468 }
430469 .listRowBackground(Color(.systemGray6).opacity(0.5))
431470
@@ -518,5 +557,8 @@ struct TrackedDomainDetailView: View {
518557 }
519558 }
520559 }
560 .sheet(item: $shareEntity) { entity in
561 CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
562 }
521563 }
522564}
DomainDig/WorkflowsView.swift +31
@@ -129,6 +129,11 @@ private struct WorkflowRowView: View {
129129 var body: some View {
130130 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
131131 HStack(alignment: .firstTextBaseline, spacing: 8) {
132 if workflow.collaboration?.isShared == true {
133 Image(systemName: "person.2.fill")
134 .font(.caption2)
135 .foregroundStyle(.cyan)
136 }
132137 Text(workflow.name)
133138 .font(appDensity.font(.callout, design: .default, weight: .semibold))
134139 .foregroundStyle(.primary)
@@ -143,6 +148,12 @@ private struct WorkflowRowView: View {
143148 .font(appDensity.font(.caption2))
144149 .foregroundStyle(.secondary)
145150
151 if let collaboration = workflow.collaboration, collaboration.isShared {
152 Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
153 .font(appDensity.font(.caption2))
154 .foregroundStyle(.secondary)
155 }
156
146157 if let notes = workflow.notes, !notes.isEmpty {
147158 Text(notes)
148159 .font(appDensity.font(.caption))
@@ -163,6 +174,7 @@ struct WorkflowDetailView: View {
163174 @State private var showingEditor = false
164175 @State private var draftDomain = ""
165176 @State private var includeAllExports = false
177 @State private var shareEntity: ShareableEntity?
166178
167179 private var workflow: DomainWorkflow? {
168180 viewModel.workflow(withID: workflowID)
@@ -186,6 +198,11 @@ struct WorkflowDetailView: View {
186198 .font(appDensity.font(.caption))
187199 .foregroundStyle(.secondary)
188200 }
201 if let collaboration = workflow.collaboration, collaboration.isShared {
202 Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
203 .font(appDensity.font(.caption))
204 .foregroundStyle(.secondary)
205 }
189206 }
190207
191208 Section("Actions") {
@@ -207,6 +224,13 @@ struct WorkflowDetailView: View {
207224 } label: {
208225 Label("Edit Workflow", systemImage: "pencil")
209226 }
227 .disabled(!viewModel.canEdit(workflow))
228
229 Button {
230 shareEntity = .workflow(workflow.id)
231 } label: {
232 Label(workflow.collaboration?.isShared == true ? "Manage Share" : "Share Workflow", systemImage: "person.2")
233 }
210234
211235 if latestSummary != nil {
212236 Toggle("Export all domains", isOn: $includeAllExports)
@@ -263,6 +287,7 @@ struct WorkflowDetailView: View {
263287 viewModel.addDomains([draftDomain], to: workflow)
264288 draftDomain = ""
265289 }
290 .disabled(!viewModel.canEdit(workflow))
266291 }
267292 }
268293
@@ -288,9 +313,11 @@ struct WorkflowDetailView: View {
288313 .buttonStyle(.plain)
289314 }
290315 .onDelete { offsets in
316 guard viewModel.canEdit(workflow) else { return }
291317 viewModel.removeWorkflowDomains(at: offsets, from: workflow)
292318 }
293319 .onMove { offsets, destination in
320 guard viewModel.canEdit(workflow) else { return }
294321 viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow)
295322 }
296323 }
@@ -302,6 +329,7 @@ struct WorkflowDetailView: View {
302329 .toolbar {
303330 if !workflow.domains.isEmpty {
304331 EditButton()
332 .disabled(!viewModel.canEdit(workflow))
305333 }
306334 }
307335 .sheet(isPresented: $showingEditor) {
@@ -310,6 +338,9 @@ struct WorkflowDetailView: View {
310338 .sheet(item: workflowSummaryBinding) { summary in
311339 WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
312340 }
341 .sheet(item: $shareEntity) { entity in
342 CloudSharingSheet(entity: entity, title: workflow.name)
343 }
313344 } else {
314345 Text("Workflow not found")
315346 .font(appDensity.font(.callout))
DomainDigCLI.swift +2
@@ -428,6 +428,8 @@ struct DomainDigCLI {
428428 domaindig backup export [path]
429429 domaindig backup import <path> [--replace]
430430 domaindig backup validate <path> [--json]
431
432 note: the CLI remains local-only and does not sync with iCloud yet.
431433 """
432434 }
433435}