diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/AppVersion.swift | 2 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 377 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 13 | ||||
| -rw-r--r-- | DomainDig/DomainMonitoringService.swift | 613 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 295 | ||||
| -rw-r--r-- | DomainDig/FeatureAccessService.swift | 19 | ||||
| -rw-r--r-- | DomainDig/Info.plist | 8 | ||||
| -rw-r--r-- | DomainDig/LocalNotificationService.swift | 33 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 223 | ||||
| -rw-r--r-- | DomainDig/MonitoringView.swift | 198 | ||||
| -rw-r--r-- | DomainDig/PaywallView.swift | 4 | ||||
| -rw-r--r-- | DomainDig/PremiumAccessService.swift | 6 | ||||
| -rw-r--r-- | DomainDig/RootTabView.swift | 7 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 21 |
14 files changed, 1738 insertions, 81 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index f28a017..346beb4 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { nonisolated static var current: String { - "3.2.0" + "3.4.0" } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index a4e8db3..e1d0a99 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -1,5 +1,6 @@ import MapKit import SwiftUI +import UniformTypeIdentifiers enum LookupInputMode: String, CaseIterable, Identifiable { case single @@ -2431,6 +2432,13 @@ struct SettingsView: View { @State private var showClearCacheConfirmation = false @State private var showClearWorkflowsConfirmation = false @State private var showClearTrackedDomainsConfirmation = false + @State private var importMode: DataPortabilityImportMode = .merge + @State private var showBackupImporter = false + @State private var showTrackedDomainsImporter = false + @State private var showWorkflowsImporter = false + @State private var pendingImportPreview: DataImportPreview? + @State private var pendingImportError: String? + @State private var showReplaceImportConfirmation = false private var customResolverError: String? { guard resolverOption == .custom else { @@ -2472,6 +2480,161 @@ struct SettingsView: View { } } + Section("Monitoring") { + Toggle( + "Enable Background Monitoring", + isOn: Binding( + get: { viewModel.monitoringSettings.isEnabled }, + set: { viewModel.setMonitoringEnabled($0) } + ) + ) + + Picker( + "Frequency", + selection: Binding( + get: { viewModel.monitoringSettings.frequency }, + set: { viewModel.setMonitoringFrequency($0) } + ) + ) { + ForEach(MonitoringFrequency.allCases) { frequency in + Text(frequency.title).tag(frequency) + } + } + + Picker( + "Domains", + selection: Binding( + get: { viewModel.monitoringSettings.scope }, + set: { viewModel.setMonitoringScope($0) } + ) + ) { + ForEach(MonitoringScope.allCases) { scope in + Text(scope.title).tag(scope) + } + } + + if viewModel.monitoringSettings.scope == .selectedOnly { + ForEach(viewModel.trackedDomains) { trackedDomain in + Toggle( + trackedDomain.domain, + isOn: Binding( + get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) }, + set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) } + ) + ) + } + } + + Toggle( + "Local Alerts", + isOn: Binding( + get: { viewModel.monitoringSettings.alertsEnabled }, + set: { isEnabled in + if isEnabled { + Task { + await viewModel.requestMonitoringNotificationAuthorization() + } + } else { + viewModel.setMonitoringAlertsEnabled(false) + } + } + ) + ) + + Picker( + "Notify For", + selection: Binding( + get: { viewModel.monitoringSettings.alertFilter }, + set: { viewModel.setMonitoringAlertFilter($0) } + ) + ) { + ForEach(MonitoringAlertFilter.allCases) { filter in + Text(filter.title).tag(filter) + } + } + + LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription()) + LabeledContent("Notification Access", value: notificationAuthorizationLabel) + + if let monitoringStatusMessage = viewModel.monitoringStatusMessage { + Text(monitoringStatusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + + if !FeatureAccessService.hasAccess(to: .automatedMonitoring) { + Text("Background monitoring and alerts are available in Pro.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + } + + Section("Data Portability") { + Picker("Import Mode", selection: $importMode) { + ForEach(DataPortabilityImportMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + + Text(importMode.explanation) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + + Button("Export Full Backup") { + exportFullBackup() + } + + Button("Import Backup") { + showBackupImporter = true + } + + Menu("Export Tracked Domains") { + Button("JSON") { + exportPortableTrackedDomainsJSON() + } + Button("CSV") { + exportPortableTrackedDomainsCSV() + } + } + + Button("Import Tracked Domains") { + showTrackedDomainsImporter = true + } + + Menu("Export Workflows") { + Button("JSON") { + exportPortableWorkflowsJSON() + } + Button("CSV") { + exportPortableWorkflowsCSV() + } + } + + Button("Import Workflows") { + showWorkflowsImporter = true + } + + Button("Export History") { + exportPortableHistoryJSON() + } + + LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") + LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") + LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") + LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)") + LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)") + + Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + + if let portabilityStatusMessage = viewModel.portabilityStatusMessage { + Text(portabilityStatusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + } + Section("Data") { Button("Clear History", role: .destructive) { showClearHistoryConfirmation = true @@ -2551,7 +2714,7 @@ struct SettingsView: View { Section("About") { LabeledContent("Version", value: appVersion) LabeledContent("Storage", value: "Local-only") - LabeledContent("Report Schema", value: "3.2.0") + LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") } } .navigationTitle("Settings") @@ -2587,12 +2750,73 @@ struct SettingsView: View { } message: { Text("This removes the watchlist only. History and workflows stay intact.") } + .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) { + Button("Replace", role: .destructive) { + applyPendingImport() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.") + } + .alert("Import Error", isPresented: Binding( + get: { pendingImportError != nil }, + set: { if !$0 { pendingImportError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(pendingImportError ?? "The import could not be completed.") + } + .sheet(isPresented: Binding( + get: { pendingImportPreview != nil }, + set: { if !$0 { pendingImportPreview = nil } } + )) { + if let pendingImportPreview { + DataImportPreviewSheet( + preview: pendingImportPreview, + mode: importMode, + onCancel: { + self.pendingImportPreview = nil + }, + onApply: { + if importMode == .replace { + showReplaceImportConfirmation = true + } else { + applyPendingImport() + } + } + ) + } + } + .fileImporter( + isPresented: $showBackupImporter, + allowedContentTypes: [UTType.json], + allowsMultipleSelection: false + ) { result in + handleImportResult(result, expectedKind: .backup) + } + .fileImporter( + isPresented: $showTrackedDomainsImporter, + allowedContentTypes: [UTType.json, UTType.commaSeparatedText], + allowsMultipleSelection: false + ) { result in + handleImportResult(result, expectedKind: .trackedDomains) + } + .fileImporter( + isPresented: $showWorkflowsImporter, + allowedContentTypes: [UTType.json, UTType.commaSeparatedText], + allowsMultipleSelection: false + ) { result in + handleImportResult(result, expectedKind: .workflows) + } .onAppear { let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) resolverOption = DNSResolverOption.option(for: currentResolverURL) customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString + viewModel.refreshMonitoringState() + viewModel.refreshDataLifecycleSummary() Task { await viewModel.refreshUsageCredits() + await viewModel.refreshMonitoringAuthorizationStatus() } } .onChange(of: resolverOption) { _, newValue in @@ -2611,6 +2835,157 @@ struct SettingsView: View { private var appVersion: String { AppVersion.current } + + private var notificationAuthorizationLabel: String { + switch viewModel.monitoringNotificationStatus { + case .authorized, .provisional, .ephemeral: + return "Allowed" + case .denied: + return "Denied" + case .notDetermined: + return "Not Requested" + @unknown default: + return "Unknown" + } + } + + private func exportFullBackup() { + guard let data = viewModel.exportFullBackupData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data) + } + + private func exportPortableTrackedDomainsJSON() { + guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data) + } + + private func exportPortableTrackedDomainsCSV() { + ExportPresenter.share( + filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"), + contents: viewModel.exportPortableTrackedDomainsCSV() + ) + } + + private func exportPortableWorkflowsJSON() { + guard let data = viewModel.exportPortableWorkflowsJSONData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data) + } + + private func exportPortableWorkflowsCSV() { + ExportPresenter.share( + filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"), + contents: viewModel.exportPortableWorkflowsCSV() + ) + } + + private func exportPortableHistoryJSON() { + guard let data = viewModel.exportPortableHistoryJSONData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data) + } + + private func handleImportResult( + _ result: Result<[URL], Error>, + expectedKind: DataPortabilityImportKind + ) { + do { + guard let url = try result.get().first else { return } + let shouldStopAccessing = url.startAccessingSecurityScopedResource() + defer { + if shouldStopAccessing { + url.stopAccessingSecurityScopedResource() + } + } + + let data = try Data(contentsOf: url) + let preview = try viewModel.prepareDataImport( + data: data, + fileName: url.lastPathComponent, + mode: importMode + ) + + guard preview.kind == expectedKind else { + pendingImportError = preview.kind == .backup + ? "That file is a full backup. Use Import Backup." + : "That file type does not match this import action." + return + } + + pendingImportPreview = preview + } catch { + pendingImportError = error.localizedDescription + } + } + + private func applyPendingImport() { + guard let pendingImportPreview else { return } + do { + _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode) + self.pendingImportPreview = nil + } catch { + pendingImportError = error.localizedDescription + } + } + + private func portabilityFilename(suffix: String, fileExtension: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)" + } +} + +private struct DataImportPreviewSheet: View { + @Environment(\.dismiss) private var dismiss + + let preview: DataImportPreview + let mode: DataPortabilityImportMode + let onCancel: () -> Void + let onApply: () -> Void + + var body: some View { + NavigationStack { + List { + Section("Summary") { + ForEach(preview.summaryLines, id: \.self) { line in + Text(line) + } + } + + Section("Projected Counts") { + LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)") + LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)") + LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)") + LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)") + LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)") + } + + if !preview.warnings.isEmpty { + Section("Warnings") { + ForEach(preview.warnings, id: \.self) { warning in + Text(warning) + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Import Preview") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + onCancel() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(mode == .replace ? "Replace" : "Import") { + onApply() + if mode == .merge { + dismiss() + } + } + } + } + } + } } #Preview { diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index ae81654..f7cd15e 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -9,12 +9,14 @@ import SwiftUI @main struct DomainDigApp: App { + @Environment(\.scenePhase) private var scenePhase @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue @State private var viewModel = DomainViewModel() @State private var purchaseService = PurchaseService.shared init() { LocalNotificationService.shared.configureForegroundPresentation() + DomainMonitoringScheduler.shared.registerBackgroundTask() } var body: some Scene { @@ -24,7 +26,18 @@ struct DomainDigApp: App { .task { let _ = purchaseService.currentTier await purchaseService.refreshEntitlements() + viewModel.refreshMonitoringState() + await viewModel.refreshMonitoringAuthorizationStatus() + viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() } } + .onChange(of: scenePhase) { _, newValue in + guard newValue == .active else { return } + viewModel.refreshMonitoringState() + Task { + await viewModel.refreshMonitoringAuthorizationStatus() + } + viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + } } } diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift new file mode 100644 index 0000000..5128c0b --- /dev/null +++ b/DomainDig/DomainMonitoringService.swift @@ -0,0 +1,613 @@ +import Foundation +import UserNotifications + +#if canImport(BackgroundTasks) +import BackgroundTasks +#endif + +#if canImport(UIKit) +import UIKit +#endif + +enum MonitoringStorage { + static let settingsKey = "monitoring.settings" + static let logsKey = "monitoring.logs" + static let trackedDomainsKey = "trackedDomains" + static let historyKey = "lookupHistory" + static let maxLogs = 40 + + static func loadSettings() -> MonitoringSettings { + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadMonitoringSettings() + } + + static func saveSettings(_ settings: MonitoringSettings) { + DomainDataPortabilityService.saveMonitoringSettings(settings) + } + + static func loadLogs() -> [MonitoringLog] { + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadMonitoringLogs() + } + + static func saveLogs(_ logs: [MonitoringLog]) { + DomainDataPortabilityService.saveMonitoringLogs(Array(logs.prefix(maxLogs))) + } + + static func loadTrackedDomains() -> [TrackedDomain] { + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadTrackedDomains() + } + + static func saveTrackedDomains(_ domains: [TrackedDomain]) { + DomainDataPortabilityService.saveTrackedDomains(domains) + } + + static func loadHistoryEntries() -> [HistoryEntry] { + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadHistoryEntries() + } + + static func saveHistoryEntries(_ entries: [HistoryEntry]) { + DomainDataPortabilityService.saveHistoryEntries(entries) + } + + static func sanitizeSettings(_ settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> MonitoringSettings { + let validIDs = Set(trackedDomains.map(\.id)) + var sanitized = settings + sanitized.selectedDomainIDs = sanitized.selectedDomainIDs.filter { validIDs.contains($0) } + return sanitized + } + + static func monitoredDomains(settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> [TrackedDomain] { + let sanitized = sanitizeSettings(settings, trackedDomains: trackedDomains) + switch sanitized.scope { + case .allTracked: + return trackedDomains.filter(\.monitoringEnabled) + case .selectedOnly: + let selected = Set(sanitized.selectedDomainIDs) + return trackedDomains.filter { selected.contains($0.id) && $0.monitoringEnabled } + } + } +} + +struct MonitoringRunOutcome { + let success: Bool + let message: String + let log: MonitoringLog? +} + +@MainActor +final class DomainMonitoringScheduler { + static let shared = DomainMonitoringScheduler() + static let taskIdentifier = "net.cleberg.DomainDig.monitor.refresh" + + private var isRegistered = false + + private init() {} + + func registerBackgroundTask() { + #if canImport(BackgroundTasks) + guard !isRegistered else { return } + isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + self.handleAppRefresh(task: refreshTask) + } + #endif + } + + func backgroundRefreshStatusDescription() -> String { + #if canImport(UIKit) + switch UIApplication.shared.backgroundRefreshStatus { + case .available: + return "Available" + case .denied: + return "Disabled in Settings" + case .restricted: + return "Restricted by the system" + @unknown default: + return "Unknown" + } + #else + return "Unavailable on this platform" + #endif + } + + @discardableResult + func syncSchedule() -> String? { + #if canImport(BackgroundTasks) + let settings = MonitoringStorage.loadSettings() + guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + return nil + } + + #if canImport(UIKit) + guard UIApplication.shared.backgroundRefreshStatus == .available else { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + return "Background refresh is unavailable." + } + #endif + + let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) + request.earliestBeginDate = Date( + timeIntervalSinceNow: max(settings.frequency.schedulingInterval, 15 * 60) + ) + + do { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + try BGTaskScheduler.shared.submit(request) + return nil + } catch { + return "Could not schedule monitoring." + } + #else + return "Background monitoring is unavailable on this platform." + #endif + } + +#if canImport(BackgroundTasks) + private func handleAppRefresh(task: BGAppRefreshTask) { + _ = syncSchedule() + + let worker = Task { + let outcome = await DomainMonitoringService.shared.performMonitoring( + trigger: .background, + requireEnabledSetting: true + ) + task.setTaskCompleted(success: outcome.success) + } + + task.expirationHandler = { + worker.cancel() + } + } +#endif +} + +@MainActor +final class DomainMonitoringService { + static let shared = DomainMonitoringService() + + private let inspectionService = DomainInspectionService() + private let maxHistoryEntries = 250 + + func performMonitoring( + trigger: MonitoringRunTrigger, + requireEnabledSetting: Bool + ) async -> MonitoringRunOutcome { + let trackedDomains = MonitoringStorage.loadTrackedDomains() + var settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains) + MonitoringStorage.saveSettings(settings) + + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + if settings.isEnabled { + settings.isEnabled = false + MonitoringStorage.saveSettings(settings) + await MainActor.run { + _ = DomainMonitoringScheduler.shared.syncSchedule() + } + } + return MonitoringRunOutcome(success: false, message: "Monitoring requires Pro.", log: nil) + } + + if requireEnabledSetting, !settings.isEnabled { + return MonitoringRunOutcome(success: false, message: "Monitoring is disabled.", log: nil) + } + + var history = MonitoringStorage.loadHistoryEntries() + var mutableTrackedDomains = trackedDomains + let eligibleDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains) + + guard !eligibleDomains.isEmpty else { + let log = MonitoringLog( + timestamp: Date(), + trigger: trigger, + domainsChecked: 0, + changesFound: 0, + alertsTriggered: 0, + checkedDomains: [], + errors: ["No tracked domains are configured for monitoring."] + ) + saveLog(log) + return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log) + } + + let notificationsAuthorized: Bool + if settings.alertsEnabled { + notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts() + } else { + notificationsAuthorized = false + } + var results: [MonitoringDomainResult] = [] + var errors: [String] = [] + var alertsTriggered = 0 + + for trackedDomain in eligibleDomains { + guard !Task.isCancelled else { + return MonitoringRunOutcome(success: false, message: "Monitoring cancelled.", log: nil) + } + + let previousSnapshot = latestSnapshot(for: trackedDomain, history: history) + let inspectedSnapshot = await inspectionService.inspectSnapshot( + domain: trackedDomain.domain, + previousSnapshot: previousSnapshot + ) + let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previousSnapshot) + let savedEntry: HistoryEntry? + if snapshot.statusMessage == nil { + savedEntry = persistSnapshot( + snapshot, + trackedDomainID: trackedDomain.id, + trackedDomains: &mutableTrackedDomains, + history: &history + ) + } else { + savedEntry = history.first(where: { $0.id == snapshot.historyEntryID }) + } + + let alertDescriptor = alertDescriptor( + previousSnapshot: previousSnapshot, + snapshot: snapshot, + entry: savedEntry + ) + + if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { + mutableTrackedDomains[index].lastMonitoredAt = Date() + } + + if notificationsAuthorized, + let alertDescriptor, + alertDescriptor.severity >= settings.alertFilter.minimumSeverity { + await LocalNotificationService.shared.notifyMonitoringAlert( + domain: trackedDomain.domain, + message: alertDescriptor.message, + severity: alertDescriptor.severity + ) + alertsTriggered += 1 + if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { + mutableTrackedDomains[index].lastAlertAt = Date() + } + } + + let result = MonitoringDomainResult( + domain: trackedDomain.domain, + historyEntryID: savedEntry?.id ?? snapshot.historyEntryID, + checkedAt: Date(), + didChange: snapshot.statusMessage == nil && savedEntry?.changeSummary?.hasChanges == true, + summaryMessage: snapshot.statusMessage + ?? savedEntry?.changeSummary?.message + ?? "No meaningful changes", + alertSeverity: alertDescriptor?.severity, + certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot), + resultSource: snapshot.resultSource, + errorMessage: snapshot.statusMessage + ) + results.append(result) + + if let errorMessage = result.errorMessage { + errors.append("\(trackedDomain.domain): \(errorMessage)") + } + } + + MonitoringStorage.saveTrackedDomains(mutableTrackedDomains) + MonitoringStorage.saveHistoryEntries(history) + + let log = MonitoringLog( + timestamp: Date(), + trigger: trigger, + domainsChecked: results.count, + changesFound: results.filter(\.didChange).count, + alertsTriggered: alertsTriggered, + checkedDomains: results.sorted { + $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending + }, + errors: errors + ) + saveLog(log) + + return MonitoringRunOutcome( + success: errors.count < results.count, + message: log.summary, + log: log + ) + } + + private func saveLog(_ log: MonitoringLog) { + var logs = MonitoringStorage.loadLogs() + logs.insert(log, at: 0) + MonitoringStorage.saveLogs(logs) + } + + private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? { + history.first(where: { entry in + if let trackedDomainID = entry.trackedDomainID { + return trackedDomainID == trackedDomain.id + } + return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame + })?.snapshot + } + + private func persistSnapshot( + _ snapshot: LookupSnapshot, + trackedDomainID: UUID, + trackedDomains: inout [TrackedDomain], + history: inout [HistoryEntry] + ) -> HistoryEntry? { + let previousSnapshot = latestSnapshot( + for: trackedDomains.first(where: { $0.id == trackedDomainID }) ?? TrackedDomain(domain: snapshot.domain), + history: history + ) + let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) + let changeSummary = previousSnapshot.map { + DomainDiffService.summary( + from: $0, + to: snapshot, + generatedAt: snapshot.timestamp, + riskAssessment: analysis.riskAssessment, + insights: analysis.insights + ) + } + + let entry = HistoryEntry( + domain: snapshot.domain, + timestamp: snapshot.timestamp, + trackedDomainID: trackedDomainID, + note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note, + dnsSections: snapshot.dnsSections, + sslInfo: snapshot.sslInfo, + httpHeaders: snapshot.httpHeaders, + reachabilityResults: snapshot.reachabilityResults, + ipGeolocation: snapshot.ipGeolocation, + emailSecurity: snapshot.emailSecurity, + mtaSts: snapshot.emailSecurity?.mtaSts, + ownership: snapshot.ownership, + ownershipHistory: snapshot.ownershipHistory, + ptrRecord: snapshot.ptrRecord, + redirectChain: snapshot.redirectChain, + subdomains: snapshot.subdomains, + extendedSubdomains: snapshot.extendedSubdomains, + dnsHistory: snapshot.dnsHistory, + domainPricing: snapshot.domainPricing, + portScanResults: snapshot.portScanResults, + hstsPreloaded: snapshot.hstsPreloaded, + availabilityResult: snapshot.availabilityResult, + suggestions: snapshot.suggestions, + appVersion: snapshot.appVersion, + resultSource: snapshot.resultSource, + dataSources: snapshot.dataSources, + provenanceBySection: snapshot.provenanceBySection, + availabilityConfidence: snapshot.availabilityConfidence, + ownershipConfidence: snapshot.ownershipConfidence, + subdomainConfidence: snapshot.subdomainConfidence, + emailSecurityConfidence: snapshot.emailSecurityConfidence, + geolocationConfidence: snapshot.geolocationConfidence, + errorDetails: snapshot.errorDetails, + isPartialSnapshot: snapshot.isPartialSnapshot, + validationIssues: snapshot.validationIssues, + resolverDisplayName: snapshot.resolverDisplayName, + resolverURLString: snapshot.resolverURLString, + totalLookupDurationMs: snapshot.totalLookupDurationMs, + primaryIP: Self.primaryIPAddress(from: snapshot), + finalRedirectURL: snapshot.redirectChain.last?.url, + tlsStatusSummary: Self.tlsSummary(from: snapshot), + emailSecuritySummary: Self.emailSummary(from: snapshot), + httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, + changeSummary: changeSummary, + sslError: snapshot.sslError, + httpHeadersError: snapshot.httpHeadersError, + reachabilityError: snapshot.reachabilityError, + ipGeolocationError: snapshot.ipGeolocationError, + emailSecurityError: snapshot.emailSecurityError, + ownershipError: snapshot.ownershipError, + ownershipHistoryError: snapshot.ownershipHistoryError, + ptrError: snapshot.ptrError, + redirectChainError: snapshot.redirectChainError, + subdomainsError: snapshot.subdomainsError, + extendedSubdomainsError: snapshot.extendedSubdomainsError, + dnsHistoryError: snapshot.dnsHistoryError, + domainPricingError: snapshot.domainPricingError, + portScanError: snapshot.portScanError + ) + + history.insert(entry, at: 0) + if history.count > maxHistoryEntries { + history = Array(history.prefix(maxHistoryEntries)) + } + + if let index = trackedDomains.firstIndex(where: { $0.id == trackedDomainID }) { + trackedDomains[index].lastSnapshotID = entry.id + trackedDomains[index].lastKnownAvailability = snapshot.availabilityResult?.status + trackedDomains[index].updatedAt = snapshot.timestamp + trackedDomains[index].lastChangeSummary = changeSummary + trackedDomains[index].lastChangeSeverity = changeSummary?.severity + trackedDomains[index].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot) + trackedDomains[index].certificateDaysRemaining = snapshot.sslInfo?.daysUntilExpiry + } + + return entry + } + + private func alertDescriptor( + previousSnapshot: LookupSnapshot?, + snapshot: LookupSnapshot, + entry: HistoryEntry? + ) -> (severity: MonitoringAlertSeverity, message: String)? { + let changedLabels = Set( + (previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []) + .flatMap(\.items) + .filter(\.hasChanges) + .map(\.label) + ) + + if changedLabels.contains("Availability") { + return (.critical, "Availability changed") + } + if changedLabels.contains("Primary IP") { + return (.critical, "Primary IP changed") + } + if changedLabels.contains("Redirect Target") { + return (.critical, "Redirect target changed") + } + + let ownershipLabels: Set<String> = [ + "Registrar", + "Registration Date", + "Expiration Date", + "Ownership Status", + "Abuse Contact" + ] + if !changedLabels.isDisjoint(with: ownershipLabels) { + return (.warning, "Ownership changed") + } + if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) { + return (.warning, "DNS changed") + } + + let oldCertificateLevel = previousSnapshot.map { DomainDiffService.certificateWarningLevel(for: $0) } ?? .none + let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot) + if newCertificateLevel != .none, newCertificateLevel != oldCertificateLevel { + let daysRemaining = snapshot.sslInfo?.daysUntilExpiry ?? 0 + return (.warning, "Certificate expires in \(daysRemaining) days") + } + + if entry?.changeSummary?.hasChanges == true, let message = entry?.changeSummary?.message { + return (.info, message) + } + + return nil + } + + private static func resolvedSnapshotAfterFallback( + _ snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot? + ) -> LookupSnapshot { + guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else { + return snapshot + } + + return LookupSnapshot( + historyEntryID: previousSnapshot.historyEntryID, + domain: previousSnapshot.domain, + timestamp: previousSnapshot.timestamp, + trackedDomainID: previousSnapshot.trackedDomainID, + note: previousSnapshot.note, + appVersion: previousSnapshot.appVersion, + resolverDisplayName: previousSnapshot.resolverDisplayName, + resolverURLString: previousSnapshot.resolverURLString, + dataSources: previousSnapshot.dataSources, + provenanceBySection: previousSnapshot.provenanceBySection, + availabilityConfidence: previousSnapshot.availabilityConfidence, + ownershipConfidence: previousSnapshot.ownershipConfidence, + subdomainConfidence: previousSnapshot.subdomainConfidence, + emailSecurityConfidence: previousSnapshot.emailSecurityConfidence, + geolocationConfidence: previousSnapshot.geolocationConfidence, + errorDetails: previousSnapshot.errorDetails, + isPartialSnapshot: previousSnapshot.isPartialSnapshot, + validationIssues: previousSnapshot.validationIssues, + totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, + dnsSections: previousSnapshot.dnsSections, + dnsError: previousSnapshot.dnsError, + availabilityResult: previousSnapshot.availabilityResult, + suggestions: previousSnapshot.suggestions, + sslInfo: previousSnapshot.sslInfo, + sslError: previousSnapshot.sslError, + hstsPreloaded: previousSnapshot.hstsPreloaded, + httpHeaders: previousSnapshot.httpHeaders, + httpSecurityGrade: previousSnapshot.httpSecurityGrade, + httpStatusCode: previousSnapshot.httpStatusCode, + httpResponseTimeMs: previousSnapshot.httpResponseTimeMs, + httpProtocol: previousSnapshot.httpProtocol, + http3Advertised: previousSnapshot.http3Advertised, + httpHeadersError: previousSnapshot.httpHeadersError, + reachabilityResults: previousSnapshot.reachabilityResults, + reachabilityError: previousSnapshot.reachabilityError, + ipGeolocation: previousSnapshot.ipGeolocation, + ipGeolocationError: previousSnapshot.ipGeolocationError, + emailSecurity: previousSnapshot.emailSecurity, + emailSecurityError: previousSnapshot.emailSecurityError, + ownership: previousSnapshot.ownership, + ownershipError: previousSnapshot.ownershipError, + ownershipHistory: previousSnapshot.ownershipHistory, + ownershipHistoryError: previousSnapshot.ownershipHistoryError, + ptrRecord: previousSnapshot.ptrRecord, + ptrError: previousSnapshot.ptrError, + redirectChain: previousSnapshot.redirectChain, + redirectChainError: previousSnapshot.redirectChainError, + subdomains: previousSnapshot.subdomains, + subdomainsError: previousSnapshot.subdomainsError, + extendedSubdomains: previousSnapshot.extendedSubdomains, + extendedSubdomainsError: previousSnapshot.extendedSubdomainsError, + dnsHistory: previousSnapshot.dnsHistory, + dnsHistoryError: previousSnapshot.dnsHistoryError, + domainPricing: previousSnapshot.domainPricing, + domainPricingError: previousSnapshot.domainPricingError, + portScanResults: previousSnapshot.portScanResults, + portScanError: previousSnapshot.portScanError, + changeSummary: previousSnapshot.changeSummary, + resultSource: .snapshot, + cachedSections: [], + statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))" + ) + } + + private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool { + let candidateMessages = [ + snapshot.dnsError, + snapshot.httpHeadersError, + snapshot.sslError, + snapshot.ownershipError, + snapshot.subdomainsError, + snapshot.redirectChainError, + snapshot.ipGeolocationError + ] + .compactMap { $0?.lowercased() } + + guard !candidateMessages.isEmpty else { return false } + let connectivityFailure = candidateMessages.allSatisfy { message in + message.hasPrefix("network error:") + || message.hasPrefix("timeout:") + || message.hasPrefix("rate limit:") + } + + let hasMaterialData = !snapshot.dnsSections.isEmpty + || !snapshot.httpHeaders.isEmpty + || snapshot.sslInfo != nil + || snapshot.ownership != nil + || !snapshot.subdomains.isEmpty + + return connectivityFailure && !hasMaterialData + } + + private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { + snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value + } + + private static func tlsSummary(from snapshot: LookupSnapshot) -> String? { + guard let sslInfo = snapshot.sslInfo else { return snapshot.sslError } + switch DomainDiffService.certificateWarningLevel(for: snapshot) { + case .critical: + return "Critical (\(sslInfo.daysUntilExpiry)d)" + case .warning: + return "Warning (\(sslInfo.daysUntilExpiry)d)" + case .none: + return "Healthy (\(sslInfo.daysUntilExpiry)d)" + } + } + + private static func emailSummary(from snapshot: LookupSnapshot) -> String? { + if let emailSecurity = snapshot.emailSecurity { + return [ + "spf:\(emailSecurity.spf.found)", + "dmarc:\(emailSecurity.dmarc.found)", + "dkim:\(emailSecurity.dkim.found)", + "bimi:\(emailSecurity.bimi.found)", + "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)" + ].joined(separator: "|") + } + return snapshot.emailSecurityError + } +} 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] { diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift index 535ba7c..9fae3bd 100644 --- a/DomainDig/FeatureAccessService.swift +++ b/DomainDig/FeatureAccessService.swift @@ -25,6 +25,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable { case limitedTracking case workflows case batchOperations + case automatedMonitoring + case localAlerts case advancedExports case ownershipHistory case dnsHistory @@ -45,6 +47,10 @@ enum FeatureCapability: String, CaseIterable, Identifiable { return "Workflows" case .batchOperations: return "Batch operations" + case .automatedMonitoring: + return "Background monitoring" + case .localAlerts: + return "Local alerts" case .advancedExports: return "Advanced exports" case .ownershipHistory: @@ -94,7 +100,16 @@ enum FeatureAccessService { case .pro: return FeatureEntitlements( tier: .pro, - capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports], + capabilities: [ + .singleLookup, + .basicHistory, + .limitedTracking, + .workflows, + .batchOperations, + .automatedMonitoring, + .localAlerts, + .advancedExports + ], trackedDomainLimit: effectivelyUnlimitedTrackedDomains, workflowLimit: nil, batchSizeLimit: nil @@ -140,7 +155,7 @@ enum FeatureAccessService { static func upgradeMessage(for capability: FeatureCapability) -> String { switch capability { - case .workflows, .batchOperations, .advancedExports: + case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports: return "Available in Pro" case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: return "Available in Data+" diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist index e397175..a24a71e 100644 --- a/DomainDig/Info.plist +++ b/DomainDig/Info.plist @@ -6,5 +6,13 @@ <true/> <key>App Transport Security Settings (or NSAppTransportSecurity)</key> <dict/> + <key>BGTaskSchedulerPermittedIdentifiers</key> + <array> + <string>net.cleberg.DomainDig.monitor.refresh</string> + </array> + <key>UIBackgroundModes</key> + <array> + <string>fetch</string> + </array> </dict> </plist> diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift index 450d844..7b43d67 100644 --- a/DomainDig/LocalNotificationService.swift +++ b/DomainDig/LocalNotificationService.swift @@ -27,6 +27,18 @@ final class LocalNotificationService { } } + func isAuthorizedForAlerts() async -> Bool { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .denied, .notDetermined: + return false + @unknown default: + return false + } + } + func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async { await schedule( identifier: "domain-change-\(domain)", @@ -45,6 +57,27 @@ final class LocalNotificationService { ) } + func notifyMonitoringAlert( + domain: String, + message: String, + severity: MonitoringAlertSeverity + ) async { + let interruptionLevel: UNNotificationInterruptionLevel + switch severity { + case .critical: + interruptionLevel = .timeSensitive + case .warning, .info: + interruptionLevel = .active + } + + await schedule( + identifier: "monitoring-\(domain)-\(UUID().uuidString)", + title: domain, + body: message, + interruptionLevel: interruptionLevel + ) + } + func notifySweepComplete(summary: BatchSweepSummary) async { let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged" await schedule( diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 530a89e..7715b4a 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -591,12 +591,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable { var updatedAt: Date var note: String? var isPinned: Bool + var monitoringEnabled: Bool var lastKnownAvailability: DomainAvailabilityStatus? var lastSnapshotID: UUID? var lastChangeSummary: DomainChangeSummary? var lastChangeSeverity: ChangeSeverity? var certificateWarningLevel: CertificateWarningLevel var certificateDaysRemaining: Int? + var lastMonitoredAt: Date? + var lastAlertAt: Date? init( id: UUID = UUID(), @@ -605,12 +608,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable { updatedAt: Date = Date(), note: String? = nil, isPinned: Bool = false, + monitoringEnabled: Bool = true, lastKnownAvailability: DomainAvailabilityStatus? = nil, lastSnapshotID: UUID? = nil, lastChangeSummary: DomainChangeSummary? = nil, lastChangeSeverity: ChangeSeverity? = nil, certificateWarningLevel: CertificateWarningLevel = .none, - certificateDaysRemaining: Int? = nil + certificateDaysRemaining: Int? = nil, + lastMonitoredAt: Date? = nil, + lastAlertAt: Date? = nil ) { self.id = id self.domain = domain @@ -618,12 +624,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable { self.updatedAt = updatedAt self.note = note self.isPinned = isPinned + self.monitoringEnabled = monitoringEnabled self.lastKnownAvailability = lastKnownAvailability self.lastSnapshotID = lastSnapshotID self.lastChangeSummary = lastChangeSummary self.lastChangeSeverity = lastChangeSeverity self.certificateWarningLevel = certificateWarningLevel self.certificateDaysRemaining = certificateDaysRemaining + self.lastMonitoredAt = lastMonitoredAt + self.lastAlertAt = lastAlertAt } init(from decoder: Decoder) throws { @@ -634,12 +643,224 @@ struct TrackedDomain: Codable, Identifiable, Equatable { updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt note = try container.decodeIfPresent(String.self, forKey: .note) isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + monitoringEnabled = try container.decodeIfPresent(Bool.self, forKey: .monitoringEnabled) ?? true lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability) lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID) lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary) lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining) + lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt) + lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt) + } +} + +enum MonitoringFrequency: String, Codable, CaseIterable, Identifiable { + case daily + case twiceDaily + + var id: String { rawValue } + + var title: String { + switch self { + case .daily: + return "Daily" + case .twiceDaily: + return "Twice Daily" + } + } + + var schedulingInterval: TimeInterval { + switch self { + case .daily: + return 24 * 60 * 60 + case .twiceDaily: + return 12 * 60 * 60 + } + } +} + +enum MonitoringScope: String, Codable, CaseIterable, Identifiable { + case allTracked + case selectedOnly + + var id: String { rawValue } + + var title: String { + switch self { + case .allTracked: + return "All Tracked" + case .selectedOnly: + return "Selected Only" + } + } +} + +enum MonitoringAlertSeverity: Int, Codable, CaseIterable, Comparable { + case info + case warning + case critical + + static func < (lhs: MonitoringAlertSeverity, rhs: MonitoringAlertSeverity) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var title: String { + switch self { + case .info: + return "Info" + case .warning: + return "Warning" + case .critical: + return "Critical" + } + } +} + +enum MonitoringAlertFilter: String, Codable, CaseIterable, Identifiable { + case criticalOnly + case criticalAndWarnings + case allChanges + + var id: String { rawValue } + + var title: String { + switch self { + case .criticalOnly: + return "Critical Only" + case .criticalAndWarnings: + return "Critical + Warnings" + case .allChanges: + return "All Changes" + } + } + + var minimumSeverity: MonitoringAlertSeverity { + switch self { + case .criticalOnly: + return .critical + case .criticalAndWarnings: + return .warning + case .allChanges: + return .info + } + } +} + +enum MonitoringRunTrigger: String, Codable { + case manual + case background + case cli + + var title: String { + switch self { + case .manual: + return "Manual" + case .background: + return "Background" + case .cli: + return "CLI" + } + } +} + +struct MonitoringSettings: Codable, Equatable { + var isEnabled: Bool + var frequency: MonitoringFrequency + var scope: MonitoringScope + var selectedDomainIDs: [UUID] + var alertFilter: MonitoringAlertFilter + var alertsEnabled: Bool + + init( + isEnabled: Bool = false, + frequency: MonitoringFrequency = .daily, + scope: MonitoringScope = .allTracked, + selectedDomainIDs: [UUID] = [], + alertFilter: MonitoringAlertFilter = .criticalAndWarnings, + alertsEnabled: Bool = false + ) { + self.isEnabled = isEnabled + self.frequency = frequency + self.scope = scope + self.selectedDomainIDs = selectedDomainIDs + self.alertFilter = alertFilter + self.alertsEnabled = alertsEnabled + } +} + +struct MonitoringDomainResult: Codable, Identifiable, Equatable { + let id: UUID + let domain: String + let historyEntryID: UUID? + let checkedAt: Date + let didChange: Bool + let summaryMessage: String + let alertSeverity: MonitoringAlertSeverity? + let certificateWarningLevel: CertificateWarningLevel + let resultSource: LookupResultSource + let errorMessage: String? + + init( + id: UUID = UUID(), + domain: String, + historyEntryID: UUID?, + checkedAt: Date, + didChange: Bool, + summaryMessage: String, + alertSeverity: MonitoringAlertSeverity?, + certificateWarningLevel: CertificateWarningLevel, + resultSource: LookupResultSource, + errorMessage: String? = nil + ) { + self.id = id + self.domain = domain + self.historyEntryID = historyEntryID + self.checkedAt = checkedAt + self.didChange = didChange + self.summaryMessage = summaryMessage + self.alertSeverity = alertSeverity + self.certificateWarningLevel = certificateWarningLevel + self.resultSource = resultSource + self.errorMessage = errorMessage + } +} + +struct MonitoringLog: Codable, Identifiable, Equatable { + let id: UUID + let timestamp: Date + let trigger: MonitoringRunTrigger + let domainsChecked: Int + let changesFound: Int + let alertsTriggered: Int + let checkedDomains: [MonitoringDomainResult] + let errors: [String] + + init( + id: UUID = UUID(), + timestamp: Date, + trigger: MonitoringRunTrigger, + domainsChecked: Int, + changesFound: Int, + alertsTriggered: Int, + checkedDomains: [MonitoringDomainResult], + errors: [String] = [] + ) { + self.id = id + self.timestamp = timestamp + self.trigger = trigger + self.domainsChecked = domainsChecked + self.changesFound = changesFound + self.alertsTriggered = alertsTriggered + self.checkedDomains = checkedDomains + self.errors = errors + } + + var summary: String { + if errors.isEmpty { + return "\(changesFound) changes across \(domainsChecked) domains" + } + return "\(changesFound) changes, \(errors.count) errors" } } diff --git a/DomainDig/MonitoringView.swift b/DomainDig/MonitoringView.swift new file mode 100644 index 0000000..0b7a916 --- /dev/null +++ b/DomainDig/MonitoringView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct MonitoringView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + + private var monitoredDomainsCount: Int { + MonitoringStorage.monitoredDomains( + settings: viewModel.monitoringSettings, + trackedDomains: viewModel.trackedDomains + ).count + } + + var body: some View { + List { + Section("Overview") { + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only") + LabeledContent("Domains", value: "\(monitoredDomainsCount)") + LabeledContent("Frequency", value: viewModel.monitoringSettings.frequency.title) + LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off") + + if let monitoringStatusMessage = viewModel.monitoringStatusMessage, + !monitoringStatusMessage.isEmpty { + Text(monitoringStatusMessage) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + + Button(viewModel.monitoringRunInProgress ? "Monitoring…" : "Run Now") { + viewModel.runMonitoringNow() + } + .disabled(viewModel.monitoringRunInProgress) + } + .padding(.vertical, 4) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if viewModel.monitoringLogs.isEmpty { + Section { + EmptyStateCardView( + title: "No Monitoring Runs Yet", + message: "Monitoring history appears here after manual or background runs finish.", + suggestion: "Enable monitoring in Settings or run a manual monitoring sweep.", + systemImage: "waveform.path.ecg" + ) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + Section("Recent Runs") { + ForEach(viewModel.monitoringLogs) { log in + NavigationLink { + MonitoringLogDetailView(viewModel: viewModel, log: log) + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(log.trigger.title) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Spacer() + Text(log.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + + Text(log.summary) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + metricBadge(title: "\(log.domainsChecked) checked") + if log.changesFound > 0 { + metricBadge(title: "\(log.changesFound) changed", tint: .orange) + } + if log.alertsTriggered > 0 { + metricBadge(title: "\(log.alertsTriggered) alerts", tint: .red) + } + } + } + .padding(.vertical, 4) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Monitoring") + .preferredColorScheme(.dark) + .onAppear { + viewModel.refreshMonitoringState() + } + } + + private func metricBadge(title: String, tint: Color = .cyan) -> some View { + Text(title) + .font(appDensity.font(.caption2)) + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tint.opacity(0.16)) + .clipShape(Capsule()) + } +} + +struct MonitoringLogDetailView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + let log: MonitoringLog + + var body: some View { + List { + Section("Summary") { + LabeledContent("Trigger", value: log.trigger.title) + LabeledContent("Checked", value: "\(log.domainsChecked)") + LabeledContent("Changes", value: "\(log.changesFound)") + LabeledContent("Alerts", value: "\(log.alertsTriggered)") + LabeledContent("Timestamp", value: log.timestamp.formatted(date: .abbreviated, time: .shortened)) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Domains") { + ForEach(log.checkedDomains) { result in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(result.domain) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Spacer() + if let alertSeverity = result.alertSeverity { + Text(alertSeverity.title.uppercased()) + .font(appDensity.font(.caption2)) + .foregroundStyle(color(for: alertSeverity)) + } + } + + Text(result.summaryMessage) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + Text(result.resultSource.label) + Text(result.didChange ? "Changed" : "No change") + if result.certificateWarningLevel != .none { + Text(result.certificateWarningLevel.title) + } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + + if let errorMessage = result.errorMessage { + Text(errorMessage) + .font(appDensity.font(.caption2)) + .foregroundStyle(.yellow) + } + + if let historyEntryID = result.historyEntryID, + let entry = viewModel.history.first(where: { $0.id == historyEntryID }) { + NavigationLink("Open Snapshot") { + HistoryDetailView(viewModel: viewModel, entry: entry) + } + .font(appDensity.font(.caption)) + } + } + .padding(.vertical, 4) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if !log.errors.isEmpty { + Section("Errors") { + ForEach(log.errors, id: \.self) { error in + Text(error) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Run Details") + .preferredColorScheme(.dark) + } + + private func color(for severity: MonitoringAlertSeverity) -> Color { + switch severity { + case .info: + return .secondary + case .warning: + return .yellow + case .critical: + return .red + } + } +} diff --git a/DomainDig/PaywallView.swift b/DomainDig/PaywallView.swift index d0293a8..90578c7 100644 --- a/DomainDig/PaywallView.swift +++ b/DomainDig/PaywallView.swift @@ -10,7 +10,7 @@ struct PaywallView: View { NavigationStack { List { Section { - Text("Pro unlocks workflows, scale, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.") + Text("Pro unlocks workflows, scale, monitoring automation, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.") .font(appDensity.font(.body, design: .default)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -20,6 +20,8 @@ struct PaywallView: View { featureRow("Unlimited tracked domains") featureRow("Workflows") featureRow("Larger batch sizes") + featureRow("Background monitoring") + featureRow("Local alerts") featureRow("Advanced exports") } diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift index 8de2626..35c172f 100644 --- a/DomainDig/PremiumAccessService.swift +++ b/DomainDig/PremiumAccessService.swift @@ -9,8 +9,10 @@ enum PremiumAccessService { return FeatureAccessService.hasAccess(to: .batchOperations) case .unlimitedTrackedDomains: return FeatureAccessService.currentTier != .free - case .automatedMonitoring, .pushAlerts: - return false + case .automatedMonitoring: + return FeatureAccessService.hasAccess(to: .automatedMonitoring) + case .pushAlerts: + return FeatureAccessService.hasAccess(to: .localAlerts) } } diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift index 2d2b8ed..adf5702 100644 --- a/DomainDig/RootTabView.swift +++ b/DomainDig/RootTabView.swift @@ -21,6 +21,13 @@ struct RootTabView: View { } NavigationStack { + MonitoringView(viewModel: viewModel) + } + .tabItem { + Label("Monitoring", systemImage: "waveform.path.ecg") + } + + NavigationStack { HistoryView(viewModel: viewModel) } .tabItem { diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index b4e5011..968914f 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -287,6 +287,18 @@ struct WatchlistRowView: View { .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off") + if let lastMonitoredAt = trackedDomain.lastMonitoredAt { + Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))") + } + if let lastAlertAt = trackedDomain.lastAlertAt { + Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))") + } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + indicatorRow if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { @@ -400,6 +412,15 @@ struct TrackedDomainDetailView: View { } Button { + viewModel.toggleMonitoring(for: liveTrackedDomain) + } label: { + Label( + liveTrackedDomain.monitoringEnabled ? "Disable Monitoring" : "Enable Monitoring", + systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell" + ) + } + + Button { noteDraft = liveTrackedDomain.note ?? "" isEditingNote = true } label: { |
