summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/ContentView.swift377
-rw-r--r--DomainDig/DomainDigApp.swift13
-rw-r--r--DomainDig/DomainMonitoringService.swift613
-rw-r--r--DomainDig/DomainViewModel.swift295
-rw-r--r--DomainDig/FeatureAccessService.swift19
-rw-r--r--DomainDig/Info.plist8
-rw-r--r--DomainDig/LocalNotificationService.swift33
-rw-r--r--DomainDig/Models.swift223
-rw-r--r--DomainDig/MonitoringView.swift198
-rw-r--r--DomainDig/PaywallView.swift4
-rw-r--r--DomainDig/PremiumAccessService.swift6
-rw-r--r--DomainDig/RootTabView.swift7
-rw-r--r--DomainDig/WatchlistView.swift21
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 NSApp​Transport​Security)</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: {