diff options
| author | Christian Cleberg <[email protected]> | 2026-04-24 15:04:54 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-24 15:04:54 -0500 |
| commit | 3f0b7746b1bee4c2f626eb805e3fd13c5f2f5d22 (patch) | |
| tree | 17c710faf6191ac86ea5b6cab4df240cf2cefef9 /DomainDig | |
| parent | d0b3b7a15b59266b4ae4262fdb0364245092c17c (diff) | |
| download | domain-dig-3f0b7746b1bee4c2f626eb805e3fd13c5f2f5d22.tar.gz domain-dig-3f0b7746b1bee4c2f626eb805e3fd13c5f2f5d22.tar.bz2 domain-dig-3f0b7746b1bee4c2f626eb805e3fd13c5f2f5d22.zip | |
DomainDig v3.8.0 — Smart Monitoring
* Stable domain intervals
* Frequent changes decrease intervals
* Duplicate states do not trigger alerts
* Quiet hours suppress alerts
* Quiet hours across midnight work correctly
* Sensitivity levels alter behavior as expected
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/BatchResultsView.swift | 28 | ||||
| -rw-r--r-- | DomainDig/CloudSyncService.swift | 22 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 771 | ||||
| -rw-r--r-- | DomainDig/DomainDigUI.swift | 17 | ||||
| -rw-r--r-- | DomainDig/DomainMonitoringService.swift | 378 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 138 | ||||
| -rw-r--r-- | DomainDig/FeatureAccessService.swift | 14 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 3 | ||||
| -rw-r--r-- | DomainDig/LocalNotificationService.swift | 29 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 292 | ||||
| -rw-r--r-- | DomainDig/MonitoringView.swift | 19 | ||||
| -rw-r--r-- | DomainDig/PaywallView.swift | 12 | ||||
| -rw-r--r-- | DomainDig/PurchaseService.swift | 77 | ||||
| -rw-r--r-- | DomainDig/RootTabView.swift | 9 | ||||
| -rw-r--r-- | DomainDig/TimelineView.swift | 60 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 96 |
16 files changed, 1516 insertions, 449 deletions
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift index c8dde42..e97899e 100644 --- a/DomainDig/BatchResultsView.swift +++ b/DomainDig/BatchResultsView.swift @@ -72,12 +72,25 @@ struct BatchResultRowView: View { } HStack(spacing: 10) { - AppStatusBadgeView(model: AppStatusFactory.availability(result.availability)) + AppStatusBadgeView(model: availabilityBadgeModel) if let riskScore = result.riskScore, let riskLevel = result.riskLevel { Text("Risk \(riskScore) \(riskLevel.title)") + .lineLimit(1) + .minimumScaleFactor(0.85) } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { Text(result.primaryIP ?? "No IP") + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 8) + Text(result.timestamp.formatted(date: .abbreviated, time: .shortened)) + .lineLimit(1) } .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) @@ -116,6 +129,19 @@ struct BatchResultRowView: View { } } + private var availabilityBadgeModel: AppStatusBadgeModel { + guard result.status == .failed else { + return AppStatusFactory.availability(result.availability) + } + + return .init( + title: availabilityText, + systemImage: "exclamationmark.circle", + foregroundColor: .secondary, + backgroundColor: Color(.systemGray5).opacity(0.55) + ) + } + private var quickStatusBadge: AppStatusBadgeModel { switch result.status { case .pending: diff --git a/DomainDig/CloudSyncService.swift b/DomainDig/CloudSyncService.swift index 9947e08..1cf41d9 100644 --- a/DomainDig/CloudSyncService.swift +++ b/DomainDig/CloudSyncService.swift @@ -1453,6 +1453,8 @@ final class CloudSyncService { certificateDaysRemaining: trackedDomain.certificateDaysRemaining, lastMonitoredAt: trackedDomain.lastMonitoredAt, lastAlertAt: trackedDomain.lastAlertAt, + monitoringState: trackedDomain.monitoringState, + pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts, collaboration: trackedDomain.collaboration ) } @@ -1476,6 +1478,8 @@ final class CloudSyncService { certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining, lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(), lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(), + monitoringState: preferredMonitoringState(lhs.monitoringState, rhs.monitoringState), + pendingMonitoringAlerts: deduplicatedPendingAlerts(lhs.pendingMonitoringAlerts + rhs.pendingMonitoringAlerts), collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration) ) } @@ -1501,6 +1505,24 @@ final class CloudSyncService { return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs } + private func preferredMonitoringState(_ lhs: MonitoringState, _ rhs: MonitoringState) -> MonitoringState { + let lhsDate = [lhs.lastCheck, lhs.lastAlertDate, lhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast + let rhsDate = [rhs.lastCheck, rhs.lastAlertDate, rhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast + return lhsDate >= rhsDate ? lhs : rhs + } + + private func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] { + var uniqueByHash: [String: MonitoringPendingAlert] = [:] + for alert in alerts { + if let existing = uniqueByHash[alert.changeHash] { + uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert + } else { + uniqueByHash[alert.changeHash] = alert + } + } + return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt } + } + private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow { let lhsRank = collaborationRank(lhs.collaboration) let rhsRank = collaborationRank(rhs.collaboration) diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 76817cd..5f119ca 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -19,6 +19,11 @@ enum ResultSection: String, Hashable { case subdomains } +private enum LookupInputField: Hashable { + case singleDomain + case bulkDomains +} + private struct WorkflowNavigationTarget: Hashable { let workflowID: UUID } @@ -28,7 +33,7 @@ struct ContentView: View { @Bindable var viewModel: DomainViewModel @State private var purchaseService = PurchaseService.shared @State private var navigationPath = NavigationPath() - @FocusState private var domainFieldFocused: Bool + @FocusState private var focusedInputField: LookupInputField? @State private var customPortInput = "" @State private var customPortsExpanded = false @State private var trackingNoteDraft = "" @@ -174,13 +179,20 @@ struct ContentView: View { } } } + + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Dismiss Keyboard") { + focusedInputField = nil + } + } } .navigationDestination(for: WorkflowNavigationTarget.self) { target in WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID) } } .onAppear { - domainFieldFocused = true + focusedInputField = .singleDomain } .task { await viewModel.refreshUsageCredits() @@ -190,7 +202,24 @@ struct ContentView: View { } .onChange(of: viewModel.rerunNavigationToken) { _, _ in navigationPath = NavigationPath() - domainFieldFocused = false + focusedInputField = nil + } + .onChange(of: inputMode) { _, newValue in + viewModel.clearPresentedResults() + focusedInputField = newValue == .single ? .singleDomain : .bulkDomains + } + .onChange(of: viewModel.domain) { _, newValue in + guard inputMode == .single else { return } + let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized != viewModel.searchedDomain else { return } + guard viewModel.hasRun || !viewModel.batchResults.isEmpty else { return } + viewModel.clearPresentedResults() + } + .onChange(of: viewModel.bulkInput) { _, newValue in + guard inputMode == .bulk else { return } + let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty || viewModel.hasRun || !viewModel.batchResults.isEmpty else { return } + viewModel.clearPresentedResults() } .sheet(item: $editingTrackedDomain) { trackedDomain in NavigationStack { @@ -257,11 +286,14 @@ struct ContentView: View { .padding(.vertical, appDensity.metrics.controlVerticalPadding) .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) - .focused($domainFieldFocused) - .onSubmit { viewModel.run() } + .focused($focusedInputField, equals: .singleDomain) + .onSubmit { + focusedInputField = nil + viewModel.run() + } Button { - domainFieldFocused = false + focusedInputField = nil viewModel.run() } label: { Text("Run") @@ -298,9 +330,10 @@ struct ContentView: View { .padding(.vertical, appDensity.metrics.controlVerticalPadding) .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) + .focused($focusedInputField, equals: .bulkDomains) Button { - domainFieldFocused = false + focusedInputField = nil viewModel.runBulkLookup() } label: { Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") @@ -568,7 +601,7 @@ struct ContentView: View { ForEach(viewModel.recentSearches, id: \.self) { domain in Button { viewModel.domain = domain - domainFieldFocused = false + focusedInputField = nil viewModel.run() } label: { Text(domain) @@ -822,7 +855,7 @@ struct InsightsSummaryCardView: View { } else { ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in HStack(alignment: .top, spacing: 8) { - Image(systemName: "sparkline") + Image(systemName: "chart.line.uptrend.xyaxis") .font(appDensity.font(.caption2)) .foregroundStyle(.cyan) .padding(.top, 2) @@ -1401,7 +1434,7 @@ struct DomainSectionView: View { MessageRowView(text: pricingError, isError: false) .padding(.top, 4) } else if showsPricingPlaceholder { - MessageRowView(text: "Pricing signals available in Data+", isError: false) + MessageRowView(text: "Pricing signals available in Pro+", isError: false) .padding(.top, 4) } } @@ -1473,8 +1506,8 @@ struct OwnershipSectionView: View { .font(appDensity.font(.caption)) .foregroundStyle(.secondary) Spacer() - if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { - Button("Load (\(historyCreditStatus.remaining) left)") { + if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { + Button("Load") { onLoadHistory() } .buttonStyle(.bordered) @@ -1500,7 +1533,7 @@ struct OwnershipSectionView: View { } else if let historyError { MessageRowView(text: historyError, isError: false) } else if showsHistoryPlaceholder { - MessageRowView(text: "Ownership history available in Data+", isError: false) + MessageRowView(text: "Ownership history available in Pro+", isError: false) } } } @@ -1565,12 +1598,12 @@ struct SubdomainsSectionView: View { } else if rows.isEmpty { MessageRowView(text: error ?? "No passive subdomains found", isError: false) if showsExtendedPlaceholder { - MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false) + MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false) .padding(.top, 4) } } else { - if let extendedCreditStatus, let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder { - Button("Load extended results (\(extendedCreditStatus.remaining) left)") { + if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder { + Button("Load extended results") { onLoadExtended() } .buttonStyle(.bordered) @@ -1621,7 +1654,7 @@ struct SubdomainsSectionView: View { MessageRowView(text: extendedError, isError: false) .padding(.top, 4) } else if showsExtendedPlaceholder { - MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false) + MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false) .padding(.top, 4) } } @@ -1748,8 +1781,8 @@ struct DNSSectionView: View { .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) Spacer() - if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { - Button("Load (\(historyCreditStatus.remaining) left)") { + if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { + Button("Load") { onLoadHistory() } .buttonStyle(.bordered) @@ -1782,7 +1815,7 @@ struct DNSSectionView: View { } else if let historyError { MessageRowView(text: historyError, isError: false) } else if showsHistoryPlaceholder { - MessageRowView(text: "DNS history available in Data+", isError: false) + MessageRowView(text: "DNS history available in Pro+", isError: false) } } } @@ -2444,36 +2477,94 @@ struct SettingsView: View { @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel @State private var purchaseService = PurchaseService.shared - @State private var cloudSyncService = CloudSyncService.shared - @AppStorage(DNSResolverOption.userDefaultsKey) - private var storedResolverURL = DNSResolverOption.defaultURLString - @AppStorage(AppDensity.userDefaultsKey) - private var storedDensity = AppDensity.compact.rawValue - @State private var resolverOption: DNSResolverOption = .cloudflare - @State private var customResolverURL = DNSResolverOption.defaultURLString - @State private var showClearHistoryConfirmation = false - @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 + var body: some View { + let _ = purchaseService.currentTier - private var customResolverError: String? { - guard resolverOption == .custom else { - return nil + List { + Section("Tier") { + LabeledContent("Status", value: purchaseService.currentTier.title) + + if purchaseService.currentTier == .free { + Button("Upgrade") { + viewModel.isPaywallPresented = true + } + } else { + Button("Manage Subscription") { + Task { + await purchaseService.manageSubscription() + } + } + } + + Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { + Task { + await purchaseService.restorePurchases() + } + } + .disabled(purchaseService.isRestoring || purchaseService.isPurchasing) + + if let statusMessage = purchaseService.statusMessage { + Text(statusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + + if let errorMessage = purchaseService.errorMessage { + Text(errorMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.red) + } + } + + Section("Preferences") { + NavigationLink("Workflows") { + WorkflowsView(viewModel: viewModel) + } + + NavigationLink("Display") { + DisplaySettingsView() + } + + NavigationLink("History & Network") { + HistoryNetworkSettingsView(viewModel: viewModel) + } + } + + Section("Services") { + NavigationLink("iCloud Sync") { + CloudSyncSettingsView() + } + + NavigationLink("Monitoring") { + MonitoringSettingsView(viewModel: viewModel) + } + } + + Section("Data") { + NavigationLink("Import & Export") { + DataPortabilitySettingsView(viewModel: viewModel) + } + + NavigationLink("Data Management") { + DataManagementSettingsView(viewModel: viewModel) + } + } + + Section("About") { + NavigationLink("App Info") { + AboutSettingsView() + } + } } - return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" + .navigationTitle("More") } +} - var body: some View { - let _ = purchaseService.currentTier +private struct DisplaySettingsView: View { + @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue + var body: some View { Form { Section("Display") { Picker("Density", selection: $storedDensity) { @@ -2482,7 +2573,27 @@ struct SettingsView: View { } } } + } + .navigationTitle("Display") + } +} + +private struct HistoryNetworkSettingsView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString + @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue + + @State private var resolverOption: DNSResolverOption = .cloudflare + @State private var customResolverURL = DNSResolverOption.defaultURLString + + private var customResolverError: String? { + guard resolverOption == .custom else { return nil } + return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" + } + var body: some View { + Form { Section("History") { Picker( "Auto-prune", @@ -2521,7 +2632,51 @@ struct SettingsView: View { } } } + } + .navigationTitle("History & Network") + .onAppear { + let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) + resolverOption = DNSResolverOption.option(for: currentResolverURL) + customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString + } + .onChange(of: resolverOption) { _, newValue in + guard let presetURL = newValue.urlString else { + storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: storedDensity + ) + return + } + storedResolverURL = presetURL + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: storedDensity + ) + } + .onChange(of: customResolverURL) { _, newValue in + guard resolverOption == .custom else { return } + storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: storedDensity + ) + } + .onChange(of: storedDensity) { _, newValue in + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: newValue + ) + } + } +} + +private struct CloudSyncSettingsView: View { + @Environment(\.appDensity) private var appDensity + @State private var cloudSyncService = CloudSyncService.shared + var body: some View { + Form { Section("iCloud Sync") { Toggle( "Enable iCloud Sync", @@ -2558,7 +2713,33 @@ struct SettingsView: View { .foregroundStyle(.red) } } + } + .navigationTitle("iCloud Sync") + .task { + await cloudSyncService.refreshAvailability() + } + } +} + +private struct MonitoringSettingsView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + 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" + } + } + + var body: some View { + Form { Section("Monitoring") { Toggle( "Enable Background Monitoring", @@ -2569,14 +2750,88 @@ struct SettingsView: View { ) Picker( - "Frequency", + "Base Interval", selection: Binding( - get: { viewModel.monitoringSettings.frequency }, - set: { viewModel.setMonitoringFrequency($0) } + get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) }, + set: { viewModel.setMonitoringBaseInterval($0) } ) ) { - ForEach(MonitoringFrequency.allCases) { frequency in - Text(frequency.title).tag(frequency) + ForEach(MonitoringBaseInterval.allCases) { interval in + Text(interval.title).tag(interval) + } + } + + Toggle( + "Adaptive Monitoring", + isOn: Binding( + get: { viewModel.monitoringSettings.adaptiveEnabled }, + set: { viewModel.setMonitoringAdaptiveEnabled($0) } + ) + ) + + Picker( + "Sensitivity", + selection: Binding( + get: { viewModel.monitoringSettings.sensitivity }, + set: { viewModel.setMonitoringSensitivity($0) } + ) + ) { + ForEach(MonitoringSensitivity.allCases) { sensitivity in + Text(sensitivity.title).tag(sensitivity) + } + } + + let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22 + let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7 + Toggle( + "Quiet Hours", + isOn: Binding( + get: { viewModel.monitoringSettings.quietHours != nil }, + set: { isEnabled in + viewModel.setMonitoringQuietHours( + startHour: quietHoursStart, + endHour: quietHoursEnd, + isEnabled: isEnabled + ) + } + ) + ) + + if viewModel.monitoringSettings.quietHours != nil { + Picker( + "Quiet Starts", + selection: Binding( + get: { quietHoursStart }, + set: { startHour in + viewModel.setMonitoringQuietHours( + startHour: startHour, + endHour: quietHoursEnd, + isEnabled: true + ) + } + ) + ) { + ForEach(0..<24, id: \.self) { hour in + Text(Self.monitoringHourLabel(for: hour)).tag(hour) + } + } + + Picker( + "Quiet Ends", + selection: Binding( + get: { quietHoursEnd }, + set: { endHour in + viewModel.setMonitoringQuietHours( + startHour: quietHoursStart, + endHour: endHour, + isEnabled: true + ) + } + ) + ) { + ForEach(0..<24, id: \.self) { hour in + Text(Self.monitoringHourLabel(for: hour)).tag(hour) + } } } @@ -2647,8 +2902,65 @@ struct SettingsView: View { .foregroundStyle(.secondary) } } + } + .navigationTitle("Monitoring") + .onAppear { + viewModel.refreshMonitoringState() + Task { + await viewModel.refreshMonitoringAuthorizationStatus() + } + } + } - Section("Data Portability") { + private static func monitoringHourLabel(for hour: Int) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "h a" + let components = DateComponents(calendar: .current, hour: hour) + return components.date.map(formatter.string(from:)) ?? "\(hour):00" + } +} + +private struct DataPortabilitySettingsView: View { + private enum ImportTarget { + case backup + case trackedDomains + case workflows + + var expectedKind: DataPortabilityImportKind { + switch self { + case .backup: + return .backup + case .trackedDomains: + return .trackedDomains + case .workflows: + return .workflows + } + } + + var allowedContentTypes: [UTType] { + switch self { + case .backup: + return [UTType.json] + case .trackedDomains, .workflows: + return [UTType.json, UTType.commaSeparatedText] + } + } + } + + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + + @State private var importMode: DataPortabilityImportMode = .merge + @State private var activeImportTarget: ImportTarget? + @State private var pendingImportTarget: ImportTarget? + @State private var importDebugStatus: String? + @State private var pendingImportPreview: DataImportPreview? + @State private var pendingImportError: String? + @State private var showReplaceImportConfirmation = false + + var body: some View { + Form { + Section("Import & Export") { Picker("Import Mode", selection: $importMode) { ForEach(DataPortabilityImportMode.allCases) { mode in Text(mode.title).tag(mode) @@ -2664,7 +2976,9 @@ struct SettingsView: View { } Button("Import Backup") { - showBackupImporter = true + recordImportDebugStatus("Tapped Import Backup") + pendingImportTarget = .backup + activeImportTarget = .backup } Menu("Export Tracked Domains") { @@ -2677,7 +2991,9 @@ struct SettingsView: View { } Button("Import Tracked Domains") { - showTrackedDomainsImporter = true + recordImportDebugStatus("Tapped Import Tracked Domains") + pendingImportTarget = .trackedDomains + activeImportTarget = .trackedDomains } Menu("Export Workflows") { @@ -2690,13 +3006,17 @@ struct SettingsView: View { } Button("Import Workflows") { - showWorkflowsImporter = true + recordImportDebugStatus("Tapped Import Workflows") + pendingImportTarget = .workflows + activeImportTarget = .workflows } Button("Export History") { exportPortableHistoryJSON() } + } + Section("Local Data") { LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") @@ -2714,121 +3034,18 @@ struct SettingsView: View { } } - Section("Data") { - Button("Clear History", role: .destructive) { - showClearHistoryConfirmation = true - } - - Button("Clear Cache", role: .destructive) { - showClearCacheConfirmation = true - } - - Button("Clear Workflows", role: .destructive) { - showClearWorkflowsConfirmation = true - } - - Button("Clear Tracked Domains", role: .destructive) { - showClearTrackedDomainsConfirmation = true - } - } - - Section("Tier") { - LabeledContent("Status", value: purchaseService.currentTier.title) - - if purchaseService.currentTier == .free { - Button("Upgrade") { - viewModel.isPaywallPresented = true - } - } else { - Button("Manage Subscription") { - Task { - await purchaseService.manageSubscription() - } - } - } - - Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { - Task { - await purchaseService.restorePurchases() - } - } - .disabled(purchaseService.isRestoring || purchaseService.isPurchasing) - - if let statusMessage = purchaseService.statusMessage { - Text(statusMessage) + #if DEBUG + if let importDebugStatus { + Section("Import Debug") { + Text(importDebugStatus) .font(appDensity.font(.caption, design: .default)) .foregroundStyle(.secondary) + .textSelection(.enabled) } - - if let errorMessage = purchaseService.errorMessage { - Text(errorMessage) - .font(appDensity.font(.caption, design: .default)) - .foregroundStyle(.red) - } - } - - Section("Data+ Usage") { - ForEach(UsageCreditFeature.allCases) { feature in - let status = viewModel.usageCredits[feature] ?? UsageCreditStatus( - feature: feature, - remaining: feature.defaultAllowance, - total: feature.defaultAllowance, - resetContext: "Resets with app version \(AppVersion.current)" - ) - VStack(alignment: .leading, spacing: 2) { - LabeledContent(feature.title, value: status.summary) - Text(status.resetContext) - .font(appDensity.font(.caption, design: .default)) - .foregroundStyle(.secondary) - } - } - } - - Section("Features") { - ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in - Text(label) - } - } - - Section("About") { - LabeledContent("Version", value: appVersion) - LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") - LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") } + #endif } - .navigationTitle("Settings") - .alert("Clear history?", isPresented: $showClearHistoryConfirmation) { - Button("Clear", role: .destructive) { - viewModel.clearHistory() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This removes saved lookup snapshots from this device.") - } - .alert("Clear cache?", isPresented: $showClearCacheConfirmation) { - Button("Clear", role: .destructive) { - viewModel.clearLookupCache() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") - } - .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) { - Button("Clear", role: .destructive) { - viewModel.clearWorkflows() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.") - } - .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) { - Button("Clear", role: .destructive) { - viewModel.clearTrackedDomains() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This removes the watchlist only. History and workflows stay intact.") - } + .navigationTitle("Import & Export") .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) { Button("Replace", role: .destructive) { applyPendingImport() @@ -2867,83 +3084,24 @@ struct SettingsView: View { } } .fileImporter( - isPresented: $showBackupImporter, - allowedContentTypes: [UTType.json], + isPresented: Binding( + get: { activeImportTarget != nil }, + set: { if !$0 { activeImportTarget = nil } } + ), + allowedContentTypes: activeImportTarget?.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() - await cloudSyncService.refreshAvailability() - } - } - .onChange(of: resolverOption) { _, newValue in - guard let presetURL = newValue.urlString else { - storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) - viewModel.persistCurrentAppSettings( - resolverURLString: storedResolverURL, - appDensityRawValue: storedDensity - ) + guard let pendingImportTarget else { + recordImportDebugStatus("fileImporter returned with no active target") return } - storedResolverURL = presetURL - viewModel.persistCurrentAppSettings( - resolverURLString: storedResolverURL, - appDensityRawValue: storedDensity - ) - } - .onChange(of: customResolverURL) { _, newValue in - guard resolverOption == .custom else { return } - storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - viewModel.persistCurrentAppSettings( - resolverURLString: storedResolverURL, - appDensityRawValue: storedDensity - ) + recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)") + handleImportResult(result, expectedKind: pendingImportTarget.expectedKind) + self.pendingImportTarget = nil + self.activeImportTarget = nil } - .onChange(of: storedDensity) { _, newValue in - viewModel.persistCurrentAppSettings( - resolverURLString: storedResolverURL, - appDensityRawValue: newValue - ) - } - } - - 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" + .onAppear { + viewModel.refreshDataLifecycleSummary() } } @@ -2985,32 +3143,55 @@ struct SettingsView: View { _ result: Result<[URL], Error>, expectedKind: DataPortabilityImportKind ) { + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)") + recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)") do { - guard let url = try result.get().first else { return } + let urls = try result.get() + guard let url = urls.first else { + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned") + recordImportDebugStatus("No URL returned from picker") + return + } + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)") + recordImportDebugStatus("Selected \(url.lastPathComponent)") let shouldStopAccessing = url.startAccessingSecurityScopedResource() + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)") + recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)") defer { if shouldStopAccessing { url.stopAccessingSecurityScopedResource() + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased") } } let data = try Data(contentsOf: url) + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)") + recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)") let preview = try viewModel.prepareDataImport( data: data, fileName: url.lastPathComponent, mode: importMode ) + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)") + recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)") guard preview.kind == expectedKind else { - pendingImportError = preview.kind == .backup + let message = preview.kind == .backup ? "That file is a full backup. Use Import Backup." : "That file type does not match this import action." + DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)") + recordImportDebugStatus("Kind mismatch: \(message)") + presentImportError(message) return } - pendingImportPreview = preview + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)") + recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)") + presentImportPreview(preview) } catch { - pendingImportError = error.localizedDescription + DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)") + recordImportDebugStatus("Import failed: \(error.localizedDescription)") + presentImportError(error.localizedDescription) } } @@ -3029,6 +3210,118 @@ struct SettingsView: View { formatter.dateFormat = "yyyyMMdd_HHmmss" return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)" } + + private func presentImportPreview(_ preview: DataImportPreview) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)") + recordImportDebugStatus("Preview presented for \(preview.fileName)") + pendingImportPreview = preview + } + } + + private func presentImportError(_ message: String) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)") + recordImportDebugStatus("Error presented: \(message)") + pendingImportError = message + } + } + + private func recordImportDebugStatus(_ message: String) { + #if DEBUG + let status = "[Import Debug] \(message)" + importDebugStatus = status + print(status) + #endif + } +} + +private struct DataManagementSettingsView: View { + @Bindable var viewModel: DomainViewModel + + @State private var showClearHistoryConfirmation = false + @State private var showClearCacheConfirmation = false + @State private var showClearWorkflowsConfirmation = false + @State private var showClearTrackedDomainsConfirmation = false + + var body: some View { + Form { + Section("Data") { + Button("Clear History", role: .destructive) { + showClearHistoryConfirmation = true + } + + Button("Clear Cache", role: .destructive) { + showClearCacheConfirmation = true + } + + Button("Clear Workflows", role: .destructive) { + showClearWorkflowsConfirmation = true + } + + Button("Clear Tracked Domains", role: .destructive) { + showClearTrackedDomainsConfirmation = true + } + } + } + .navigationTitle("Data Management") + .alert("Clear history?", isPresented: $showClearHistoryConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearHistory() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes saved lookup snapshots and clears monitoring run history on this device.") + } + .alert("Clear cache?", isPresented: $showClearCacheConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearLookupCache() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") + } + .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearWorkflows() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.") + } + .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearTrackedDomains() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.") + } + } +} + +private struct AboutSettingsView: View { + @State private var cloudSyncService = CloudSyncService.shared + + private var appVersion: String { + AppVersion.current + } + + var body: some View { + Form { + Section("About") { + LabeledContent("Version", value: appVersion) + LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") + LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") + } + } + .navigationTitle("App Info") + .task { + await cloudSyncService.refreshAvailability() + } + } } private struct DataImportPreviewSheet: View { diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift index c3caf59..592d7a7 100644 --- a/DomainDig/DomainDigUI.swift +++ b/DomainDig/DomainDigUI.swift @@ -235,6 +235,21 @@ struct EmptyStateCardView: View { let message: String let suggestion: String let systemImage: String + let showsCardBackground: Bool + + init( + title: String, + message: String, + suggestion: String, + systemImage: String, + showsCardBackground: Bool = true + ) { + self.title = title + self.message = message + self.suggestion = suggestion + self.systemImage = systemImage + self.showsCardBackground = showsCardBackground + } var body: some View { VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { @@ -253,7 +268,7 @@ struct EmptyStateCardView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(appDensity.metrics.cardPadding) - .background(Color(.systemGray6).opacity(0.45)) + .background(showsCardBackground ? Color(.systemGray6).opacity(0.45) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) } } diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift index 56dfe3f..5034402 100644 --- a/DomainDig/DomainMonitoringService.swift +++ b/DomainDig/DomainMonitoringService.swift @@ -1,5 +1,6 @@ import Foundation import UserNotifications +import CryptoKit #if canImport(BackgroundTasks) import BackgroundTasks @@ -132,9 +133,10 @@ final class DomainMonitoringScheduler { } #endif + let trackedDomains = MonitoringStorage.loadTrackedDomains() let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) request.earliestBeginDate = Date( - timeIntervalSinceNow: max(settings.frequency.schedulingInterval, 15 * 60) + timeIntervalSinceNow: max(Self.nextScheduleInterval(settings: settings, trackedDomains: trackedDomains), 15 * 60) ) do { @@ -149,6 +151,38 @@ final class DomainMonitoringScheduler { #endif } + private static func nextScheduleInterval( + settings: MonitoringSettings, + trackedDomains: [TrackedDomain], + now: Date = Date() + ) -> TimeInterval { + let monitored = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: trackedDomains) + guard !monitored.isEmpty else { + return settings.config.sanitizedBaseInterval + } + + return monitored + .map { trackedDomain in + let state = normalizedMonitoringState(for: trackedDomain, settings: settings) + guard let lastCheck = trackedDomain.monitoringState.lastCheck else { + return 0 + } + return max(0, lastCheck.addingTimeInterval(state.currentInterval).timeIntervalSince(now)) + } + .min() ?? settings.config.sanitizedBaseInterval + } + + static func normalizedMonitoringState( + for trackedDomain: TrackedDomain, + settings: MonitoringSettings + ) -> MonitoringState { + var state = trackedDomain.monitoringState + if state.currentInterval <= 0 { + state.currentInterval = settings.config.sanitizedBaseInterval + } + return state + } + #if canImport(BackgroundTasks) private func handleAppRefresh(task: BGAppRefreshTask) { _ = syncSchedule() @@ -174,6 +208,7 @@ final class DomainMonitoringService { private let inspectionService = DomainInspectionService() private let maxHistoryEntries = 250 + private let calendar = Calendar.current func performMonitoring( trigger: MonitoringRunTrigger, @@ -200,9 +235,9 @@ final class DomainMonitoringService { var history = MonitoringStorage.loadHistoryEntries() var mutableTrackedDomains = trackedDomains - let eligibleDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains) + let monitoredDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains) - guard !eligibleDomains.isEmpty else { + guard !monitoredDomains.isEmpty else { let log = MonitoringLog( timestamp: Date(), trigger: trigger, @@ -216,6 +251,24 @@ final class DomainMonitoringService { return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log) } + let eligibleDomains = monitoredDomains.filter { trackedDomain in + shouldInspect(trackedDomain: trackedDomain, trigger: trigger, settings: settings) + } + + guard !eligibleDomains.isEmpty else { + let log = MonitoringLog( + timestamp: Date(), + trigger: trigger, + domainsChecked: 0, + changesFound: 0, + alertsTriggered: 0, + checkedDomains: [], + errors: [] + ) + saveLog(log) + return MonitoringRunOutcome(success: true, message: "No domains are due for monitoring.", log: log) + } + let notificationsAuthorized: Bool if settings.alertsEnabled { notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts() @@ -225,6 +278,7 @@ final class DomainMonitoringService { var results: [MonitoringDomainResult] = [] var errors: [String] = [] var alertsTriggered = 0 + let now = Date() for trackedDomain in eligibleDomains { guard !Task.isCancelled else { @@ -252,33 +306,32 @@ final class DomainMonitoringService { let alertDescriptor = alertDescriptor( previousSnapshot: previousSnapshot, snapshot: snapshot, - entry: savedEntry + entry: savedEntry, + sensitivity: settings.sensitivity ) 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 + mutableTrackedDomains[index].lastMonitoredAt = now + let outcome = await processAdaptiveMonitoringState( + trackedDomain: mutableTrackedDomains[index], + snapshot: snapshot, + previousSnapshot: previousSnapshot, + alertDescriptor: alertDescriptor, + settings: settings, + notificationsAuthorized: notificationsAuthorized, + now: now ) - alertsTriggered += 1 - if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { - mutableTrackedDomains[index].lastAlertAt = Date() - } + mutableTrackedDomains[index] = outcome.trackedDomain + alertsTriggered += outcome.alertsTriggered } let result = MonitoringDomainResult( domain: trackedDomain.domain, historyEntryID: savedEntry?.id ?? snapshot.historyEntryID, - checkedAt: Date(), - didChange: snapshot.statusMessage == nil && savedEntry?.changeSummary?.hasChanges == true, + checkedAt: now, + didChange: alertDescriptor != nil, summaryMessage: snapshot.statusMessage + ?? alertDescriptor?.message ?? savedEntry?.changeSummary?.message ?? "No meaningful changes", alertSeverity: alertDescriptor?.severity, @@ -431,54 +484,283 @@ final class DomainMonitoringService { return entry } + private func shouldInspect( + trackedDomain: TrackedDomain, + trigger: MonitoringRunTrigger, + settings: MonitoringSettings + ) -> Bool { + guard trigger == .background else { return true } + guard let lastCheck = trackedDomain.monitoringState.lastCheck else { return true } + let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings) + return lastCheck.addingTimeInterval(state.currentInterval) <= Date() + } + + private func processAdaptiveMonitoringState( + trackedDomain: TrackedDomain, + snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot?, + alertDescriptor: MonitoringAlertDescriptor?, + settings: MonitoringSettings, + notificationsAuthorized: Bool, + now: Date + ) async -> (trackedDomain: TrackedDomain, alertsTriggered: Int) { + var updated = trackedDomain + var state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings) + let baseInterval = settings.config.sanitizedBaseInterval + let minInterval = settings.sensitivity.minimumInterval + let maxInterval = settings.config.maxInterval + let isQuiet = settings.quietHours?.contains(now, calendar: calendar) == true + var alertsTriggered = 0 + + if let alertDescriptor { + if state.lastChangeHash != alertDescriptor.changeHash { + state.lastChangeHash = alertDescriptor.changeHash + state.lastChangeDate = now + state.consecutiveStableChecks = 0 + state.currentInterval = settings.adaptiveEnabled ? minInterval : baseInterval + + if settings.alertsEnabled, + alertDescriptor.severity >= settings.alertFilter.minimumSeverity { + let pendingAlert = MonitoringPendingAlert( + detectedAt: now, + message: alertDescriptor.message, + severity: alertDescriptor.severity, + changeHash: alertDescriptor.changeHash + ) + + if isQuiet || !notificationsAuthorized { + updated.pendingMonitoringAlerts = deduplicatedPendingAlerts( + updated.pendingMonitoringAlerts + [pendingAlert] + ) + } else { + let pendingAlerts = deduplicatedPendingAlerts( + updated.pendingMonitoringAlerts + [pendingAlert] + ) + if pendingAlerts.count > 1 { + await LocalNotificationService.shared.notifyMonitoringSummary( + domain: trackedDomain.domain, + alerts: pendingAlerts + ) + } else { + await LocalNotificationService.shared.notifyMonitoringAlert( + domain: trackedDomain.domain, + message: alertDescriptor.message, + severity: alertDescriptor.severity + ) + } + alertsTriggered += 1 + updated.pendingMonitoringAlerts = [] + updated.lastAlertAt = now + state.lastAlertDate = now + } + } + } + } else { + state.consecutiveStableChecks += 1 + if settings.adaptiveEnabled { + state.currentInterval = max( + baseInterval, + min(maxInterval, state.currentInterval * settings.sensitivity.intervalMultiplier) + ) + } else { + state.currentInterval = baseInterval + } + } + + if !isQuiet, + settings.alertsEnabled, + notificationsAuthorized, + !updated.pendingMonitoringAlerts.isEmpty, + alertDescriptor == nil { + await LocalNotificationService.shared.notifyMonitoringSummary( + domain: trackedDomain.domain, + alerts: updated.pendingMonitoringAlerts + ) + alertsTriggered += 1 + updated.pendingMonitoringAlerts = [] + updated.lastAlertAt = now + state.lastAlertDate = now + } + + state.lastCheck = now + updated.monitoringState = state + return (updated, alertsTriggered) + } + + private func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] { + var uniqueByHash: [String: MonitoringPendingAlert] = [:] + for alert in alerts { + if let existing = uniqueByHash[alert.changeHash] { + uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert + } else { + uniqueByHash[alert.changeHash] = alert + } + } + return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt } + } + + private struct MonitoringAlertDescriptor { + let severity: MonitoringAlertSeverity + let message: String + let changeHash: String + } + + private struct HeaderFingerprint: Encodable, Equatable { + let name: String + let value: String + } + private func alertDescriptor( previousSnapshot: LookupSnapshot?, snapshot: LookupSnapshot, - entry: HistoryEntry? - ) -> (severity: MonitoringAlertSeverity, message: String)? { + entry: HistoryEntry?, + sensitivity: MonitoringSensitivity + ) -> MonitoringAlertDescriptor? { + guard let previousSnapshot else { return nil } + let previousHash = monitoringHash(for: previousSnapshot, sensitivity: sensitivity) + let currentHash = monitoringHash(for: snapshot, sensitivity: sensitivity) + guard previousHash != currentHash else { return nil } + let changedLabels = Set( - (previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []) + DomainDiffService.diff(from: previousSnapshot, to: snapshot) .flatMap(\.items) .filter(\.hasChanges) .map(\.label) ) - if changedLabels.contains("Availability") { - return (.critical, "Availability changed") + if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) { + return MonitoringAlertDescriptor(severity: .warning, message: "DNS records changed", changeHash: currentHash) } - if changedLabels.contains("Primary IP") { - return (.critical, "Primary IP changed") + + let oldCertificateLevel = DomainDiffService.certificateWarningLevel(for: previousSnapshot) + let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot) + if snapshot.sslInfo?.issuer != previousSnapshot.sslInfo?.issuer + || snapshot.sslInfo?.validUntil != previousSnapshot.sslInfo?.validUntil + || (newCertificateLevel != .none && newCertificateLevel != oldCertificateLevel) { + return MonitoringAlertDescriptor(severity: .warning, message: "Certificate updated", changeHash: currentHash) } - if changedLabels.contains("Redirect Target") { - return (.critical, "Redirect target changed") + + if sensitivity != .low, + previousSnapshot.redirectChain.map(\.url) != snapshot.redirectChain.map(\.url) + || previousSnapshot.redirectChain.map(\.statusCode) != snapshot.redirectChain.map(\.statusCode) { + return MonitoringAlertDescriptor(severity: .warning, message: "Redirect chain changed", changeHash: currentHash) } - let ownershipLabels: Set<String> = [ - "Registrar", - "Registration Date", - "Expiration Date", - "Ownership Status", - "Abuse Contact" - ] - if !changedLabels.isDisjoint(with: ownershipLabels) { - return (.warning, "Ownership changed") + if sensitivity != .low, + filteredHTTPHeaders(previousSnapshot.httpHeaders, sensitivity: sensitivity) + != filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity) { + return MonitoringAlertDescriptor(severity: .info, message: "HTTP headers changed", changeHash: currentHash) } - if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) { - return (.warning, "DNS changed") + + if sensitivity == .high, + entry?.changeSummary?.hasChanges == true, + let message = entry?.changeSummary?.message { + return MonitoringAlertDescriptor(severity: .info, message: message, changeHash: currentHash) } - 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") + return nil + } + + private func monitoringHash(for snapshot: LookupSnapshot, sensitivity: MonitoringSensitivity) -> String { + struct DNSRecordPayload: Encodable { + let type: String + let records: [String] + let wildcardRecords: [String] } - if entry?.changeSummary?.hasChanges == true, let message = entry?.changeSummary?.message { - return (.info, message) + struct CertificatePayload: Encodable { + let issuer: String? + let commonName: String? + let validUntil: Date? } - return nil + struct RedirectPayload: Encodable { + let url: String + let statusCode: Int + } + + struct HeaderPayload: Encodable { + let name: String + let value: String + } + + struct MonitoringHashPayload: Encodable { + let dns: [DNSRecordPayload] + let certificate: CertificatePayload + let headers: [HeaderPayload] + let redirects: [RedirectPayload] + } + + let payload = MonitoringHashPayload( + dns: snapshot.dnsSections + .sorted { $0.recordType.rawValue < $1.recordType.rawValue } + .map { section in + DNSRecordPayload( + type: section.recordType.rawValue, + records: section.records + .sorted { lhs, rhs in + lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value + } + .map { "\($0.value)|\($0.ttl)" }, + wildcardRecords: section.wildcardRecords + .sorted { lhs, rhs in + lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value + } + .map { "\($0.value)|\($0.ttl)" } + ) + }, + certificate: CertificatePayload( + issuer: snapshot.sslInfo?.issuer, + commonName: snapshot.sslInfo?.commonName, + validUntil: snapshot.sslInfo?.validUntil + ), + headers: filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity).map { + HeaderPayload(name: $0.name, value: $0.value) + }, + redirects: sensitivity == .low ? [] : snapshot.redirectChain.map { + RedirectPayload(url: $0.url, statusCode: $0.statusCode) + } + ) + + let encoder = JSONEncoder() + if #available(iOS 11.0, macOS 10.13, *) { + encoder.outputFormatting = [.sortedKeys] + } + let data = (try? encoder.encode(payload)) ?? Data() + return SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined() + } + + private func filteredHTTPHeaders( + _ headers: [HTTPHeader], + sensitivity: MonitoringSensitivity + ) -> [HeaderFingerprint] { + let mediumHeaderNames: Set<String> = [ + "cache-control", + "content-security-policy", + "location", + "permissions-policy", + "referrer-policy", + "server", + "strict-transport-security", + "x-content-type-options", + "x-frame-options" + ] + + return headers + .filter { header in + switch sensitivity { + case .low: + return false + case .medium: + return mediumHeaderNames.contains(header.name.lowercased()) + case .high: + return true + } + } + .map { HeaderFingerprint(name: $0.name.lowercased(), value: $0.value) } + .sorted { lhs, rhs in + lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name + } } private static func resolvedSnapshotAfterFallback( diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 656529c..03e9ad4 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -756,6 +756,7 @@ final class DomainViewModel { func clearHistory() { history.removeAll() persistHistory() + clearMonitoringLogs() refreshDataLifecycleSummary() } @@ -779,6 +780,7 @@ final class DomainViewModel { refreshingTrackedDomainID = nil persistTrackedDomains() sanitizeMonitoringSelection() + clearMonitoringLogs() refreshDataLifecycleSummary() } @@ -842,16 +844,45 @@ final class DomainViewModel { persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) } - func setMonitoringFrequency(_ frequency: MonitoringFrequency) { + func setMonitoringBaseInterval(_ baseInterval: MonitoringBaseInterval) { guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) return } - monitoringSettings.frequency = frequency + monitoringSettings.baseInterval = baseInterval.interval persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() } + func setMonitoringAdaptiveEnabled(_ isEnabled: Bool) { + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + monitoringSettings.adaptiveEnabled = isEnabled + persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) + monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + } + + func setMonitoringSensitivity(_ sensitivity: MonitoringSensitivity) { + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + monitoringSettings.sensitivity = sensitivity + persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) + monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + } + + func setMonitoringQuietHours(startHour: Int, endHour: Int, isEnabled: Bool) { + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + monitoringSettings.quietHours = isEnabled ? QuietHours(startHour: startHour, endHour: endHour) : nil + persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) + } + func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) { guard FeatureAccessService.hasAccess(to: .localAlerts) else { monitoringSettings.alertsEnabled = false @@ -928,6 +959,30 @@ final class DomainViewModel { } } + func monitoringIntervalLabel(for trackedDomain: TrackedDomain) -> String { + let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: monitoringSettings) + return Self.intervalLabel(for: state.currentInterval) + } + + func monitoringStatusLabel(for trackedDomain: TrackedDomain) -> String { + let state = trackedDomain.monitoringState + if let lastChangeDate = state.lastChangeDate, + Date().timeIntervalSince(lastChangeDate) <= 6 * 60 * 60 { + return "Recently Changed" + } + if state.consecutiveStableChecks >= 2 { + return "Stable" + } + return trackedDomain.monitoringEnabled ? "Active" : "Paused" + } + + private static func intervalLabel(for interval: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = interval < 3600 ? [.minute] : [.hour, .minute] + formatter.unitsStyle = .full + return formatter.string(from: interval) ?? "Unknown" + } + func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) { if useSnapshotResolver { UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) @@ -968,6 +1023,22 @@ final class DomainViewModel { clearLookupState() } + func clearPresentedResults() { + lookupTask?.cancel() + customPortScanTask?.cancel() + batchTask?.cancel() + hasRun = false + searchedDomain = "" + lastLookupDurationMs = nil + currentDiffSections = [] + currentChangeSummary = nil + ownershipDiff = [] + currentReport = nil + refreshingTrackedDomainID = nil + clearBatchState() + clearLookupState() + } + func run() { let target = trimmedDomain guard !target.isEmpty else { return } @@ -1222,13 +1293,6 @@ final class DomainViewModel { } guard ownershipHistory.isEmpty else { return } - let creditStatus = await UsageCreditService.shared.status(for: .ownershipHistory) - guard !creditStatus.isExhausted else { - ownershipHistoryError = "No ownership history credits remaining" - await refreshUsageCredits() - return - } - ownershipHistoryLoading = true ownershipHistoryError = nil @@ -1242,15 +1306,9 @@ final class DomainViewModel { case let .success(events): ownershipHistory = events ownershipHistoryError = nil - if outcome.source != .cached { - _ = await UsageCreditService.shared.consume(.ownershipHistory) - } case let .empty(message): ownershipHistory = [] ownershipHistoryError = message - if outcome.source != .cached { - _ = await UsageCreditService.shared.consume(.ownershipHistory) - } case let .error(message): ownershipHistory = [] ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable") @@ -1258,7 +1316,6 @@ final class DomainViewModel { ownershipHistoryLoading = false _ = saveHistoryEntry(replaceLatest: true) - await refreshUsageCredits() } func loadDNSHistory() async { @@ -1269,13 +1326,6 @@ final class DomainViewModel { } guard dnsHistory.isEmpty else { return } - let creditStatus = await UsageCreditService.shared.status(for: .dnsHistory) - guard !creditStatus.isExhausted else { - dnsHistoryError = "No DNS history credits remaining" - await refreshUsageCredits() - return - } - dnsHistoryLoading = true dnsHistoryError = nil @@ -1289,15 +1339,9 @@ final class DomainViewModel { case let .success(events): dnsHistory = events dnsHistoryError = nil - if outcome.source != .cached { - _ = await UsageCreditService.shared.consume(.dnsHistory) - } case let .empty(message): dnsHistory = [] dnsHistoryError = message - if outcome.source != .cached { - _ = await UsageCreditService.shared.consume(.dnsHistory) - } case let .error(message): dnsHistory = [] dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable") @@ -1305,7 +1349,6 @@ final class DomainViewModel { dnsHistoryLoading = false _ = saveHistoryEntry(replaceLatest: true) - await refreshUsageCredits() } func loadExtendedSubdomains() async { @@ -1316,13 +1359,6 @@ final class DomainViewModel { } guard extendedSubdomains.isEmpty else { return } - let creditStatus = await UsageCreditService.shared.status(for: .extendedSubdomains) - guard !creditStatus.isExhausted else { - extendedSubdomainsError = "No extended subdomain credits remaining" - await refreshUsageCredits() - return - } - extendedSubdomainsLoading = true extendedSubdomainsError = nil @@ -1335,15 +1371,9 @@ final class DomainViewModel { case let .success(results): extendedSubdomains = results extendedSubdomainsError = nil - if outcome.source != .cached { - _ = await UsageCreditService.shared.consume(.extendedSubdomains) - } case let .empty(message): extendedSubdomains = [] extendedSubdomainsError = message - if outcome.source != .cached { - _ = await UsageCreditService.shared.consume(.extendedSubdomains) - } case let .error(message): extendedSubdomains = [] extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable") @@ -1351,7 +1381,6 @@ final class DomainViewModel { extendedSubdomainsLoading = false _ = saveHistoryEntry(replaceLatest: true) - await refreshUsageCredits() } func refreshUsageCredits() async { @@ -1525,14 +1554,12 @@ final class DomainViewModel { } guard snapshot.statusMessage == nil else { - await refreshUsageCredits() return history.first(where: { $0.id == snapshot.historyEntryID }) } let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain) let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true) DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain) - await refreshUsageCredits() DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain) return entry } @@ -2246,6 +2273,12 @@ final class DomainViewModel { CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false) } + private func clearMonitoringLogs() { + monitoringLogs.removeAll() + monitoringStatusMessage = nil + MonitoringStorage.saveLogs([]) + } + private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { return @@ -2533,14 +2566,9 @@ final class DomainViewModel { return } - let entry: HistoryEntry? - if payload.snapshot.statusMessage == nil { - entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) - } else { - entry = payload.snapshot.historyEntryID.flatMap { id in - history.first(where: { $0.id == id }) - } - } + let entry = payload.snapshot.historyEntryID.flatMap { id in + history.first(where: { $0.id == id }) + } ?? saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment let quickStatus: String @@ -3568,7 +3596,7 @@ final class DomainViewModel { } } if !DataAccessService.hasAccess(to: .extendedSubdomains) { - lines.append(" Extended subdomain discovery (Data+)") + lines.append(" Extended subdomain discovery (Pro+)") } } diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift index 9fae3bd..a601b33 100644 --- a/DomainDig/FeatureAccessService.swift +++ b/DomainDig/FeatureAccessService.swift @@ -3,7 +3,7 @@ import Foundation enum FeatureTier: String, Codable, CaseIterable, Identifiable { case free case pro - case dataPlus + case proPlus = "dataPlus" var id: String { rawValue } @@ -13,8 +13,8 @@ enum FeatureTier: String, Codable, CaseIterable, Identifiable { return "Free" case .pro: return "Pro" - case .dataPlus: - return "Data+" + case .proPlus: + return "Pro+" } } } @@ -114,9 +114,9 @@ enum FeatureAccessService { workflowLimit: nil, batchSizeLimit: nil ) - case .dataPlus: + case .proPlus: return FeatureEntitlements( - tier: .dataPlus, + tier: .proPlus, capabilities: Set(FeatureCapability.allCases), trackedDomainLimit: effectivelyUnlimitedTrackedDomains, workflowLimit: nil, @@ -158,7 +158,7 @@ enum FeatureAccessService { case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports: return "Available in Pro" case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: - return "Available in Data+" + return "Available in Pro+" case .limitedTracking: return "Tracking is limited on Free" case .singleLookup, .basicHistory: @@ -220,7 +220,7 @@ enum FeatureAccessService { let title: String switch capability { case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: - title = "Available in Data+" + title = "Available in Pro+" default: title = "Available in Pro" } diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index be7548a..3d6151c 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -22,7 +22,8 @@ struct HistoryView: View { title: "No History Yet", message: "History stores local snapshots of previous inspections so you can revisit and compare them later.", suggestion: "Run a lookup to create your first saved snapshot.", - systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90" + systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", + showsCardBackground: false ) .listRowBackground(Color(.systemGray6).opacity(0.5)) } else { diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift index 7b43d67..93beca5 100644 --- a/DomainDig/LocalNotificationService.swift +++ b/DomainDig/LocalNotificationService.swift @@ -78,6 +78,35 @@ final class LocalNotificationService { ) } + func notifyMonitoringSummary( + domain: String, + alerts: [MonitoringPendingAlert] + ) async { + let summary = alerts + .sorted { $0.detectedAt < $1.detectedAt } + .prefix(2) + .map(\.message) + .joined(separator: " • ") + let body: String + if alerts.count <= 1 { + body = alerts.first?.message ?? "Monitoring change detected" + } else if summary.isEmpty { + body = "\(alerts.count) monitoring changes detected" + } else { + body = "\(alerts.count) monitoring changes: \(summary)" + } + + let severity = alerts.map(\.severity).max() ?? .info + let interruptionLevel: UNNotificationInterruptionLevel = severity == .critical ? .timeSensitive : .active + + await schedule( + identifier: "monitoring-summary-\(domain)-\(UUID().uuidString)", + title: domain, + body: body, + 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 51273b4..f872aab 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -728,6 +728,179 @@ struct CollaborationMetadata: Codable, Equatable { } } +enum MonitoringSensitivity: String, Codable, CaseIterable, Identifiable, Sendable { + case low + case medium + case high + + var id: String { rawValue } + + var title: String { + rawValue.capitalized + } + + var minimumInterval: TimeInterval { + switch self { + case .low: + return 30 * 60 + case .medium: + return 15 * 60 + case .high: + return 5 * 60 + } + } + + var intervalMultiplier: Double { + switch self { + case .low: + return 1.5 + case .medium: + return 1.25 + case .high: + return 1.1 + } + } +} + +struct QuietHours: Codable, Equatable, Sendable { + var startHour: Int + var endHour: Int + + init(startHour: Int, endHour: Int) { + self.startHour = max(0, min(23, startHour)) + self.endHour = max(0, min(23, endHour)) + } + + func contains(_ date: Date, calendar: Calendar = .current) -> Bool { + let hour = calendar.component(.hour, from: date) + if startHour <= endHour { + return hour >= startHour && hour < endHour + } + return hour >= startHour || hour < endHour + } +} + +enum MonitoringBaseInterval: String, Codable, CaseIterable, Identifiable { + case thirtyMinutes + case hourly + case sixHours + case twelveHours + case daily + + var id: String { rawValue } + + var title: String { + switch self { + case .thirtyMinutes: + return "Every 30 Minutes" + case .hourly: + return "Hourly" + case .sixHours: + return "Every 6 Hours" + case .twelveHours: + return "Every 12 Hours" + case .daily: + return "Daily" + } + } + + var interval: TimeInterval { + switch self { + case .thirtyMinutes: + return 30 * 60 + case .hourly: + return 60 * 60 + case .sixHours: + return 6 * 60 * 60 + case .twelveHours: + return 12 * 60 * 60 + case .daily: + return 24 * 60 * 60 + } + } + + static func nearest(to interval: TimeInterval) -> MonitoringBaseInterval { + allCases.min { abs($0.interval - interval) < abs($1.interval - interval) } ?? .twelveHours + } +} + +struct MonitoringConfig: Codable, Equatable, Sendable { + var isEnabled: Bool + var baseInterval: TimeInterval + var adaptiveEnabled: Bool + var sensitivity: MonitoringSensitivity + var quietHours: QuietHours? + + init( + isEnabled: Bool = false, + baseInterval: TimeInterval = MonitoringBaseInterval.twelveHours.interval, + adaptiveEnabled: Bool = true, + sensitivity: MonitoringSensitivity = .medium, + quietHours: QuietHours? = nil + ) { + self.isEnabled = isEnabled + self.baseInterval = baseInterval + self.adaptiveEnabled = adaptiveEnabled + self.sensitivity = sensitivity + self.quietHours = quietHours + } + + var maxInterval: TimeInterval { + 24 * 60 * 60 + } + + var sanitizedBaseInterval: TimeInterval { + max(5 * 60, min(baseInterval, maxInterval)) + } +} + +struct MonitoringState: Codable, Equatable, Sendable { + var lastCheck: Date? + var lastChangeHash: String? + var lastAlertDate: Date? + var consecutiveStableChecks: Int + var currentInterval: TimeInterval + var lastChangeDate: Date? + + init( + lastCheck: Date? = nil, + lastChangeHash: String? = nil, + lastAlertDate: Date? = nil, + consecutiveStableChecks: Int = 0, + currentInterval: TimeInterval = 0, + lastChangeDate: Date? = nil + ) { + self.lastCheck = lastCheck + self.lastChangeHash = lastChangeHash + self.lastAlertDate = lastAlertDate + self.consecutiveStableChecks = consecutiveStableChecks + self.currentInterval = currentInterval + self.lastChangeDate = lastChangeDate + } +} + +struct MonitoringPendingAlert: Codable, Identifiable, Equatable, Sendable { + let id: UUID + var detectedAt: Date + var message: String + var severity: MonitoringAlertSeverity + var changeHash: String + + init( + id: UUID = UUID(), + detectedAt: Date, + message: String, + severity: MonitoringAlertSeverity, + changeHash: String + ) { + self.id = id + self.detectedAt = detectedAt + self.message = message + self.severity = severity + self.changeHash = changeHash + } +} + struct TrackedDomain: Codable, Identifiable, Equatable { let id: UUID var domain: String @@ -744,6 +917,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable { var certificateDaysRemaining: Int? var lastMonitoredAt: Date? var lastAlertAt: Date? + var monitoringState: MonitoringState + var pendingMonitoringAlerts: [MonitoringPendingAlert] var collaboration: CollaborationMetadata? init( @@ -762,6 +937,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable { certificateDaysRemaining: Int? = nil, lastMonitoredAt: Date? = nil, lastAlertAt: Date? = nil, + monitoringState: MonitoringState = MonitoringState(), + pendingMonitoringAlerts: [MonitoringPendingAlert] = [], collaboration: CollaborationMetadata? = nil ) { self.id = id @@ -779,6 +956,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable { self.certificateDaysRemaining = certificateDaysRemaining self.lastMonitoredAt = lastMonitoredAt self.lastAlertAt = lastAlertAt + self.monitoringState = monitoringState + self.pendingMonitoringAlerts = pendingMonitoringAlerts self.collaboration = collaboration } @@ -799,35 +978,12 @@ struct TrackedDomain: Codable, Identifiable, Equatable { certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining) lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt) lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt) + monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState() + pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? [] collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration) } } -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 @@ -913,28 +1069,102 @@ enum MonitoringRunTrigger: String, Codable { } struct MonitoringSettings: Codable, Equatable { - var isEnabled: Bool - var frequency: MonitoringFrequency + private enum CodingKeys: String, CodingKey { + case config + case isEnabled + case frequency + case scope + case selectedDomainIDs + case alertFilter + case alertsEnabled + } + + var config: MonitoringConfig var scope: MonitoringScope var selectedDomainIDs: [UUID] var alertFilter: MonitoringAlertFilter var alertsEnabled: Bool init( - isEnabled: Bool = false, - frequency: MonitoringFrequency = .daily, + config: MonitoringConfig = MonitoringConfig(), scope: MonitoringScope = .allTracked, selectedDomainIDs: [UUID] = [], alertFilter: MonitoringAlertFilter = .criticalAndWarnings, alertsEnabled: Bool = false ) { - self.isEnabled = isEnabled - self.frequency = frequency + self.config = config self.scope = scope self.selectedDomainIDs = selectedDomainIDs self.alertFilter = alertFilter self.alertsEnabled = alertsEnabled } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let legacyEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false + let legacyFrequencyRaw = try container.decodeIfPresent(String.self, forKey: .frequency) + + if let decodedConfig = try container.decodeIfPresent(MonitoringConfig.self, forKey: .config) { + config = decodedConfig + } else { + let mappedInterval: TimeInterval + switch legacyFrequencyRaw { + case "daily": + mappedInterval = MonitoringBaseInterval.daily.interval + case "twiceDaily": + mappedInterval = MonitoringBaseInterval.twelveHours.interval + default: + mappedInterval = MonitoringBaseInterval.twelveHours.interval + } + + config = MonitoringConfig( + isEnabled: legacyEnabled, + baseInterval: mappedInterval, + adaptiveEnabled: true, + sensitivity: .medium, + quietHours: nil + ) + } + + scope = try container.decodeIfPresent(MonitoringScope.self, forKey: .scope) ?? .allTracked + selectedDomainIDs = try container.decodeIfPresent([UUID].self, forKey: .selectedDomainIDs) ?? [] + alertFilter = try container.decodeIfPresent(MonitoringAlertFilter.self, forKey: .alertFilter) ?? .criticalAndWarnings + alertsEnabled = try container.decodeIfPresent(Bool.self, forKey: .alertsEnabled) ?? false + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(config, forKey: .config) + try container.encode(scope, forKey: .scope) + try container.encode(selectedDomainIDs, forKey: .selectedDomainIDs) + try container.encode(alertFilter, forKey: .alertFilter) + try container.encode(alertsEnabled, forKey: .alertsEnabled) + } + + var isEnabled: Bool { + get { config.isEnabled } + set { config.isEnabled = newValue } + } + + var baseInterval: TimeInterval { + get { config.baseInterval } + set { config.baseInterval = newValue } + } + + var adaptiveEnabled: Bool { + get { config.adaptiveEnabled } + set { config.adaptiveEnabled = newValue } + } + + var sensitivity: MonitoringSensitivity { + get { config.sensitivity } + set { config.sensitivity = newValue } + } + + var quietHours: QuietHours? { + get { config.quietHours } + set { config.quietHours = newValue } + } } struct MonitoringDomainResult: Codable, Identifiable, Equatable { diff --git a/DomainDig/MonitoringView.swift b/DomainDig/MonitoringView.swift index 0b7a916..fc0b8ab 100644 --- a/DomainDig/MonitoringView.swift +++ b/DomainDig/MonitoringView.swift @@ -17,7 +17,14 @@ struct MonitoringView: View { 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("Base Interval", value: MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval).title) + LabeledContent("Adaptive", value: viewModel.monitoringSettings.adaptiveEnabled ? viewModel.monitoringSettings.sensitivity.title : "Off") + LabeledContent( + "Quiet Hours", + value: viewModel.monitoringSettings.quietHours.map { + "\(Self.hourLabel($0.startHour)) to \(Self.hourLabel($0.endHour))" + } ?? "Off" + ) LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off") if let monitoringStatusMessage = viewModel.monitoringStatusMessage, @@ -42,7 +49,8 @@ struct MonitoringView: View { 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" + systemImage: "waveform.path.ecg", + showsCardBackground: false ) } .listRowBackground(Color(.systemGray6).opacity(0.5)) @@ -102,6 +110,13 @@ struct MonitoringView: View { .background(tint.opacity(0.16)) .clipShape(Capsule()) } + + private static func hourLabel(_ hour: Int) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "h a" + let components = DateComponents(calendar: .current, hour: hour) + return components.date.map(formatter.string(from:)) ?? "\(hour):00" + } } struct MonitoringLogDetailView: View { diff --git a/DomainDig/PaywallView.swift b/DomainDig/PaywallView.swift index 90578c7..cad8be4 100644 --- a/DomainDig/PaywallView.swift +++ b/DomainDig/PaywallView.swift @@ -10,7 +10,7 @@ struct PaywallView: View { NavigationStack { List { Section { - Text("Pro unlocks workflows, scale, monitoring automation, 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. Pro+ adds deeper external intelligence with no additional usage limits.") .font(appDensity.font(.body, design: .default)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -25,7 +25,7 @@ struct PaywallView: View { featureRow("Advanced exports") } - Section("What Data+ Unlocks") { + Section("What Pro+ Unlocks") { featureRow("Ownership history") featureRow("DNS history") featureRow("Extended subdomains") @@ -125,10 +125,10 @@ struct PaywallView: View { return "Pro Monthly" case PurchaseService.yearlyProductID: return "Pro Yearly" - case PurchaseService.dataPlusMonthlyProductID: - return "Data+ Monthly" - case PurchaseService.dataPlusYearlyProductID: - return "Data+ Yearly" + case PurchaseService.proPlusMonthlyProductID: + return "Pro+ Monthly" + case PurchaseService.proPlusYearlyProductID: + return "Pro+ Yearly" default: return product.displayName } diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift index 20ee818..af34a33 100644 --- a/DomainDig/PurchaseService.swift +++ b/DomainDig/PurchaseService.swift @@ -17,18 +17,30 @@ final class PurchaseService { static let shared = PurchaseService() static let monthlyProductID = "domaindig.pro.monthly" static let yearlyProductID = "domaindig.pro.yearly" - static let dataPlusMonthlyProductID = "domaindig.dataplus.monthly" - static let dataPlusYearlyProductID = "domaindig.dataplus.yearly" + static let proPlusMonthlyProductID = "domaindig.dataplus.monthly" + static let proPlusYearlyProductID = "domaindig.dataplus.yearly" static let productIDs = [ monthlyProductID, yearlyProductID, - dataPlusMonthlyProductID, - dataPlusYearlyProductID + proPlusMonthlyProductID, + proPlusYearlyProductID ] private static let entitlementCacheKey = "purchase.cachedEntitlement" + #if DEBUG + // Local-only screenshot/testing override. Release builds always use StoreKit entitlements. + private static let debugForceFreeArgument = "DOMAIN_DIG_FORCE_FREE" + private static let debugForceProArgument = "DOMAIN_DIG_FORCE_PRO" + private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS" + #endif static var cachedEntitlement: CachedEntitlement? { + #if DEBUG + if let forcedEntitlement = debugForcedEntitlement { + return forcedEntitlement + } + #endif + guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } return try? JSONDecoder().decode(CachedEntitlement.self, from: data) } @@ -51,6 +63,7 @@ final class PurchaseService { private init() { currentTier = Self.cachedTier activeProductID = Self.cachedEntitlement?.activeProductID + applyDebugOverrideIfNeeded() updatesTask = observeTransactionUpdates() Task { await refreshProducts() @@ -62,8 +75,8 @@ final class PurchaseService { currentTier != .free } - var hasDataPlusAccess: Bool { - currentTier == .dataPlus + var hasProPlusAccess: Bool { + currentTier == .proPlus } func refreshProducts() async { @@ -104,6 +117,7 @@ final class PurchaseService { self.activeProductID = activeProductID currentTier = tier(for: activeProductID) persistCurrentEntitlement() + applyDebugOverrideIfNeeded() } func purchase(_ product: Product) async { @@ -120,7 +134,7 @@ final class PurchaseService { apply(transaction: transaction) await transaction.finish() await refreshEntitlements() - statusMessage = currentTier == .dataPlus ? "Data+ is active." : "Pro is active." + statusMessage = currentTier == .proPlus ? "Pro+ is active." : "Pro is active." case .userCancelled: break case .pending: @@ -190,6 +204,7 @@ final class PurchaseService { activeProductID = transaction.productID currentTier = tier(for: transaction.productID) persistCurrentEntitlement() + applyDebugOverrideIfNeeded() } private func observeTransactionUpdates() -> Task<Void, Never> { @@ -220,6 +235,14 @@ final class PurchaseService { } } + private func applyDebugOverrideIfNeeded() { + #if DEBUG + guard let forcedEntitlement = Self.debugForcedEntitlement else { return } + currentTier = forcedEntitlement.tier + activeProductID = forcedEntitlement.activeProductID + #endif + } + private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction { switch result { case .verified(let transaction): @@ -235,9 +258,9 @@ final class PurchaseService { return 0 case Self.yearlyProductID: return 1 - case Self.dataPlusMonthlyProductID: + case Self.proPlusMonthlyProductID: return 2 - case Self.dataPlusYearlyProductID: + case Self.proPlusYearlyProductID: return 3 default: return Int.max @@ -248,8 +271,8 @@ final class PurchaseService { switch productID { case Self.monthlyProductID, Self.yearlyProductID: return .pro - case Self.dataPlusMonthlyProductID, Self.dataPlusYearlyProductID: - return .dataPlus + case Self.proPlusMonthlyProductID, Self.proPlusYearlyProductID: + return .proPlus default: return .free } @@ -268,4 +291,36 @@ final class PurchaseService { let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) return message.isEmpty ? fallback : message } + + #if DEBUG + private static var debugForcedEntitlement: CachedEntitlement? { + let arguments = ProcessInfo.processInfo.arguments + + if arguments.contains(debugForceFreeArgument) { + return CachedEntitlement( + tier: .free, + activeProductID: nil, + updatedAt: .distantPast + ) + } + + if arguments.contains(debugForceProPlusArgument) { + return CachedEntitlement( + tier: .proPlus, + activeProductID: proPlusMonthlyProductID, + updatedAt: .distantPast + ) + } + + if arguments.contains(debugForceProArgument) { + return CachedEntitlement( + tier: .pro, + activeProductID: monthlyProductID, + updatedAt: .distantPast + ) + } + + return nil + } + #endif } diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift index adf5702..06a9352 100644 --- a/DomainDig/RootTabView.swift +++ b/DomainDig/RootTabView.swift @@ -35,17 +35,10 @@ struct RootTabView: View { } NavigationStack { - WorkflowsView(viewModel: viewModel) - } - .tabItem { - Label("Workflows", systemImage: "square.stack.3d.down.right") - } - - NavigationStack { SettingsView(viewModel: viewModel) } .tabItem { - Label("Settings", systemImage: "gearshape") + Label("More", systemImage: "ellipsis.circle") } } .sheet(isPresented: Binding( diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift index 404fc1e..b163c76 100644 --- a/DomainDig/TimelineView.swift +++ b/DomainDig/TimelineView.swift @@ -12,6 +12,10 @@ struct TimelineView: View { viewModel.timelineSections(for: domain) } + private var compareButtonDisabled: Bool { + viewModel.selectedSnapshots.count != 2 && viewModel.historyEntries(for: domain).count < 2 + } + var body: some View { List { ForEach(timelineSections) { section in @@ -61,10 +65,16 @@ struct TimelineView: View { } Button("Compare") { - presentedDiff = viewModel.generateDiffForSelectedSnapshots() + if viewModel.selectedSnapshots.count == 2 { + presentedDiff = viewModel.generateDiffForSelectedSnapshots() + } else { + let entries = viewModel.historyEntries(for: domain) + guard entries.count >= 2 else { return } + presentedDiff = viewModel.generateDiff(from: entries[1], to: entries[0]) + } focusedSectionID = viewModel.currentDiffTargetSectionID } - .disabled(viewModel.selectedSnapshots.count != 2) + .disabled(compareButtonDisabled) Menu("Export") { Button("Export TXT") { @@ -119,46 +129,24 @@ private struct TimelineRow: View { HStack(spacing: 8) { AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability)) - if let primaryIP = summary.primaryIP { - AppStatusBadgeView( - model: .init( - title: primaryIP, - systemImage: "network", - foregroundColor: .blue, - backgroundColor: .blue.opacity(0.16) - ) - ) - } - if let tlsStatus = summary.tlsStatus { - AppStatusBadgeView( - model: .init( - title: tlsStatus.capitalized, - systemImage: "lock.shield", - foregroundColor: .green, - backgroundColor: .green.opacity(0.16) - ) - ) - } if let riskScore = summary.riskScore { - AppStatusBadgeView( - model: .init( - title: "Risk \(riskScore)", - systemImage: "exclamationmark.shield", - foregroundColor: riskScore >= 70 ? .red : .orange, - backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16) - ) - ) + Text("Risk \(riskScore)") + .lineLimit(1) + .minimumScaleFactor(0.85) } } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) HStack(spacing: 8) { - if let snapshotIndex = summary.snapshotIndex { - Text("#\(snapshotIndex)") - } - Text(summary.timestamp.formatted(.relative(presentation: .named))) - if summary.changeCount > 0 { - Text("\(summary.changeCount) changes") + if let primaryIP = summary.primaryIP { + Text(primaryIP) + .lineLimit(1) + .truncationMode(.middle) } + Spacer(minLength: 8) + Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened)) + .lineLimit(1) } .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index 31c6202..d092f65 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -6,6 +6,10 @@ struct WatchlistView: View { @Environment(\.dismiss) private var dismiss @State private var purchaseService = PurchaseService.shared @State private var showWorkflowAddSheet = false + @State private var showAddDomainSheet = false + @State private var newTrackedDomain = "" + @State private var addDomainError: String? + @FocusState private var isAddDomainFieldFocused: Bool private var pinnedDomains: [TrackedDomain] { viewModel.filteredTrackedDomains.filter(\.isPinned) @@ -53,7 +57,8 @@ struct WatchlistView: View { title: "No Tracked Domains", message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.", suggestion: "Run an inspection and use the Track action on a domain you care about.", - systemImage: "eye" + systemImage: "eye", + showsCardBackground: false ) } .listRowBackground(Color(.systemGray6).opacity(0.5)) @@ -82,8 +87,16 @@ struct WatchlistView: View { .navigationTitle("Watchlist") .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains") .toolbar { - if !viewModel.filteredTrackedDomains.isEmpty { - ToolbarItemGroup(placement: .topBarTrailing) { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + addDomainError = nil + newTrackedDomain = "" + showAddDomainSheet = true + } label: { + Image(systemName: "plus") + } + + if !viewModel.filteredTrackedDomains.isEmpty { Menu { Picker("Filter", selection: $viewModel.watchlistFilter) { ForEach(WatchlistFilterOption.allCases) { option in @@ -139,6 +152,52 @@ struct WatchlistView: View { .sheet(item: batchSummaryBinding) { summary in BatchSweepSummaryView(viewModel: viewModel, summary: summary) } + .sheet(isPresented: $showAddDomainSheet) { + NavigationStack { + Form { + Section("Domain") { + TextField("example.com", text: $newTrackedDomain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .textContentType(.URL) + .focused($isAddDomainFieldFocused) + .onSubmit(addTrackedDomain) + } + + Section { + Text("Adds the domain directly to your watchlist so monitoring can run without a prior inspection.") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + + if let addDomainError { + Section { + Text(addDomainError) + .font(appDensity.font(.caption)) + .foregroundStyle(.red) + } + } + } + .navigationTitle("Add Domain") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + showAddDomainSheet = false + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Add", action: addTrackedDomain) + } + } + .onAppear { + DispatchQueue.main.async { + isAddDomainFieldFocused = true + } + } + } + } .sheet(isPresented: $showWorkflowAddSheet) { WorkflowBulkAddSheet( viewModel: viewModel, @@ -271,6 +330,24 @@ struct WatchlistView: View { ExportPresenter.share(filename: filename, data: data) } + + private func addTrackedDomain() { + let draft = newTrackedDomain.trimmingCharacters(in: .whitespacesAndNewlines) + guard !draft.isEmpty else { + addDomainError = "Enter a domain to add." + return + } + + if viewModel.trackDomain(domain: draft, availabilityStatus: nil) { + AppHaptics.track() + isAddDomainFieldFocused = false + addDomainError = nil + newTrackedDomain = "" + showAddDomainSheet = false + } else if viewModel.upgradePrompt == nil { + addDomainError = "Enter a valid domain like example.com." + } + } } struct WatchlistRowView: View { @@ -468,6 +545,19 @@ struct TrackedDomainDetailView: View { } .listRowBackground(Color(.systemGray6).opacity(0.5)) + Section("Monitoring Status") { + LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain)) + LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain)) + LabeledContent( + "Last Change", + value: liveTrackedDomain.monitoringState.lastChangeDate?.formatted(date: .abbreviated, time: .shortened) ?? "None" + ) + if !liveTrackedDomain.pendingMonitoringAlerts.isEmpty { + LabeledContent("Queued Alerts", value: "\(liveTrackedDomain.pendingMonitoringAlerts.count)") + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) { Section("Latest Change Summary") { DomainChangeSummaryView(summary: summary) |
