summaryrefslogtreecommitdiff
path: root/DomainDig/DomainViewModel.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-23 23:12:34 -0500
committerChristian Cleberg <[email protected]>2026-04-23 23:12:34 -0500
commitb35f1b432fc24fbab1fa05593c9a0bca7e4d95a6 (patch)
tree099cea2cc4324331a719ed2cad8c0815033b5471 /DomainDig/DomainViewModel.swift
parentf045a5d339a796f40a9e958436ebc6f5314bdab8 (diff)
downloaddomain-dig-b35f1b432fc24fbab1fa05593c9a0bca7e4d95a6.tar.gz
domain-dig-b35f1b432fc24fbab1fa05593c9a0bca7e4d95a6.tar.bz2
domain-dig-b35f1b432fc24fbab1fa05593c9a0bca7e4d95a6.zip
feat(v3.6.0): add iCloud sharing for workflows and tracked domains
- implement CloudKit sharing (CKShare) for TrackedDomain and DomainWorkflow - support read-only and editable permissions - add Share actions and shared state indicators in UI - handle shared record ownership and participant roles - implement deterministic conflict resolution to prevent duplication - ensure compatibility with existing iCloud sync layer - maintain local-first behavior with graceful offline handling - remove debug logging for domain availability/rdap messages notes: - no custom backend or accounts introduced - sharing is Apple ecosystem only (iCloud-based) - large history data remains local-only
Diffstat (limited to 'DomainDig/DomainViewModel.swift')
-rw-r--r--DomainDig/DomainViewModel.swift102
1 files changed, 91 insertions, 11 deletions
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 527bf5e..02e09f7 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -607,12 +607,14 @@ final class DomainViewModel {
savedDomains.append(searchedDomain)
}
DomainDataPortabilityService.saveSavedDomains(savedDomains)
+ CloudSyncService.shared.markAppSettingsChanged()
refreshDataLifecycleSummary()
}
func removeSavedDomains(at offsets: IndexSet) {
savedDomains.remove(atOffsets: offsets)
DomainDataPortabilityService.saveSavedDomains(savedDomains)
+ CloudSyncService.shared.markAppSettingsChanged()
refreshDataLifecycleSummary()
}
@@ -641,7 +643,12 @@ final class DomainViewModel {
domain: normalizedDomain,
createdAt: Date(),
updatedAt: Date(),
- lastKnownAvailability: availabilityStatus
+ lastKnownAvailability: availabilityStatus,
+ collaboration: CollaborationMetadata(
+ scope: .privateDatabase,
+ ownership: .owner,
+ permission: .editable
+ )
),
at: 0
)
@@ -666,7 +673,9 @@ final class DomainViewModel {
}
func deleteTrackedDomains(at offsets: IndexSet) {
- let ids = offsets.map { sortedTrackedDomains[$0].id }
+ let removedDomains = offsets.map { sortedTrackedDomains[$0] }
+ let ids = removedDomains.map(\.id)
+ CloudSyncService.shared.recordTrackedDomainReset(removedDomains)
trackedDomains.removeAll { ids.contains($0.id) }
history.indices.forEach { index in
if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
@@ -679,6 +688,8 @@ final class DomainViewModel {
}
func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
+ guard canDelete(trackedDomain) else { return }
+ CloudSyncService.shared.recordTrackedDomainDeletion(trackedDomain)
trackedDomains.removeAll { $0.id == trackedDomain.id }
history.indices.forEach { index in
if history[index].trackedDomainID == trackedDomain.id {
@@ -691,14 +702,19 @@ final class DomainViewModel {
}
func togglePinned(for trackedDomain: TrackedDomain) {
+ guard canEdit(trackedDomain) else { return }
guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
trackedDomains[index].isPinned.toggle()
+ trackedDomains[index].updatedAt = Date()
persistTrackedDomains()
}
func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
+ guard canEdit(trackedDomain) else { return }
guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ trackedDomains[index].updatedAt = Date()
+ CloudSyncService.shared.markNoteChanged(for: trackedDomains[index].domain, updatedAt: trackedDomains[index].updatedAt)
persistTrackedDomains()
}
@@ -725,6 +741,7 @@ final class DomainViewModel {
}
func clearWorkflows() {
+ CloudSyncService.shared.recordWorkflowReset(workflows)
workflows.removeAll()
latestWorkflowRunSummary = nil
persistWorkflows()
@@ -732,6 +749,7 @@ final class DomainViewModel {
}
func clearTrackedDomains() {
+ CloudSyncService.shared.recordTrackedDomainReset(trackedDomains)
trackedDomains.removeAll()
refreshingTrackedDomainID = nil
persistTrackedDomains()
@@ -742,6 +760,7 @@ final class DomainViewModel {
func clearRecentSearches() {
recentSearches.removeAll()
DomainDataPortabilityService.saveRecentSearches([])
+ CloudSyncService.shared.markAppSettingsChanged()
refreshDataLifecycleSummary()
}
@@ -789,13 +808,13 @@ final class DomainViewModel {
}
monitoringSettings.isEnabled = isEnabled
- persistMonitoringSettings()
+ persistMonitoringSettings(localActivationConfirmed: isEnabled)
monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
}
func setMonitoringScope(_ scope: MonitoringScope) {
monitoringSettings.scope = scope
- persistMonitoringSettings()
+ persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
}
func setMonitoringFrequency(_ frequency: MonitoringFrequency) {
@@ -804,7 +823,7 @@ final class DomainViewModel {
return
}
monitoringSettings.frequency = frequency
- persistMonitoringSettings()
+ persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
}
@@ -815,7 +834,7 @@ final class DomainViewModel {
return
}
monitoringSettings.alertFilter = filter
- persistMonitoringSettings()
+ persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
}
func setMonitoringAlertsEnabled(_ isEnabled: Bool) {
@@ -825,7 +844,7 @@ final class DomainViewModel {
return
}
monitoringSettings.alertsEnabled = isEnabled
- persistMonitoringSettings()
+ persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
}
func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) {
@@ -836,24 +855,27 @@ final class DomainViewModel {
} else {
monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id }
}
- persistMonitoringSettings()
+ persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
}
func toggleMonitoring(for trackedDomain: TrackedDomain) {
+ guard canEdit(trackedDomain) else { return }
guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
return
}
guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
trackedDomains[index].monitoringEnabled.toggle()
+ trackedDomains[index].updatedAt = Date()
MonitoringStorage.saveTrackedDomains(trackedDomains)
+ CloudSyncService.shared.scheduleSyncIfNeeded()
sanitizeMonitoringSelection()
}
func requestMonitoringNotificationAuthorization() async {
let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
monitoringSettings.alertsEnabled = granted
- persistMonitoringSettings()
+ persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
await refreshMonitoringAuthorizationStatus()
}
@@ -991,6 +1013,32 @@ final class DomainViewModel {
}
}
+ func canEdit(_ trackedDomain: TrackedDomain) -> Bool {
+ trackedDomain.collaboration?.canEdit ?? true
+ }
+
+ func canDelete(_ trackedDomain: TrackedDomain) -> Bool {
+ trackedDomain.collaboration?.isOwner ?? true
+ }
+
+ func collaborationLabel(for trackedDomain: TrackedDomain) -> String? {
+ guard let collaboration = trackedDomain.collaboration, collaboration.isShared else { return nil }
+ return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
+ }
+
+ func canEdit(_ workflow: DomainWorkflow) -> Bool {
+ workflow.collaboration?.canEdit ?? true
+ }
+
+ func canDelete(_ workflow: DomainWorkflow) -> Bool {
+ workflow.collaboration?.isOwner ?? true
+ }
+
+ func collaborationLabel(for workflow: DomainWorkflow) -> String? {
+ guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil }
+ return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
+ }
+
@discardableResult
func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
let normalizedDomains = normalizedDomains(domains)
@@ -1006,7 +1054,12 @@ final class DomainViewModel {
domains: normalizedDomains,
createdAt: Date(),
updatedAt: Date(),
- notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
+ collaboration: CollaborationMetadata(
+ scope: .privateDatabase,
+ ownership: .owner,
+ permission: .editable
+ )
)
workflows.insert(workflow, at: 0)
persistWorkflows()
@@ -1014,6 +1067,7 @@ final class DomainViewModel {
}
func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
+ guard canEdit(workflow) else { return }
guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
let normalizedDomains = normalizedDomains(domains)
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1027,6 +1081,8 @@ final class DomainViewModel {
}
func deleteWorkflow(_ workflow: DomainWorkflow) {
+ guard canDelete(workflow) else { return }
+ CloudSyncService.shared.recordWorkflowDeletion(workflow)
workflows.removeAll { $0.id == workflow.id }
if latestWorkflowRunSummary?.workflowID == workflow.id {
latestWorkflowRunSummary = nil
@@ -1039,6 +1095,7 @@ final class DomainViewModel {
}
func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
+ guard canEdit(workflow) else { return }
guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
let mergedDomains = normalizedDomains(workflows[index].domains + domains)
guard mergedDomains != workflows[index].domains else { return }
@@ -1048,6 +1105,7 @@ final class DomainViewModel {
}
func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
+ guard canEdit(workflow) else { return }
guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
workflows[index].domains.remove(atOffsets: offsets)
workflows[index].updatedAt = Date()
@@ -1055,6 +1113,7 @@ final class DomainViewModel {
}
func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
+ guard canEdit(workflow) else { return }
guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
workflows[index].updatedAt = Date()
@@ -1349,9 +1408,25 @@ final class DomainViewModel {
let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
refreshPersistedData()
portabilityStatusMessage = result.summary
+ CloudSyncService.shared.markAppSettingsChanged()
+ CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled)
+ CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .import)
return result
}
+ func persistCurrentAppSettings(resolverURLString: String, appDensityRawValue: String) {
+ DomainDataPortabilityService.saveAppSettings(
+ AppSettingsSnapshot(
+ recentSearches: recentSearches,
+ savedDomains: savedDomains,
+ resolverURLString: resolverURLString,
+ appDensityRawValue: appDensityRawValue
+ )
+ )
+ CloudSyncService.shared.markAppSettingsChanged()
+ refreshDataLifecycleSummary()
+ }
+
func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
let reports = workflowReports(from: summary, changedOnly: changedOnly)
let base = DomainReportExporter.batchText(
@@ -2011,17 +2086,20 @@ final class DomainViewModel {
private func persistTrackedDomains() {
DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
+ CloudSyncService.shared.scheduleSyncIfNeeded()
refreshDataLifecycleSummary()
}
- private func persistMonitoringSettings() {
+ private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
MonitoringStorage.saveSettings(monitoringSettings)
+ CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed)
}
private func sanitizeMonitoringSelection() {
monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
MonitoringStorage.saveSettings(monitoringSettings)
+ CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)
}
private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
@@ -2150,6 +2228,7 @@ final class DomainViewModel {
recentSearches = Array(recentSearches.prefix(Self.maxRecent))
}
DomainDataPortabilityService.saveRecentSearches(recentSearches)
+ CloudSyncService.shared.markAppSettingsChanged()
refreshDataLifecycleSummary()
}
@@ -2723,6 +2802,7 @@ final class DomainViewModel {
private func persistWorkflows() {
DomainDataPortabilityService.saveWorkflows(workflows)
+ CloudSyncService.shared.scheduleSyncIfNeeded()
refreshDataLifecycleSummary()
}