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 @@
169 TargetAttributes = { 169 TargetAttributes = {
170 8B7800682F6090E300933221 = { 170 8B7800682F6090E300933221 = {
171 CreatedOnToolsVersion = 26.3; 171 CreatedOnToolsVersion = 26.3;
172 SystemCapabilities = {
173 com.apple.iCloud = {
174 enabled = 1;
175 };
176 };
172 }; 177 };
173 }; 178 };
174 }; 179 };
@@ -359,8 +364,9 @@
359 buildSettings = { 364 buildSettings = {
360 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 365 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
361 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 366 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
367 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
362 CODE_SIGN_STYLE = Automatic; 368 CODE_SIGN_STYLE = Automatic;
363 CURRENT_PROJECT_VERSION = 26; 369 CURRENT_PROJECT_VERSION = 28;
364 DEVELOPMENT_TEAM = ZCNAX3VL9D; 370 DEVELOPMENT_TEAM = ZCNAX3VL9D;
365 ENABLE_PREVIEWS = YES; 371 ENABLE_PREVIEWS = YES;
366 GENERATE_INFOPLIST_FILE = YES; 372 GENERATE_INFOPLIST_FILE = YES;
@@ -377,7 +383,7 @@
377 "$(inherited)", 383 "$(inherited)",
378 "@executable_path/Frameworks", 384 "@executable_path/Frameworks",
379 ); 385 );
380 MARKETING_VERSION = 3.4.0; 386 MARKETING_VERSION = 3.6.0;
381 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 387 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
382 PRODUCT_NAME = "$(TARGET_NAME)"; 388 PRODUCT_NAME = "$(TARGET_NAME)";
383 STRING_CATALOG_GENERATE_SYMBOLS = YES; 389 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -395,8 +401,9 @@
395 buildSettings = { 401 buildSettings = {
396 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 402 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
397 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 403 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
404 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
398 CODE_SIGN_STYLE = Automatic; 405 CODE_SIGN_STYLE = Automatic;
399 CURRENT_PROJECT_VERSION = 26; 406 CURRENT_PROJECT_VERSION = 28;
400 DEVELOPMENT_TEAM = ZCNAX3VL9D; 407 DEVELOPMENT_TEAM = ZCNAX3VL9D;
401 ENABLE_PREVIEWS = YES; 408 ENABLE_PREVIEWS = YES;
402 GENERATE_INFOPLIST_FILE = YES; 409 GENERATE_INFOPLIST_FILE = YES;
@@ -413,7 +420,7 @@
413 "$(inherited)", 420 "$(inherited)",
414 "@executable_path/Frameworks", 421 "@executable_path/Frameworks",
415 ); 422 );
416 MARKETING_VERSION = 3.4.0; 423 MARKETING_VERSION = 3.6.0;
417 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 424 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
418 PRODUCT_NAME = "$(TARGET_NAME)"; 425 PRODUCT_NAME = "$(TARGET_NAME)";
419 STRING_CATALOG_GENERATE_SYMBOLS = YES; 426 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +2 −2
@@ -7,12 +7,12 @@
7 <key>DomainDig.xcscheme_^#shared#^_</key> 7 <key>DomainDig.xcscheme_^#shared#^_</key>
8 <dict> 8 <dict>
9 <key>orderHint</key> 9 <key>orderHint</key>
10 <integer>1</integer> 10 <integer>0</integer>
11 </dict> 11 </dict>
12 <key>DomainDigCLI.xcscheme_^#shared#^_</key> 12 <key>DomainDigCLI.xcscheme_^#shared#^_</key>
13 <dict> 13 <dict>
14 <key>orderHint</key> 14 <key>orderHint</key>
15 <integer>0</integer> 15 <integer>1</integer>
16 </dict> 16 </dict>
17 </dict> 17 </dict>
18</dict> 18</dict>
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
2 2
3enum AppVersion { 3enum AppVersion {
4 nonisolated static var current: String { 4 nonisolated static var current: String {
5 "3.4.0" 5 "3.5.0"
6 } 6 }
7} 7}
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 {
2421 @Environment(\.appDensity) private var appDensity 2421 @Environment(\.appDensity) private var appDensity
2422 @Bindable var viewModel: DomainViewModel 2422 @Bindable var viewModel: DomainViewModel
2423 @State private var purchaseService = PurchaseService.shared 2423 @State private var purchaseService = PurchaseService.shared
2424 @State private var cloudSyncService = CloudSyncService.shared
2424 @AppStorage(DNSResolverOption.userDefaultsKey) 2425 @AppStorage(DNSResolverOption.userDefaultsKey)
2425 private var storedResolverURL = DNSResolverOption.defaultURLString 2426 private var storedResolverURL = DNSResolverOption.defaultURLString
2426 @AppStorage(AppDensity.userDefaultsKey) 2427 @AppStorage(AppDensity.userDefaultsKey)
@@ -2480,6 +2481,43 @@ struct SettingsView: View {
2480 } 2481 }
2481 } 2482 }
2482 2483
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
2483 Section("Monitoring") { 2521 Section("Monitoring") {
2484 Toggle( 2522 Toggle(
2485 "Enable Background Monitoring", 2523 "Enable Background Monitoring",
@@ -2713,7 +2751,7 @@ struct SettingsView: View {
2713 2751
2714 Section("About") { 2752 Section("About") {
2715 LabeledContent("Version", value: appVersion) 2753 LabeledContent("Version", value: appVersion)
2716 LabeledContent("Storage", value: "Local-only") 2754 LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
2717 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") 2755 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
2718 } 2756 }
2719 } 2757 }
@@ -2817,18 +2855,37 @@ struct SettingsView: View {
2817 Task { 2855 Task {
2818 await viewModel.refreshUsageCredits() 2856 await viewModel.refreshUsageCredits()
2819 await viewModel.refreshMonitoringAuthorizationStatus() 2857 await viewModel.refreshMonitoringAuthorizationStatus()
2858 await cloudSyncService.refreshAvailability()
2820 } 2859 }
2821 } 2860 }
2822 .onChange(of: resolverOption) { _, newValue in 2861 .onChange(of: resolverOption) { _, newValue in
2823 guard let presetURL = newValue.urlString else { 2862 guard let presetURL = newValue.urlString else {
2824 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) 2863 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2864 viewModel.persistCurrentAppSettings(
2865 resolverURLString: storedResolverURL,
2866 appDensityRawValue: storedDensity
2867 )
2825 return 2868 return
2826 } 2869 }
2827 storedResolverURL = presetURL 2870 storedResolverURL = presetURL
2871 viewModel.persistCurrentAppSettings(
2872 resolverURLString: storedResolverURL,
2873 appDensityRawValue: storedDensity
2874 )
2828 } 2875 }
2829 .onChange(of: customResolverURL) { _, newValue in 2876 .onChange(of: customResolverURL) { _, newValue in
2830 guard resolverOption == .custom else { return } 2877 guard resolverOption == .custom else { return }
2831 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) 2878 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 )
2832 } 2889 }
2833 } 2890 }
2834 2891
DomainDig/DomainAvailabilityService.swift −20
@@ -10,17 +10,14 @@ struct DomainAvailabilityService {
10 } 10 }
11 11
12 if await checkViaRDAP(domain: normalizedDomain) == .registered { 12 if await checkViaRDAP(domain: normalizedDomain) == .registered {
13 debugLog("rdap", domain: normalizedDomain, status: .registered)
14 return DomainAvailabilityResult(domain: normalizedDomain, status: .registered) 13 return DomainAvailabilityResult(domain: normalizedDomain, status: .registered)
15 } 14 }
16 15
17 let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain) 16 let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain)
18 if fallbackStatus == .registered { 17 if fallbackStatus == .registered {
19 debugLog("dns-evidence", domain: normalizedDomain, details: "DNS exists but RDAP did not confirm registration")
20 return DomainAvailabilityResult(domain: normalizedDomain, status: .unknown) 18 return DomainAvailabilityResult(domain: normalizedDomain, status: .unknown)
21 } 19 }
22 20
23 debugLog("fallback", domain: normalizedDomain, status: fallbackStatus)
24 return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus) 21 return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus)
25 } 22 }
26 23
@@ -40,9 +37,6 @@ struct DomainAvailabilityService {
40 37
41 private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? { 38 private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? {
42 let status = await RDAPService.registrationStatus(for: domain) 39 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 }
46 return status 40 return status
47 } 41 }
48 42
@@ -53,7 +47,6 @@ struct DomainAvailabilityService {
53 return .registered 47 return .registered
54 } 48 }
55 } catch { 49 } catch {
56 debugLog("dns-a-error", domain: domain, details: error.localizedDescription)
57 } 50 }
58 51
59 do { 52 do {
@@ -63,7 +56,6 @@ struct DomainAvailabilityService {
63 } 56 }
64 return .unknown 57 return .unknown
65 } catch { 58 } catch {
66 debugLog("dns-ns-error", domain: domain, details: error.localizedDescription)
67 return .unknown 59 return .unknown
68 } 60 }
69 } 61 }
@@ -99,16 +91,4 @@ struct DomainAvailabilityService {
99 .trimmingCharacters(in: .whitespacesAndNewlines) 91 .trimmingCharacters(in: .whitespacesAndNewlines)
100 .lowercased() 92 .lowercased()
101 } 93 }
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 }
114} 94}
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
9 9
10@main 10@main
11struct DomainDigApp: App { 11struct DomainDigApp: App {
12 @UIApplicationDelegateAdaptor(DomainDigAppDelegate.self) private var appDelegate
12 @Environment(\.scenePhase) private var scenePhase 13 @Environment(\.scenePhase) private var scenePhase
13 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue 14 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
14 @State private var viewModel = DomainViewModel() 15 @State private var viewModel = DomainViewModel()
15 @State private var purchaseService = PurchaseService.shared 16 @State private var purchaseService = PurchaseService.shared
17 @State private var cloudSyncService = CloudSyncService.shared
16 18
17 init() { 19 init() {
18 LocalNotificationService.shared.configureForegroundPresentation() 20 LocalNotificationService.shared.configureForegroundPresentation()
@@ -25,9 +27,16 @@ struct DomainDigApp: App {
25 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) 27 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
26 .task { 28 .task {
27 let _ = purchaseService.currentTier 29 let _ = purchaseService.currentTier
30 let _ = cloudSyncService.status
28 await purchaseService.refreshEntitlements() 31 await purchaseService.refreshEntitlements()
29 viewModel.refreshMonitoringState() 32 viewModel.refreshMonitoringState()
30 await viewModel.refreshMonitoringAuthorizationStatus() 33 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()
31 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 40 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
32 } 41 }
33 } 42 }
@@ -36,7 +45,9 @@ struct DomainDigApp: App {
36 viewModel.refreshMonitoringState() 45 viewModel.refreshMonitoringState()
37 Task { 46 Task {
38 await viewModel.refreshMonitoringAuthorizationStatus() 47 await viewModel.refreshMonitoringAuthorizationStatus()
48 await cloudSyncService.refreshAvailability()
39 } 49 }
50 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
40 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 51 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
41 } 52 }
42 } 53 }
DomainDig/DomainViewModel.swift +91 −11
@@ -607,12 +607,14 @@ final class DomainViewModel {
607 savedDomains.append(searchedDomain) 607 savedDomains.append(searchedDomain)
608 } 608 }
609 DomainDataPortabilityService.saveSavedDomains(savedDomains) 609 DomainDataPortabilityService.saveSavedDomains(savedDomains)
610 CloudSyncService.shared.markAppSettingsChanged()
610 refreshDataLifecycleSummary() 611 refreshDataLifecycleSummary()
611 } 612 }
612 613
613 func removeSavedDomains(at offsets: IndexSet) { 614 func removeSavedDomains(at offsets: IndexSet) {
614 savedDomains.remove(atOffsets: offsets) 615 savedDomains.remove(atOffsets: offsets)
615 DomainDataPortabilityService.saveSavedDomains(savedDomains) 616 DomainDataPortabilityService.saveSavedDomains(savedDomains)
617 CloudSyncService.shared.markAppSettingsChanged()
616 refreshDataLifecycleSummary() 618 refreshDataLifecycleSummary()
617 } 619 }
618 620
@@ -641,7 +643,12 @@ final class DomainViewModel {
641 domain: normalizedDomain, 643 domain: normalizedDomain,
642 createdAt: Date(), 644 createdAt: Date(),
643 updatedAt: Date(), 645 updatedAt: Date(),
644 lastKnownAvailability: availabilityStatus 646 lastKnownAvailability: availabilityStatus,
647 collaboration: CollaborationMetadata(
648 scope: .privateDatabase,
649 ownership: .owner,
650 permission: .editable
651 )
645 ), 652 ),
646 at: 0 653 at: 0
647 ) 654 )
@@ -666,7 +673,9 @@ final class DomainViewModel {
666 } 673 }
667 674
668 func deleteTrackedDomains(at offsets: IndexSet) { 675 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)
670 trackedDomains.removeAll { ids.contains($0.id) } 679 trackedDomains.removeAll { ids.contains($0.id) }
671 history.indices.forEach { index in 680 history.indices.forEach { index in
672 if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) { 681 if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
@@ -679,6 +688,8 @@ final class DomainViewModel {
679 } 688 }
680 689
681 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) { 690 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
691 guard canDelete(trackedDomain) else { return }
692 CloudSyncService.shared.recordTrackedDomainDeletion(trackedDomain)
682 trackedDomains.removeAll { $0.id == trackedDomain.id } 693 trackedDomains.removeAll { $0.id == trackedDomain.id }
683 history.indices.forEach { index in 694 history.indices.forEach { index in
684 if history[index].trackedDomainID == trackedDomain.id { 695 if history[index].trackedDomainID == trackedDomain.id {
@@ -691,14 +702,19 @@ final class DomainViewModel {
691 } 702 }
692 703
693 func togglePinned(for trackedDomain: TrackedDomain) { 704 func togglePinned(for trackedDomain: TrackedDomain) {
705 guard canEdit(trackedDomain) else { return }
694 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } 706 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
695 trackedDomains[index].isPinned.toggle() 707 trackedDomains[index].isPinned.toggle()
708 trackedDomains[index].updatedAt = Date()
696 persistTrackedDomains() 709 persistTrackedDomains()
697 } 710 }
698 711
699 func updateNote(_ note: String, for trackedDomain: TrackedDomain) { 712 func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
713 guard canEdit(trackedDomain) else { return }
700 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } 714 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
701 trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty 715 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)
702 persistTrackedDomains() 718 persistTrackedDomains()
703 } 719 }
704 720
@@ -725,6 +741,7 @@ final class DomainViewModel {
725 } 741 }
726 742
727 func clearWorkflows() { 743 func clearWorkflows() {
744 CloudSyncService.shared.recordWorkflowReset(workflows)
728 workflows.removeAll() 745 workflows.removeAll()
729 latestWorkflowRunSummary = nil 746 latestWorkflowRunSummary = nil
730 persistWorkflows() 747 persistWorkflows()
@@ -732,6 +749,7 @@ final class DomainViewModel {
732 } 749 }
733 750
734 func clearTrackedDomains() { 751 func clearTrackedDomains() {
752 CloudSyncService.shared.recordTrackedDomainReset(trackedDomains)
735 trackedDomains.removeAll() 753 trackedDomains.removeAll()
736 refreshingTrackedDomainID = nil 754 refreshingTrackedDomainID = nil
737 persistTrackedDomains() 755 persistTrackedDomains()
@@ -742,6 +760,7 @@ final class DomainViewModel {
742 func clearRecentSearches() { 760 func clearRecentSearches() {
743 recentSearches.removeAll() 761 recentSearches.removeAll()
744 DomainDataPortabilityService.saveRecentSearches([]) 762 DomainDataPortabilityService.saveRecentSearches([])
763 CloudSyncService.shared.markAppSettingsChanged()
745 refreshDataLifecycleSummary() 764 refreshDataLifecycleSummary()
746 } 765 }
747 766
@@ -789,13 +808,13 @@ final class DomainViewModel {
789 } 808 }
790 809
791 monitoringSettings.isEnabled = isEnabled 810 monitoringSettings.isEnabled = isEnabled
792 persistMonitoringSettings() 811 persistMonitoringSettings(localActivationConfirmed: isEnabled)
793 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 812 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
794 } 813 }
795 814
796 func setMonitoringScope(_ scope: MonitoringScope) { 815 func setMonitoringScope(_ scope: MonitoringScope) {
797 monitoringSettings.scope = scope 816 monitoringSettings.scope = scope
798 persistMonitoringSettings() 817 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
799 } 818 }
800 819
801 func setMonitoringFrequency(_ frequency: MonitoringFrequency) { 820 func setMonitoringFrequency(_ frequency: MonitoringFrequency) {
@@ -804,7 +823,7 @@ final class DomainViewModel {
804 return 823 return
805 } 824 }
806 monitoringSettings.frequency = frequency 825 monitoringSettings.frequency = frequency
807 persistMonitoringSettings() 826 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
808 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 827 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
809 } 828 }
810 829
@@ -815,7 +834,7 @@ final class DomainViewModel {
815 return 834 return
816 } 835 }
817 monitoringSettings.alertFilter = filter 836 monitoringSettings.alertFilter = filter
818 persistMonitoringSettings() 837 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
819 } 838 }
820 839
821 func setMonitoringAlertsEnabled(_ isEnabled: Bool) { 840 func setMonitoringAlertsEnabled(_ isEnabled: Bool) {
@@ -825,7 +844,7 @@ final class DomainViewModel {
825 return 844 return
826 } 845 }
827 monitoringSettings.alertsEnabled = isEnabled 846 monitoringSettings.alertsEnabled = isEnabled
828 persistMonitoringSettings() 847 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
829 } 848 }
830 849
831 func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) { 850 func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) {
@@ -836,24 +855,27 @@ final class DomainViewModel {
836 } else { 855 } else {
837 monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id } 856 monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id }
838 } 857 }
839 persistMonitoringSettings() 858 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
840 } 859 }
841 860
842 func toggleMonitoring(for trackedDomain: TrackedDomain) { 861 func toggleMonitoring(for trackedDomain: TrackedDomain) {
862 guard canEdit(trackedDomain) else { return }
843 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { 863 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
844 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) 864 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
845 return 865 return
846 } 866 }
847 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } 867 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
848 trackedDomains[index].monitoringEnabled.toggle() 868 trackedDomains[index].monitoringEnabled.toggle()
869 trackedDomains[index].updatedAt = Date()
849 MonitoringStorage.saveTrackedDomains(trackedDomains) 870 MonitoringStorage.saveTrackedDomains(trackedDomains)
871 CloudSyncService.shared.scheduleSyncIfNeeded()
850 sanitizeMonitoringSelection() 872 sanitizeMonitoringSelection()
851 } 873 }
852 874
853 func requestMonitoringNotificationAuthorization() async { 875 func requestMonitoringNotificationAuthorization() async {
854 let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded() 876 let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
855 monitoringSettings.alertsEnabled = granted 877 monitoringSettings.alertsEnabled = granted
856 persistMonitoringSettings() 878 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
857 await refreshMonitoringAuthorizationStatus() 879 await refreshMonitoringAuthorizationStatus()
858 } 880 }
859 881
@@ -991,6 +1013,32 @@ final class DomainViewModel {
991 } 1013 }
992 } 1014 }
993 1015
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
994 @discardableResult 1042 @discardableResult
995 func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? { 1043 func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
996 let normalizedDomains = normalizedDomains(domains) 1044 let normalizedDomains = normalizedDomains(domains)
@@ -1006,7 +1054,12 @@ final class DomainViewModel {
1006 domains: normalizedDomains, 1054 domains: normalizedDomains,
1007 createdAt: Date(), 1055 createdAt: Date(),
1008 updatedAt: Date(), 1056 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 )
1010 ) 1063 )
1011 workflows.insert(workflow, at: 0) 1064 workflows.insert(workflow, at: 0)
1012 persistWorkflows() 1065 persistWorkflows()
@@ -1014,6 +1067,7 @@ final class DomainViewModel {
1014 } 1067 }
1015 1068
1016 func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) { 1069 func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
1070 guard canEdit(workflow) else { return }
1017 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } 1071 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1018 let normalizedDomains = normalizedDomains(domains) 1072 let normalizedDomains = normalizedDomains(domains)
1019 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) 1073 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1027,6 +1081,8 @@ final class DomainViewModel {
1027 } 1081 }
1028 1082
1029 func deleteWorkflow(_ workflow: DomainWorkflow) { 1083 func deleteWorkflow(_ workflow: DomainWorkflow) {
1084 guard canDelete(workflow) else { return }
1085 CloudSyncService.shared.recordWorkflowDeletion(workflow)
1030 workflows.removeAll { $0.id == workflow.id } 1086 workflows.removeAll { $0.id == workflow.id }
1031 if latestWorkflowRunSummary?.workflowID == workflow.id { 1087 if latestWorkflowRunSummary?.workflowID == workflow.id {
1032 latestWorkflowRunSummary = nil 1088 latestWorkflowRunSummary = nil
@@ -1039,6 +1095,7 @@ final class DomainViewModel {
1039 } 1095 }
1040 1096
1041 func addDomains(_ domains: [String], to workflow: DomainWorkflow) { 1097 func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
1098 guard canEdit(workflow) else { return }
1042 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } 1099 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1043 let mergedDomains = normalizedDomains(workflows[index].domains + domains) 1100 let mergedDomains = normalizedDomains(workflows[index].domains + domains)
1044 guard mergedDomains != workflows[index].domains else { return } 1101 guard mergedDomains != workflows[index].domains else { return }
@@ -1048,6 +1105,7 @@ final class DomainViewModel {
1048 } 1105 }
1049 1106
1050 func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) { 1107 func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
1108 guard canEdit(workflow) else { return }
1051 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } 1109 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1052 workflows[index].domains.remove(atOffsets: offsets) 1110 workflows[index].domains.remove(atOffsets: offsets)
1053 workflows[index].updatedAt = Date() 1111 workflows[index].updatedAt = Date()
@@ -1055,6 +1113,7 @@ final class DomainViewModel {
1055 } 1113 }
1056 1114
1057 func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) { 1115 func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
1116 guard canEdit(workflow) else { return }
1058 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } 1117 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1059 workflows[index].domains.move(fromOffsets: offsets, toOffset: destination) 1118 workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
1060 workflows[index].updatedAt = Date() 1119 workflows[index].updatedAt = Date()
@@ -1349,9 +1408,25 @@ final class DomainViewModel {
1349 let result = try DomainDataPortabilityService.applyImport(preview, mode: mode) 1408 let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
1350 refreshPersistedData() 1409 refreshPersistedData()
1351 portabilityStatusMessage = result.summary 1410 portabilityStatusMessage = result.summary
1411 CloudSyncService.shared.markAppSettingsChanged()
1412 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled)
1413 CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .import)
1352 return result 1414 return result
1353 } 1415 }
1354 1416
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
1355 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String { 1430 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
1356 let reports = workflowReports(from: summary, changedOnly: changedOnly) 1431 let reports = workflowReports(from: summary, changedOnly: changedOnly)
1357 let base = DomainReportExporter.batchText( 1432 let base = DomainReportExporter.batchText(
@@ -2011,17 +2086,20 @@ final class DomainViewModel {
2011 2086
2012 private func persistTrackedDomains() { 2087 private func persistTrackedDomains() {
2013 DomainDataPortabilityService.saveTrackedDomains(trackedDomains) 2088 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2089 CloudSyncService.shared.scheduleSyncIfNeeded()
2014 refreshDataLifecycleSummary() 2090 refreshDataLifecycleSummary()
2015 } 2091 }
2016 2092
2017 private func persistMonitoringSettings() { 2093 private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
2018 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) 2094 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2019 MonitoringStorage.saveSettings(monitoringSettings) 2095 MonitoringStorage.saveSettings(monitoringSettings)
2096 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed)
2020 } 2097 }
2021 2098
2022 private func sanitizeMonitoringSelection() { 2099 private func sanitizeMonitoringSelection() {
2023 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) 2100 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2024 MonitoringStorage.saveSettings(monitoringSettings) 2101 MonitoringStorage.saveSettings(monitoringSettings)
2102 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)
2025 } 2103 }
2026 2104
2027 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { 2105 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
@@ -2150,6 +2228,7 @@ final class DomainViewModel {
2150 recentSearches = Array(recentSearches.prefix(Self.maxRecent)) 2228 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
2151 } 2229 }
2152 DomainDataPortabilityService.saveRecentSearches(recentSearches) 2230 DomainDataPortabilityService.saveRecentSearches(recentSearches)
2231 CloudSyncService.shared.markAppSettingsChanged()
2153 refreshDataLifecycleSummary() 2232 refreshDataLifecycleSummary()
2154 } 2233 }
2155 2234
@@ -2723,6 +2802,7 @@ final class DomainViewModel {
2723 2802
2724 private func persistWorkflows() { 2803 private func persistWorkflows() {
2725 DomainDataPortabilityService.saveWorkflows(workflows) 2804 DomainDataPortabilityService.saveWorkflows(workflows)
2805 CloudSyncService.shared.scheduleSyncIfNeeded()
2726 refreshDataLifecycleSummary() 2806 refreshDataLifecycleSummary()
2727 } 2807 }
2728 2808
DomainDig/Info.plist +2
@@ -10,6 +10,8 @@
10 <array> 10 <array>
11 <string>net.cleberg.DomainDig.monitor.refresh</string> 11 <string>net.cleberg.DomainDig.monitor.refresh</string>
12 </array> 12 </array>
13 <key>CKSharingSupported</key>
14 <true/>
13 <key>UIBackgroundModes</key> 15 <key>UIBackgroundModes</key>
14 <array> 16 <array>
15 <string>fetch</string> 17 <string>fetch</string>
DomainDig/Models.swift +73 −2
@@ -332,6 +332,7 @@ struct DomainWorkflow: Codable, Identifiable, Equatable {
332 var createdAt: Date 332 var createdAt: Date
333 var updatedAt: Date 333 var updatedAt: Date
334 var notes: String? 334 var notes: String?
335 var collaboration: CollaborationMetadata?
335 336
336 init( 337 init(
337 id: UUID = UUID(), 338 id: UUID = UUID(),
@@ -339,7 +340,8 @@ struct DomainWorkflow: Codable, Identifiable, Equatable {
339 domains: [String], 340 domains: [String],
340 createdAt: Date = Date(), 341 createdAt: Date = Date(),
341 updatedAt: Date = Date(), 342 updatedAt: Date = Date(),
342 notes: String? = nil 343 notes: String? = nil,
344 collaboration: CollaborationMetadata? = nil
343 ) { 345 ) {
344 self.id = id 346 self.id = id
345 self.name = name 347 self.name = name
@@ -347,6 +349,7 @@ struct DomainWorkflow: Codable, Identifiable, Equatable {
347 self.createdAt = createdAt 349 self.createdAt = createdAt
348 self.updatedAt = updatedAt 350 self.updatedAt = updatedAt
349 self.notes = notes 351 self.notes = notes
352 self.collaboration = collaboration
350 } 353 }
351} 354}
352 355
@@ -584,6 +587,70 @@ enum PremiumCapability: String, Codable {
584 case advancedExports 587 case advancedExports
585} 588}
586 589
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
587struct TrackedDomain: Codable, Identifiable, Equatable { 654struct TrackedDomain: Codable, Identifiable, Equatable {
588 let id: UUID 655 let id: UUID
589 var domain: String 656 var domain: String
@@ -600,6 +667,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
600 var certificateDaysRemaining: Int? 667 var certificateDaysRemaining: Int?
601 var lastMonitoredAt: Date? 668 var lastMonitoredAt: Date?
602 var lastAlertAt: Date? 669 var lastAlertAt: Date?
670 var collaboration: CollaborationMetadata?
603 671
604 init( 672 init(
605 id: UUID = UUID(), 673 id: UUID = UUID(),
@@ -616,7 +684,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
616 certificateWarningLevel: CertificateWarningLevel = .none, 684 certificateWarningLevel: CertificateWarningLevel = .none,
617 certificateDaysRemaining: Int? = nil, 685 certificateDaysRemaining: Int? = nil,
618 lastMonitoredAt: Date? = nil, 686 lastMonitoredAt: Date? = nil,
619 lastAlertAt: Date? = nil 687 lastAlertAt: Date? = nil,
688 collaboration: CollaborationMetadata? = nil
620 ) { 689 ) {
621 self.id = id 690 self.id = id
622 self.domain = domain 691 self.domain = domain
@@ -633,6 +702,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
633 self.certificateDaysRemaining = certificateDaysRemaining 702 self.certificateDaysRemaining = certificateDaysRemaining
634 self.lastMonitoredAt = lastMonitoredAt 703 self.lastMonitoredAt = lastMonitoredAt
635 self.lastAlertAt = lastAlertAt 704 self.lastAlertAt = lastAlertAt
705 self.collaboration = collaboration
636 } 706 }
637 707
638 init(from decoder: Decoder) throws { 708 init(from decoder: Decoder) throws {
@@ -652,6 +722,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
652 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining) 722 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
653 lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt) 723 lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
654 lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt) 724 lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt)
725 collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
655 } 726 }
656} 727}
657 728
DomainDig/WatchlistView.swift +49 −7
@@ -193,10 +193,12 @@ struct WatchlistView: View {
193 } 193 }
194 .tint(.cyan) 194 .tint(.cyan)
195 195
196 Button(role: .destructive) { 196 if viewModel.canDelete(trackedDomain) {
197 viewModel.deleteTrackedDomain(trackedDomain) 197 Button(role: .destructive) {
198 } label: { 198 viewModel.deleteTrackedDomain(trackedDomain)
199 Label("Delete", systemImage: "trash") 199 } label: {
200 Label("Delete", systemImage: "trash")
201 }
200 } 202 }
201 } 203 }
202 .contextMenu { 204 .contextMenu {
@@ -219,11 +221,21 @@ struct WatchlistView: View {
219 } label: { 221 } label: {
220 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") 222 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
221 } 223 }
224 .disabled(!viewModel.canEdit(trackedDomain))
222 225
223 Button(role: .destructive) { 226 Button {
224 viewModel.deleteTrackedDomain(trackedDomain) 227 // The system sharing UI manages participants and permissions.
225 } label: { 228 } 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 }
227 } 239 }
228 } 240 }
229 .listRowBackground(Color(.systemGray6).opacity(0.5)) 241 .listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -287,6 +299,12 @@ struct WatchlistRowView: View {
287 .font(appDensity.font(.caption2)) 299 .font(appDensity.font(.caption2))
288 .foregroundStyle(.secondary) 300 .foregroundStyle(.secondary)
289 301
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
290 HStack(spacing: 8) { 308 HStack(spacing: 8) {
291 Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off") 309 Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
292 if let lastMonitoredAt = trackedDomain.lastMonitoredAt { 310 if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
@@ -340,6 +358,17 @@ struct WatchlistRowView: View {
340 @ViewBuilder 358 @ViewBuilder
341 private var indicatorRow: some View { 359 private var indicatorRow: some View {
342 HStack(spacing: 8) { 360 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
343 AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary)) 372 AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary))
344 373
345 if trackedDomain.certificateWarningLevel != .none { 374 if trackedDomain.certificateWarningLevel != .none {
@@ -369,6 +398,7 @@ struct TrackedDomainDetailView: View {
369 @State private var noteDraft = "" 398 @State private var noteDraft = ""
370 @State private var isEditingNote = false 399 @State private var isEditingNote = false
371 @State private var showRerunOptions = false 400 @State private var showRerunOptions = false
401 @State private var shareEntity: ShareableEntity?
372 402
373 private var liveTrackedDomain: TrackedDomain { 403 private var liveTrackedDomain: TrackedDomain {
374 viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain 404 viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
@@ -410,6 +440,7 @@ struct TrackedDomainDetailView: View {
410 } label: { 440 } label: {
411 Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin") 441 Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
412 } 442 }
443 .disabled(!viewModel.canEdit(liveTrackedDomain))
413 444
414 Button { 445 Button {
415 viewModel.toggleMonitoring(for: liveTrackedDomain) 446 viewModel.toggleMonitoring(for: liveTrackedDomain)
@@ -419,6 +450,7 @@ struct TrackedDomainDetailView: View {
419 systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell" 450 systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell"
420 ) 451 )
421 } 452 }
453 .disabled(!viewModel.canEdit(liveTrackedDomain))
422 454
423 Button { 455 Button {
424 noteDraft = liveTrackedDomain.note ?? "" 456 noteDraft = liveTrackedDomain.note ?? ""
@@ -426,6 +458,13 @@ struct TrackedDomainDetailView: View {
426 } label: { 458 } label: {
427 Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text") 459 Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
428 } 460 }
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 }
429 } 468 }
430 .listRowBackground(Color(.systemGray6).opacity(0.5)) 469 .listRowBackground(Color(.systemGray6).opacity(0.5))
431 470
@@ -518,5 +557,8 @@ struct TrackedDomainDetailView: View {
518 } 557 }
519 } 558 }
520 } 559 }
560 .sheet(item: $shareEntity) { entity in
561 CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
562 }
521 } 563 }
522} 564}
DomainDig/WorkflowsView.swift +31
@@ -129,6 +129,11 @@ private struct WorkflowRowView: View {
129 var body: some View { 129 var body: some View {
130 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { 130 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
131 HStack(alignment: .firstTextBaseline, spacing: 8) { 131 HStack(alignment: .firstTextBaseline, spacing: 8) {
132 if workflow.collaboration?.isShared == true {
133 Image(systemName: "person.2.fill")
134 .font(.caption2)
135 .foregroundStyle(.cyan)
136 }
132 Text(workflow.name) 137 Text(workflow.name)
133 .font(appDensity.font(.callout, design: .default, weight: .semibold)) 138 .font(appDensity.font(.callout, design: .default, weight: .semibold))
134 .foregroundStyle(.primary) 139 .foregroundStyle(.primary)
@@ -143,6 +148,12 @@ private struct WorkflowRowView: View {
143 .font(appDensity.font(.caption2)) 148 .font(appDensity.font(.caption2))
144 .foregroundStyle(.secondary) 149 .foregroundStyle(.secondary)
145 150
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
146 if let notes = workflow.notes, !notes.isEmpty { 157 if let notes = workflow.notes, !notes.isEmpty {
147 Text(notes) 158 Text(notes)
148 .font(appDensity.font(.caption)) 159 .font(appDensity.font(.caption))
@@ -163,6 +174,7 @@ struct WorkflowDetailView: View {
163 @State private var showingEditor = false 174 @State private var showingEditor = false
164 @State private var draftDomain = "" 175 @State private var draftDomain = ""
165 @State private var includeAllExports = false 176 @State private var includeAllExports = false
177 @State private var shareEntity: ShareableEntity?
166 178
167 private var workflow: DomainWorkflow? { 179 private var workflow: DomainWorkflow? {
168 viewModel.workflow(withID: workflowID) 180 viewModel.workflow(withID: workflowID)
@@ -186,6 +198,11 @@ struct WorkflowDetailView: View {
186 .font(appDensity.font(.caption)) 198 .font(appDensity.font(.caption))
187 .foregroundStyle(.secondary) 199 .foregroundStyle(.secondary)
188 } 200 }
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 }
189 } 206 }
190 207
191 Section("Actions") { 208 Section("Actions") {
@@ -207,6 +224,13 @@ struct WorkflowDetailView: View {
207 } label: { 224 } label: {
208 Label("Edit Workflow", systemImage: "pencil") 225 Label("Edit Workflow", systemImage: "pencil")
209 } 226 }
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 }
210 234
211 if latestSummary != nil { 235 if latestSummary != nil {
212 Toggle("Export all domains", isOn: $includeAllExports) 236 Toggle("Export all domains", isOn: $includeAllExports)
@@ -263,6 +287,7 @@ struct WorkflowDetailView: View {
263 viewModel.addDomains([draftDomain], to: workflow) 287 viewModel.addDomains([draftDomain], to: workflow)
264 draftDomain = "" 288 draftDomain = ""
265 } 289 }
290 .disabled(!viewModel.canEdit(workflow))
266 } 291 }
267 } 292 }
268 293
@@ -288,9 +313,11 @@ struct WorkflowDetailView: View {
288 .buttonStyle(.plain) 313 .buttonStyle(.plain)
289 } 314 }
290 .onDelete { offsets in 315 .onDelete { offsets in
316 guard viewModel.canEdit(workflow) else { return }
291 viewModel.removeWorkflowDomains(at: offsets, from: workflow) 317 viewModel.removeWorkflowDomains(at: offsets, from: workflow)
292 } 318 }
293 .onMove { offsets, destination in 319 .onMove { offsets, destination in
320 guard viewModel.canEdit(workflow) else { return }
294 viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow) 321 viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow)
295 } 322 }
296 } 323 }
@@ -302,6 +329,7 @@ struct WorkflowDetailView: View {
302 .toolbar { 329 .toolbar {
303 if !workflow.domains.isEmpty { 330 if !workflow.domains.isEmpty {
304 EditButton() 331 EditButton()
332 .disabled(!viewModel.canEdit(workflow))
305 } 333 }
306 } 334 }
307 .sheet(isPresented: $showingEditor) { 335 .sheet(isPresented: $showingEditor) {
@@ -310,6 +338,9 @@ struct WorkflowDetailView: View {
310 .sheet(item: workflowSummaryBinding) { summary in 338 .sheet(item: workflowSummaryBinding) { summary in
311 WorkflowRunSummaryView(viewModel: viewModel, summary: summary) 339 WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
312 } 340 }
341 .sheet(item: $shareEntity) { entity in
342 CloudSharingSheet(entity: entity, title: workflow.name)
343 }
313 } else { 344 } else {
314 Text("Workflow not found") 345 Text("Workflow not found")
315 .font(appDensity.font(.callout)) 346 .font(appDensity.font(.callout))
DomainDigCLI.swift +2
@@ -428,6 +428,8 @@ struct DomainDigCLI {
428 domaindig backup export [path] 428 domaindig backup export [path]
429 domaindig backup import <path> [--replace] 429 domaindig backup import <path> [--replace]
430 domaindig backup validate <path> [--json] 430 domaindig backup validate <path> [--json]
431
432 note: the CLI remains local-only and does not sync with iCloud yet.
431 """ 433 """
432 } 434 }
433} 435}