Commit 3f0b7746b1
Verified · cmc
Layout: unified · split
DomainDataPortabilityService.swift +27 −3
| @@ -231,7 +231,9 @@ enum DataPortabilityCSV { | ||
| 231 | 231 | certificateWarningLevel: certificateLevel, |
| 232 | 232 | certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""), |
| 233 | 233 | lastMonitoredAt: parseDate(row["lastMonitoredAt"]), |
| 234 | lastAlertAt: parseDate(row["lastAlertAt"]) | |
| 234 | lastAlertAt: parseDate(row["lastAlertAt"]), | |
| 235 | monitoringState: MonitoringState(), | |
| 236 | pendingMonitoringAlerts: [] | |
| 235 | 237 | ) |
| 236 | 238 | } |
| 237 | 239 | } |
| @@ -1104,7 +1106,9 @@ enum DomainDataPortabilityService { | ||
| 1104 | 1106 | certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel), |
| 1105 | 1107 | certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining, |
| 1106 | 1108 | lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(), |
| 1107 | lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max() | |
| 1109 | lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max(), | |
| 1110 | monitoringState: preferredMonitoringState(existing.monitoringState, incoming.monitoringState), | |
| 1111 | pendingMonitoringAlerts: deduplicatedPendingAlerts(existing.pendingMonitoringAlerts + incoming.pendingMonitoringAlerts) | |
| 1108 | 1112 | ) |
| 1109 | 1113 | } |
| 1110 | 1114 | |
| @@ -1272,10 +1276,30 @@ enum DomainDataPortabilityService { | ||
| 1272 | 1276 | certificateWarningLevel: trackedDomain.certificateWarningLevel, |
| 1273 | 1277 | certificateDaysRemaining: trackedDomain.certificateDaysRemaining, |
| 1274 | 1278 | lastMonitoredAt: trackedDomain.lastMonitoredAt, |
| 1275 | lastAlertAt: trackedDomain.lastAlertAt | |
| 1279 | lastAlertAt: trackedDomain.lastAlertAt, | |
| 1280 | monitoringState: trackedDomain.monitoringState, | |
| 1281 | pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts | |
| 1276 | 1282 | ) |
| 1277 | 1283 | } |
| 1278 | 1284 | |
| 1285 | private static func preferredMonitoringState(_ lhs: MonitoringState, _ rhs: MonitoringState) -> MonitoringState { | |
| 1286 | let lhsDate = [lhs.lastCheck, lhs.lastAlertDate, lhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast | |
| 1287 | let rhsDate = [rhs.lastCheck, rhs.lastAlertDate, rhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast | |
| 1288 | return lhsDate >= rhsDate ? lhs : rhs | |
| 1289 | } | |
| 1290 | ||
| 1291 | private static func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] { | |
| 1292 | var uniqueByHash: [String: MonitoringPendingAlert] = [:] | |
| 1293 | for alert in alerts { | |
| 1294 | if let existing = uniqueByHash[alert.changeHash] { | |
| 1295 | uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert | |
| 1296 | } else { | |
| 1297 | uniqueByHash[alert.changeHash] = alert | |
| 1298 | } | |
| 1299 | } | |
| 1300 | return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt } | |
| 1301 | } | |
| 1302 | ||
| 1279 | 1303 | private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow { |
| 1280 | 1304 | DomainWorkflow( |
| 1281 | 1305 | id: workflow.id, |
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -366,7 +366,7 @@ | ||
| 366 | 366 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 367 | 367 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 368 | 368 | CODE_SIGN_STYLE = Automatic; |
| 369 | CURRENT_PROJECT_VERSION = 29; | |
| 369 | CURRENT_PROJECT_VERSION = 30; | |
| 370 | 370 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 371 | 371 | ENABLE_PREVIEWS = YES; |
| 372 | 372 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -383,7 +383,7 @@ | ||
| 383 | 383 | "$(inherited)", |
| 384 | 384 | "@executable_path/Frameworks", |
| 385 | 385 | ); |
| 386 | MARKETING_VERSION = 3.7.0; | |
| 386 | MARKETING_VERSION = 3.8.0; | |
| 387 | 387 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 388 | 388 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 389 | 389 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -403,7 +403,7 @@ | ||
| 403 | 403 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 404 | 404 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 405 | 405 | CODE_SIGN_STYLE = Automatic; |
| 406 | CURRENT_PROJECT_VERSION = 29; | |
| 406 | CURRENT_PROJECT_VERSION = 30; | |
| 407 | 407 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 408 | 408 | ENABLE_PREVIEWS = YES; |
| 409 | 409 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -420,7 +420,7 @@ | ||
| 420 | 420 | "$(inherited)", |
| 421 | 421 | "@executable_path/Frameworks", |
| 422 | 422 | ); |
| 423 | MARKETING_VERSION = 3.7.0; | |
| 423 | MARKETING_VERSION = 3.8.0; | |
| 424 | 424 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 425 | 425 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 426 | 426 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | <Scheme | |
| 3 | LastUpgradeVersion = "2640" | |
| 4 | version = "1.7"> | |
| 5 | <BuildAction | |
| 6 | parallelizeBuildables = "YES" | |
| 7 | buildImplicitDependencies = "YES" | |
| 8 | buildArchitectures = "Automatic"> | |
| 9 | <BuildActionEntries> | |
| 10 | <BuildActionEntry | |
| 11 | buildForTesting = "YES" | |
| 12 | buildForRunning = "YES" | |
| 13 | buildForProfiling = "YES" | |
| 14 | buildForArchiving = "YES" | |
| 15 | buildForAnalyzing = "YES"> | |
| 16 | <BuildableReference | |
| 17 | BuildableIdentifier = "primary" | |
| 18 | BlueprintIdentifier = "8B7800682F6090E300933221" | |
| 19 | BuildableName = "DomainDig.app" | |
| 20 | BlueprintName = "DomainDig" | |
| 21 | ReferencedContainer = "container:DomainDig.xcodeproj"> | |
| 22 | </BuildableReference> | |
| 23 | </BuildActionEntry> | |
| 24 | </BuildActionEntries> | |
| 25 | </BuildAction> | |
| 26 | <TestAction | |
| 27 | buildConfiguration = "Debug" | |
| 28 | selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" | |
| 29 | selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" | |
| 30 | shouldUseLaunchSchemeArgsEnv = "YES" | |
| 31 | shouldAutocreateTestPlan = "YES"> | |
| 32 | </TestAction> | |
| 33 | <LaunchAction | |
| 34 | buildConfiguration = "Debug" | |
| 35 | selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" | |
| 36 | selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" | |
| 37 | launchStyle = "0" | |
| 38 | useCustomWorkingDirectory = "NO" | |
| 39 | ignoresPersistentStateOnLaunch = "NO" | |
| 40 | debugDocumentVersioning = "YES" | |
| 41 | debugServiceExtension = "internal" | |
| 42 | allowLocationSimulation = "YES" | |
| 43 | queueDebuggingEnableBacktraceRecording = "Yes"> | |
| 44 | <BuildableProductRunnable | |
| 45 | runnableDebuggingMode = "0"> | |
| 46 | <BuildableReference | |
| 47 | BuildableIdentifier = "primary" | |
| 48 | BlueprintIdentifier = "8B7800682F6090E300933221" | |
| 49 | BuildableName = "DomainDig.app" | |
| 50 | BlueprintName = "DomainDig" | |
| 51 | ReferencedContainer = "container:DomainDig.xcodeproj"> | |
| 52 | </BuildableReference> | |
| 53 | </BuildableProductRunnable> | |
| 54 | <CommandLineArguments> | |
| 55 | <CommandLineArgument | |
| 56 | argument = "DOMAIN_DIG_FORCE_PRO_PLUS" | |
| 57 | isEnabled = "YES"> | |
| 58 | </CommandLineArgument> | |
| 59 | </CommandLineArguments> | |
| 60 | </LaunchAction> | |
| 61 | <ProfileAction | |
| 62 | buildConfiguration = "Release" | |
| 63 | shouldUseLaunchSchemeArgsEnv = "YES" | |
| 64 | savedToolIdentifier = "" | |
| 65 | useCustomWorkingDirectory = "NO" | |
| 66 | debugDocumentVersioning = "YES"> | |
| 67 | <BuildableProductRunnable | |
| 68 | runnableDebuggingMode = "0"> | |
| 69 | <BuildableReference | |
| 70 | BuildableIdentifier = "primary" | |
| 71 | BlueprintIdentifier = "8B7800682F6090E300933221" | |
| 72 | BuildableName = "DomainDig.app" | |
| 73 | BlueprintName = "DomainDig" | |
| 74 | ReferencedContainer = "container:DomainDig.xcodeproj"> | |
| 75 | </BuildableReference> | |
| 76 | </BuildableProductRunnable> | |
| 77 | </ProfileAction> | |
| 78 | <AnalyzeAction | |
| 79 | buildConfiguration = "Debug"> | |
| 80 | </AnalyzeAction> | |
| 81 | <ArchiveAction | |
| 82 | buildConfiguration = "Release" | |
| 83 | revealArchiveInOrganizer = "YES"> | |
| 84 | </ArchiveAction> | |
| 85 | </Scheme> | |
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +8
| @@ -15,5 +15,13 @@ | ||
| 15 | 15 | <integer>0</integer> |
| 16 | 16 | </dict> |
| 17 | 17 | </dict> |
| 18 | <key>SuppressBuildableAutocreation</key> | |
| 19 | <dict> | |
| 20 | <key>8B7800682F6090E300933221</key> | |
| 21 | <dict> | |
| 22 | <key>primary</key> | |
| 23 | <true/> | |
| 24 | </dict> | |
| 25 | </dict> | |
| 18 | 26 | </dict> |
| 19 | 27 | </plist> |
DomainDig/BatchResultsView.swift +27 −1
| @@ -72,12 +72,25 @@ struct BatchResultRowView: View { | ||
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | HStack(spacing: 10) { |
| 75 | AppStatusBadgeView(model: AppStatusFactory.availability(result.availability)) | |
| 75 | AppStatusBadgeView(model: availabilityBadgeModel) | |
| 76 | 76 | if let riskScore = result.riskScore, let riskLevel = result.riskLevel { |
| 77 | 77 | Text("Risk \(riskScore) \(riskLevel.title)") |
| 78 | .lineLimit(1) | |
| 79 | .minimumScaleFactor(0.85) | |
| 78 | 80 | } |
| 81 | } | |
| 82 | .font(appDensity.font(.caption2)) | |
| 83 | .foregroundStyle(.secondary) | |
| 84 | ||
| 85 | HStack(spacing: 10) { | |
| 79 | 86 | Text(result.primaryIP ?? "No IP") |
| 87 | .lineLimit(1) | |
| 88 | .truncationMode(.middle) | |
| 89 | ||
| 90 | Spacer(minLength: 8) | |
| 91 | ||
| 80 | 92 | Text(result.timestamp.formatted(date: .abbreviated, time: .shortened)) |
| 93 | .lineLimit(1) | |
| 81 | 94 | } |
| 82 | 95 | .font(appDensity.font(.caption2)) |
| 83 | 96 | .foregroundStyle(.secondary) |
| @@ -116,6 +129,19 @@ struct BatchResultRowView: View { | ||
| 116 | 129 | } |
| 117 | 130 | } |
| 118 | 131 | |
| 132 | private var availabilityBadgeModel: AppStatusBadgeModel { | |
| 133 | guard result.status == .failed else { | |
| 134 | return AppStatusFactory.availability(result.availability) | |
| 135 | } | |
| 136 | ||
| 137 | return .init( | |
| 138 | title: availabilityText, | |
| 139 | systemImage: "exclamationmark.circle", | |
| 140 | foregroundColor: .secondary, | |
| 141 | backgroundColor: Color(.systemGray5).opacity(0.55) | |
| 142 | ) | |
| 143 | } | |
| 144 | ||
| 119 | 145 | private var quickStatusBadge: AppStatusBadgeModel { |
| 120 | 146 | switch result.status { |
| 121 | 147 | case .pending: |
DomainDig/CloudSyncService.swift +22
| @@ -1453,6 +1453,8 @@ final class CloudSyncService { | ||
| 1453 | 1453 | certificateDaysRemaining: trackedDomain.certificateDaysRemaining, |
| 1454 | 1454 | lastMonitoredAt: trackedDomain.lastMonitoredAt, |
| 1455 | 1455 | lastAlertAt: trackedDomain.lastAlertAt, |
| 1456 | monitoringState: trackedDomain.monitoringState, | |
| 1457 | pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts, | |
| 1456 | 1458 | collaboration: trackedDomain.collaboration |
| 1457 | 1459 | ) |
| 1458 | 1460 | } |
| @@ -1476,6 +1478,8 @@ final class CloudSyncService { | ||
| 1476 | 1478 | certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining, |
| 1477 | 1479 | lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(), |
| 1478 | 1480 | lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(), |
| 1481 | monitoringState: preferredMonitoringState(lhs.monitoringState, rhs.monitoringState), | |
| 1482 | pendingMonitoringAlerts: deduplicatedPendingAlerts(lhs.pendingMonitoringAlerts + rhs.pendingMonitoringAlerts), | |
| 1479 | 1483 | collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration) |
| 1480 | 1484 | ) |
| 1481 | 1485 | } |
| @@ -1501,6 +1505,24 @@ final class CloudSyncService { | ||
| 1501 | 1505 | return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs |
| 1502 | 1506 | } |
| 1503 | 1507 | |
| 1508 | private func preferredMonitoringState(_ lhs: MonitoringState, _ rhs: MonitoringState) -> MonitoringState { | |
| 1509 | let lhsDate = [lhs.lastCheck, lhs.lastAlertDate, lhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast | |
| 1510 | let rhsDate = [rhs.lastCheck, rhs.lastAlertDate, rhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast | |
| 1511 | return lhsDate >= rhsDate ? lhs : rhs | |
| 1512 | } | |
| 1513 | ||
| 1514 | private func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] { | |
| 1515 | var uniqueByHash: [String: MonitoringPendingAlert] = [:] | |
| 1516 | for alert in alerts { | |
| 1517 | if let existing = uniqueByHash[alert.changeHash] { | |
| 1518 | uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert | |
| 1519 | } else { | |
| 1520 | uniqueByHash[alert.changeHash] = alert | |
| 1521 | } | |
| 1522 | } | |
| 1523 | return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt } | |
| 1524 | } | |
| 1525 | ||
| 1504 | 1526 | private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow { |
| 1505 | 1527 | let lhsRank = collaborationRank(lhs.collaboration) |
| 1506 | 1528 | let rhsRank = collaborationRank(rhs.collaboration) |
DomainDig/ContentView.swift +532 −239
| @@ -19,6 +19,11 @@ enum ResultSection: String, Hashable { | ||
| 19 | 19 | case subdomains |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | private enum LookupInputField: Hashable { | |
| 23 | case singleDomain | |
| 24 | case bulkDomains | |
| 25 | } | |
| 26 | ||
| 22 | 27 | private struct WorkflowNavigationTarget: Hashable { |
| 23 | 28 | let workflowID: UUID |
| 24 | 29 | } |
| @@ -28,7 +33,7 @@ struct ContentView: View { | ||
| 28 | 33 | @Bindable var viewModel: DomainViewModel |
| 29 | 34 | @State private var purchaseService = PurchaseService.shared |
| 30 | 35 | @State private var navigationPath = NavigationPath() |
| 31 | @FocusState private var domainFieldFocused: Bool | |
| 36 | @FocusState private var focusedInputField: LookupInputField? | |
| 32 | 37 | @State private var customPortInput = "" |
| 33 | 38 | @State private var customPortsExpanded = false |
| 34 | 39 | @State private var trackingNoteDraft = "" |
| @@ -174,13 +179,20 @@ struct ContentView: View { | ||
| 174 | 179 | } |
| 175 | 180 | } |
| 176 | 181 | } |
| 182 | ||
| 183 | ToolbarItemGroup(placement: .keyboard) { | |
| 184 | Spacer() | |
| 185 | Button("Dismiss Keyboard") { | |
| 186 | focusedInputField = nil | |
| 187 | } | |
| 188 | } | |
| 177 | 189 | } |
| 178 | 190 | .navigationDestination(for: WorkflowNavigationTarget.self) { target in |
| 179 | 191 | WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID) |
| 180 | 192 | } |
| 181 | 193 | } |
| 182 | 194 | .onAppear { |
| 183 | domainFieldFocused = true | |
| 195 | focusedInputField = .singleDomain | |
| 184 | 196 | } |
| 185 | 197 | .task { |
| 186 | 198 | await viewModel.refreshUsageCredits() |
| @@ -190,7 +202,24 @@ struct ContentView: View { | ||
| 190 | 202 | } |
| 191 | 203 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
| 192 | 204 | navigationPath = NavigationPath() |
| 193 | domainFieldFocused = false | |
| 205 | focusedInputField = nil | |
| 206 | } | |
| 207 | .onChange(of: inputMode) { _, newValue in | |
| 208 | viewModel.clearPresentedResults() | |
| 209 | focusedInputField = newValue == .single ? .singleDomain : .bulkDomains | |
| 210 | } | |
| 211 | .onChange(of: viewModel.domain) { _, newValue in | |
| 212 | guard inputMode == .single else { return } | |
| 213 | let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 214 | guard normalized != viewModel.searchedDomain else { return } | |
| 215 | guard viewModel.hasRun || !viewModel.batchResults.isEmpty else { return } | |
| 216 | viewModel.clearPresentedResults() | |
| 217 | } | |
| 218 | .onChange(of: viewModel.bulkInput) { _, newValue in | |
| 219 | guard inputMode == .bulk else { return } | |
| 220 | let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 221 | guard !normalized.isEmpty || viewModel.hasRun || !viewModel.batchResults.isEmpty else { return } | |
| 222 | viewModel.clearPresentedResults() | |
| 194 | 223 | } |
| 195 | 224 | .sheet(item: $editingTrackedDomain) { trackedDomain in |
| 196 | 225 | NavigationStack { |
| @@ -257,11 +286,14 @@ struct ContentView: View { | ||
| 257 | 286 | .padding(.vertical, appDensity.metrics.controlVerticalPadding) |
| 258 | 287 | .background(Color(.systemGray6)) |
| 259 | 288 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 260 | .focused($domainFieldFocused) | |
| 261 | .onSubmit { viewModel.run() } | |
| 289 | .focused($focusedInputField, equals: .singleDomain) | |
| 290 | .onSubmit { | |
| 291 | focusedInputField = nil | |
| 292 | viewModel.run() | |
| 293 | } | |
| 262 | 294 | |
| 263 | 295 | Button { |
| 264 | domainFieldFocused = false | |
| 296 | focusedInputField = nil | |
| 265 | 297 | viewModel.run() |
| 266 | 298 | } label: { |
| 267 | 299 | Text("Run") |
| @@ -298,9 +330,10 @@ struct ContentView: View { | ||
| 298 | 330 | .padding(.vertical, appDensity.metrics.controlVerticalPadding) |
| 299 | 331 | .background(Color(.systemGray6)) |
| 300 | 332 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 333 | .focused($focusedInputField, equals: .bulkDomains) | |
| 301 | 334 | |
| 302 | 335 | Button { |
| 303 | domainFieldFocused = false | |
| 336 | focusedInputField = nil | |
| 304 | 337 | viewModel.runBulkLookup() |
| 305 | 338 | } label: { |
| 306 | 339 | Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") |
| @@ -568,7 +601,7 @@ struct ContentView: View { | ||
| 568 | 601 | ForEach(viewModel.recentSearches, id: \.self) { domain in |
| 569 | 602 | Button { |
| 570 | 603 | viewModel.domain = domain |
| 571 | domainFieldFocused = false | |
| 604 | focusedInputField = nil | |
| 572 | 605 | viewModel.run() |
| 573 | 606 | } label: { |
| 574 | 607 | Text(domain) |
| @@ -822,7 +855,7 @@ struct InsightsSummaryCardView: View { | ||
| 822 | 855 | } else { |
| 823 | 856 | ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in |
| 824 | 857 | HStack(alignment: .top, spacing: 8) { |
| 825 | Image(systemName: "sparkline") | |
| 858 | Image(systemName: "chart.line.uptrend.xyaxis") | |
| 826 | 859 | .font(appDensity.font(.caption2)) |
| 827 | 860 | .foregroundStyle(.cyan) |
| 828 | 861 | .padding(.top, 2) |
| @@ -1401,7 +1434,7 @@ struct DomainSectionView: View { | ||
| 1401 | 1434 | MessageRowView(text: pricingError, isError: false) |
| 1402 | 1435 | .padding(.top, 4) |
| 1403 | 1436 | } else if showsPricingPlaceholder { |
| 1404 | MessageRowView(text: "Pricing signals available in Data+", isError: false) | |
| 1437 | MessageRowView(text: "Pricing signals available in Pro+", isError: false) | |
| 1405 | 1438 | .padding(.top, 4) |
| 1406 | 1439 | } |
| 1407 | 1440 | } |
| @@ -1473,8 +1506,8 @@ struct OwnershipSectionView: View { | ||
| 1473 | 1506 | .font(appDensity.font(.caption)) |
| 1474 | 1507 | .foregroundStyle(.secondary) |
| 1475 | 1508 | Spacer() |
| 1476 | if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { | |
| 1477 | Button("Load (\(historyCreditStatus.remaining) left)") { | |
| 1509 | if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { | |
| 1510 | Button("Load") { | |
| 1478 | 1511 | onLoadHistory() |
| 1479 | 1512 | } |
| 1480 | 1513 | .buttonStyle(.bordered) |
| @@ -1500,7 +1533,7 @@ struct OwnershipSectionView: View { | ||
| 1500 | 1533 | } else if let historyError { |
| 1501 | 1534 | MessageRowView(text: historyError, isError: false) |
| 1502 | 1535 | } else if showsHistoryPlaceholder { |
| 1503 | MessageRowView(text: "Ownership history available in Data+", isError: false) | |
| 1536 | MessageRowView(text: "Ownership history available in Pro+", isError: false) | |
| 1504 | 1537 | } |
| 1505 | 1538 | } |
| 1506 | 1539 | } |
| @@ -1565,12 +1598,12 @@ struct SubdomainsSectionView: View { | ||
| 1565 | 1598 | } else if rows.isEmpty { |
| 1566 | 1599 | MessageRowView(text: error ?? "No passive subdomains found", isError: false) |
| 1567 | 1600 | if showsExtendedPlaceholder { |
| 1568 | MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false) | |
| 1601 | MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false) | |
| 1569 | 1602 | .padding(.top, 4) |
| 1570 | 1603 | } |
| 1571 | 1604 | } else { |
| 1572 | if let extendedCreditStatus, let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder { | |
| 1573 | Button("Load extended results (\(extendedCreditStatus.remaining) left)") { | |
| 1605 | if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder { | |
| 1606 | Button("Load extended results") { | |
| 1574 | 1607 | onLoadExtended() |
| 1575 | 1608 | } |
| 1576 | 1609 | .buttonStyle(.bordered) |
| @@ -1621,7 +1654,7 @@ struct SubdomainsSectionView: View { | ||
| 1621 | 1654 | MessageRowView(text: extendedError, isError: false) |
| 1622 | 1655 | .padding(.top, 4) |
| 1623 | 1656 | } else if showsExtendedPlaceholder { |
| 1624 | MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false) | |
| 1657 | MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false) | |
| 1625 | 1658 | .padding(.top, 4) |
| 1626 | 1659 | } |
| 1627 | 1660 | } |
| @@ -1748,8 +1781,8 @@ struct DNSSectionView: View { | ||
| 1748 | 1781 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1749 | 1782 | .foregroundStyle(.cyan) |
| 1750 | 1783 | Spacer() |
| 1751 | if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { | |
| 1752 | Button("Load (\(historyCreditStatus.remaining) left)") { | |
| 1784 | if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { | |
| 1785 | Button("Load") { | |
| 1753 | 1786 | onLoadHistory() |
| 1754 | 1787 | } |
| 1755 | 1788 | .buttonStyle(.bordered) |
| @@ -1782,7 +1815,7 @@ struct DNSSectionView: View { | ||
| 1782 | 1815 | } else if let historyError { |
| 1783 | 1816 | MessageRowView(text: historyError, isError: false) |
| 1784 | 1817 | } else if showsHistoryPlaceholder { |
| 1785 | MessageRowView(text: "DNS history available in Data+", isError: false) | |
| 1818 | MessageRowView(text: "DNS history available in Pro+", isError: false) | |
| 1786 | 1819 | } |
| 1787 | 1820 | } |
| 1788 | 1821 | } |
| @@ -2444,36 +2477,94 @@ struct SettingsView: View { | ||
| 2444 | 2477 | @Environment(\.appDensity) private var appDensity |
| 2445 | 2478 | @Bindable var viewModel: DomainViewModel |
| 2446 | 2479 | @State private var purchaseService = PurchaseService.shared |
| 2447 | @State private var cloudSyncService = CloudSyncService.shared | |
| 2448 | @AppStorage(DNSResolverOption.userDefaultsKey) | |
| 2449 | private var storedResolverURL = DNSResolverOption.defaultURLString | |
| 2450 | @AppStorage(AppDensity.userDefaultsKey) | |
| 2451 | private var storedDensity = AppDensity.compact.rawValue | |
| 2452 | 2480 | |
| 2453 | @State private var resolverOption: DNSResolverOption = .cloudflare | |
| 2454 | @State private var customResolverURL = DNSResolverOption.defaultURLString | |
| 2455 | @State private var showClearHistoryConfirmation = false | |
| 2456 | @State private var showClearCacheConfirmation = false | |
| 2457 | @State private var showClearWorkflowsConfirmation = false | |
| 2458 | @State private var showClearTrackedDomainsConfirmation = false | |
| 2459 | @State private var importMode: DataPortabilityImportMode = .merge | |
| 2460 | @State private var showBackupImporter = false | |
| 2461 | @State private var showTrackedDomainsImporter = false | |
| 2462 | @State private var showWorkflowsImporter = false | |
| 2463 | @State private var pendingImportPreview: DataImportPreview? | |
| 2464 | @State private var pendingImportError: String? | |
| 2465 | @State private var showReplaceImportConfirmation = false | |
| 2481 | var body: some View { | |
| 2482 | let _ = purchaseService.currentTier | |
| 2466 | 2483 | |
| 2467 | private var customResolverError: String? { | |
| 2468 | guard resolverOption == .custom else { | |
| 2469 | return nil | |
| 2484 | List { | |
| 2485 | Section("Tier") { | |
| 2486 | LabeledContent("Status", value: purchaseService.currentTier.title) | |
| 2487 | ||
| 2488 | if purchaseService.currentTier == .free { | |
| 2489 | Button("Upgrade") { | |
| 2490 | viewModel.isPaywallPresented = true | |
| 2491 | } | |
| 2492 | } else { | |
| 2493 | Button("Manage Subscription") { | |
| 2494 | Task { | |
| 2495 | await purchaseService.manageSubscription() | |
| 2496 | } | |
| 2497 | } | |
| 2498 | } | |
| 2499 | ||
| 2500 | Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { | |
| 2501 | Task { | |
| 2502 | await purchaseService.restorePurchases() | |
| 2503 | } | |
| 2504 | } | |
| 2505 | .disabled(purchaseService.isRestoring || purchaseService.isPurchasing) | |
| 2506 | ||
| 2507 | if let statusMessage = purchaseService.statusMessage { | |
| 2508 | Text(statusMessage) | |
| 2509 | .font(appDensity.font(.caption, design: .default)) | |
| 2510 | .foregroundStyle(.secondary) | |
| 2511 | } | |
| 2512 | ||
| 2513 | if let errorMessage = purchaseService.errorMessage { | |
| 2514 | Text(errorMessage) | |
| 2515 | .font(appDensity.font(.caption, design: .default)) | |
| 2516 | .foregroundStyle(.red) | |
| 2517 | } | |
| 2518 | } | |
| 2519 | ||
| 2520 | Section("Preferences") { | |
| 2521 | NavigationLink("Workflows") { | |
| 2522 | WorkflowsView(viewModel: viewModel) | |
| 2523 | } | |
| 2524 | ||
| 2525 | NavigationLink("Display") { | |
| 2526 | DisplaySettingsView() | |
| 2527 | } | |
| 2528 | ||
| 2529 | NavigationLink("History & Network") { | |
| 2530 | HistoryNetworkSettingsView(viewModel: viewModel) | |
| 2531 | } | |
| 2532 | } | |
| 2533 | ||
| 2534 | Section("Services") { | |
| 2535 | NavigationLink("iCloud Sync") { | |
| 2536 | CloudSyncSettingsView() | |
| 2537 | } | |
| 2538 | ||
| 2539 | NavigationLink("Monitoring") { | |
| 2540 | MonitoringSettingsView(viewModel: viewModel) | |
| 2541 | } | |
| 2542 | } | |
| 2543 | ||
| 2544 | Section("Data") { | |
| 2545 | NavigationLink("Import & Export") { | |
| 2546 | DataPortabilitySettingsView(viewModel: viewModel) | |
| 2547 | } | |
| 2548 | ||
| 2549 | NavigationLink("Data Management") { | |
| 2550 | DataManagementSettingsView(viewModel: viewModel) | |
| 2551 | } | |
| 2552 | } | |
| 2553 | ||
| 2554 | Section("About") { | |
| 2555 | NavigationLink("App Info") { | |
| 2556 | AboutSettingsView() | |
| 2557 | } | |
| 2558 | } | |
| 2470 | 2559 | } |
| 2471 | return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" | |
| 2560 | .navigationTitle("More") | |
| 2472 | 2561 | } |
| 2562 | } | |
| 2473 | 2563 | |
| 2474 | var body: some View { | |
| 2475 | let _ = purchaseService.currentTier | |
| 2564 | private struct DisplaySettingsView: View { | |
| 2565 | @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue | |
| 2476 | 2566 | |
| 2567 | var body: some View { | |
| 2477 | 2568 | Form { |
| 2478 | 2569 | Section("Display") { |
| 2479 | 2570 | Picker("Density", selection: $storedDensity) { |
| @@ -2482,7 +2573,27 @@ struct SettingsView: View { | ||
| 2482 | 2573 | } |
| 2483 | 2574 | } |
| 2484 | 2575 | } |
| 2576 | } | |
| 2577 | .navigationTitle("Display") | |
| 2578 | } | |
| 2579 | } | |
| 2580 | ||
| 2581 | private struct HistoryNetworkSettingsView: View { | |
| 2582 | @Environment(\.appDensity) private var appDensity | |
| 2583 | @Bindable var viewModel: DomainViewModel | |
| 2584 | @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString | |
| 2585 | @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue | |
| 2586 | ||
| 2587 | @State private var resolverOption: DNSResolverOption = .cloudflare | |
| 2588 | @State private var customResolverURL = DNSResolverOption.defaultURLString | |
| 2589 | ||
| 2590 | private var customResolverError: String? { | |
| 2591 | guard resolverOption == .custom else { return nil } | |
| 2592 | return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" | |
| 2593 | } | |
| 2485 | 2594 | |
| 2595 | var body: some View { | |
| 2596 | Form { | |
| 2486 | 2597 | Section("History") { |
| 2487 | 2598 | Picker( |
| 2488 | 2599 | "Auto-prune", |
| @@ -2521,7 +2632,51 @@ struct SettingsView: View { | ||
| 2521 | 2632 | } |
| 2522 | 2633 | } |
| 2523 | 2634 | } |
| 2635 | } | |
| 2636 | .navigationTitle("History & Network") | |
| 2637 | .onAppear { | |
| 2638 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2639 | resolverOption = DNSResolverOption.option(for: currentResolverURL) | |
| 2640 | customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString | |
| 2641 | } | |
| 2642 | .onChange(of: resolverOption) { _, newValue in | |
| 2643 | guard let presetURL = newValue.urlString else { | |
| 2644 | storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2645 | viewModel.persistCurrentAppSettings( | |
| 2646 | resolverURLString: storedResolverURL, | |
| 2647 | appDensityRawValue: storedDensity | |
| 2648 | ) | |
| 2649 | return | |
| 2650 | } | |
| 2651 | storedResolverURL = presetURL | |
| 2652 | viewModel.persistCurrentAppSettings( | |
| 2653 | resolverURLString: storedResolverURL, | |
| 2654 | appDensityRawValue: storedDensity | |
| 2655 | ) | |
| 2656 | } | |
| 2657 | .onChange(of: customResolverURL) { _, newValue in | |
| 2658 | guard resolverOption == .custom else { return } | |
| 2659 | storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2660 | viewModel.persistCurrentAppSettings( | |
| 2661 | resolverURLString: storedResolverURL, | |
| 2662 | appDensityRawValue: storedDensity | |
| 2663 | ) | |
| 2664 | } | |
| 2665 | .onChange(of: storedDensity) { _, newValue in | |
| 2666 | viewModel.persistCurrentAppSettings( | |
| 2667 | resolverURLString: storedResolverURL, | |
| 2668 | appDensityRawValue: newValue | |
| 2669 | ) | |
| 2670 | } | |
| 2671 | } | |
| 2672 | } | |
| 2673 | ||
| 2674 | private struct CloudSyncSettingsView: View { | |
| 2675 | @Environment(\.appDensity) private var appDensity | |
| 2676 | @State private var cloudSyncService = CloudSyncService.shared | |
| 2524 | 2677 | |
| 2678 | var body: some View { | |
| 2679 | Form { | |
| 2525 | 2680 | Section("iCloud Sync") { |
| 2526 | 2681 | Toggle( |
| 2527 | 2682 | "Enable iCloud Sync", |
| @@ -2558,7 +2713,33 @@ struct SettingsView: View { | ||
| 2558 | 2713 | .foregroundStyle(.red) |
| 2559 | 2714 | } |
| 2560 | 2715 | } |
| 2716 | } | |
| 2717 | .navigationTitle("iCloud Sync") | |
| 2718 | .task { | |
| 2719 | await cloudSyncService.refreshAvailability() | |
| 2720 | } | |
| 2721 | } | |
| 2722 | } | |
| 2723 | ||
| 2724 | private struct MonitoringSettingsView: View { | |
| 2725 | @Environment(\.appDensity) private var appDensity | |
| 2726 | @Bindable var viewModel: DomainViewModel | |
| 2561 | 2727 | |
| 2728 | private var notificationAuthorizationLabel: String { | |
| 2729 | switch viewModel.monitoringNotificationStatus { | |
| 2730 | case .authorized, .provisional, .ephemeral: | |
| 2731 | return "Allowed" | |
| 2732 | case .denied: | |
| 2733 | return "Denied" | |
| 2734 | case .notDetermined: | |
| 2735 | return "Not Requested" | |
| 2736 | @unknown default: | |
| 2737 | return "Unknown" | |
| 2738 | } | |
| 2739 | } | |
| 2740 | ||
| 2741 | var body: some View { | |
| 2742 | Form { | |
| 2562 | 2743 | Section("Monitoring") { |
| 2563 | 2744 | Toggle( |
| 2564 | 2745 | "Enable Background Monitoring", |
| @@ -2569,14 +2750,88 @@ struct SettingsView: View { | ||
| 2569 | 2750 | ) |
| 2570 | 2751 | |
| 2571 | 2752 | Picker( |
| 2572 | "Frequency", | |
| 2753 | "Base Interval", | |
| 2573 | 2754 | selection: Binding( |
| 2574 | get: { viewModel.monitoringSettings.frequency }, | |
| 2575 | set: { viewModel.setMonitoringFrequency($0) } | |
| 2755 | get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) }, | |
| 2756 | set: { viewModel.setMonitoringBaseInterval($0) } | |
| 2576 | 2757 | ) |
| 2577 | 2758 | ) { |
| 2578 | ForEach(MonitoringFrequency.allCases) { frequency in | |
| 2579 | Text(frequency.title).tag(frequency) | |
| 2759 | ForEach(MonitoringBaseInterval.allCases) { interval in | |
| 2760 | Text(interval.title).tag(interval) | |
| 2761 | } | |
| 2762 | } | |
| 2763 | ||
| 2764 | Toggle( | |
| 2765 | "Adaptive Monitoring", | |
| 2766 | isOn: Binding( | |
| 2767 | get: { viewModel.monitoringSettings.adaptiveEnabled }, | |
| 2768 | set: { viewModel.setMonitoringAdaptiveEnabled($0) } | |
| 2769 | ) | |
| 2770 | ) | |
| 2771 | ||
| 2772 | Picker( | |
| 2773 | "Sensitivity", | |
| 2774 | selection: Binding( | |
| 2775 | get: { viewModel.monitoringSettings.sensitivity }, | |
| 2776 | set: { viewModel.setMonitoringSensitivity($0) } | |
| 2777 | ) | |
| 2778 | ) { | |
| 2779 | ForEach(MonitoringSensitivity.allCases) { sensitivity in | |
| 2780 | Text(sensitivity.title).tag(sensitivity) | |
| 2781 | } | |
| 2782 | } | |
| 2783 | ||
| 2784 | let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22 | |
| 2785 | let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7 | |
| 2786 | Toggle( | |
| 2787 | "Quiet Hours", | |
| 2788 | isOn: Binding( | |
| 2789 | get: { viewModel.monitoringSettings.quietHours != nil }, | |
| 2790 | set: { isEnabled in | |
| 2791 | viewModel.setMonitoringQuietHours( | |
| 2792 | startHour: quietHoursStart, | |
| 2793 | endHour: quietHoursEnd, | |
| 2794 | isEnabled: isEnabled | |
| 2795 | ) | |
| 2796 | } | |
| 2797 | ) | |
| 2798 | ) | |
| 2799 | ||
| 2800 | if viewModel.monitoringSettings.quietHours != nil { | |
| 2801 | Picker( | |
| 2802 | "Quiet Starts", | |
| 2803 | selection: Binding( | |
| 2804 | get: { quietHoursStart }, | |
| 2805 | set: { startHour in | |
| 2806 | viewModel.setMonitoringQuietHours( | |
| 2807 | startHour: startHour, | |
| 2808 | endHour: quietHoursEnd, | |
| 2809 | isEnabled: true | |
| 2810 | ) | |
| 2811 | } | |
| 2812 | ) | |
| 2813 | ) { | |
| 2814 | ForEach(0..<24, id: \.self) { hour in | |
| 2815 | Text(Self.monitoringHourLabel(for: hour)).tag(hour) | |
| 2816 | } | |
| 2817 | } | |
| 2818 | ||
| 2819 | Picker( | |
| 2820 | "Quiet Ends", | |
| 2821 | selection: Binding( | |
| 2822 | get: { quietHoursEnd }, | |
| 2823 | set: { endHour in | |
| 2824 | viewModel.setMonitoringQuietHours( | |
| 2825 | startHour: quietHoursStart, | |
| 2826 | endHour: endHour, | |
| 2827 | isEnabled: true | |
| 2828 | ) | |
| 2829 | } | |
| 2830 | ) | |
| 2831 | ) { | |
| 2832 | ForEach(0..<24, id: \.self) { hour in | |
| 2833 | Text(Self.monitoringHourLabel(for: hour)).tag(hour) | |
| 2834 | } | |
| 2580 | 2835 | } |
| 2581 | 2836 | } |
| 2582 | 2837 | |
| @@ -2647,8 +2902,65 @@ struct SettingsView: View { | ||
| 2647 | 2902 | .foregroundStyle(.secondary) |
| 2648 | 2903 | } |
| 2649 | 2904 | } |
| 2905 | } | |
| 2906 | .navigationTitle("Monitoring") | |
| 2907 | .onAppear { | |
| 2908 | viewModel.refreshMonitoringState() | |
| 2909 | Task { | |
| 2910 | await viewModel.refreshMonitoringAuthorizationStatus() | |
| 2911 | } | |
| 2912 | } | |
| 2913 | } | |
| 2650 | 2914 | |
| 2651 | Section("Data Portability") { | |
| 2915 | private static func monitoringHourLabel(for hour: Int) -> String { | |
| 2916 | let formatter = DateFormatter() | |
| 2917 | formatter.dateFormat = "h a" | |
| 2918 | let components = DateComponents(calendar: .current, hour: hour) | |
| 2919 | return components.date.map(formatter.string(from:)) ?? "\(hour):00" | |
| 2920 | } | |
| 2921 | } | |
| 2922 | ||
| 2923 | private struct DataPortabilitySettingsView: View { | |
| 2924 | private enum ImportTarget { | |
| 2925 | case backup | |
| 2926 | case trackedDomains | |
| 2927 | case workflows | |
| 2928 | ||
| 2929 | var expectedKind: DataPortabilityImportKind { | |
| 2930 | switch self { | |
| 2931 | case .backup: | |
| 2932 | return .backup | |
| 2933 | case .trackedDomains: | |
| 2934 | return .trackedDomains | |
| 2935 | case .workflows: | |
| 2936 | return .workflows | |
| 2937 | } | |
| 2938 | } | |
| 2939 | ||
| 2940 | var allowedContentTypes: [UTType] { | |
| 2941 | switch self { | |
| 2942 | case .backup: | |
| 2943 | return [UTType.json] | |
| 2944 | case .trackedDomains, .workflows: | |
| 2945 | return [UTType.json, UTType.commaSeparatedText] | |
| 2946 | } | |
| 2947 | } | |
| 2948 | } | |
| 2949 | ||
| 2950 | @Environment(\.appDensity) private var appDensity | |
| 2951 | @Bindable var viewModel: DomainViewModel | |
| 2952 | ||
| 2953 | @State private var importMode: DataPortabilityImportMode = .merge | |
| 2954 | @State private var activeImportTarget: ImportTarget? | |
| 2955 | @State private var pendingImportTarget: ImportTarget? | |
| 2956 | @State private var importDebugStatus: String? | |
| 2957 | @State private var pendingImportPreview: DataImportPreview? | |
| 2958 | @State private var pendingImportError: String? | |
| 2959 | @State private var showReplaceImportConfirmation = false | |
| 2960 | ||
| 2961 | var body: some View { | |
| 2962 | Form { | |
| 2963 | Section("Import & Export") { | |
| 2652 | 2964 | Picker("Import Mode", selection: $importMode) { |
| 2653 | 2965 | ForEach(DataPortabilityImportMode.allCases) { mode in |
| 2654 | 2966 | Text(mode.title).tag(mode) |
| @@ -2664,7 +2976,9 @@ struct SettingsView: View { | ||
| 2664 | 2976 | } |
| 2665 | 2977 | |
| 2666 | 2978 | Button("Import Backup") { |
| 2667 | showBackupImporter = true | |
| 2979 | recordImportDebugStatus("Tapped Import Backup") | |
| 2980 | pendingImportTarget = .backup | |
| 2981 | activeImportTarget = .backup | |
| 2668 | 2982 | } |
| 2669 | 2983 | |
| 2670 | 2984 | Menu("Export Tracked Domains") { |
| @@ -2677,7 +2991,9 @@ struct SettingsView: View { | ||
| 2677 | 2991 | } |
| 2678 | 2992 | |
| 2679 | 2993 | Button("Import Tracked Domains") { |
| 2680 | showTrackedDomainsImporter = true | |
| 2994 | recordImportDebugStatus("Tapped Import Tracked Domains") | |
| 2995 | pendingImportTarget = .trackedDomains | |
| 2996 | activeImportTarget = .trackedDomains | |
| 2681 | 2997 | } |
| 2682 | 2998 | |
| 2683 | 2999 | Menu("Export Workflows") { |
| @@ -2690,13 +3006,17 @@ struct SettingsView: View { | ||
| 2690 | 3006 | } |
| 2691 | 3007 | |
| 2692 | 3008 | Button("Import Workflows") { |
| 2693 | showWorkflowsImporter = true | |
| 3009 | recordImportDebugStatus("Tapped Import Workflows") | |
| 3010 | pendingImportTarget = .workflows | |
| 3011 | activeImportTarget = .workflows | |
| 2694 | 3012 | } |
| 2695 | 3013 | |
| 2696 | 3014 | Button("Export History") { |
| 2697 | 3015 | exportPortableHistoryJSON() |
| 2698 | 3016 | } |
| 3017 | } | |
| 2699 | 3018 | |
| 3019 | Section("Local Data") { | |
| 2700 | 3020 | LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") |
| 2701 | 3021 | LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") |
| 2702 | 3022 | LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") |
| @@ -2714,121 +3034,18 @@ struct SettingsView: View { | ||
| 2714 | 3034 | } |
| 2715 | 3035 | } |
| 2716 | 3036 | |
| 2717 | Section("Data") { | |
| 2718 | Button("Clear History", role: .destructive) { | |
| 2719 | showClearHistoryConfirmation = true | |
| 2720 | } | |
| 2721 | ||
| 2722 | Button("Clear Cache", role: .destructive) { | |
| 2723 | showClearCacheConfirmation = true | |
| 2724 | } | |
| 2725 | ||
| 2726 | Button("Clear Workflows", role: .destructive) { | |
| 2727 | showClearWorkflowsConfirmation = true | |
| 2728 | } | |
| 2729 | ||
| 2730 | Button("Clear Tracked Domains", role: .destructive) { | |
| 2731 | showClearTrackedDomainsConfirmation = true | |
| 2732 | } | |
| 2733 | } | |
| 2734 | ||
| 2735 | Section("Tier") { | |
| 2736 | LabeledContent("Status", value: purchaseService.currentTier.title) | |
| 2737 | ||
| 2738 | if purchaseService.currentTier == .free { | |
| 2739 | Button("Upgrade") { | |
| 2740 | viewModel.isPaywallPresented = true | |
| 2741 | } | |
| 2742 | } else { | |
| 2743 | Button("Manage Subscription") { | |
| 2744 | Task { | |
| 2745 | await purchaseService.manageSubscription() | |
| 2746 | } | |
| 2747 | } | |
| 2748 | } | |
| 2749 | ||
| 2750 | Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { | |
| 2751 | Task { | |
| 2752 | await purchaseService.restorePurchases() | |
| 2753 | } | |
| 2754 | } | |
| 2755 | .disabled(purchaseService.isRestoring || purchaseService.isPurchasing) | |
| 2756 | ||
| 2757 | if let statusMessage = purchaseService.statusMessage { | |
| 2758 | Text(statusMessage) | |
| 3037 | #if DEBUG | |
| 3038 | if let importDebugStatus { | |
| 3039 | Section("Import Debug") { | |
| 3040 | Text(importDebugStatus) | |
| 2759 | 3041 | .font(appDensity.font(.caption, design: .default)) |
| 2760 | 3042 | .foregroundStyle(.secondary) |
| 3043 | .textSelection(.enabled) | |
| 2761 | 3044 | } |
| 2762 | ||
| 2763 | if let errorMessage = purchaseService.errorMessage { | |
| 2764 | Text(errorMessage) | |
| 2765 | .font(appDensity.font(.caption, design: .default)) | |
| 2766 | .foregroundStyle(.red) | |
| 2767 | } | |
| 2768 | } | |
| 2769 | ||
| 2770 | Section("Data+ Usage") { | |
| 2771 | ForEach(UsageCreditFeature.allCases) { feature in | |
| 2772 | let status = viewModel.usageCredits[feature] ?? UsageCreditStatus( | |
| 2773 | feature: feature, | |
| 2774 | remaining: feature.defaultAllowance, | |
| 2775 | total: feature.defaultAllowance, | |
| 2776 | resetContext: "Resets with app version \(AppVersion.current)" | |
| 2777 | ) | |
| 2778 | VStack(alignment: .leading, spacing: 2) { | |
| 2779 | LabeledContent(feature.title, value: status.summary) | |
| 2780 | Text(status.resetContext) | |
| 2781 | .font(appDensity.font(.caption, design: .default)) | |
| 2782 | .foregroundStyle(.secondary) | |
| 2783 | } | |
| 2784 | } | |
| 2785 | } | |
| 2786 | ||
| 2787 | Section("Features") { | |
| 2788 | ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in | |
| 2789 | Text(label) | |
| 2790 | } | |
| 2791 | } | |
| 2792 | ||
| 2793 | Section("About") { | |
| 2794 | LabeledContent("Version", value: appVersion) | |
| 2795 | LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") | |
| 2796 | LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") | |
| 2797 | 3045 | } |
| 3046 | #endif | |
| 2798 | 3047 | } |
| 2799 | .navigationTitle("Settings") | |
| 2800 | .alert("Clear history?", isPresented: $showClearHistoryConfirmation) { | |
| 2801 | Button("Clear", role: .destructive) { | |
| 2802 | viewModel.clearHistory() | |
| 2803 | } | |
| 2804 | Button("Cancel", role: .cancel) {} | |
| 2805 | } message: { | |
| 2806 | Text("This removes saved lookup snapshots from this device.") | |
| 2807 | } | |
| 2808 | .alert("Clear cache?", isPresented: $showClearCacheConfirmation) { | |
| 2809 | Button("Clear", role: .destructive) { | |
| 2810 | viewModel.clearLookupCache() | |
| 2811 | } | |
| 2812 | Button("Cancel", role: .cancel) {} | |
| 2813 | } message: { | |
| 2814 | Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") | |
| 2815 | } | |
| 2816 | .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) { | |
| 2817 | Button("Clear", role: .destructive) { | |
| 2818 | viewModel.clearWorkflows() | |
| 2819 | } | |
| 2820 | Button("Cancel", role: .cancel) {} | |
| 2821 | } message: { | |
| 2822 | Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.") | |
| 2823 | } | |
| 2824 | .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) { | |
| 2825 | Button("Clear", role: .destructive) { | |
| 2826 | viewModel.clearTrackedDomains() | |
| 2827 | } | |
| 2828 | Button("Cancel", role: .cancel) {} | |
| 2829 | } message: { | |
| 2830 | Text("This removes the watchlist only. History and workflows stay intact.") | |
| 2831 | } | |
| 3048 | .navigationTitle("Import & Export") | |
| 2832 | 3049 | .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) { |
| 2833 | 3050 | Button("Replace", role: .destructive) { |
| 2834 | 3051 | applyPendingImport() |
| @@ -2867,83 +3084,24 @@ struct SettingsView: View { | ||
| 2867 | 3084 | } |
| 2868 | 3085 | } |
| 2869 | 3086 | .fileImporter( |
| 2870 | isPresented: $showBackupImporter, | |
| 2871 | allowedContentTypes: [UTType.json], | |
| 3087 | isPresented: Binding( | |
| 3088 | get: { activeImportTarget != nil }, | |
| 3089 | set: { if !$0 { activeImportTarget = nil } } | |
| 3090 | ), | |
| 3091 | allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json], | |
| 2872 | 3092 | allowsMultipleSelection: false |
| 2873 | 3093 | ) { result in |
| 2874 | handleImportResult(result, expectedKind: .backup) | |
| 2875 | } | |
| 2876 | .fileImporter( | |
| 2877 | isPresented: $showTrackedDomainsImporter, | |
| 2878 | allowedContentTypes: [UTType.json, UTType.commaSeparatedText], | |
| 2879 | allowsMultipleSelection: false | |
| 2880 | ) { result in | |
| 2881 | handleImportResult(result, expectedKind: .trackedDomains) | |
| 2882 | } | |
| 2883 | .fileImporter( | |
| 2884 | isPresented: $showWorkflowsImporter, | |
| 2885 | allowedContentTypes: [UTType.json, UTType.commaSeparatedText], | |
| 2886 | allowsMultipleSelection: false | |
| 2887 | ) { result in | |
| 2888 | handleImportResult(result, expectedKind: .workflows) | |
| 2889 | } | |
| 2890 | .onAppear { | |
| 2891 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2892 | resolverOption = DNSResolverOption.option(for: currentResolverURL) | |
| 2893 | customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString | |
| 2894 | viewModel.refreshMonitoringState() | |
| 2895 | viewModel.refreshDataLifecycleSummary() | |
| 2896 | Task { | |
| 2897 | await viewModel.refreshUsageCredits() | |
| 2898 | await viewModel.refreshMonitoringAuthorizationStatus() | |
| 2899 | await cloudSyncService.refreshAvailability() | |
| 2900 | } | |
| 2901 | } | |
| 2902 | .onChange(of: resolverOption) { _, newValue in | |
| 2903 | guard let presetURL = newValue.urlString else { | |
| 2904 | storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2905 | viewModel.persistCurrentAppSettings( | |
| 2906 | resolverURLString: storedResolverURL, | |
| 2907 | appDensityRawValue: storedDensity | |
| 2908 | ) | |
| 3094 | guard let pendingImportTarget else { | |
| 3095 | recordImportDebugStatus("fileImporter returned with no active target") | |
| 2909 | 3096 | return |
| 2910 | 3097 | } |
| 2911 | storedResolverURL = presetURL | |
| 2912 | viewModel.persistCurrentAppSettings( | |
| 2913 | resolverURLString: storedResolverURL, | |
| 2914 | appDensityRawValue: storedDensity | |
| 2915 | ) | |
| 2916 | } | |
| 2917 | .onChange(of: customResolverURL) { _, newValue in | |
| 2918 | guard resolverOption == .custom else { return } | |
| 2919 | storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2920 | viewModel.persistCurrentAppSettings( | |
| 2921 | resolverURLString: storedResolverURL, | |
| 2922 | appDensityRawValue: storedDensity | |
| 2923 | ) | |
| 3098 | recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)") | |
| 3099 | handleImportResult(result, expectedKind: pendingImportTarget.expectedKind) | |
| 3100 | self.pendingImportTarget = nil | |
| 3101 | self.activeImportTarget = nil | |
| 2924 | 3102 | } |
| 2925 | .onChange(of: storedDensity) { _, newValue in | |
| 2926 | viewModel.persistCurrentAppSettings( | |
| 2927 | resolverURLString: storedResolverURL, | |
| 2928 | appDensityRawValue: newValue | |
| 2929 | ) | |
| 2930 | } | |
| 2931 | } | |
| 2932 | ||
| 2933 | private var appVersion: String { | |
| 2934 | AppVersion.current | |
| 2935 | } | |
| 2936 | ||
| 2937 | private var notificationAuthorizationLabel: String { | |
| 2938 | switch viewModel.monitoringNotificationStatus { | |
| 2939 | case .authorized, .provisional, .ephemeral: | |
| 2940 | return "Allowed" | |
| 2941 | case .denied: | |
| 2942 | return "Denied" | |
| 2943 | case .notDetermined: | |
| 2944 | return "Not Requested" | |
| 2945 | @unknown default: | |
| 2946 | return "Unknown" | |
| 3103 | .onAppear { | |
| 3104 | viewModel.refreshDataLifecycleSummary() | |
| 2947 | 3105 | } |
| 2948 | 3106 | } |
| 2949 | 3107 | |
| @@ -2985,32 +3143,55 @@ struct SettingsView: View { | ||
| 2985 | 3143 | _ result: Result<[URL], Error>, |
| 2986 | 3144 | expectedKind: DataPortabilityImportKind |
| 2987 | 3145 | ) { |
| 3146 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)") | |
| 3147 | recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)") | |
| 2988 | 3148 | do { |
| 2989 | guard let url = try result.get().first else { return } | |
| 3149 | let urls = try result.get() | |
| 3150 | guard let url = urls.first else { | |
| 3151 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned") | |
| 3152 | recordImportDebugStatus("No URL returned from picker") | |
| 3153 | return | |
| 3154 | } | |
| 3155 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)") | |
| 3156 | recordImportDebugStatus("Selected \(url.lastPathComponent)") | |
| 2990 | 3157 | let shouldStopAccessing = url.startAccessingSecurityScopedResource() |
| 3158 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)") | |
| 3159 | recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)") | |
| 2991 | 3160 | defer { |
| 2992 | 3161 | if shouldStopAccessing { |
| 2993 | 3162 | url.stopAccessingSecurityScopedResource() |
| 3163 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased") | |
| 2994 | 3164 | } |
| 2995 | 3165 | } |
| 2996 | 3166 | |
| 2997 | 3167 | let data = try Data(contentsOf: url) |
| 3168 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)") | |
| 3169 | recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)") | |
| 2998 | 3170 | let preview = try viewModel.prepareDataImport( |
| 2999 | 3171 | data: data, |
| 3000 | 3172 | fileName: url.lastPathComponent, |
| 3001 | 3173 | mode: importMode |
| 3002 | 3174 | ) |
| 3175 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)") | |
| 3176 | recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)") | |
| 3003 | 3177 | |
| 3004 | 3178 | guard preview.kind == expectedKind else { |
| 3005 | pendingImportError = preview.kind == .backup | |
| 3179 | let message = preview.kind == .backup | |
| 3006 | 3180 | ? "That file is a full backup. Use Import Backup." |
| 3007 | 3181 | : "That file type does not match this import action." |
| 3182 | DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)") | |
| 3183 | recordImportDebugStatus("Kind mismatch: \(message)") | |
| 3184 | presentImportError(message) | |
| 3008 | 3185 | return |
| 3009 | 3186 | } |
| 3010 | 3187 | |
| 3011 | pendingImportPreview = preview | |
| 3188 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)") | |
| 3189 | recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)") | |
| 3190 | presentImportPreview(preview) | |
| 3012 | 3191 | } catch { |
| 3013 | pendingImportError = error.localizedDescription | |
| 3192 | DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)") | |
| 3193 | recordImportDebugStatus("Import failed: \(error.localizedDescription)") | |
| 3194 | presentImportError(error.localizedDescription) | |
| 3014 | 3195 | } |
| 3015 | 3196 | } |
| 3016 | 3197 | |
| @@ -3029,6 +3210,118 @@ struct SettingsView: View { | ||
| 3029 | 3210 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 3030 | 3211 | return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)" |
| 3031 | 3212 | } |
| 3213 | ||
| 3214 | private func presentImportPreview(_ preview: DataImportPreview) { | |
| 3215 | Task { @MainActor in | |
| 3216 | try? await Task.sleep(for: .milliseconds(300)) | |
| 3217 | DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)") | |
| 3218 | recordImportDebugStatus("Preview presented for \(preview.fileName)") | |
| 3219 | pendingImportPreview = preview | |
| 3220 | } | |
| 3221 | } | |
| 3222 | ||
| 3223 | private func presentImportError(_ message: String) { | |
| 3224 | Task { @MainActor in | |
| 3225 | try? await Task.sleep(for: .milliseconds(300)) | |
| 3226 | DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)") | |
| 3227 | recordImportDebugStatus("Error presented: \(message)") | |
| 3228 | pendingImportError = message | |
| 3229 | } | |
| 3230 | } | |
| 3231 | ||
| 3232 | private func recordImportDebugStatus(_ message: String) { | |
| 3233 | #if DEBUG | |
| 3234 | let status = "[Import Debug] \(message)" | |
| 3235 | importDebugStatus = status | |
| 3236 | print(status) | |
| 3237 | #endif | |
| 3238 | } | |
| 3239 | } | |
| 3240 | ||
| 3241 | private struct DataManagementSettingsView: View { | |
| 3242 | @Bindable var viewModel: DomainViewModel | |
| 3243 | ||
| 3244 | @State private var showClearHistoryConfirmation = false | |
| 3245 | @State private var showClearCacheConfirmation = false | |
| 3246 | @State private var showClearWorkflowsConfirmation = false | |
| 3247 | @State private var showClearTrackedDomainsConfirmation = false | |
| 3248 | ||
| 3249 | var body: some View { | |
| 3250 | Form { | |
| 3251 | Section("Data") { | |
| 3252 | Button("Clear History", role: .destructive) { | |
| 3253 | showClearHistoryConfirmation = true | |
| 3254 | } | |
| 3255 | ||
| 3256 | Button("Clear Cache", role: .destructive) { | |
| 3257 | showClearCacheConfirmation = true | |
| 3258 | } | |
| 3259 | ||
| 3260 | Button("Clear Workflows", role: .destructive) { | |
| 3261 | showClearWorkflowsConfirmation = true | |
| 3262 | } | |
| 3263 | ||
| 3264 | Button("Clear Tracked Domains", role: .destructive) { | |
| 3265 | showClearTrackedDomainsConfirmation = true | |
| 3266 | } | |
| 3267 | } | |
| 3268 | } | |
| 3269 | .navigationTitle("Data Management") | |
| 3270 | .alert("Clear history?", isPresented: $showClearHistoryConfirmation) { | |
| 3271 | Button("Clear", role: .destructive) { | |
| 3272 | viewModel.clearHistory() | |
| 3273 | } | |
| 3274 | Button("Cancel", role: .cancel) {} | |
| 3275 | } message: { | |
| 3276 | Text("This removes saved lookup snapshots and clears monitoring run history on this device.") | |
| 3277 | } | |
| 3278 | .alert("Clear cache?", isPresented: $showClearCacheConfirmation) { | |
| 3279 | Button("Clear", role: .destructive) { | |
| 3280 | viewModel.clearLookupCache() | |
| 3281 | } | |
| 3282 | Button("Cancel", role: .cancel) {} | |
| 3283 | } message: { | |
| 3284 | Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") | |
| 3285 | } | |
| 3286 | .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) { | |
| 3287 | Button("Clear", role: .destructive) { | |
| 3288 | viewModel.clearWorkflows() | |
| 3289 | } | |
| 3290 | Button("Cancel", role: .cancel) {} | |
| 3291 | } message: { | |
| 3292 | Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.") | |
| 3293 | } | |
| 3294 | .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) { | |
| 3295 | Button("Clear", role: .destructive) { | |
| 3296 | viewModel.clearTrackedDomains() | |
| 3297 | } | |
| 3298 | Button("Cancel", role: .cancel) {} | |
| 3299 | } message: { | |
| 3300 | Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.") | |
| 3301 | } | |
| 3302 | } | |
| 3303 | } | |
| 3304 | ||
| 3305 | private struct AboutSettingsView: View { | |
| 3306 | @State private var cloudSyncService = CloudSyncService.shared | |
| 3307 | ||
| 3308 | private var appVersion: String { | |
| 3309 | AppVersion.current | |
| 3310 | } | |
| 3311 | ||
| 3312 | var body: some View { | |
| 3313 | Form { | |
| 3314 | Section("About") { | |
| 3315 | LabeledContent("Version", value: appVersion) | |
| 3316 | LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") | |
| 3317 | LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") | |
| 3318 | } | |
| 3319 | } | |
| 3320 | .navigationTitle("App Info") | |
| 3321 | .task { | |
| 3322 | await cloudSyncService.refreshAvailability() | |
| 3323 | } | |
| 3324 | } | |
| 3032 | 3325 | } |
| 3033 | 3326 | |
| 3034 | 3327 | private struct DataImportPreviewSheet: View { |
DomainDig/DomainDigUI.swift +16 −1
| @@ -235,6 +235,21 @@ struct EmptyStateCardView: View { | ||
| 235 | 235 | let message: String |
| 236 | 236 | let suggestion: String |
| 237 | 237 | let systemImage: String |
| 238 | let showsCardBackground: Bool | |
| 239 | ||
| 240 | init( | |
| 241 | title: String, | |
| 242 | message: String, | |
| 243 | suggestion: String, | |
| 244 | systemImage: String, | |
| 245 | showsCardBackground: Bool = true | |
| 246 | ) { | |
| 247 | self.title = title | |
| 248 | self.message = message | |
| 249 | self.suggestion = suggestion | |
| 250 | self.systemImage = systemImage | |
| 251 | self.showsCardBackground = showsCardBackground | |
| 252 | } | |
| 238 | 253 | |
| 239 | 254 | var body: some View { |
| 240 | 255 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| @@ -253,7 +268,7 @@ struct EmptyStateCardView: View { | ||
| 253 | 268 | } |
| 254 | 269 | .frame(maxWidth: .infinity, alignment: .leading) |
| 255 | 270 | .padding(appDensity.metrics.cardPadding) |
| 256 | .background(Color(.systemGray6).opacity(0.45)) | |
| 271 | .background(showsCardBackground ? Color(.systemGray6).opacity(0.45) : Color.clear) | |
| 257 | 272 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 258 | 273 | } |
| 259 | 274 | } |
DomainDig/DomainMonitoringService.swift +330 −48
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import UserNotifications |
| 3 | import CryptoKit | |
| 3 | 4 | |
| 4 | 5 | #if canImport(BackgroundTasks) |
| 5 | 6 | import BackgroundTasks |
| @@ -132,9 +133,10 @@ final class DomainMonitoringScheduler { | ||
| 132 | 133 | } |
| 133 | 134 | #endif |
| 134 | 135 | |
| 136 | let trackedDomains = MonitoringStorage.loadTrackedDomains() | |
| 135 | 137 | let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) |
| 136 | 138 | request.earliestBeginDate = Date( |
| 137 | timeIntervalSinceNow: max(settings.frequency.schedulingInterval, 15 * 60) | |
| 139 | timeIntervalSinceNow: max(Self.nextScheduleInterval(settings: settings, trackedDomains: trackedDomains), 15 * 60) | |
| 138 | 140 | ) |
| 139 | 141 | |
| 140 | 142 | do { |
| @@ -149,6 +151,38 @@ final class DomainMonitoringScheduler { | ||
| 149 | 151 | #endif |
| 150 | 152 | } |
| 151 | 153 | |
| 154 | private static func nextScheduleInterval( | |
| 155 | settings: MonitoringSettings, | |
| 156 | trackedDomains: [TrackedDomain], | |
| 157 | now: Date = Date() | |
| 158 | ) -> TimeInterval { | |
| 159 | let monitored = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: trackedDomains) | |
| 160 | guard !monitored.isEmpty else { | |
| 161 | return settings.config.sanitizedBaseInterval | |
| 162 | } | |
| 163 | ||
| 164 | return monitored | |
| 165 | .map { trackedDomain in | |
| 166 | let state = normalizedMonitoringState(for: trackedDomain, settings: settings) | |
| 167 | guard let lastCheck = trackedDomain.monitoringState.lastCheck else { | |
| 168 | return 0 | |
| 169 | } | |
| 170 | return max(0, lastCheck.addingTimeInterval(state.currentInterval).timeIntervalSince(now)) | |
| 171 | } | |
| 172 | .min() ?? settings.config.sanitizedBaseInterval | |
| 173 | } | |
| 174 | ||
| 175 | static func normalizedMonitoringState( | |
| 176 | for trackedDomain: TrackedDomain, | |
| 177 | settings: MonitoringSettings | |
| 178 | ) -> MonitoringState { | |
| 179 | var state = trackedDomain.monitoringState | |
| 180 | if state.currentInterval <= 0 { | |
| 181 | state.currentInterval = settings.config.sanitizedBaseInterval | |
| 182 | } | |
| 183 | return state | |
| 184 | } | |
| 185 | ||
| 152 | 186 | #if canImport(BackgroundTasks) |
| 153 | 187 | private func handleAppRefresh(task: BGAppRefreshTask) { |
| 154 | 188 | _ = syncSchedule() |
| @@ -174,6 +208,7 @@ final class DomainMonitoringService { | ||
| 174 | 208 | |
| 175 | 209 | private let inspectionService = DomainInspectionService() |
| 176 | 210 | private let maxHistoryEntries = 250 |
| 211 | private let calendar = Calendar.current | |
| 177 | 212 | |
| 178 | 213 | func performMonitoring( |
| 179 | 214 | trigger: MonitoringRunTrigger, |
| @@ -200,9 +235,9 @@ final class DomainMonitoringService { | ||
| 200 | 235 | |
| 201 | 236 | var history = MonitoringStorage.loadHistoryEntries() |
| 202 | 237 | var mutableTrackedDomains = trackedDomains |
| 203 | let eligibleDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains) | |
| 238 | let monitoredDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains) | |
| 204 | 239 | |
| 205 | guard !eligibleDomains.isEmpty else { | |
| 240 | guard !monitoredDomains.isEmpty else { | |
| 206 | 241 | let log = MonitoringLog( |
| 207 | 242 | timestamp: Date(), |
| 208 | 243 | trigger: trigger, |
| @@ -216,6 +251,24 @@ final class DomainMonitoringService { | ||
| 216 | 251 | return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log) |
| 217 | 252 | } |
| 218 | 253 | |
| 254 | let eligibleDomains = monitoredDomains.filter { trackedDomain in | |
| 255 | shouldInspect(trackedDomain: trackedDomain, trigger: trigger, settings: settings) | |
| 256 | } | |
| 257 | ||
| 258 | guard !eligibleDomains.isEmpty else { | |
| 259 | let log = MonitoringLog( | |
| 260 | timestamp: Date(), | |
| 261 | trigger: trigger, | |
| 262 | domainsChecked: 0, | |
| 263 | changesFound: 0, | |
| 264 | alertsTriggered: 0, | |
| 265 | checkedDomains: [], | |
| 266 | errors: [] | |
| 267 | ) | |
| 268 | saveLog(log) | |
| 269 | return MonitoringRunOutcome(success: true, message: "No domains are due for monitoring.", log: log) | |
| 270 | } | |
| 271 | ||
| 219 | 272 | let notificationsAuthorized: Bool |
| 220 | 273 | if settings.alertsEnabled { |
| 221 | 274 | notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts() |
| @@ -225,6 +278,7 @@ final class DomainMonitoringService { | ||
| 225 | 278 | var results: [MonitoringDomainResult] = [] |
| 226 | 279 | var errors: [String] = [] |
| 227 | 280 | var alertsTriggered = 0 |
| 281 | let now = Date() | |
| 228 | 282 | |
| 229 | 283 | for trackedDomain in eligibleDomains { |
| 230 | 284 | guard !Task.isCancelled else { |
| @@ -252,33 +306,32 @@ final class DomainMonitoringService { | ||
| 252 | 306 | let alertDescriptor = alertDescriptor( |
| 253 | 307 | previousSnapshot: previousSnapshot, |
| 254 | 308 | snapshot: snapshot, |
| 255 | entry: savedEntry | |
| 309 | entry: savedEntry, | |
| 310 | sensitivity: settings.sensitivity | |
| 256 | 311 | ) |
| 257 | 312 | |
| 258 | 313 | if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { |
| 259 | mutableTrackedDomains[index].lastMonitoredAt = Date() | |
| 260 | } | |
| 261 | ||
| 262 | if notificationsAuthorized, | |
| 263 | let alertDescriptor, | |
| 264 | alertDescriptor.severity >= settings.alertFilter.minimumSeverity { | |
| 265 | await LocalNotificationService.shared.notifyMonitoringAlert( | |
| 266 | domain: trackedDomain.domain, | |
| 267 | message: alertDescriptor.message, | |
| 268 | severity: alertDescriptor.severity | |
| 314 | mutableTrackedDomains[index].lastMonitoredAt = now | |
| 315 | let outcome = await processAdaptiveMonitoringState( | |
| 316 | trackedDomain: mutableTrackedDomains[index], | |
| 317 | snapshot: snapshot, | |
| 318 | previousSnapshot: previousSnapshot, | |
| 319 | alertDescriptor: alertDescriptor, | |
| 320 | settings: settings, | |
| 321 | notificationsAuthorized: notificationsAuthorized, | |
| 322 | now: now | |
| 269 | 323 | ) |
| 270 | alertsTriggered += 1 | |
| 271 | if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { | |
| 272 | mutableTrackedDomains[index].lastAlertAt = Date() | |
| 273 | } | |
| 324 | mutableTrackedDomains[index] = outcome.trackedDomain | |
| 325 | alertsTriggered += outcome.alertsTriggered | |
| 274 | 326 | } |
| 275 | 327 | |
| 276 | 328 | let result = MonitoringDomainResult( |
| 277 | 329 | domain: trackedDomain.domain, |
| 278 | 330 | historyEntryID: savedEntry?.id ?? snapshot.historyEntryID, |
| 279 | checkedAt: Date(), | |
| 280 | didChange: snapshot.statusMessage == nil && savedEntry?.changeSummary?.hasChanges == true, | |
| 331 | checkedAt: now, | |
| 332 | didChange: alertDescriptor != nil, | |
| 281 | 333 | summaryMessage: snapshot.statusMessage |
| 334 | ?? alertDescriptor?.message | |
| 282 | 335 | ?? savedEntry?.changeSummary?.message |
| 283 | 336 | ?? "No meaningful changes", |
| 284 | 337 | alertSeverity: alertDescriptor?.severity, |
| @@ -431,54 +484,283 @@ final class DomainMonitoringService { | ||
| 431 | 484 | return entry |
| 432 | 485 | } |
| 433 | 486 | |
| 487 | private func shouldInspect( | |
| 488 | trackedDomain: TrackedDomain, | |
| 489 | trigger: MonitoringRunTrigger, | |
| 490 | settings: MonitoringSettings | |
| 491 | ) -> Bool { | |
| 492 | guard trigger == .background else { return true } | |
| 493 | guard let lastCheck = trackedDomain.monitoringState.lastCheck else { return true } | |
| 494 | let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings) | |
| 495 | return lastCheck.addingTimeInterval(state.currentInterval) <= Date() | |
| 496 | } | |
| 497 | ||
| 498 | private func processAdaptiveMonitoringState( | |
| 499 | trackedDomain: TrackedDomain, | |
| 500 | snapshot: LookupSnapshot, | |
| 501 | previousSnapshot: LookupSnapshot?, | |
| 502 | alertDescriptor: MonitoringAlertDescriptor?, | |
| 503 | settings: MonitoringSettings, | |
| 504 | notificationsAuthorized: Bool, | |
| 505 | now: Date | |
| 506 | ) async -> (trackedDomain: TrackedDomain, alertsTriggered: Int) { | |
| 507 | var updated = trackedDomain | |
| 508 | var state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings) | |
| 509 | let baseInterval = settings.config.sanitizedBaseInterval | |
| 510 | let minInterval = settings.sensitivity.minimumInterval | |
| 511 | let maxInterval = settings.config.maxInterval | |
| 512 | let isQuiet = settings.quietHours?.contains(now, calendar: calendar) == true | |
| 513 | var alertsTriggered = 0 | |
| 514 | ||
| 515 | if let alertDescriptor { | |
| 516 | if state.lastChangeHash != alertDescriptor.changeHash { | |
| 517 | state.lastChangeHash = alertDescriptor.changeHash | |
| 518 | state.lastChangeDate = now | |
| 519 | state.consecutiveStableChecks = 0 | |
| 520 | state.currentInterval = settings.adaptiveEnabled ? minInterval : baseInterval | |
| 521 | ||
| 522 | if settings.alertsEnabled, | |
| 523 | alertDescriptor.severity >= settings.alertFilter.minimumSeverity { | |
| 524 | let pendingAlert = MonitoringPendingAlert( | |
| 525 | detectedAt: now, | |
| 526 | message: alertDescriptor.message, | |
| 527 | severity: alertDescriptor.severity, | |
| 528 | changeHash: alertDescriptor.changeHash | |
| 529 | ) | |
| 530 | ||
| 531 | if isQuiet || !notificationsAuthorized { | |
| 532 | updated.pendingMonitoringAlerts = deduplicatedPendingAlerts( | |
| 533 | updated.pendingMonitoringAlerts + [pendingAlert] | |
| 534 | ) | |
| 535 | } else { | |
| 536 | let pendingAlerts = deduplicatedPendingAlerts( | |
| 537 | updated.pendingMonitoringAlerts + [pendingAlert] | |
| 538 | ) | |
| 539 | if pendingAlerts.count > 1 { | |
| 540 | await LocalNotificationService.shared.notifyMonitoringSummary( | |
| 541 | domain: trackedDomain.domain, | |
| 542 | alerts: pendingAlerts | |
| 543 | ) | |
| 544 | } else { | |
| 545 | await LocalNotificationService.shared.notifyMonitoringAlert( | |
| 546 | domain: trackedDomain.domain, | |
| 547 | message: alertDescriptor.message, | |
| 548 | severity: alertDescriptor.severity | |
| 549 | ) | |
| 550 | } | |
| 551 | alertsTriggered += 1 | |
| 552 | updated.pendingMonitoringAlerts = [] | |
| 553 | updated.lastAlertAt = now | |
| 554 | state.lastAlertDate = now | |
| 555 | } | |
| 556 | } | |
| 557 | } | |
| 558 | } else { | |
| 559 | state.consecutiveStableChecks += 1 | |
| 560 | if settings.adaptiveEnabled { | |
| 561 | state.currentInterval = max( | |
| 562 | baseInterval, | |
| 563 | min(maxInterval, state.currentInterval * settings.sensitivity.intervalMultiplier) | |
| 564 | ) | |
| 565 | } else { | |
| 566 | state.currentInterval = baseInterval | |
| 567 | } | |
| 568 | } | |
| 569 | ||
| 570 | if !isQuiet, | |
| 571 | settings.alertsEnabled, | |
| 572 | notificationsAuthorized, | |
| 573 | !updated.pendingMonitoringAlerts.isEmpty, | |
| 574 | alertDescriptor == nil { | |
| 575 | await LocalNotificationService.shared.notifyMonitoringSummary( | |
| 576 | domain: trackedDomain.domain, | |
| 577 | alerts: updated.pendingMonitoringAlerts | |
| 578 | ) | |
| 579 | alertsTriggered += 1 | |
| 580 | updated.pendingMonitoringAlerts = [] | |
| 581 | updated.lastAlertAt = now | |
| 582 | state.lastAlertDate = now | |
| 583 | } | |
| 584 | ||
| 585 | state.lastCheck = now | |
| 586 | updated.monitoringState = state | |
| 587 | return (updated, alertsTriggered) | |
| 588 | } | |
| 589 | ||
| 590 | private func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] { | |
| 591 | var uniqueByHash: [String: MonitoringPendingAlert] = [:] | |
| 592 | for alert in alerts { | |
| 593 | if let existing = uniqueByHash[alert.changeHash] { | |
| 594 | uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert | |
| 595 | } else { | |
| 596 | uniqueByHash[alert.changeHash] = alert | |
| 597 | } | |
| 598 | } | |
| 599 | return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt } | |
| 600 | } | |
| 601 | ||
| 602 | private struct MonitoringAlertDescriptor { | |
| 603 | let severity: MonitoringAlertSeverity | |
| 604 | let message: String | |
| 605 | let changeHash: String | |
| 606 | } | |
| 607 | ||
| 608 | private struct HeaderFingerprint: Encodable, Equatable { | |
| 609 | let name: String | |
| 610 | let value: String | |
| 611 | } | |
| 612 | ||
| 434 | 613 | private func alertDescriptor( |
| 435 | 614 | previousSnapshot: LookupSnapshot?, |
| 436 | 615 | snapshot: LookupSnapshot, |
| 437 | entry: HistoryEntry? | |
| 438 | ) -> (severity: MonitoringAlertSeverity, message: String)? { | |
| 616 | entry: HistoryEntry?, | |
| 617 | sensitivity: MonitoringSensitivity | |
| 618 | ) -> MonitoringAlertDescriptor? { | |
| 619 | guard let previousSnapshot else { return nil } | |
| 620 | let previousHash = monitoringHash(for: previousSnapshot, sensitivity: sensitivity) | |
| 621 | let currentHash = monitoringHash(for: snapshot, sensitivity: sensitivity) | |
| 622 | guard previousHash != currentHash else { return nil } | |
| 623 | ||
| 439 | 624 | let changedLabels = Set( |
| 440 | (previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []) | |
| 625 | DomainDiffService.diff(from: previousSnapshot, to: snapshot) | |
| 441 | 626 | .flatMap(\.items) |
| 442 | 627 | .filter(\.hasChanges) |
| 443 | 628 | .map(\.label) |
| 444 | 629 | ) |
| 445 | 630 | |
| 446 | if changedLabels.contains("Availability") { | |
| 447 | return (.critical, "Availability changed") | |
| 631 | if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) { | |
| 632 | return MonitoringAlertDescriptor(severity: .warning, message: "DNS records changed", changeHash: currentHash) | |
| 448 | 633 | } |
| 449 | if changedLabels.contains("Primary IP") { | |
| 450 | return (.critical, "Primary IP changed") | |
| 634 | ||
| 635 | let oldCertificateLevel = DomainDiffService.certificateWarningLevel(for: previousSnapshot) | |
| 636 | let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot) | |
| 637 | if snapshot.sslInfo?.issuer != previousSnapshot.sslInfo?.issuer | |
| 638 | || snapshot.sslInfo?.validUntil != previousSnapshot.sslInfo?.validUntil | |
| 639 | || (newCertificateLevel != .none && newCertificateLevel != oldCertificateLevel) { | |
| 640 | return MonitoringAlertDescriptor(severity: .warning, message: "Certificate updated", changeHash: currentHash) | |
| 451 | 641 | } |
| 452 | if changedLabels.contains("Redirect Target") { | |
| 453 | return (.critical, "Redirect target changed") | |
| 642 | ||
| 643 | if sensitivity != .low, | |
| 644 | previousSnapshot.redirectChain.map(\.url) != snapshot.redirectChain.map(\.url) | |
| 645 | || previousSnapshot.redirectChain.map(\.statusCode) != snapshot.redirectChain.map(\.statusCode) { | |
| 646 | return MonitoringAlertDescriptor(severity: .warning, message: "Redirect chain changed", changeHash: currentHash) | |
| 454 | 647 | } |
| 455 | 648 | |
| 456 | let ownershipLabels: Set<String> = [ | |
| 457 | "Registrar", | |
| 458 | "Registration Date", | |
| 459 | "Expiration Date", | |
| 460 | "Ownership Status", | |
| 461 | "Abuse Contact" | |
| 462 | ] | |
| 463 | if !changedLabels.isDisjoint(with: ownershipLabels) { | |
| 464 | return (.warning, "Ownership changed") | |
| 649 | if sensitivity != .low, | |
| 650 | filteredHTTPHeaders(previousSnapshot.httpHeaders, sensitivity: sensitivity) | |
| 651 | != filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity) { | |
| 652 | return MonitoringAlertDescriptor(severity: .info, message: "HTTP headers changed", changeHash: currentHash) | |
| 465 | 653 | } |
| 466 | if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) { | |
| 467 | return (.warning, "DNS changed") | |
| 654 | ||
| 655 | if sensitivity == .high, | |
| 656 | entry?.changeSummary?.hasChanges == true, | |
| 657 | let message = entry?.changeSummary?.message { | |
| 658 | return MonitoringAlertDescriptor(severity: .info, message: message, changeHash: currentHash) | |
| 468 | 659 | } |
| 469 | 660 | |
| 470 | let oldCertificateLevel = previousSnapshot.map { DomainDiffService.certificateWarningLevel(for: $0) } ?? .none | |
| 471 | let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot) | |
| 472 | if newCertificateLevel != .none, newCertificateLevel != oldCertificateLevel { | |
| 473 | let daysRemaining = snapshot.sslInfo?.daysUntilExpiry ?? 0 | |
| 474 | return (.warning, "Certificate expires in \(daysRemaining) days") | |
| 661 | return nil | |
| 662 | } | |
| 663 | ||
| 664 | private func monitoringHash(for snapshot: LookupSnapshot, sensitivity: MonitoringSensitivity) -> String { | |
| 665 | struct DNSRecordPayload: Encodable { | |
| 666 | let type: String | |
| 667 | let records: [String] | |
| 668 | let wildcardRecords: [String] | |
| 475 | 669 | } |
| 476 | 670 | |
| 477 | if entry?.changeSummary?.hasChanges == true, let message = entry?.changeSummary?.message { | |
| 478 | return (.info, message) | |
| 671 | struct CertificatePayload: Encodable { | |
| 672 | let issuer: String? | |
| 673 | let commonName: String? | |
| 674 | let validUntil: Date? | |
| 479 | 675 | } |
| 480 | 676 | |
| 481 | return nil | |
| 677 | struct RedirectPayload: Encodable { | |
| 678 | let url: String | |
| 679 | let statusCode: Int | |
| 680 | } | |
| 681 | ||
| 682 | struct HeaderPayload: Encodable { | |
| 683 | let name: String | |
| 684 | let value: String | |
| 685 | } | |
| 686 | ||
| 687 | struct MonitoringHashPayload: Encodable { | |
| 688 | let dns: [DNSRecordPayload] | |
| 689 | let certificate: CertificatePayload | |
| 690 | let headers: [HeaderPayload] | |
| 691 | let redirects: [RedirectPayload] | |
| 692 | } | |
| 693 | ||
| 694 | let payload = MonitoringHashPayload( | |
| 695 | dns: snapshot.dnsSections | |
| 696 | .sorted { $0.recordType.rawValue < $1.recordType.rawValue } | |
| 697 | .map { section in | |
| 698 | DNSRecordPayload( | |
| 699 | type: section.recordType.rawValue, | |
| 700 | records: section.records | |
| 701 | .sorted { lhs, rhs in | |
| 702 | lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value | |
| 703 | } | |
| 704 | .map { "\($0.value)|\($0.ttl)" }, | |
| 705 | wildcardRecords: section.wildcardRecords | |
| 706 | .sorted { lhs, rhs in | |
| 707 | lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value | |
| 708 | } | |
| 709 | .map { "\($0.value)|\($0.ttl)" } | |
| 710 | ) | |
| 711 | }, | |
| 712 | certificate: CertificatePayload( | |
| 713 | issuer: snapshot.sslInfo?.issuer, | |
| 714 | commonName: snapshot.sslInfo?.commonName, | |
| 715 | validUntil: snapshot.sslInfo?.validUntil | |
| 716 | ), | |
| 717 | headers: filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity).map { | |
| 718 | HeaderPayload(name: $0.name, value: $0.value) | |
| 719 | }, | |
| 720 | redirects: sensitivity == .low ? [] : snapshot.redirectChain.map { | |
| 721 | RedirectPayload(url: $0.url, statusCode: $0.statusCode) | |
| 722 | } | |
| 723 | ) | |
| 724 | ||
| 725 | let encoder = JSONEncoder() | |
| 726 | if #available(iOS 11.0, macOS 10.13, *) { | |
| 727 | encoder.outputFormatting = [.sortedKeys] | |
| 728 | } | |
| 729 | let data = (try? encoder.encode(payload)) ?? Data() | |
| 730 | return SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined() | |
| 731 | } | |
| 732 | ||
| 733 | private func filteredHTTPHeaders( | |
| 734 | _ headers: [HTTPHeader], | |
| 735 | sensitivity: MonitoringSensitivity | |
| 736 | ) -> [HeaderFingerprint] { | |
| 737 | let mediumHeaderNames: Set<String> = [ | |
| 738 | "cache-control", | |
| 739 | "content-security-policy", | |
| 740 | "location", | |
| 741 | "permissions-policy", | |
| 742 | "referrer-policy", | |
| 743 | "server", | |
| 744 | "strict-transport-security", | |
| 745 | "x-content-type-options", | |
| 746 | "x-frame-options" | |
| 747 | ] | |
| 748 | ||
| 749 | return headers | |
| 750 | .filter { header in | |
| 751 | switch sensitivity { | |
| 752 | case .low: | |
| 753 | return false | |
| 754 | case .medium: | |
| 755 | return mediumHeaderNames.contains(header.name.lowercased()) | |
| 756 | case .high: | |
| 757 | return true | |
| 758 | } | |
| 759 | } | |
| 760 | .map { HeaderFingerprint(name: $0.name.lowercased(), value: $0.value) } | |
| 761 | .sorted { lhs, rhs in | |
| 762 | lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name | |
| 763 | } | |
| 482 | 764 | } |
| 483 | 765 | |
| 484 | 766 | private static func resolvedSnapshotAfterFallback( |
DomainDig/DomainViewModel.swift +83 −55
| @@ -756,6 +756,7 @@ final class DomainViewModel { | ||
| 756 | 756 | func clearHistory() { |
| 757 | 757 | history.removeAll() |
| 758 | 758 | persistHistory() |
| 759 | clearMonitoringLogs() | |
| 759 | 760 | refreshDataLifecycleSummary() |
| 760 | 761 | } |
| 761 | 762 | |
| @@ -779,6 +780,7 @@ final class DomainViewModel { | ||
| 779 | 780 | refreshingTrackedDomainID = nil |
| 780 | 781 | persistTrackedDomains() |
| 781 | 782 | sanitizeMonitoringSelection() |
| 783 | clearMonitoringLogs() | |
| 782 | 784 | refreshDataLifecycleSummary() |
| 783 | 785 | } |
| 784 | 786 | |
| @@ -842,16 +844,45 @@ final class DomainViewModel { | ||
| 842 | 844 | persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) |
| 843 | 845 | } |
| 844 | 846 | |
| 845 | func setMonitoringFrequency(_ frequency: MonitoringFrequency) { | |
| 847 | func setMonitoringBaseInterval(_ baseInterval: MonitoringBaseInterval) { | |
| 846 | 848 | guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { |
| 847 | 849 | upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) |
| 848 | 850 | return |
| 849 | 851 | } |
| 850 | monitoringSettings.frequency = frequency | |
| 852 | monitoringSettings.baseInterval = baseInterval.interval | |
| 851 | 853 | persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) |
| 852 | 854 | monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() |
| 853 | 855 | } |
| 854 | 856 | |
| 857 | func setMonitoringAdaptiveEnabled(_ isEnabled: Bool) { | |
| 858 | guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { | |
| 859 | upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) | |
| 860 | return | |
| 861 | } | |
| 862 | monitoringSettings.adaptiveEnabled = isEnabled | |
| 863 | persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) | |
| 864 | monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() | |
| 865 | } | |
| 866 | ||
| 867 | func setMonitoringSensitivity(_ sensitivity: MonitoringSensitivity) { | |
| 868 | guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { | |
| 869 | upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) | |
| 870 | return | |
| 871 | } | |
| 872 | monitoringSettings.sensitivity = sensitivity | |
| 873 | persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) | |
| 874 | monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() | |
| 875 | } | |
| 876 | ||
| 877 | func setMonitoringQuietHours(startHour: Int, endHour: Int, isEnabled: Bool) { | |
| 878 | guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { | |
| 879 | upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) | |
| 880 | return | |
| 881 | } | |
| 882 | monitoringSettings.quietHours = isEnabled ? QuietHours(startHour: startHour, endHour: endHour) : nil | |
| 883 | persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) | |
| 884 | } | |
| 885 | ||
| 855 | 886 | func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) { |
| 856 | 887 | guard FeatureAccessService.hasAccess(to: .localAlerts) else { |
| 857 | 888 | monitoringSettings.alertsEnabled = false |
| @@ -928,6 +959,30 @@ final class DomainViewModel { | ||
| 928 | 959 | } |
| 929 | 960 | } |
| 930 | 961 | |
| 962 | func monitoringIntervalLabel(for trackedDomain: TrackedDomain) -> String { | |
| 963 | let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: monitoringSettings) | |
| 964 | return Self.intervalLabel(for: state.currentInterval) | |
| 965 | } | |
| 966 | ||
| 967 | func monitoringStatusLabel(for trackedDomain: TrackedDomain) -> String { | |
| 968 | let state = trackedDomain.monitoringState | |
| 969 | if let lastChangeDate = state.lastChangeDate, | |
| 970 | Date().timeIntervalSince(lastChangeDate) <= 6 * 60 * 60 { | |
| 971 | return "Recently Changed" | |
| 972 | } | |
| 973 | if state.consecutiveStableChecks >= 2 { | |
| 974 | return "Stable" | |
| 975 | } | |
| 976 | return trackedDomain.monitoringEnabled ? "Active" : "Paused" | |
| 977 | } | |
| 978 | ||
| 979 | private static func intervalLabel(for interval: TimeInterval) -> String { | |
| 980 | let formatter = DateComponentsFormatter() | |
| 981 | formatter.allowedUnits = interval < 3600 ? [.minute] : [.hour, .minute] | |
| 982 | formatter.unitsStyle = .full | |
| 983 | return formatter.string(from: interval) ?? "Unknown" | |
| 984 | } | |
| 985 | ||
| 931 | 986 | func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) { |
| 932 | 987 | if useSnapshotResolver { |
| 933 | 988 | UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) |
| @@ -968,6 +1023,22 @@ final class DomainViewModel { | ||
| 968 | 1023 | clearLookupState() |
| 969 | 1024 | } |
| 970 | 1025 | |
| 1026 | func clearPresentedResults() { | |
| 1027 | lookupTask?.cancel() | |
| 1028 | customPortScanTask?.cancel() | |
| 1029 | batchTask?.cancel() | |
| 1030 | hasRun = false | |
| 1031 | searchedDomain = "" | |
| 1032 | lastLookupDurationMs = nil | |
| 1033 | currentDiffSections = [] | |
| 1034 | currentChangeSummary = nil | |
| 1035 | ownershipDiff = [] | |
| 1036 | currentReport = nil | |
| 1037 | refreshingTrackedDomainID = nil | |
| 1038 | clearBatchState() | |
| 1039 | clearLookupState() | |
| 1040 | } | |
| 1041 | ||
| 971 | 1042 | func run() { |
| 972 | 1043 | let target = trimmedDomain |
| 973 | 1044 | guard !target.isEmpty else { return } |
| @@ -1222,13 +1293,6 @@ final class DomainViewModel { | ||
| 1222 | 1293 | } |
| 1223 | 1294 | guard ownershipHistory.isEmpty else { return } |
| 1224 | 1295 | |
| 1225 | let creditStatus = await UsageCreditService.shared.status(for: .ownershipHistory) | |
| 1226 | guard !creditStatus.isExhausted else { | |
| 1227 | ownershipHistoryError = "No ownership history credits remaining" | |
| 1228 | await refreshUsageCredits() | |
| 1229 | return | |
| 1230 | } | |
| 1231 | ||
| 1232 | 1296 | ownershipHistoryLoading = true |
| 1233 | 1297 | ownershipHistoryError = nil |
| 1234 | 1298 | |
| @@ -1242,15 +1306,9 @@ final class DomainViewModel { | ||
| 1242 | 1306 | case let .success(events): |
| 1243 | 1307 | ownershipHistory = events |
| 1244 | 1308 | ownershipHistoryError = nil |
| 1245 | if outcome.source != .cached { | |
| 1246 | _ = await UsageCreditService.shared.consume(.ownershipHistory) | |
| 1247 | } | |
| 1248 | 1309 | case let .empty(message): |
| 1249 | 1310 | ownershipHistory = [] |
| 1250 | 1311 | ownershipHistoryError = message |
| 1251 | if outcome.source != .cached { | |
| 1252 | _ = await UsageCreditService.shared.consume(.ownershipHistory) | |
| 1253 | } | |
| 1254 | 1312 | case let .error(message): |
| 1255 | 1313 | ownershipHistory = [] |
| 1256 | 1314 | ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable") |
| @@ -1258,7 +1316,6 @@ final class DomainViewModel { | ||
| 1258 | 1316 | |
| 1259 | 1317 | ownershipHistoryLoading = false |
| 1260 | 1318 | _ = saveHistoryEntry(replaceLatest: true) |
| 1261 | await refreshUsageCredits() | |
| 1262 | 1319 | } |
| 1263 | 1320 | |
| 1264 | 1321 | func loadDNSHistory() async { |
| @@ -1269,13 +1326,6 @@ final class DomainViewModel { | ||
| 1269 | 1326 | } |
| 1270 | 1327 | guard dnsHistory.isEmpty else { return } |
| 1271 | 1328 | |
| 1272 | let creditStatus = await UsageCreditService.shared.status(for: .dnsHistory) | |
| 1273 | guard !creditStatus.isExhausted else { | |
| 1274 | dnsHistoryError = "No DNS history credits remaining" | |
| 1275 | await refreshUsageCredits() | |
| 1276 | return | |
| 1277 | } | |
| 1278 | ||
| 1279 | 1329 | dnsHistoryLoading = true |
| 1280 | 1330 | dnsHistoryError = nil |
| 1281 | 1331 | |
| @@ -1289,15 +1339,9 @@ final class DomainViewModel { | ||
| 1289 | 1339 | case let .success(events): |
| 1290 | 1340 | dnsHistory = events |
| 1291 | 1341 | dnsHistoryError = nil |
| 1292 | if outcome.source != .cached { | |
| 1293 | _ = await UsageCreditService.shared.consume(.dnsHistory) | |
| 1294 | } | |
| 1295 | 1342 | case let .empty(message): |
| 1296 | 1343 | dnsHistory = [] |
| 1297 | 1344 | dnsHistoryError = message |
| 1298 | if outcome.source != .cached { | |
| 1299 | _ = await UsageCreditService.shared.consume(.dnsHistory) | |
| 1300 | } | |
| 1301 | 1345 | case let .error(message): |
| 1302 | 1346 | dnsHistory = [] |
| 1303 | 1347 | dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable") |
| @@ -1305,7 +1349,6 @@ final class DomainViewModel { | ||
| 1305 | 1349 | |
| 1306 | 1350 | dnsHistoryLoading = false |
| 1307 | 1351 | _ = saveHistoryEntry(replaceLatest: true) |
| 1308 | await refreshUsageCredits() | |
| 1309 | 1352 | } |
| 1310 | 1353 | |
| 1311 | 1354 | func loadExtendedSubdomains() async { |
| @@ -1316,13 +1359,6 @@ final class DomainViewModel { | ||
| 1316 | 1359 | } |
| 1317 | 1360 | guard extendedSubdomains.isEmpty else { return } |
| 1318 | 1361 | |
| 1319 | let creditStatus = await UsageCreditService.shared.status(for: .extendedSubdomains) | |
| 1320 | guard !creditStatus.isExhausted else { | |
| 1321 | extendedSubdomainsError = "No extended subdomain credits remaining" | |
| 1322 | await refreshUsageCredits() | |
| 1323 | return | |
| 1324 | } | |
| 1325 | ||
| 1326 | 1362 | extendedSubdomainsLoading = true |
| 1327 | 1363 | extendedSubdomainsError = nil |
| 1328 | 1364 | |
| @@ -1335,15 +1371,9 @@ final class DomainViewModel { | ||
| 1335 | 1371 | case let .success(results): |
| 1336 | 1372 | extendedSubdomains = results |
| 1337 | 1373 | extendedSubdomainsError = nil |
| 1338 | if outcome.source != .cached { | |
| 1339 | _ = await UsageCreditService.shared.consume(.extendedSubdomains) | |
| 1340 | } | |
| 1341 | 1374 | case let .empty(message): |
| 1342 | 1375 | extendedSubdomains = [] |
| 1343 | 1376 | extendedSubdomainsError = message |
| 1344 | if outcome.source != .cached { | |
| 1345 | _ = await UsageCreditService.shared.consume(.extendedSubdomains) | |
| 1346 | } | |
| 1347 | 1377 | case let .error(message): |
| 1348 | 1378 | extendedSubdomains = [] |
| 1349 | 1379 | extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable") |
| @@ -1351,7 +1381,6 @@ final class DomainViewModel { | ||
| 1351 | 1381 | |
| 1352 | 1382 | extendedSubdomainsLoading = false |
| 1353 | 1383 | _ = saveHistoryEntry(replaceLatest: true) |
| 1354 | await refreshUsageCredits() | |
| 1355 | 1384 | } |
| 1356 | 1385 | |
| 1357 | 1386 | func refreshUsageCredits() async { |
| @@ -1525,14 +1554,12 @@ final class DomainViewModel { | ||
| 1525 | 1554 | } |
| 1526 | 1555 | |
| 1527 | 1556 | guard snapshot.statusMessage == nil else { |
| 1528 | await refreshUsageCredits() | |
| 1529 | 1557 | return history.first(where: { $0.id == snapshot.historyEntryID }) |
| 1530 | 1558 | } |
| 1531 | 1559 | |
| 1532 | 1560 | let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain) |
| 1533 | 1561 | let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true) |
| 1534 | 1562 | DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain) |
| 1535 | await refreshUsageCredits() | |
| 1536 | 1563 | DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain) |
| 1537 | 1564 | return entry |
| 1538 | 1565 | } |
| @@ -2246,6 +2273,12 @@ final class DomainViewModel { | ||
| 2246 | 2273 | CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false) |
| 2247 | 2274 | } |
| 2248 | 2275 | |
| 2276 | private func clearMonitoringLogs() { | |
| 2277 | monitoringLogs.removeAll() | |
| 2278 | monitoringStatusMessage = nil | |
| 2279 | MonitoringStorage.saveLogs([]) | |
| 2280 | } | |
| 2281 | ||
| 2249 | 2282 | private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { |
| 2250 | 2283 | guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { |
| 2251 | 2284 | return |
| @@ -2533,14 +2566,9 @@ final class DomainViewModel { | ||
| 2533 | 2566 | return |
| 2534 | 2567 | } |
| 2535 | 2568 | |
| 2536 | let entry: HistoryEntry? | |
| 2537 | if payload.snapshot.statusMessage == nil { | |
| 2538 | entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) | |
| 2539 | } else { | |
| 2540 | entry = payload.snapshot.historyEntryID.flatMap { id in | |
| 2541 | history.first(where: { $0.id == id }) | |
| 2542 | } | |
| 2543 | } | |
| 2569 | let entry = payload.snapshot.historyEntryID.flatMap { id in | |
| 2570 | history.first(where: { $0.id == id }) | |
| 2571 | } ?? saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) | |
| 2544 | 2572 | let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) |
| 2545 | 2573 | let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment |
| 2546 | 2574 | let quickStatus: String |
| @@ -3568,7 +3596,7 @@ final class DomainViewModel { | ||
| 3568 | 3596 | } |
| 3569 | 3597 | } |
| 3570 | 3598 | if !DataAccessService.hasAccess(to: .extendedSubdomains) { |
| 3571 | lines.append(" Extended subdomain discovery (Data+)") | |
| 3599 | lines.append(" Extended subdomain discovery (Pro+)") | |
| 3572 | 3600 | } |
| 3573 | 3601 | } |
| 3574 | 3602 | |
DomainDig/FeatureAccessService.swift +7 −7
| @@ -3,7 +3,7 @@ import Foundation | ||
| 3 | 3 | enum FeatureTier: String, Codable, CaseIterable, Identifiable { |
| 4 | 4 | case free |
| 5 | 5 | case pro |
| 6 | case dataPlus | |
| 6 | case proPlus = "dataPlus" | |
| 7 | 7 | |
| 8 | 8 | var id: String { rawValue } |
| 9 | 9 | |
| @@ -13,8 +13,8 @@ enum FeatureTier: String, Codable, CaseIterable, Identifiable { | ||
| 13 | 13 | return "Free" |
| 14 | 14 | case .pro: |
| 15 | 15 | return "Pro" |
| 16 | case .dataPlus: | |
| 17 | return "Data+" | |
| 16 | case .proPlus: | |
| 17 | return "Pro+" | |
| 18 | 18 | } |
| 19 | 19 | } |
| 20 | 20 | } |
| @@ -114,9 +114,9 @@ enum FeatureAccessService { | ||
| 114 | 114 | workflowLimit: nil, |
| 115 | 115 | batchSizeLimit: nil |
| 116 | 116 | ) |
| 117 | case .dataPlus: | |
| 117 | case .proPlus: | |
| 118 | 118 | return FeatureEntitlements( |
| 119 | tier: .dataPlus, | |
| 119 | tier: .proPlus, | |
| 120 | 120 | capabilities: Set(FeatureCapability.allCases), |
| 121 | 121 | trackedDomainLimit: effectivelyUnlimitedTrackedDomains, |
| 122 | 122 | workflowLimit: nil, |
| @@ -158,7 +158,7 @@ enum FeatureAccessService { | ||
| 158 | 158 | case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports: |
| 159 | 159 | return "Available in Pro" |
| 160 | 160 | case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: |
| 161 | return "Available in Data+" | |
| 161 | return "Available in Pro+" | |
| 162 | 162 | case .limitedTracking: |
| 163 | 163 | return "Tracking is limited on Free" |
| 164 | 164 | case .singleLookup, .basicHistory: |
| @@ -220,7 +220,7 @@ enum FeatureAccessService { | ||
| 220 | 220 | let title: String |
| 221 | 221 | switch capability { |
| 222 | 222 | case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: |
| 223 | title = "Available in Data+" | |
| 223 | title = "Available in Pro+" | |
| 224 | 224 | default: |
| 225 | 225 | title = "Available in Pro" |
| 226 | 226 | } |
DomainDig/HistoryView.swift +2 −1
| @@ -22,7 +22,8 @@ struct HistoryView: View { | ||
| 22 | 22 | title: "No History Yet", |
| 23 | 23 | message: "History stores local snapshots of previous inspections so you can revisit and compare them later.", |
| 24 | 24 | suggestion: "Run a lookup to create your first saved snapshot.", |
| 25 | systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90" | |
| 25 | systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", | |
| 26 | showsCardBackground: false | |
| 26 | 27 | ) |
| 27 | 28 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| 28 | 29 | } else { |
DomainDig/LocalNotificationService.swift +29
| @@ -78,6 +78,35 @@ final class LocalNotificationService { | ||
| 78 | 78 | ) |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | func notifyMonitoringSummary( | |
| 82 | domain: String, | |
| 83 | alerts: [MonitoringPendingAlert] | |
| 84 | ) async { | |
| 85 | let summary = alerts | |
| 86 | .sorted { $0.detectedAt < $1.detectedAt } | |
| 87 | .prefix(2) | |
| 88 | .map(\.message) | |
| 89 | .joined(separator: " • ") | |
| 90 | let body: String | |
| 91 | if alerts.count <= 1 { | |
| 92 | body = alerts.first?.message ?? "Monitoring change detected" | |
| 93 | } else if summary.isEmpty { | |
| 94 | body = "\(alerts.count) monitoring changes detected" | |
| 95 | } else { | |
| 96 | body = "\(alerts.count) monitoring changes: \(summary)" | |
| 97 | } | |
| 98 | ||
| 99 | let severity = alerts.map(\.severity).max() ?? .info | |
| 100 | let interruptionLevel: UNNotificationInterruptionLevel = severity == .critical ? .timeSensitive : .active | |
| 101 | ||
| 102 | await schedule( | |
| 103 | identifier: "monitoring-summary-\(domain)-\(UUID().uuidString)", | |
| 104 | title: domain, | |
| 105 | body: body, | |
| 106 | interruptionLevel: interruptionLevel | |
| 107 | ) | |
| 108 | } | |
| 109 | ||
| 81 | 110 | func notifySweepComplete(summary: BatchSweepSummary) async { |
| 82 | 111 | let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged" |
| 83 | 112 | await schedule( |
DomainDig/Models.swift +261 −31
| @@ -728,6 +728,179 @@ struct CollaborationMetadata: Codable, Equatable { | ||
| 728 | 728 | } |
| 729 | 729 | } |
| 730 | 730 | |
| 731 | enum MonitoringSensitivity: String, Codable, CaseIterable, Identifiable, Sendable { | |
| 732 | case low | |
| 733 | case medium | |
| 734 | case high | |
| 735 | ||
| 736 | var id: String { rawValue } | |
| 737 | ||
| 738 | var title: String { | |
| 739 | rawValue.capitalized | |
| 740 | } | |
| 741 | ||
| 742 | var minimumInterval: TimeInterval { | |
| 743 | switch self { | |
| 744 | case .low: | |
| 745 | return 30 * 60 | |
| 746 | case .medium: | |
| 747 | return 15 * 60 | |
| 748 | case .high: | |
| 749 | return 5 * 60 | |
| 750 | } | |
| 751 | } | |
| 752 | ||
| 753 | var intervalMultiplier: Double { | |
| 754 | switch self { | |
| 755 | case .low: | |
| 756 | return 1.5 | |
| 757 | case .medium: | |
| 758 | return 1.25 | |
| 759 | case .high: | |
| 760 | return 1.1 | |
| 761 | } | |
| 762 | } | |
| 763 | } | |
| 764 | ||
| 765 | struct QuietHours: Codable, Equatable, Sendable { | |
| 766 | var startHour: Int | |
| 767 | var endHour: Int | |
| 768 | ||
| 769 | init(startHour: Int, endHour: Int) { | |
| 770 | self.startHour = max(0, min(23, startHour)) | |
| 771 | self.endHour = max(0, min(23, endHour)) | |
| 772 | } | |
| 773 | ||
| 774 | func contains(_ date: Date, calendar: Calendar = .current) -> Bool { | |
| 775 | let hour = calendar.component(.hour, from: date) | |
| 776 | if startHour <= endHour { | |
| 777 | return hour >= startHour && hour < endHour | |
| 778 | } | |
| 779 | return hour >= startHour || hour < endHour | |
| 780 | } | |
| 781 | } | |
| 782 | ||
| 783 | enum MonitoringBaseInterval: String, Codable, CaseIterable, Identifiable { | |
| 784 | case thirtyMinutes | |
| 785 | case hourly | |
| 786 | case sixHours | |
| 787 | case twelveHours | |
| 788 | case daily | |
| 789 | ||
| 790 | var id: String { rawValue } | |
| 791 | ||
| 792 | var title: String { | |
| 793 | switch self { | |
| 794 | case .thirtyMinutes: | |
| 795 | return "Every 30 Minutes" | |
| 796 | case .hourly: | |
| 797 | return "Hourly" | |
| 798 | case .sixHours: | |
| 799 | return "Every 6 Hours" | |
| 800 | case .twelveHours: | |
| 801 | return "Every 12 Hours" | |
| 802 | case .daily: | |
| 803 | return "Daily" | |
| 804 | } | |
| 805 | } | |
| 806 | ||
| 807 | var interval: TimeInterval { | |
| 808 | switch self { | |
| 809 | case .thirtyMinutes: | |
| 810 | return 30 * 60 | |
| 811 | case .hourly: | |
| 812 | return 60 * 60 | |
| 813 | case .sixHours: | |
| 814 | return 6 * 60 * 60 | |
| 815 | case .twelveHours: | |
| 816 | return 12 * 60 * 60 | |
| 817 | case .daily: | |
| 818 | return 24 * 60 * 60 | |
| 819 | } | |
| 820 | } | |
| 821 | ||
| 822 | static func nearest(to interval: TimeInterval) -> MonitoringBaseInterval { | |
| 823 | allCases.min { abs($0.interval - interval) < abs($1.interval - interval) } ?? .twelveHours | |
| 824 | } | |
| 825 | } | |
| 826 | ||
| 827 | struct MonitoringConfig: Codable, Equatable, Sendable { | |
| 828 | var isEnabled: Bool | |
| 829 | var baseInterval: TimeInterval | |
| 830 | var adaptiveEnabled: Bool | |
| 831 | var sensitivity: MonitoringSensitivity | |
| 832 | var quietHours: QuietHours? | |
| 833 | ||
| 834 | init( | |
| 835 | isEnabled: Bool = false, | |
| 836 | baseInterval: TimeInterval = MonitoringBaseInterval.twelveHours.interval, | |
| 837 | adaptiveEnabled: Bool = true, | |
| 838 | sensitivity: MonitoringSensitivity = .medium, | |
| 839 | quietHours: QuietHours? = nil | |
| 840 | ) { | |
| 841 | self.isEnabled = isEnabled | |
| 842 | self.baseInterval = baseInterval | |
| 843 | self.adaptiveEnabled = adaptiveEnabled | |
| 844 | self.sensitivity = sensitivity | |
| 845 | self.quietHours = quietHours | |
| 846 | } | |
| 847 | ||
| 848 | var maxInterval: TimeInterval { | |
| 849 | 24 * 60 * 60 | |
| 850 | } | |
| 851 | ||
| 852 | var sanitizedBaseInterval: TimeInterval { | |
| 853 | max(5 * 60, min(baseInterval, maxInterval)) | |
| 854 | } | |
| 855 | } | |
| 856 | ||
| 857 | struct MonitoringState: Codable, Equatable, Sendable { | |
| 858 | var lastCheck: Date? | |
| 859 | var lastChangeHash: String? | |
| 860 | var lastAlertDate: Date? | |
| 861 | var consecutiveStableChecks: Int | |
| 862 | var currentInterval: TimeInterval | |
| 863 | var lastChangeDate: Date? | |
| 864 | ||
| 865 | init( | |
| 866 | lastCheck: Date? = nil, | |
| 867 | lastChangeHash: String? = nil, | |
| 868 | lastAlertDate: Date? = nil, | |
| 869 | consecutiveStableChecks: Int = 0, | |
| 870 | currentInterval: TimeInterval = 0, | |
| 871 | lastChangeDate: Date? = nil | |
| 872 | ) { | |
| 873 | self.lastCheck = lastCheck | |
| 874 | self.lastChangeHash = lastChangeHash | |
| 875 | self.lastAlertDate = lastAlertDate | |
| 876 | self.consecutiveStableChecks = consecutiveStableChecks | |
| 877 | self.currentInterval = currentInterval | |
| 878 | self.lastChangeDate = lastChangeDate | |
| 879 | } | |
| 880 | } | |
| 881 | ||
| 882 | struct MonitoringPendingAlert: Codable, Identifiable, Equatable, Sendable { | |
| 883 | let id: UUID | |
| 884 | var detectedAt: Date | |
| 885 | var message: String | |
| 886 | var severity: MonitoringAlertSeverity | |
| 887 | var changeHash: String | |
| 888 | ||
| 889 | init( | |
| 890 | id: UUID = UUID(), | |
| 891 | detectedAt: Date, | |
| 892 | message: String, | |
| 893 | severity: MonitoringAlertSeverity, | |
| 894 | changeHash: String | |
| 895 | ) { | |
| 896 | self.id = id | |
| 897 | self.detectedAt = detectedAt | |
| 898 | self.message = message | |
| 899 | self.severity = severity | |
| 900 | self.changeHash = changeHash | |
| 901 | } | |
| 902 | } | |
| 903 | ||
| 731 | 904 | struct TrackedDomain: Codable, Identifiable, Equatable { |
| 732 | 905 | let id: UUID |
| 733 | 906 | var domain: String |
| @@ -744,6 +917,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 744 | 917 | var certificateDaysRemaining: Int? |
| 745 | 918 | var lastMonitoredAt: Date? |
| 746 | 919 | var lastAlertAt: Date? |
| 920 | var monitoringState: MonitoringState | |
| 921 | var pendingMonitoringAlerts: [MonitoringPendingAlert] | |
| 747 | 922 | var collaboration: CollaborationMetadata? |
| 748 | 923 | |
| 749 | 924 | init( |
| @@ -762,6 +937,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 762 | 937 | certificateDaysRemaining: Int? = nil, |
| 763 | 938 | lastMonitoredAt: Date? = nil, |
| 764 | 939 | lastAlertAt: Date? = nil, |
| 940 | monitoringState: MonitoringState = MonitoringState(), | |
| 941 | pendingMonitoringAlerts: [MonitoringPendingAlert] = [], | |
| 765 | 942 | collaboration: CollaborationMetadata? = nil |
| 766 | 943 | ) { |
| 767 | 944 | self.id = id |
| @@ -779,6 +956,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 779 | 956 | self.certificateDaysRemaining = certificateDaysRemaining |
| 780 | 957 | self.lastMonitoredAt = lastMonitoredAt |
| 781 | 958 | self.lastAlertAt = lastAlertAt |
| 959 | self.monitoringState = monitoringState | |
| 960 | self.pendingMonitoringAlerts = pendingMonitoringAlerts | |
| 782 | 961 | self.collaboration = collaboration |
| 783 | 962 | } |
| 784 | 963 | |
| @@ -799,35 +978,12 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 799 | 978 | certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining) |
| 800 | 979 | lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt) |
| 801 | 980 | lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt) |
| 981 | monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState() | |
| 982 | pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? [] | |
| 802 | 983 | collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration) |
| 803 | 984 | } |
| 804 | 985 | } |
| 805 | 986 | |
| 806 | enum MonitoringFrequency: String, Codable, CaseIterable, Identifiable { | |
| 807 | case daily | |
| 808 | case twiceDaily | |
| 809 | ||
| 810 | var id: String { rawValue } | |
| 811 | ||
| 812 | var title: String { | |
| 813 | switch self { | |
| 814 | case .daily: | |
| 815 | return "Daily" | |
| 816 | case .twiceDaily: | |
| 817 | return "Twice Daily" | |
| 818 | } | |
| 819 | } | |
| 820 | ||
| 821 | var schedulingInterval: TimeInterval { | |
| 822 | switch self { | |
| 823 | case .daily: | |
| 824 | return 24 * 60 * 60 | |
| 825 | case .twiceDaily: | |
| 826 | return 12 * 60 * 60 | |
| 827 | } | |
| 828 | } | |
| 829 | } | |
| 830 | ||
| 831 | 987 | enum MonitoringScope: String, Codable, CaseIterable, Identifiable { |
| 832 | 988 | case allTracked |
| 833 | 989 | case selectedOnly |
| @@ -913,28 +1069,102 @@ enum MonitoringRunTrigger: String, Codable { | ||
| 913 | 1069 | } |
| 914 | 1070 | |
| 915 | 1071 | struct MonitoringSettings: Codable, Equatable { |
| 916 | var isEnabled: Bool | |
| 917 | var frequency: MonitoringFrequency | |
| 1072 | private enum CodingKeys: String, CodingKey { | |
| 1073 | case config | |
| 1074 | case isEnabled | |
| 1075 | case frequency | |
| 1076 | case scope | |
| 1077 | case selectedDomainIDs | |
| 1078 | case alertFilter | |
| 1079 | case alertsEnabled | |
| 1080 | } | |
| 1081 | ||
| 1082 | var config: MonitoringConfig | |
| 918 | 1083 | var scope: MonitoringScope |
| 919 | 1084 | var selectedDomainIDs: [UUID] |
| 920 | 1085 | var alertFilter: MonitoringAlertFilter |
| 921 | 1086 | var alertsEnabled: Bool |
| 922 | 1087 | |
| 923 | 1088 | init( |
| 924 | isEnabled: Bool = false, | |
| 925 | frequency: MonitoringFrequency = .daily, | |
| 1089 | config: MonitoringConfig = MonitoringConfig(), | |
| 926 | 1090 | scope: MonitoringScope = .allTracked, |
| 927 | 1091 | selectedDomainIDs: [UUID] = [], |
| 928 | 1092 | alertFilter: MonitoringAlertFilter = .criticalAndWarnings, |
| 929 | 1093 | alertsEnabled: Bool = false |
| 930 | 1094 | ) { |
| 931 | self.isEnabled = isEnabled | |
| 932 | self.frequency = frequency | |
| 1095 | self.config = config | |
| 933 | 1096 | self.scope = scope |
| 934 | 1097 | self.selectedDomainIDs = selectedDomainIDs |
| 935 | 1098 | self.alertFilter = alertFilter |
| 936 | 1099 | self.alertsEnabled = alertsEnabled |
| 937 | 1100 | } |
| 1101 | ||
| 1102 | init(from decoder: Decoder) throws { | |
| 1103 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 1104 | let legacyEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false | |
| 1105 | let legacyFrequencyRaw = try container.decodeIfPresent(String.self, forKey: .frequency) | |
| 1106 | ||
| 1107 | if let decodedConfig = try container.decodeIfPresent(MonitoringConfig.self, forKey: .config) { | |
| 1108 | config = decodedConfig | |
| 1109 | } else { | |
| 1110 | let mappedInterval: TimeInterval | |
| 1111 | switch legacyFrequencyRaw { | |
| 1112 | case "daily": | |
| 1113 | mappedInterval = MonitoringBaseInterval.daily.interval | |
| 1114 | case "twiceDaily": | |
| 1115 | mappedInterval = MonitoringBaseInterval.twelveHours.interval | |
| 1116 | default: | |
| 1117 | mappedInterval = MonitoringBaseInterval.twelveHours.interval | |
| 1118 | } | |
| 1119 | ||
| 1120 | config = MonitoringConfig( | |
| 1121 | isEnabled: legacyEnabled, | |
| 1122 | baseInterval: mappedInterval, | |
| 1123 | adaptiveEnabled: true, | |
| 1124 | sensitivity: .medium, | |
| 1125 | quietHours: nil | |
| 1126 | ) | |
| 1127 | } | |
| 1128 | ||
| 1129 | scope = try container.decodeIfPresent(MonitoringScope.self, forKey: .scope) ?? .allTracked | |
| 1130 | selectedDomainIDs = try container.decodeIfPresent([UUID].self, forKey: .selectedDomainIDs) ?? [] | |
| 1131 | alertFilter = try container.decodeIfPresent(MonitoringAlertFilter.self, forKey: .alertFilter) ?? .criticalAndWarnings | |
| 1132 | alertsEnabled = try container.decodeIfPresent(Bool.self, forKey: .alertsEnabled) ?? false | |
| 1133 | } | |
| 1134 | ||
| 1135 | func encode(to encoder: Encoder) throws { | |
| 1136 | var container = encoder.container(keyedBy: CodingKeys.self) | |
| 1137 | try container.encode(config, forKey: .config) | |
| 1138 | try container.encode(scope, forKey: .scope) | |
| 1139 | try container.encode(selectedDomainIDs, forKey: .selectedDomainIDs) | |
| 1140 | try container.encode(alertFilter, forKey: .alertFilter) | |
| 1141 | try container.encode(alertsEnabled, forKey: .alertsEnabled) | |
| 1142 | } | |
| 1143 | ||
| 1144 | var isEnabled: Bool { | |
| 1145 | get { config.isEnabled } | |
| 1146 | set { config.isEnabled = newValue } | |
| 1147 | } | |
| 1148 | ||
| 1149 | var baseInterval: TimeInterval { | |
| 1150 | get { config.baseInterval } | |
| 1151 | set { config.baseInterval = newValue } | |
| 1152 | } | |
| 1153 | ||
| 1154 | var adaptiveEnabled: Bool { | |
| 1155 | get { config.adaptiveEnabled } | |
| 1156 | set { config.adaptiveEnabled = newValue } | |
| 1157 | } | |
| 1158 | ||
| 1159 | var sensitivity: MonitoringSensitivity { | |
| 1160 | get { config.sensitivity } | |
| 1161 | set { config.sensitivity = newValue } | |
| 1162 | } | |
| 1163 | ||
| 1164 | var quietHours: QuietHours? { | |
| 1165 | get { config.quietHours } | |
| 1166 | set { config.quietHours = newValue } | |
| 1167 | } | |
| 938 | 1168 | } |
| 939 | 1169 | |
| 940 | 1170 | struct MonitoringDomainResult: Codable, Identifiable, Equatable { |
DomainDig/MonitoringView.swift +17 −2
| @@ -17,7 +17,14 @@ struct MonitoringView: View { | ||
| 17 | 17 | VStack(alignment: .leading, spacing: 8) { |
| 18 | 18 | LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only") |
| 19 | 19 | LabeledContent("Domains", value: "\(monitoredDomainsCount)") |
| 20 | LabeledContent("Frequency", value: viewModel.monitoringSettings.frequency.title) | |
| 20 | LabeledContent("Base Interval", value: MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval).title) | |
| 21 | LabeledContent("Adaptive", value: viewModel.monitoringSettings.adaptiveEnabled ? viewModel.monitoringSettings.sensitivity.title : "Off") | |
| 22 | LabeledContent( | |
| 23 | "Quiet Hours", | |
| 24 | value: viewModel.monitoringSettings.quietHours.map { | |
| 25 | "\(Self.hourLabel($0.startHour)) to \(Self.hourLabel($0.endHour))" | |
| 26 | } ?? "Off" | |
| 27 | ) | |
| 21 | 28 | LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off") |
| 22 | 29 | |
| 23 | 30 | if let monitoringStatusMessage = viewModel.monitoringStatusMessage, |
| @@ -42,7 +49,8 @@ struct MonitoringView: View { | ||
| 42 | 49 | title: "No Monitoring Runs Yet", |
| 43 | 50 | message: "Monitoring history appears here after manual or background runs finish.", |
| 44 | 51 | suggestion: "Enable monitoring in Settings or run a manual monitoring sweep.", |
| 45 | systemImage: "waveform.path.ecg" | |
| 52 | systemImage: "waveform.path.ecg", | |
| 53 | showsCardBackground: false | |
| 46 | 54 | ) |
| 47 | 55 | } |
| 48 | 56 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| @@ -102,6 +110,13 @@ struct MonitoringView: View { | ||
| 102 | 110 | .background(tint.opacity(0.16)) |
| 103 | 111 | .clipShape(Capsule()) |
| 104 | 112 | } |
| 113 | ||
| 114 | private static func hourLabel(_ hour: Int) -> String { | |
| 115 | let formatter = DateFormatter() | |
| 116 | formatter.dateFormat = "h a" | |
| 117 | let components = DateComponents(calendar: .current, hour: hour) | |
| 118 | return components.date.map(formatter.string(from:)) ?? "\(hour):00" | |
| 119 | } | |
| 105 | 120 | } |
| 106 | 121 | |
| 107 | 122 | struct MonitoringLogDetailView: View { |
DomainDig/PaywallView.swift +6 −6
| @@ -10,7 +10,7 @@ struct PaywallView: View { | ||
| 10 | 10 | NavigationStack { |
| 11 | 11 | List { |
| 12 | 12 | Section { |
| 13 | Text("Pro unlocks workflows, scale, monitoring automation, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.") | |
| 13 | Text("Pro unlocks workflows, scale, monitoring automation, and exports. Pro+ adds deeper external intelligence with no additional usage limits.") | |
| 14 | 14 | .font(appDensity.font(.body, design: .default)) |
| 15 | 15 | .foregroundStyle(.secondary) |
| 16 | 16 | .fixedSize(horizontal: false, vertical: true) |
| @@ -25,7 +25,7 @@ struct PaywallView: View { | ||
| 25 | 25 | featureRow("Advanced exports") |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | Section("What Data+ Unlocks") { | |
| 28 | Section("What Pro+ Unlocks") { | |
| 29 | 29 | featureRow("Ownership history") |
| 30 | 30 | featureRow("DNS history") |
| 31 | 31 | featureRow("Extended subdomains") |
| @@ -125,10 +125,10 @@ struct PaywallView: View { | ||
| 125 | 125 | return "Pro Monthly" |
| 126 | 126 | case PurchaseService.yearlyProductID: |
| 127 | 127 | return "Pro Yearly" |
| 128 | case PurchaseService.dataPlusMonthlyProductID: | |
| 129 | return "Data+ Monthly" | |
| 130 | case PurchaseService.dataPlusYearlyProductID: | |
| 131 | return "Data+ Yearly" | |
| 128 | case PurchaseService.proPlusMonthlyProductID: | |
| 129 | return "Pro+ Monthly" | |
| 130 | case PurchaseService.proPlusYearlyProductID: | |
| 131 | return "Pro+ Yearly" | |
| 132 | 132 | default: |
| 133 | 133 | return product.displayName |
| 134 | 134 | } |
DomainDig/PurchaseService.swift +66 −11
| @@ -17,18 +17,30 @@ final class PurchaseService { | ||
| 17 | 17 | static let shared = PurchaseService() |
| 18 | 18 | static let monthlyProductID = "domaindig.pro.monthly" |
| 19 | 19 | static let yearlyProductID = "domaindig.pro.yearly" |
| 20 | static let dataPlusMonthlyProductID = "domaindig.dataplus.monthly" | |
| 21 | static let dataPlusYearlyProductID = "domaindig.dataplus.yearly" | |
| 20 | static let proPlusMonthlyProductID = "domaindig.dataplus.monthly" | |
| 21 | static let proPlusYearlyProductID = "domaindig.dataplus.yearly" | |
| 22 | 22 | static let productIDs = [ |
| 23 | 23 | monthlyProductID, |
| 24 | 24 | yearlyProductID, |
| 25 | dataPlusMonthlyProductID, | |
| 26 | dataPlusYearlyProductID | |
| 25 | proPlusMonthlyProductID, | |
| 26 | proPlusYearlyProductID | |
| 27 | 27 | ] |
| 28 | 28 | |
| 29 | 29 | private static let entitlementCacheKey = "purchase.cachedEntitlement" |
| 30 | #if DEBUG | |
| 31 | // Local-only screenshot/testing override. Release builds always use StoreKit entitlements. | |
| 32 | private static let debugForceFreeArgument = "DOMAIN_DIG_FORCE_FREE" | |
| 33 | private static let debugForceProArgument = "DOMAIN_DIG_FORCE_PRO" | |
| 34 | private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS" | |
| 35 | #endif | |
| 30 | 36 | |
| 31 | 37 | static var cachedEntitlement: CachedEntitlement? { |
| 38 | #if DEBUG | |
| 39 | if let forcedEntitlement = debugForcedEntitlement { | |
| 40 | return forcedEntitlement | |
| 41 | } | |
| 42 | #endif | |
| 43 | ||
| 32 | 44 | guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } |
| 33 | 45 | return try? JSONDecoder().decode(CachedEntitlement.self, from: data) |
| 34 | 46 | } |
| @@ -51,6 +63,7 @@ final class PurchaseService { | ||
| 51 | 63 | private init() { |
| 52 | 64 | currentTier = Self.cachedTier |
| 53 | 65 | activeProductID = Self.cachedEntitlement?.activeProductID |
| 66 | applyDebugOverrideIfNeeded() | |
| 54 | 67 | updatesTask = observeTransactionUpdates() |
| 55 | 68 | Task { |
| 56 | 69 | await refreshProducts() |
| @@ -62,8 +75,8 @@ final class PurchaseService { | ||
| 62 | 75 | currentTier != .free |
| 63 | 76 | } |
| 64 | 77 | |
| 65 | var hasDataPlusAccess: Bool { | |
| 66 | currentTier == .dataPlus | |
| 78 | var hasProPlusAccess: Bool { | |
| 79 | currentTier == .proPlus | |
| 67 | 80 | } |
| 68 | 81 | |
| 69 | 82 | func refreshProducts() async { |
| @@ -104,6 +117,7 @@ final class PurchaseService { | ||
| 104 | 117 | self.activeProductID = activeProductID |
| 105 | 118 | currentTier = tier(for: activeProductID) |
| 106 | 119 | persistCurrentEntitlement() |
| 120 | applyDebugOverrideIfNeeded() | |
| 107 | 121 | } |
| 108 | 122 | |
| 109 | 123 | func purchase(_ product: Product) async { |
| @@ -120,7 +134,7 @@ final class PurchaseService { | ||
| 120 | 134 | apply(transaction: transaction) |
| 121 | 135 | await transaction.finish() |
| 122 | 136 | await refreshEntitlements() |
| 123 | statusMessage = currentTier == .dataPlus ? "Data+ is active." : "Pro is active." | |
| 137 | statusMessage = currentTier == .proPlus ? "Pro+ is active." : "Pro is active." | |
| 124 | 138 | case .userCancelled: |
| 125 | 139 | break |
| 126 | 140 | case .pending: |
| @@ -190,6 +204,7 @@ final class PurchaseService { | ||
| 190 | 204 | activeProductID = transaction.productID |
| 191 | 205 | currentTier = tier(for: transaction.productID) |
| 192 | 206 | persistCurrentEntitlement() |
| 207 | applyDebugOverrideIfNeeded() | |
| 193 | 208 | } |
| 194 | 209 | |
| 195 | 210 | private func observeTransactionUpdates() -> Task<Void, Never> { |
| @@ -220,6 +235,14 @@ final class PurchaseService { | ||
| 220 | 235 | } |
| 221 | 236 | } |
| 222 | 237 | |
| 238 | private func applyDebugOverrideIfNeeded() { | |
| 239 | #if DEBUG | |
| 240 | guard let forcedEntitlement = Self.debugForcedEntitlement else { return } | |
| 241 | currentTier = forcedEntitlement.tier | |
| 242 | activeProductID = forcedEntitlement.activeProductID | |
| 243 | #endif | |
| 244 | } | |
| 245 | ||
| 223 | 246 | private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction { |
| 224 | 247 | switch result { |
| 225 | 248 | case .verified(let transaction): |
| @@ -235,9 +258,9 @@ final class PurchaseService { | ||
| 235 | 258 | return 0 |
| 236 | 259 | case Self.yearlyProductID: |
| 237 | 260 | return 1 |
| 238 | case Self.dataPlusMonthlyProductID: | |
| 261 | case Self.proPlusMonthlyProductID: | |
| 239 | 262 | return 2 |
| 240 | case Self.dataPlusYearlyProductID: | |
| 263 | case Self.proPlusYearlyProductID: | |
| 241 | 264 | return 3 |
| 242 | 265 | default: |
| 243 | 266 | return Int.max |
| @@ -248,8 +271,8 @@ final class PurchaseService { | ||
| 248 | 271 | switch productID { |
| 249 | 272 | case Self.monthlyProductID, Self.yearlyProductID: |
| 250 | 273 | return .pro |
| 251 | case Self.dataPlusMonthlyProductID, Self.dataPlusYearlyProductID: | |
| 252 | return .dataPlus | |
| 274 | case Self.proPlusMonthlyProductID, Self.proPlusYearlyProductID: | |
| 275 | return .proPlus | |
| 253 | 276 | default: |
| 254 | 277 | return .free |
| 255 | 278 | } |
| @@ -268,4 +291,36 @@ final class PurchaseService { | ||
| 268 | 291 | let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) |
| 269 | 292 | return message.isEmpty ? fallback : message |
| 270 | 293 | } |
| 294 | ||
| 295 | #if DEBUG | |
| 296 | private static var debugForcedEntitlement: CachedEntitlement? { | |
| 297 | let arguments = ProcessInfo.processInfo.arguments | |
| 298 | ||
| 299 | if arguments.contains(debugForceFreeArgument) { | |
| 300 | return CachedEntitlement( | |
| 301 | tier: .free, | |
| 302 | activeProductID: nil, | |
| 303 | updatedAt: .distantPast | |
| 304 | ) | |
| 305 | } | |
| 306 | ||
| 307 | if arguments.contains(debugForceProPlusArgument) { | |
| 308 | return CachedEntitlement( | |
| 309 | tier: .proPlus, | |
| 310 | activeProductID: proPlusMonthlyProductID, | |
| 311 | updatedAt: .distantPast | |
| 312 | ) | |
| 313 | } | |
| 314 | ||
| 315 | if arguments.contains(debugForceProArgument) { | |
| 316 | return CachedEntitlement( | |
| 317 | tier: .pro, | |
| 318 | activeProductID: monthlyProductID, | |
| 319 | updatedAt: .distantPast | |
| 320 | ) | |
| 321 | } | |
| 322 | ||
| 323 | return nil | |
| 324 | } | |
| 325 | #endif | |
| 271 | 326 | } |
DomainDig/RootTabView.swift +1 −8
| @@ -34,18 +34,11 @@ struct RootTabView: View { | ||
| 34 | 34 | Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | NavigationStack { | |
| 38 | WorkflowsView(viewModel: viewModel) | |
| 39 | } | |
| 40 | .tabItem { | |
| 41 | Label("Workflows", systemImage: "square.stack.3d.down.right") | |
| 42 | } | |
| 43 | ||
| 44 | 37 | NavigationStack { |
| 45 | 38 | SettingsView(viewModel: viewModel) |
| 46 | 39 | } |
| 47 | 40 | .tabItem { |
| 48 | Label("Settings", systemImage: "gearshape") | |
| 41 | Label("More", systemImage: "ellipsis.circle") | |
| 49 | 42 | } |
| 50 | 43 | } |
| 51 | 44 | .sheet(isPresented: Binding( |
DomainDig/TimelineView.swift +24 −36
| @@ -12,6 +12,10 @@ struct TimelineView: View { | ||
| 12 | 12 | viewModel.timelineSections(for: domain) |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | private var compareButtonDisabled: Bool { | |
| 16 | viewModel.selectedSnapshots.count != 2 && viewModel.historyEntries(for: domain).count < 2 | |
| 17 | } | |
| 18 | ||
| 15 | 19 | var body: some View { |
| 16 | 20 | List { |
| 17 | 21 | ForEach(timelineSections) { section in |
| @@ -61,10 +65,16 @@ struct TimelineView: View { | ||
| 61 | 65 | } |
| 62 | 66 | |
| 63 | 67 | Button("Compare") { |
| 64 | presentedDiff = viewModel.generateDiffForSelectedSnapshots() | |
| 68 | if viewModel.selectedSnapshots.count == 2 { | |
| 69 | presentedDiff = viewModel.generateDiffForSelectedSnapshots() | |
| 70 | } else { | |
| 71 | let entries = viewModel.historyEntries(for: domain) | |
| 72 | guard entries.count >= 2 else { return } | |
| 73 | presentedDiff = viewModel.generateDiff(from: entries[1], to: entries[0]) | |
| 74 | } | |
| 65 | 75 | focusedSectionID = viewModel.currentDiffTargetSectionID |
| 66 | 76 | } |
| 67 | .disabled(viewModel.selectedSnapshots.count != 2) | |
| 77 | .disabled(compareButtonDisabled) | |
| 68 | 78 | |
| 69 | 79 | Menu("Export") { |
| 70 | 80 | Button("Export TXT") { |
| @@ -119,46 +129,24 @@ private struct TimelineRow: View { | ||
| 119 | 129 | |
| 120 | 130 | HStack(spacing: 8) { |
| 121 | 131 | AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability)) |
| 122 | if let primaryIP = summary.primaryIP { | |
| 123 | AppStatusBadgeView( | |
| 124 | model: .init( | |
| 125 | title: primaryIP, | |
| 126 | systemImage: "network", | |
| 127 | foregroundColor: .blue, | |
| 128 | backgroundColor: .blue.opacity(0.16) | |
| 129 | ) | |
| 130 | ) | |
| 131 | } | |
| 132 | if let tlsStatus = summary.tlsStatus { | |
| 133 | AppStatusBadgeView( | |
| 134 | model: .init( | |
| 135 | title: tlsStatus.capitalized, | |
| 136 | systemImage: "lock.shield", | |
| 137 | foregroundColor: .green, | |
| 138 | backgroundColor: .green.opacity(0.16) | |
| 139 | ) | |
| 140 | ) | |
| 141 | } | |
| 142 | 132 | if let riskScore = summary.riskScore { |
| 143 | AppStatusBadgeView( | |
| 144 | model: .init( | |
| 145 | title: "Risk \(riskScore)", | |
| 146 | systemImage: "exclamationmark.shield", | |
| 147 | foregroundColor: riskScore >= 70 ? .red : .orange, | |
| 148 | backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16) | |
| 149 | ) | |
| 150 | ) | |
| 133 | Text("Risk \(riskScore)") | |
| 134 | .lineLimit(1) | |
| 135 | .minimumScaleFactor(0.85) | |
| 151 | 136 | } |
| 152 | 137 | } |
| 138 | .font(appDensity.font(.caption2)) | |
| 139 | .foregroundStyle(.secondary) | |
| 153 | 140 | |
| 154 | 141 | HStack(spacing: 8) { |
| 155 | if let snapshotIndex = summary.snapshotIndex { | |
| 156 | Text("#\(snapshotIndex)") | |
| 157 | } | |
| 158 | Text(summary.timestamp.formatted(.relative(presentation: .named))) | |
| 159 | if summary.changeCount > 0 { | |
| 160 | Text("\(summary.changeCount) changes") | |
| 142 | if let primaryIP = summary.primaryIP { | |
| 143 | Text(primaryIP) | |
| 144 | .lineLimit(1) | |
| 145 | .truncationMode(.middle) | |
| 161 | 146 | } |
| 147 | Spacer(minLength: 8) | |
| 148 | Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened)) | |
| 149 | .lineLimit(1) | |
| 162 | 150 | } |
| 163 | 151 | .font(appDensity.font(.caption2)) |
| 164 | 152 | .foregroundStyle(.secondary) |
DomainDig/WatchlistView.swift +93 −3
| @@ -6,6 +6,10 @@ struct WatchlistView: View { | ||
| 6 | 6 | @Environment(\.dismiss) private var dismiss |
| 7 | 7 | @State private var purchaseService = PurchaseService.shared |
| 8 | 8 | @State private var showWorkflowAddSheet = false |
| 9 | @State private var showAddDomainSheet = false | |
| 10 | @State private var newTrackedDomain = "" | |
| 11 | @State private var addDomainError: String? | |
| 12 | @FocusState private var isAddDomainFieldFocused: Bool | |
| 9 | 13 | |
| 10 | 14 | private var pinnedDomains: [TrackedDomain] { |
| 11 | 15 | viewModel.filteredTrackedDomains.filter(\.isPinned) |
| @@ -53,7 +57,8 @@ struct WatchlistView: View { | ||
| 53 | 57 | title: "No Tracked Domains", |
| 54 | 58 | message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.", |
| 55 | 59 | suggestion: "Run an inspection and use the Track action on a domain you care about.", |
| 56 | systemImage: "eye" | |
| 60 | systemImage: "eye", | |
| 61 | showsCardBackground: false | |
| 57 | 62 | ) |
| 58 | 63 | } |
| 59 | 64 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| @@ -82,8 +87,16 @@ struct WatchlistView: View { | ||
| 82 | 87 | .navigationTitle("Watchlist") |
| 83 | 88 | .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains") |
| 84 | 89 | .toolbar { |
| 85 | if !viewModel.filteredTrackedDomains.isEmpty { | |
| 86 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 90 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 91 | Button { | |
| 92 | addDomainError = nil | |
| 93 | newTrackedDomain = "" | |
| 94 | showAddDomainSheet = true | |
| 95 | } label: { | |
| 96 | Image(systemName: "plus") | |
| 97 | } | |
| 98 | ||
| 99 | if !viewModel.filteredTrackedDomains.isEmpty { | |
| 87 | 100 | Menu { |
| 88 | 101 | Picker("Filter", selection: $viewModel.watchlistFilter) { |
| 89 | 102 | ForEach(WatchlistFilterOption.allCases) { option in |
| @@ -139,6 +152,52 @@ struct WatchlistView: View { | ||
| 139 | 152 | .sheet(item: batchSummaryBinding) { summary in |
| 140 | 153 | BatchSweepSummaryView(viewModel: viewModel, summary: summary) |
| 141 | 154 | } |
| 155 | .sheet(isPresented: $showAddDomainSheet) { | |
| 156 | NavigationStack { | |
| 157 | Form { | |
| 158 | Section("Domain") { | |
| 159 | TextField("example.com", text: $newTrackedDomain) | |
| 160 | .textInputAutocapitalization(.never) | |
| 161 | .autocorrectionDisabled() | |
| 162 | .keyboardType(.URL) | |
| 163 | .textContentType(.URL) | |
| 164 | .focused($isAddDomainFieldFocused) | |
| 165 | .onSubmit(addTrackedDomain) | |
| 166 | } | |
| 167 | ||
| 168 | Section { | |
| 169 | Text("Adds the domain directly to your watchlist so monitoring can run without a prior inspection.") | |
| 170 | .font(appDensity.font(.caption)) | |
| 171 | .foregroundStyle(.secondary) | |
| 172 | } | |
| 173 | ||
| 174 | if let addDomainError { | |
| 175 | Section { | |
| 176 | Text(addDomainError) | |
| 177 | .font(appDensity.font(.caption)) | |
| 178 | .foregroundStyle(.red) | |
| 179 | } | |
| 180 | } | |
| 181 | } | |
| 182 | .navigationTitle("Add Domain") | |
| 183 | .toolbar { | |
| 184 | ToolbarItem(placement: .cancellationAction) { | |
| 185 | Button("Cancel") { | |
| 186 | showAddDomainSheet = false | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 190 | ToolbarItem(placement: .confirmationAction) { | |
| 191 | Button("Add", action: addTrackedDomain) | |
| 192 | } | |
| 193 | } | |
| 194 | .onAppear { | |
| 195 | DispatchQueue.main.async { | |
| 196 | isAddDomainFieldFocused = true | |
| 197 | } | |
| 198 | } | |
| 199 | } | |
| 200 | } | |
| 142 | 201 | .sheet(isPresented: $showWorkflowAddSheet) { |
| 143 | 202 | WorkflowBulkAddSheet( |
| 144 | 203 | viewModel: viewModel, |
| @@ -271,6 +330,24 @@ struct WatchlistView: View { | ||
| 271 | 330 | |
| 272 | 331 | ExportPresenter.share(filename: filename, data: data) |
| 273 | 332 | } |
| 333 | ||
| 334 | private func addTrackedDomain() { | |
| 335 | let draft = newTrackedDomain.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 336 | guard !draft.isEmpty else { | |
| 337 | addDomainError = "Enter a domain to add." | |
| 338 | return | |
| 339 | } | |
| 340 | ||
| 341 | if viewModel.trackDomain(domain: draft, availabilityStatus: nil) { | |
| 342 | AppHaptics.track() | |
| 343 | isAddDomainFieldFocused = false | |
| 344 | addDomainError = nil | |
| 345 | newTrackedDomain = "" | |
| 346 | showAddDomainSheet = false | |
| 347 | } else if viewModel.upgradePrompt == nil { | |
| 348 | addDomainError = "Enter a valid domain like example.com." | |
| 349 | } | |
| 350 | } | |
| 274 | 351 | } |
| 275 | 352 | |
| 276 | 353 | struct WatchlistRowView: View { |
| @@ -468,6 +545,19 @@ struct TrackedDomainDetailView: View { | ||
| 468 | 545 | } |
| 469 | 546 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| 470 | 547 | |
| 548 | Section("Monitoring Status") { | |
| 549 | LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain)) | |
| 550 | LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain)) | |
| 551 | LabeledContent( | |
| 552 | "Last Change", | |
| 553 | value: liveTrackedDomain.monitoringState.lastChangeDate?.formatted(date: .abbreviated, time: .shortened) ?? "None" | |
| 554 | ) | |
| 555 | if !liveTrackedDomain.pendingMonitoringAlerts.isEmpty { | |
| 556 | LabeledContent("Queued Alerts", value: "\(liveTrackedDomain.pendingMonitoringAlerts.count)") | |
| 557 | } | |
| 558 | } | |
| 559 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | |
| 560 | ||
| 471 | 561 | if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) { |
| 472 | 562 | Section("Latest Change Summary") { |
| 473 | 563 | DomainChangeSummaryView(summary: summary) |
DomainDigCLI.swift +7 −42
| @@ -36,7 +36,6 @@ struct DomainDigCLI { | ||
| 36 | 36 | let wantsDNSHistory = arguments.contains("--dns-history") |
| 37 | 37 | let wantsExtendedSubdomains = arguments.contains("--extended-subdomains") |
| 38 | 38 | let wantsPricing = arguments.contains("--pricing") |
| 39 | let wantsUsage = arguments.contains("--show-usage") | |
| 40 | 39 | let domains = arguments.filter { !$0.hasPrefix("-") } |
| 41 | 40 | |
| 42 | 41 | let requestedDomains = domains |
| @@ -52,8 +51,6 @@ struct DomainDigCLI { | ||
| 52 | 51 | let reportBuilder = DomainReportBuilder() |
| 53 | 52 | var reports: [DomainReport] = [] |
| 54 | 53 | var seen = Set<String>() |
| 55 | var usageImpact: [String] = [] | |
| 56 | ||
| 57 | 54 | for domain in requestedDomains { |
| 58 | 55 | let normalizedDomain = domain.lowercased() |
| 59 | 56 | guard seen.insert(normalizedDomain).inserted else { continue } |
| @@ -63,8 +60,7 @@ struct DomainDigCLI { | ||
| 63 | 60 | wantsOwnershipHistory: wantsOwnershipHistory, |
| 64 | 61 | wantsDNSHistory: wantsDNSHistory, |
| 65 | 62 | wantsExtendedSubdomains: wantsExtendedSubdomains, |
| 66 | wantsPricing: wantsPricing, | |
| 67 | usageImpact: &usageImpact | |
| 63 | wantsPricing: wantsPricing | |
| 68 | 64 | ) |
| 69 | 65 | reports.append(reportBuilder.build(from: enrichedSnapshot)) |
| 70 | 66 | } |
| @@ -83,9 +79,6 @@ struct DomainDigCLI { | ||
| 83 | 79 | title: "DomainDig Batch Report" |
| 84 | 80 | ) |
| 85 | 81 | } |
| 86 | if wantsUsage, !usageImpact.isEmpty { | |
| 87 | FileHandle.standardError.write(Data(("Data+ usage impact: " + usageImpact.joined(separator: ", ") + "\n").utf8)) | |
| 88 | } | |
| 89 | 82 | FileHandle.standardOutput.write(data) |
| 90 | 83 | if data.last != 0x0A { |
| 91 | 84 | FileHandle.standardOutput.write(Data([0x0A])) |
| @@ -101,10 +94,9 @@ struct DomainDigCLI { | ||
| 101 | 94 | wantsOwnershipHistory: Bool, |
| 102 | 95 | wantsDNSHistory: Bool, |
| 103 | 96 | wantsExtendedSubdomains: Bool, |
| 104 | wantsPricing: Bool, | |
| 105 | usageImpact: inout [String] | |
| 97 | wantsPricing: Bool | |
| 106 | 98 | ) async -> LookupSnapshot { |
| 107 | guard FeatureAccessService.currentTier == .dataPlus else { | |
| 99 | guard FeatureAccessService.currentTier == .proPlus else { | |
| 108 | 100 | return snapshot |
| 109 | 101 | } |
| 110 | 102 | |
| @@ -118,8 +110,7 @@ struct DomainDigCLI { | ||
| 118 | 110 | var domainPricing = snapshot.domainPricing |
| 119 | 111 | var domainPricingError = snapshot.domainPricingError |
| 120 | 112 | |
| 121 | if wantsOwnershipHistory, | |
| 122 | await UsageCreditService.shared.canUse(.ownershipHistory) { | |
| 113 | if wantsOwnershipHistory { | |
| 123 | 114 | let outcome = await ExternalDataService.shared.ownershipHistory( |
| 124 | 115 | domain: snapshot.domain, |
| 125 | 116 | currentOwnership: snapshot.ownership, |
| @@ -129,23 +120,14 @@ struct DomainDigCLI { | ||
| 129 | 120 | case let .success(events): |
| 130 | 121 | ownershipHistory = events |
| 131 | 122 | ownershipHistoryError = nil |
| 132 | if outcome.source != .cached { | |
| 133 | _ = await UsageCreditService.shared.consume(.ownershipHistory) | |
| 134 | usageImpact.append("ownership history -1") | |
| 135 | } | |
| 136 | 123 | case let .empty(message): |
| 137 | 124 | ownershipHistoryError = message |
| 138 | if outcome.source != .cached { | |
| 139 | _ = await UsageCreditService.shared.consume(.ownershipHistory) | |
| 140 | usageImpact.append("ownership history -1") | |
| 141 | } | |
| 142 | 125 | case let .error(message): |
| 143 | 126 | ownershipHistoryError = message |
| 144 | 127 | } |
| 145 | 128 | } |
| 146 | 129 | |
| 147 | if wantsDNSHistory, | |
| 148 | await UsageCreditService.shared.canUse(.dnsHistory) { | |
| 130 | if wantsDNSHistory { | |
| 149 | 131 | let outcome = await ExternalDataService.shared.dnsHistory( |
| 150 | 132 | domain: snapshot.domain, |
| 151 | 133 | dnsSections: snapshot.dnsSections, |
| @@ -155,23 +137,14 @@ struct DomainDigCLI { | ||
| 155 | 137 | case let .success(events): |
| 156 | 138 | dnsHistory = events |
| 157 | 139 | dnsHistoryError = nil |
| 158 | if outcome.source != .cached { | |
| 159 | _ = await UsageCreditService.shared.consume(.dnsHistory) | |
| 160 | usageImpact.append("dns history -1") | |
| 161 | } | |
| 162 | 140 | case let .empty(message): |
| 163 | 141 | dnsHistoryError = message |
| 164 | if outcome.source != .cached { | |
| 165 | _ = await UsageCreditService.shared.consume(.dnsHistory) | |
| 166 | usageImpact.append("dns history -1") | |
| 167 | } | |
| 168 | 142 | case let .error(message): |
| 169 | 143 | dnsHistoryError = message |
| 170 | 144 | } |
| 171 | 145 | } |
| 172 | 146 | |
| 173 | if wantsExtendedSubdomains, | |
| 174 | await UsageCreditService.shared.canUse(.extendedSubdomains) { | |
| 147 | if wantsExtendedSubdomains { | |
| 175 | 148 | let outcome = await ExternalDataService.shared.extendedSubdomains( |
| 176 | 149 | domain: snapshot.domain, |
| 177 | 150 | existing: snapshot.subdomains |
| @@ -180,16 +153,8 @@ struct DomainDigCLI { | ||
| 180 | 153 | case let .success(results): |
| 181 | 154 | extendedSubdomains = results |
| 182 | 155 | extendedSubdomainsError = nil |
| 183 | if outcome.source != .cached { | |
| 184 | _ = await UsageCreditService.shared.consume(.extendedSubdomains) | |
| 185 | usageImpact.append("extended subdomains -1") | |
| 186 | } | |
| 187 | 156 | case let .empty(message): |
| 188 | 157 | extendedSubdomainsError = message |
| 189 | if outcome.source != .cached { | |
| 190 | _ = await UsageCreditService.shared.consume(.extendedSubdomains) | |
| 191 | usageImpact.append("extended subdomains -1") | |
| 192 | } | |
| 193 | 158 | case let .error(message): |
| 194 | 159 | extendedSubdomainsError = message |
| 195 | 160 | } |
| @@ -538,7 +503,7 @@ struct DomainDigCLI { | ||
| 538 | 503 | |
| 539 | 504 | private static var usageText: String { |
| 540 | 505 | """ |
| 541 | usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage] | |
| 506 | usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] | |
| 542 | 507 | domaindig history <domain> [--json] |
| 543 | 508 | domaindig diff <domain> --from <id> --to <id> [--json] |
| 544 | 509 | domaindig monitor [--json] |