diff options
| author | Christian Cleberg <[email protected]> | 2026-04-23 22:15:20 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-23 22:15:20 -0500 |
| commit | f045a5d339a796f40a9e958436ebc6f5314bdab8 (patch) | |
| tree | c1d122f3ed7a9110dfcd001c86a56ed67980dfcc /DomainDig/DomainViewModel.swift | |
| parent | 6c23daaaba2a954220ece2afa7193dbdcad55c22 (diff) | |
| download | domain-dig-f045a5d339a796f40a9e958436ebc6f5314bdab8.tar.gz domain-dig-f045a5d339a796f40a9e958436ebc6f5314bdab8.tar.bz2 domain-dig-f045a5d339a796f40a9e958436ebc6f5314bdab8.zip | |
feat(v3.4.0): add backup, restore, and data portability
• introduce versioned DomainDig backup format
• add full backup export/import with merge and replace modes
• implement data validation and conflict handling
• add partial exports for tracked domains, workflows, and history
• add Data Portability settings section
• support Files/iCloud Drive import and export
• harden migrations for evolving local models
• extend CLI with backup export and validation
Diffstat (limited to 'DomainDig/DomainViewModel.swift')
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 295 |
1 files changed, 222 insertions, 73 deletions
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 83d282c..527bf5e 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UserNotifications enum ResultTone { case primary @@ -218,10 +219,10 @@ final class DomainViewModel { private static let recentSearchesKey = "recentSearches" private static let maxRecent = 20 - var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] + var recentSearches: [String] = DomainDataPortabilityService.loadRecentSearches() private static let savedDomainsKey = "savedDomains" - var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] + var savedDomains: [String] = DomainDataPortabilityService.loadSavedDomains() private static let trackedDomainsKey = "trackedDomains" private static let legacyWatchedDomainsKey = "watchedDomains" @@ -239,6 +240,13 @@ final class DomainViewModel { var watchlistSearchText = "" var watchlistFilter: WatchlistFilterOption = .all var watchlistSortOption: WatchlistSortOption = .pinned + var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings() + var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs() + var monitoringRunInProgress = false + var monitoringStatusMessage: String? + var monitoringNotificationStatus: UNAuthorizationStatus = .notDetermined + var dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary() + var portabilityStatusMessage: String? var upgradePrompt: UpgradePromptContext? var isPaywallPresented = false @@ -598,12 +606,14 @@ final class DomainViewModel { } else { savedDomains.append(searchedDomain) } - UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) + DomainDataPortabilityService.saveSavedDomains(savedDomains) + refreshDataLifecycleSummary() } func removeSavedDomains(at offsets: IndexSet) { savedDomains.remove(atOffsets: offsets) - UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) + DomainDataPortabilityService.saveSavedDomains(savedDomains) + refreshDataLifecycleSummary() } @discardableResult @@ -636,6 +646,7 @@ final class DomainViewModel { at: 0 ) persistTrackedDomains() + sanitizeMonitoringSelection() linkTrackedDomainHistory(for: normalizedDomain) return true } @@ -664,6 +675,7 @@ final class DomainViewModel { } persistTrackedDomains() persistHistory() + sanitizeMonitoringSelection() } func deleteTrackedDomain(_ trackedDomain: TrackedDomain) { @@ -675,6 +687,7 @@ final class DomainViewModel { } persistTrackedDomains() persistHistory() + sanitizeMonitoringSelection() } func togglePinned(for trackedDomain: TrackedDomain) { @@ -702,6 +715,7 @@ final class DomainViewModel { func clearHistory() { history.removeAll() persistHistory() + refreshDataLifecycleSummary() } func clearLookupCache() { @@ -714,17 +728,157 @@ final class DomainViewModel { workflows.removeAll() latestWorkflowRunSummary = nil persistWorkflows() + refreshDataLifecycleSummary() } func clearTrackedDomains() { trackedDomains.removeAll() refreshingTrackedDomainID = nil persistTrackedDomains() + sanitizeMonitoringSelection() + refreshDataLifecycleSummary() } func clearRecentSearches() { recentSearches.removeAll() - UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) + DomainDataPortabilityService.saveRecentSearches([]) + refreshDataLifecycleSummary() + } + + func refreshMonitoringState() { + DataMigrationService.migrateIfNeeded() + trackedDomains = Self.loadTrackedDomains() + history = Self.loadHistoryEntries() + monitoringSettings = MonitoringStorage.sanitizeSettings( + MonitoringStorage.loadSettings(), + trackedDomains: trackedDomains + ) + monitoringLogs = MonitoringStorage.loadLogs() + persistMonitoringSettings() + refreshDataLifecycleSummary() + } + + func refreshDataLifecycleSummary() { + dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary() + } + + func refreshPersistedData() { + recentSearches = DomainDataPortabilityService.loadRecentSearches() + savedDomains = DomainDataPortabilityService.loadSavedDomains() + trackedDomains = Self.loadTrackedDomains() + history = Self.loadHistoryEntries() + workflows = Self.loadWorkflows() + monitoringSettings = MonitoringStorage.sanitizeSettings( + MonitoringStorage.loadSettings(), + trackedDomains: trackedDomains + ) + monitoringLogs = MonitoringStorage.loadLogs() + refreshDataLifecycleSummary() + } + + func refreshMonitoringAuthorizationStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + monitoringNotificationStatus = settings.authorizationStatus + } + + func setMonitoringEnabled(_ isEnabled: Bool) { + guard !isEnabled || FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + monitoringSettings.isEnabled = false + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + + monitoringSettings.isEnabled = isEnabled + persistMonitoringSettings() + monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + } + + func setMonitoringScope(_ scope: MonitoringScope) { + monitoringSettings.scope = scope + persistMonitoringSettings() + } + + func setMonitoringFrequency(_ frequency: MonitoringFrequency) { + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + monitoringSettings.frequency = frequency + persistMonitoringSettings() + monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + } + + func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) { + guard FeatureAccessService.hasAccess(to: .localAlerts) else { + monitoringSettings.alertsEnabled = false + upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts) + return + } + monitoringSettings.alertFilter = filter + persistMonitoringSettings() + } + + func setMonitoringAlertsEnabled(_ isEnabled: Bool) { + guard !isEnabled || FeatureAccessService.hasAccess(to: .localAlerts) else { + monitoringSettings.alertsEnabled = false + upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts) + return + } + monitoringSettings.alertsEnabled = isEnabled + persistMonitoringSettings() + } + + func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) { + if isSelected { + if !monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) { + monitoringSettings.selectedDomainIDs.append(trackedDomain.id) + } + } else { + monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id } + } + persistMonitoringSettings() + } + + func toggleMonitoring(for trackedDomain: TrackedDomain) { + 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() + MonitoringStorage.saveTrackedDomains(trackedDomains) + sanitizeMonitoringSelection() + } + + func requestMonitoringNotificationAuthorization() async { + let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded() + monitoringSettings.alertsEnabled = granted + persistMonitoringSettings() + await refreshMonitoringAuthorizationStatus() + } + + func runMonitoringNow() { + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + guard !monitoringRunInProgress else { return } + + monitoringRunInProgress = true + monitoringStatusMessage = nil + + Task { [weak self] in + guard let self else { return } + let outcome = await DomainMonitoringService.shared.performMonitoring( + trigger: .manual, + requireEnabledSetting: false + ) + await MainActor.run { + self.refreshMonitoringState() + self.monitoringRunInProgress = false + self.monitoringStatusMessage = outcome.message + } + } } func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) { @@ -1159,6 +1313,45 @@ final class DomainViewModel { ) } + func exportFullBackupData() -> Data? { + try? DomainDataPortabilityService.backupData() + } + + func exportPortableTrackedDomainsJSONData() -> Data? { + try? DomainDataPortabilityService.trackedDomainsExportData() + } + + func exportPortableTrackedDomainsCSV() -> String { + DomainDataPortabilityService.trackedDomainsCSV() + } + + func exportPortableWorkflowsJSONData() -> Data? { + try? DomainDataPortabilityService.workflowsExportData() + } + + func exportPortableWorkflowsCSV() -> String { + DomainDataPortabilityService.workflowsCSV() + } + + func exportPortableHistoryJSONData() -> Data? { + try? DomainDataPortabilityService.historyExportData() + } + + func prepareDataImport( + data: Data, + fileName: String, + mode: DataPortabilityImportMode + ) throws -> DataImportPreview { + try DomainDataPortabilityService.prepareImport(data: data, fileName: fileName, mode: mode) + } + + func applyDataImport(_ preview: DataImportPreview, mode: DataPortabilityImportMode) throws -> DataImportResult { + let result = try DomainDataPortabilityService.applyImport(preview, mode: mode) + refreshPersistedData() + portabilityStatusMessage = result.summary + return result + } + func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String { let reports = workflowReports(from: summary, changedOnly: changedOnly) let base = DomainReportExporter.batchText( @@ -1806,9 +1999,8 @@ final class DomainViewModel { } private func persistHistory() { - if let data = try? JSONEncoder().encode(history) { - UserDefaults.standard.set(data, forKey: Self.historyKey) - } + DomainDataPortabilityService.saveHistoryEntries(history) + refreshDataLifecycleSummary() } func updateHistoryNote(_ note: String, for entry: HistoryEntry) { @@ -1818,9 +2010,18 @@ final class DomainViewModel { } private func persistTrackedDomains() { - if let data = try? JSONEncoder().encode(trackedDomains) { - UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) - } + DomainDataPortabilityService.saveTrackedDomains(trackedDomains) + refreshDataLifecycleSummary() + } + + private func persistMonitoringSettings() { + monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) + MonitoringStorage.saveSettings(monitoringSettings) + } + + private func sanitizeMonitoringSelection() { + monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) + MonitoringStorage.saveSettings(monitoringSettings) } private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { @@ -1948,7 +2149,8 @@ final class DomainViewModel { if recentSearches.count > Self.maxRecent { recentSearches = Array(recentSearches.prefix(Self.maxRecent)) } - UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) + DomainDataPortabilityService.saveRecentSearches(recentSearches) + refreshDataLifecycleSummary() } private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID { @@ -2510,76 +2712,23 @@ final class DomainViewModel { } private static func loadHistoryEntries() -> [HistoryEntry] { - let defaults = UserDefaults.standard - guard let data = defaults.data(forKey: historyKey) else { - return [] - } - - if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) { - return entries - } - - guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { - return [] - } - - let decoder = JSONDecoder() - return rawArray.compactMap { item in - guard JSONSerialization.isValidJSONObject(item), - let itemData = try? JSONSerialization.data(withJSONObject: item), - let entry = try? decoder.decode(HistoryEntry.self, from: itemData) else { - return nil - } - return entry - } + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadHistoryEntries() } private static func loadTrackedDomains() -> [TrackedDomain] { - let defaults = UserDefaults.standard - let decoder = JSONDecoder() - - if let data = defaults.data(forKey: trackedDomainsKey), - let domains = try? decoder.decode([TrackedDomain].self, from: data) { - return deduplicatedTrackedDomains(domains) - } - - if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey), - let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) { - return deduplicatedTrackedDomains( - legacyDomains.map { - TrackedDomain( - id: $0.id, - domain: $0.domain.lowercased(), - createdAt: $0.createdAt, - updatedAt: $0.createdAt, - lastKnownAvailability: $0.lastKnownAvailability - ) - } - ) - } - - return [] + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadTrackedDomains() } private func persistWorkflows() { - if let data = try? JSONEncoder().encode(workflows) { - UserDefaults.standard.set(data, forKey: Self.workflowsKey) - } + DomainDataPortabilityService.saveWorkflows(workflows) + refreshDataLifecycleSummary() } private static func loadWorkflows() -> [DomainWorkflow] { - let defaults = UserDefaults.standard - guard let data = defaults.data(forKey: workflowsKey), - let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else { - return [] - } - - return workflows.sorted { lhs, rhs in - if lhs.updatedAt != rhs.updatedAt { - return lhs.updatedAt > rhs.updatedAt - } - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadWorkflows() } private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] { |
