Commit 3f0b7746b1

3f0b7746b1bee4c2f626eb805e3fd13c5f2f5d22

parent: d0b3b7a15b

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-24 20:04 UTC

DomainDig v3.8.0 — Smart Monitoring

* Stable domain intervals
* Frequent changes decrease intervals
* Duplicate states do not trigger alerts
* Quiet hours suppress alerts
* Quiet hours across midnight work correctly
* Sensitivity levels alter behavior as expected

Layout: unified · split

DomainDataPortabilityService.swift +27 −3
@@ -231,7 +231,9 @@ enum DataPortabilityCSV {
231 certificateWarningLevel: certificateLevel, 231 certificateWarningLevel: certificateLevel,
232 certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""), 232 certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""),
233 lastMonitoredAt: parseDate(row["lastMonitoredAt"]), 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 certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel), 1106 certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel),
1105 certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining, 1107 certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining,
1106 lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(), 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 certificateWarningLevel: trackedDomain.certificateWarningLevel, 1276 certificateWarningLevel: trackedDomain.certificateWarningLevel,
1273 certificateDaysRemaining: trackedDomain.certificateDaysRemaining, 1277 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
1274 lastMonitoredAt: trackedDomain.lastMonitoredAt, 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 private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow { 1303 private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
1280 DomainWorkflow( 1304 DomainWorkflow(
1281 id: workflow.id, 1305 id: workflow.id,
DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -366,7 +366,7 @@
366 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 366 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
367 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 367 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
368 CODE_SIGN_STYLE = Automatic; 368 CODE_SIGN_STYLE = Automatic;
369 CURRENT_PROJECT_VERSION = 29; 369 CURRENT_PROJECT_VERSION = 30;
370 DEVELOPMENT_TEAM = ZCNAX3VL9D; 370 DEVELOPMENT_TEAM = ZCNAX3VL9D;
371 ENABLE_PREVIEWS = YES; 371 ENABLE_PREVIEWS = YES;
372 GENERATE_INFOPLIST_FILE = YES; 372 GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
383 "$(inherited)", 383 "$(inherited)",
384 "@executable_path/Frameworks", 384 "@executable_path/Frameworks",
385 ); 385 );
386 MARKETING_VERSION = 3.7.0; 386 MARKETING_VERSION = 3.8.0;
387 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 387 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
388 PRODUCT_NAME = "$(TARGET_NAME)"; 388 PRODUCT_NAME = "$(TARGET_NAME)";
389 STRING_CATALOG_GENERATE_SYMBOLS = YES; 389 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
403 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 403 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
404 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 404 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
405 CODE_SIGN_STYLE = Automatic; 405 CODE_SIGN_STYLE = Automatic;
406 CURRENT_PROJECT_VERSION = 29; 406 CURRENT_PROJECT_VERSION = 30;
407 DEVELOPMENT_TEAM = ZCNAX3VL9D; 407 DEVELOPMENT_TEAM = ZCNAX3VL9D;
408 ENABLE_PREVIEWS = YES; 408 ENABLE_PREVIEWS = YES;
409 GENERATE_INFOPLIST_FILE = YES; 409 GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
420 "$(inherited)", 420 "$(inherited)",
421 "@executable_path/Frameworks", 421 "@executable_path/Frameworks",
422 ); 422 );
423 MARKETING_VERSION = 3.7.0; 423 MARKETING_VERSION = 3.8.0;
424 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 424 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
425 PRODUCT_NAME = "$(TARGET_NAME)"; 425 PRODUCT_NAME = "$(TARGET_NAME)";
426 STRING_CATALOG_GENERATE_SYMBOLS = YES; 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 <integer>0</integer> 15 <integer>0</integer>
16 </dict> 16 </dict>
17 </dict> 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</dict> 26</dict>
19</plist> 27</plist>
DomainDig/BatchResultsView.swift +27 −1
@@ -72,12 +72,25 @@ struct BatchResultRowView: View {
72 } 72 }
73 73
74 HStack(spacing: 10) { 74 HStack(spacing: 10) {
75 AppStatusBadgeView(model: AppStatusFactory.availability(result.availability)) 75 AppStatusBadgeView(model: availabilityBadgeModel)
76 if let riskScore = result.riskScore, let riskLevel = result.riskLevel { 76 if let riskScore = result.riskScore, let riskLevel = result.riskLevel {
77 Text("Risk \(riskScore) \(riskLevel.title)") 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 Text(result.primaryIP ?? "No IP") 86 Text(result.primaryIP ?? "No IP")
87 .lineLimit(1)
88 .truncationMode(.middle)
89
90 Spacer(minLength: 8)
91
80 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened)) 92 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened))
93 .lineLimit(1)
81 } 94 }
82 .font(appDensity.font(.caption2)) 95 .font(appDensity.font(.caption2))
83 .foregroundStyle(.secondary) 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 private var quickStatusBadge: AppStatusBadgeModel { 145 private var quickStatusBadge: AppStatusBadgeModel {
120 switch result.status { 146 switch result.status {
121 case .pending: 147 case .pending:
DomainDig/CloudSyncService.swift +22
@@ -1453,6 +1453,8 @@ final class CloudSyncService {
1453 certificateDaysRemaining: trackedDomain.certificateDaysRemaining, 1453 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
1454 lastMonitoredAt: trackedDomain.lastMonitoredAt, 1454 lastMonitoredAt: trackedDomain.lastMonitoredAt,
1455 lastAlertAt: trackedDomain.lastAlertAt, 1455 lastAlertAt: trackedDomain.lastAlertAt,
1456 monitoringState: trackedDomain.monitoringState,
1457 pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts,
1456 collaboration: trackedDomain.collaboration 1458 collaboration: trackedDomain.collaboration
1457 ) 1459 )
1458 } 1460 }
@@ -1476,6 +1478,8 @@ final class CloudSyncService {
1476 certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining, 1478 certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining,
1477 lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(), 1479 lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(),
1478 lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(), 1480 lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(),
1481 monitoringState: preferredMonitoringState(lhs.monitoringState, rhs.monitoringState),
1482 pendingMonitoringAlerts: deduplicatedPendingAlerts(lhs.pendingMonitoringAlerts + rhs.pendingMonitoringAlerts),
1479 collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration) 1483 collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration)
1480 ) 1484 )
1481 } 1485 }
@@ -1501,6 +1505,24 @@ final class CloudSyncService {
1501 return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs 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 private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow { 1526 private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow {
1505 let lhsRank = collaborationRank(lhs.collaboration) 1527 let lhsRank = collaborationRank(lhs.collaboration)
1506 let rhsRank = collaborationRank(rhs.collaboration) 1528 let rhsRank = collaborationRank(rhs.collaboration)
DomainDig/ContentView.swift +532 −239
@@ -19,6 +19,11 @@ enum ResultSection: String, Hashable {
19 case subdomains 19 case subdomains
20} 20}
21 21
22private enum LookupInputField: Hashable {
23 case singleDomain
24 case bulkDomains
25}
26
22private struct WorkflowNavigationTarget: Hashable { 27private struct WorkflowNavigationTarget: Hashable {
23 let workflowID: UUID 28 let workflowID: UUID
24} 29}
@@ -28,7 +33,7 @@ struct ContentView: View {
28 @Bindable var viewModel: DomainViewModel 33 @Bindable var viewModel: DomainViewModel
29 @State private var purchaseService = PurchaseService.shared 34 @State private var purchaseService = PurchaseService.shared
30 @State private var navigationPath = NavigationPath() 35 @State private var navigationPath = NavigationPath()
31 @FocusState private var domainFieldFocused: Bool 36 @FocusState private var focusedInputField: LookupInputField?
32 @State private var customPortInput = "" 37 @State private var customPortInput = ""
33 @State private var customPortsExpanded = false 38 @State private var customPortsExpanded = false
34 @State private var trackingNoteDraft = "" 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 .navigationDestination(for: WorkflowNavigationTarget.self) { target in 190 .navigationDestination(for: WorkflowNavigationTarget.self) { target in
179 WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID) 191 WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
180 } 192 }
181 } 193 }
182 .onAppear { 194 .onAppear {
183 domainFieldFocused = true 195 focusedInputField = .singleDomain
184 } 196 }
185 .task { 197 .task {
186 await viewModel.refreshUsageCredits() 198 await viewModel.refreshUsageCredits()
@@ -190,7 +202,24 @@ struct ContentView: View {
190 } 202 }
191 .onChange(of: viewModel.rerunNavigationToken) { _, _ in 203 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
192 navigationPath = NavigationPath() 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 .sheet(item: $editingTrackedDomain) { trackedDomain in 224 .sheet(item: $editingTrackedDomain) { trackedDomain in
196 NavigationStack { 225 NavigationStack {
@@ -257,11 +286,14 @@ struct ContentView: View {
257 .padding(.vertical, appDensity.metrics.controlVerticalPadding) 286 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
258 .background(Color(.systemGray6)) 287 .background(Color(.systemGray6))
259 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) 288 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
260 .focused($domainFieldFocused) 289 .focused($focusedInputField, equals: .singleDomain)
261 .onSubmit { viewModel.run() } 290 .onSubmit {
291 focusedInputField = nil
292 viewModel.run()
293 }
262 294
263 Button { 295 Button {
264 domainFieldFocused = false 296 focusedInputField = nil
265 viewModel.run() 297 viewModel.run()
266 } label: { 298 } label: {
267 Text("Run") 299 Text("Run")
@@ -298,9 +330,10 @@ struct ContentView: View {
298 .padding(.vertical, appDensity.metrics.controlVerticalPadding) 330 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
299 .background(Color(.systemGray6)) 331 .background(Color(.systemGray6))
300 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) 332 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
333 .focused($focusedInputField, equals: .bulkDomains)
301 334
302 Button { 335 Button {
303 domainFieldFocused = false 336 focusedInputField = nil
304 viewModel.runBulkLookup() 337 viewModel.runBulkLookup()
305 } label: { 338 } label: {
306 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") 339 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
@@ -568,7 +601,7 @@ struct ContentView: View {
568 ForEach(viewModel.recentSearches, id: \.self) { domain in 601 ForEach(viewModel.recentSearches, id: \.self) { domain in
569 Button { 602 Button {
570 viewModel.domain = domain 603 viewModel.domain = domain
571 domainFieldFocused = false 604 focusedInputField = nil
572 viewModel.run() 605 viewModel.run()
573 } label: { 606 } label: {
574 Text(domain) 607 Text(domain)
@@ -822,7 +855,7 @@ struct InsightsSummaryCardView: View {
822 } else { 855 } else {
823 ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in 856 ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
824 HStack(alignment: .top, spacing: 8) { 857 HStack(alignment: .top, spacing: 8) {
825 Image(systemName: "sparkline") 858 Image(systemName: "chart.line.uptrend.xyaxis")
826 .font(appDensity.font(.caption2)) 859 .font(appDensity.font(.caption2))
827 .foregroundStyle(.cyan) 860 .foregroundStyle(.cyan)
828 .padding(.top, 2) 861 .padding(.top, 2)
@@ -1401,7 +1434,7 @@ struct DomainSectionView: View {
1401 MessageRowView(text: pricingError, isError: false) 1434 MessageRowView(text: pricingError, isError: false)
1402 .padding(.top, 4) 1435 .padding(.top, 4)
1403 } else if showsPricingPlaceholder { 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 .padding(.top, 4) 1438 .padding(.top, 4)
1406 } 1439 }
1407 } 1440 }
@@ -1473,8 +1506,8 @@ struct OwnershipSectionView: View {
1473 .font(appDensity.font(.caption)) 1506 .font(appDensity.font(.caption))
1474 .foregroundStyle(.secondary) 1507 .foregroundStyle(.secondary)
1475 Spacer() 1508 Spacer()
1476 if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { 1509 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1477 Button("Load (\(historyCreditStatus.remaining) left)") { 1510 Button("Load") {
1478 onLoadHistory() 1511 onLoadHistory()
1479 } 1512 }
1480 .buttonStyle(.bordered) 1513 .buttonStyle(.bordered)
@@ -1500,7 +1533,7 @@ struct OwnershipSectionView: View {
1500 } else if let historyError { 1533 } else if let historyError {
1501 MessageRowView(text: historyError, isError: false) 1534 MessageRowView(text: historyError, isError: false)
1502 } else if showsHistoryPlaceholder { 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 } else if rows.isEmpty { 1598 } else if rows.isEmpty {
1566 MessageRowView(text: error ?? "No passive subdomains found", isError: false) 1599 MessageRowView(text: error ?? "No passive subdomains found", isError: false)
1567 if showsExtendedPlaceholder { 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 .padding(.top, 4) 1602 .padding(.top, 4)
1570 } 1603 }
1571 } else { 1604 } else {
1572 if let extendedCreditStatus, let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder { 1605 if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
1573 Button("Load extended results (\(extendedCreditStatus.remaining) left)") { 1606 Button("Load extended results") {
1574 onLoadExtended() 1607 onLoadExtended()
1575 } 1608 }
1576 .buttonStyle(.bordered) 1609 .buttonStyle(.bordered)
@@ -1621,7 +1654,7 @@ struct SubdomainsSectionView: View {
1621 MessageRowView(text: extendedError, isError: false) 1654 MessageRowView(text: extendedError, isError: false)
1622 .padding(.top, 4) 1655 .padding(.top, 4)
1623 } else if showsExtendedPlaceholder { 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 .padding(.top, 4) 1658 .padding(.top, 4)
1626 } 1659 }
1627 } 1660 }
@@ -1748,8 +1781,8 @@ struct DNSSectionView: View {
1748 .font(appDensity.font(.subheadline, weight: .semibold)) 1781 .font(appDensity.font(.subheadline, weight: .semibold))
1749 .foregroundStyle(.cyan) 1782 .foregroundStyle(.cyan)
1750 Spacer() 1783 Spacer()
1751 if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { 1784 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1752 Button("Load (\(historyCreditStatus.remaining) left)") { 1785 Button("Load") {
1753 onLoadHistory() 1786 onLoadHistory()
1754 } 1787 }
1755 .buttonStyle(.bordered) 1788 .buttonStyle(.bordered)
@@ -1782,7 +1815,7 @@ struct DNSSectionView: View {
1782 } else if let historyError { 1815 } else if let historyError {
1783 MessageRowView(text: historyError, isError: false) 1816 MessageRowView(text: historyError, isError: false)
1784 } else if showsHistoryPlaceholder { 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 @Environment(\.appDensity) private var appDensity 2477 @Environment(\.appDensity) private var appDensity
2445 @Bindable var viewModel: DomainViewModel 2478 @Bindable var viewModel: DomainViewModel
2446 @State private var purchaseService = PurchaseService.shared 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 2481 var body: some View {
2454 @State private var customResolverURL = DNSResolverOption.defaultURLString 2482 let _ = purchaseService.currentTier
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
2466 2483
2467 private var customResolverError: String? { 2484 List {
2468 guard resolverOption == .custom else { 2485 Section("Tier") {
2469 return nil 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 { 2564private struct DisplaySettingsView: View {
2475 let _ = purchaseService.currentTier 2565 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
2476 2566
2567 var body: some View {
2477 Form { 2568 Form {
2478 Section("Display") { 2569 Section("Display") {
2479 Picker("Density", selection: $storedDensity) { 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
2581private 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 Section("History") { 2597 Section("History") {
2487 Picker( 2598 Picker(
2488 "Auto-prune", 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
2674private 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 Section("iCloud Sync") { 2680 Section("iCloud Sync") {
2526 Toggle( 2681 Toggle(
2527 "Enable iCloud Sync", 2682 "Enable iCloud Sync",
@@ -2558,7 +2713,33 @@ struct SettingsView: View {
2558 .foregroundStyle(.red) 2713 .foregroundStyle(.red)
2559 } 2714 }
2560 } 2715 }
2716 }
2717 .navigationTitle("iCloud Sync")
2718 .task {
2719 await cloudSyncService.refreshAvailability()
2720 }
2721 }
2722}
2723
2724private 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 Section("Monitoring") { 2743 Section("Monitoring") {
2563 Toggle( 2744 Toggle(
2564 "Enable Background Monitoring", 2745 "Enable Background Monitoring",
@@ -2569,14 +2750,88 @@ struct SettingsView: View {
2569 ) 2750 )
2570 2751
2571 Picker( 2752 Picker(
2572 "Frequency", 2753 "Base Interval",
2573 selection: Binding( 2754 selection: Binding(
2574 get: { viewModel.monitoringSettings.frequency }, 2755 get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) },
2575 set: { viewModel.setMonitoringFrequency($0) } 2756 set: { viewModel.setMonitoringBaseInterval($0) }
2576 ) 2757 )
2577 ) { 2758 ) {
2578 ForEach(MonitoringFrequency.allCases) { frequency in 2759 ForEach(MonitoringBaseInterval.allCases) { interval in
2579 Text(frequency.title).tag(frequency) 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 .foregroundStyle(.secondary) 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
2923private 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 Picker("Import Mode", selection: $importMode) { 2964 Picker("Import Mode", selection: $importMode) {
2653 ForEach(DataPortabilityImportMode.allCases) { mode in 2965 ForEach(DataPortabilityImportMode.allCases) { mode in
2654 Text(mode.title).tag(mode) 2966 Text(mode.title).tag(mode)
@@ -2664,7 +2976,9 @@ struct SettingsView: View {
2664 } 2976 }
2665 2977
2666 Button("Import Backup") { 2978 Button("Import Backup") {
2667 showBackupImporter = true 2979 recordImportDebugStatus("Tapped Import Backup")
2980 pendingImportTarget = .backup
2981 activeImportTarget = .backup
2668 } 2982 }
2669 2983
2670 Menu("Export Tracked Domains") { 2984 Menu("Export Tracked Domains") {
@@ -2677,7 +2991,9 @@ struct SettingsView: View {
2677 } 2991 }
2678 2992
2679 Button("Import Tracked Domains") { 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 Menu("Export Workflows") { 2999 Menu("Export Workflows") {
@@ -2690,13 +3006,17 @@ struct SettingsView: View {
2690 } 3006 }
2691 3007
2692 Button("Import Workflows") { 3008 Button("Import Workflows") {
2693 showWorkflowsImporter = true 3009 recordImportDebugStatus("Tapped Import Workflows")
3010 pendingImportTarget = .workflows
3011 activeImportTarget = .workflows
2694 } 3012 }
2695 3013
2696 Button("Export History") { 3014 Button("Export History") {
2697 exportPortableHistoryJSON() 3015 exportPortableHistoryJSON()
2698 } 3016 }
3017 }
2699 3018
3019 Section("Local Data") {
2700 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") 3020 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
2701 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") 3021 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
2702 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") 3022 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
@@ -2714,121 +3034,18 @@ struct SettingsView: View {
2714 } 3034 }
2715 } 3035 }
2716 3036
2717 Section("Data") { 3037 #if DEBUG
2718 Button("Clear History", role: .destructive) { 3038 if let importDebugStatus {
2719 showClearHistoryConfirmation = true 3039 Section("Import Debug") {
2720 } 3040 Text(importDebugStatus)
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)
2759 .font(appDensity.font(.caption, design: .default)) 3041 .font(appDensity.font(.caption, design: .default))
2760 .foregroundStyle(.secondary) 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") 3048 .navigationTitle("Import & Export")
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 }
2832 .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) { 3049 .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
2833 Button("Replace", role: .destructive) { 3050 Button("Replace", role: .destructive) {
2834 applyPendingImport() 3051 applyPendingImport()
@@ -2867,83 +3084,24 @@ struct SettingsView: View {
2867 } 3084 }
2868 } 3085 }
2869 .fileImporter( 3086 .fileImporter(
2870 isPresented: $showBackupImporter, 3087 isPresented: Binding(
2871 allowedContentTypes: [UTType.json], 3088 get: { activeImportTarget != nil },
3089 set: { if !$0 { activeImportTarget = nil } }
3090 ),
3091 allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json],
2872 allowsMultipleSelection: false 3092 allowsMultipleSelection: false
2873 ) { result in 3093 ) { result in
2874 handleImportResult(result, expectedKind: .backup) 3094 guard let pendingImportTarget else {
2875 } 3095 recordImportDebugStatus("fileImporter returned with no active target")
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 )
2909 return 3096 return
2910 } 3097 }
2911 storedResolverURL = presetURL 3098 recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)")
2912 viewModel.persistCurrentAppSettings( 3099 handleImportResult(result, expectedKind: pendingImportTarget.expectedKind)
2913 resolverURLString: storedResolverURL, 3100 self.pendingImportTarget = nil
2914 appDensityRawValue: storedDensity 3101 self.activeImportTarget = nil
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 )
2924 } 3102 }
2925 .onChange(of: storedDensity) { _, newValue in 3103 .onAppear {
2926 viewModel.persistCurrentAppSettings( 3104 viewModel.refreshDataLifecycleSummary()
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"
2947 } 3105 }
2948 } 3106 }
2949 3107
@@ -2985,32 +3143,55 @@ struct SettingsView: View {
2985 _ result: Result<[URL], Error>, 3143 _ result: Result<[URL], Error>,
2986 expectedKind: DataPortabilityImportKind 3144 expectedKind: DataPortabilityImportKind
2987 ) { 3145 ) {
3146 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)")
3147 recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)")
2988 do { 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 let shouldStopAccessing = url.startAccessingSecurityScopedResource() 3157 let shouldStopAccessing = url.startAccessingSecurityScopedResource()
3158 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)")
3159 recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)")
2991 defer { 3160 defer {
2992 if shouldStopAccessing { 3161 if shouldStopAccessing {
2993 url.stopAccessingSecurityScopedResource() 3162 url.stopAccessingSecurityScopedResource()
3163 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased")
2994 } 3164 }
2995 } 3165 }
2996 3166
2997 let data = try Data(contentsOf: url) 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 let preview = try viewModel.prepareDataImport( 3170 let preview = try viewModel.prepareDataImport(
2999 data: data, 3171 data: data,
3000 fileName: url.lastPathComponent, 3172 fileName: url.lastPathComponent,
3001 mode: importMode 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 guard preview.kind == expectedKind else { 3178 guard preview.kind == expectedKind else {
3005 pendingImportError = preview.kind == .backup 3179 let message = preview.kind == .backup
3006 ? "That file is a full backup. Use Import Backup." 3180 ? "That file is a full backup. Use Import Backup."
3007 : "That file type does not match this import action." 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 return 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 } catch { 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 formatter.dateFormat = "yyyyMMdd_HHmmss" 3210 formatter.dateFormat = "yyyyMMdd_HHmmss"
3030 return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)" 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
3241private 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
3305private 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
3034private struct DataImportPreviewSheet: View { 3327private struct DataImportPreviewSheet: View {
DomainDig/DomainDigUI.swift +16 −1
@@ -235,6 +235,21 @@ struct EmptyStateCardView: View {
235 let message: String 235 let message: String
236 let suggestion: String 236 let suggestion: String
237 let systemImage: String 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 var body: some View { 254 var body: some View {
240 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { 255 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
@@ -253,7 +268,7 @@ struct EmptyStateCardView: View {
253 } 268 }
254 .frame(maxWidth: .infinity, alignment: .leading) 269 .frame(maxWidth: .infinity, alignment: .leading)
255 .padding(appDensity.metrics.cardPadding) 270 .padding(appDensity.metrics.cardPadding)
256 .background(Color(.systemGray6).opacity(0.45)) 271 .background(showsCardBackground ? Color(.systemGray6).opacity(0.45) : Color.clear)
257 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) 272 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
258 } 273 }
259} 274}
DomainDig/DomainMonitoringService.swift +330 −48
@@ -1,5 +1,6 @@
1import Foundation 1import Foundation
2import UserNotifications 2import UserNotifications
3import CryptoKit
3 4
4#if canImport(BackgroundTasks) 5#if canImport(BackgroundTasks)
5import BackgroundTasks 6import BackgroundTasks
@@ -132,9 +133,10 @@ final class DomainMonitoringScheduler {
132 } 133 }
133 #endif 134 #endif
134 135
136 let trackedDomains = MonitoringStorage.loadTrackedDomains()
135 let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) 137 let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
136 request.earliestBeginDate = Date( 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 do { 142 do {
@@ -149,6 +151,38 @@ final class DomainMonitoringScheduler {
149 #endif 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#if canImport(BackgroundTasks) 186#if canImport(BackgroundTasks)
153 private func handleAppRefresh(task: BGAppRefreshTask) { 187 private func handleAppRefresh(task: BGAppRefreshTask) {
154 _ = syncSchedule() 188 _ = syncSchedule()
@@ -174,6 +208,7 @@ final class DomainMonitoringService {
174 208
175 private let inspectionService = DomainInspectionService() 209 private let inspectionService = DomainInspectionService()
176 private let maxHistoryEntries = 250 210 private let maxHistoryEntries = 250
211 private let calendar = Calendar.current
177 212
178 func performMonitoring( 213 func performMonitoring(
179 trigger: MonitoringRunTrigger, 214 trigger: MonitoringRunTrigger,
@@ -200,9 +235,9 @@ final class DomainMonitoringService {
200 235
201 var history = MonitoringStorage.loadHistoryEntries() 236 var history = MonitoringStorage.loadHistoryEntries()
202 var mutableTrackedDomains = trackedDomains 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 let log = MonitoringLog( 241 let log = MonitoringLog(
207 timestamp: Date(), 242 timestamp: Date(),
208 trigger: trigger, 243 trigger: trigger,
@@ -216,6 +251,24 @@ final class DomainMonitoringService {
216 return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log) 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 let notificationsAuthorized: Bool 272 let notificationsAuthorized: Bool
220 if settings.alertsEnabled { 273 if settings.alertsEnabled {
221 notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts() 274 notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts()
@@ -225,6 +278,7 @@ final class DomainMonitoringService {
225 var results: [MonitoringDomainResult] = [] 278 var results: [MonitoringDomainResult] = []
226 var errors: [String] = [] 279 var errors: [String] = []
227 var alertsTriggered = 0 280 var alertsTriggered = 0
281 let now = Date()
228 282
229 for trackedDomain in eligibleDomains { 283 for trackedDomain in eligibleDomains {
230 guard !Task.isCancelled else { 284 guard !Task.isCancelled else {
@@ -252,33 +306,32 @@ final class DomainMonitoringService {
252 let alertDescriptor = alertDescriptor( 306 let alertDescriptor = alertDescriptor(
253 previousSnapshot: previousSnapshot, 307 previousSnapshot: previousSnapshot,
254 snapshot: snapshot, 308 snapshot: snapshot,
255 entry: savedEntry 309 entry: savedEntry,
310 sensitivity: settings.sensitivity
256 ) 311 )
257 312
258 if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { 313 if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
259 mutableTrackedDomains[index].lastMonitoredAt = Date() 314 mutableTrackedDomains[index].lastMonitoredAt = now
260 } 315 let outcome = await processAdaptiveMonitoringState(
261 316 trackedDomain: mutableTrackedDomains[index],
262 if notificationsAuthorized, 317 snapshot: snapshot,
263 let alertDescriptor, 318 previousSnapshot: previousSnapshot,
264 alertDescriptor.severity >= settings.alertFilter.minimumSeverity { 319 alertDescriptor: alertDescriptor,
265 await LocalNotificationService.shared.notifyMonitoringAlert( 320 settings: settings,
266 domain: trackedDomain.domain, 321 notificationsAuthorized: notificationsAuthorized,
267 message: alertDescriptor.message, 322 now: now
268 severity: alertDescriptor.severity
269 ) 323 )
270 alertsTriggered += 1 324 mutableTrackedDomains[index] = outcome.trackedDomain
271 if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { 325 alertsTriggered += outcome.alertsTriggered
272 mutableTrackedDomains[index].lastAlertAt = Date()
273 }
274 } 326 }
275 327
276 let result = MonitoringDomainResult( 328 let result = MonitoringDomainResult(
277 domain: trackedDomain.domain, 329 domain: trackedDomain.domain,
278 historyEntryID: savedEntry?.id ?? snapshot.historyEntryID, 330 historyEntryID: savedEntry?.id ?? snapshot.historyEntryID,
279 checkedAt: Date(), 331 checkedAt: now,
280 didChange: snapshot.statusMessage == nil && savedEntry?.changeSummary?.hasChanges == true, 332 didChange: alertDescriptor != nil,
281 summaryMessage: snapshot.statusMessage 333 summaryMessage: snapshot.statusMessage
334 ?? alertDescriptor?.message
282 ?? savedEntry?.changeSummary?.message 335 ?? savedEntry?.changeSummary?.message
283 ?? "No meaningful changes", 336 ?? "No meaningful changes",
284 alertSeverity: alertDescriptor?.severity, 337 alertSeverity: alertDescriptor?.severity,
@@ -431,54 +484,283 @@ final class DomainMonitoringService {
431 return entry 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 private func alertDescriptor( 613 private func alertDescriptor(
435 previousSnapshot: LookupSnapshot?, 614 previousSnapshot: LookupSnapshot?,
436 snapshot: LookupSnapshot, 615 snapshot: LookupSnapshot,
437 entry: HistoryEntry? 616 entry: HistoryEntry?,
438 ) -> (severity: MonitoringAlertSeverity, message: String)? { 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 let changedLabels = Set( 624 let changedLabels = Set(
440 (previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []) 625 DomainDiffService.diff(from: previousSnapshot, to: snapshot)
441 .flatMap(\.items) 626 .flatMap(\.items)
442 .filter(\.hasChanges) 627 .filter(\.hasChanges)
443 .map(\.label) 628 .map(\.label)
444 ) 629 )
445 630
446 if changedLabels.contains("Availability") { 631 if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
447 return (.critical, "Availability changed") 632 return MonitoringAlertDescriptor(severity: .warning, message: "DNS records changed", changeHash: currentHash)
448 } 633 }
449 if changedLabels.contains("Primary IP") { 634
450 return (.critical, "Primary IP changed") 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") { 642
453 return (.critical, "Redirect target changed") 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> = [ 649 if sensitivity != .low,
457 "Registrar", 650 filteredHTTPHeaders(previousSnapshot.httpHeaders, sensitivity: sensitivity)
458 "Registration Date", 651 != filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity) {
459 "Expiration Date", 652 return MonitoringAlertDescriptor(severity: .info, message: "HTTP headers changed", changeHash: currentHash)
460 "Ownership Status",
461 "Abuse Contact"
462 ]
463 if !changedLabels.isDisjoint(with: ownershipLabels) {
464 return (.warning, "Ownership changed")
465 } 653 }
466 if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) { 654
467 return (.warning, "DNS changed") 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 661 return nil
471 let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot) 662 }
472 if newCertificateLevel != .none, newCertificateLevel != oldCertificateLevel { 663
473 let daysRemaining = snapshot.sslInfo?.daysUntilExpiry ?? 0 664 private func monitoringHash(for snapshot: LookupSnapshot, sensitivity: MonitoringSensitivity) -> String {
474 return (.warning, "Certificate expires in \(daysRemaining) days") 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 { 671 struct CertificatePayload: Encodable {
478 return (.info, message) 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 private static func resolvedSnapshotAfterFallback( 766 private static func resolvedSnapshotAfterFallback(
DomainDig/DomainViewModel.swift +83 −55
@@ -756,6 +756,7 @@ final class DomainViewModel {
756 func clearHistory() { 756 func clearHistory() {
757 history.removeAll() 757 history.removeAll()
758 persistHistory() 758 persistHistory()
759 clearMonitoringLogs()
759 refreshDataLifecycleSummary() 760 refreshDataLifecycleSummary()
760 } 761 }
761 762
@@ -779,6 +780,7 @@ final class DomainViewModel {
779 refreshingTrackedDomainID = nil 780 refreshingTrackedDomainID = nil
780 persistTrackedDomains() 781 persistTrackedDomains()
781 sanitizeMonitoringSelection() 782 sanitizeMonitoringSelection()
783 clearMonitoringLogs()
782 refreshDataLifecycleSummary() 784 refreshDataLifecycleSummary()
783 } 785 }
784 786
@@ -842,16 +844,45 @@ final class DomainViewModel {
842 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) 844 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
843 } 845 }
844 846
845 func setMonitoringFrequency(_ frequency: MonitoringFrequency) { 847 func setMonitoringBaseInterval(_ baseInterval: MonitoringBaseInterval) {
846 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { 848 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
847 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) 849 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
848 return 850 return
849 } 851 }
850 monitoringSettings.frequency = frequency 852 monitoringSettings.baseInterval = baseInterval.interval
851 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled) 853 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
852 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 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 func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) { 886 func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) {
856 guard FeatureAccessService.hasAccess(to: .localAlerts) else { 887 guard FeatureAccessService.hasAccess(to: .localAlerts) else {
857 monitoringSettings.alertsEnabled = false 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 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) { 986 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) {
932 if useSnapshotResolver { 987 if useSnapshotResolver {
933 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) 988 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
@@ -968,6 +1023,22 @@ final class DomainViewModel {
968 clearLookupState() 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 func run() { 1042 func run() {
972 let target = trimmedDomain 1043 let target = trimmedDomain
973 guard !target.isEmpty else { return } 1044 guard !target.isEmpty else { return }
@@ -1222,13 +1293,6 @@ final class DomainViewModel {
1222 } 1293 }
1223 guard ownershipHistory.isEmpty else { return } 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 ownershipHistoryLoading = true 1296 ownershipHistoryLoading = true
1233 ownershipHistoryError = nil 1297 ownershipHistoryError = nil
1234 1298
@@ -1242,15 +1306,9 @@ final class DomainViewModel {
1242 case let .success(events): 1306 case let .success(events):
1243 ownershipHistory = events 1307 ownershipHistory = events
1244 ownershipHistoryError = nil 1308 ownershipHistoryError = nil
1245 if outcome.source != .cached {
1246 _ = await UsageCreditService.shared.consume(.ownershipHistory)
1247 }
1248 case let .empty(message): 1309 case let .empty(message):
1249 ownershipHistory = [] 1310 ownershipHistory = []
1250 ownershipHistoryError = message 1311 ownershipHistoryError = message
1251 if outcome.source != .cached {
1252 _ = await UsageCreditService.shared.consume(.ownershipHistory)
1253 }
1254 case let .error(message): 1312 case let .error(message):
1255 ownershipHistory = [] 1313 ownershipHistory = []
1256 ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable") 1314 ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable")
@@ -1258,7 +1316,6 @@ final class DomainViewModel {
1258 1316
1259 ownershipHistoryLoading = false 1317 ownershipHistoryLoading = false
1260 _ = saveHistoryEntry(replaceLatest: true) 1318 _ = saveHistoryEntry(replaceLatest: true)
1261 await refreshUsageCredits()
1262 } 1319 }
1263 1320
1264 func loadDNSHistory() async { 1321 func loadDNSHistory() async {
@@ -1269,13 +1326,6 @@ final class DomainViewModel {
1269 } 1326 }
1270 guard dnsHistory.isEmpty else { return } 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 dnsHistoryLoading = true 1329 dnsHistoryLoading = true
1280 dnsHistoryError = nil 1330 dnsHistoryError = nil
1281 1331
@@ -1289,15 +1339,9 @@ final class DomainViewModel {
1289 case let .success(events): 1339 case let .success(events):
1290 dnsHistory = events 1340 dnsHistory = events
1291 dnsHistoryError = nil 1341 dnsHistoryError = nil
1292 if outcome.source != .cached {
1293 _ = await UsageCreditService.shared.consume(.dnsHistory)
1294 }
1295 case let .empty(message): 1342 case let .empty(message):
1296 dnsHistory = [] 1343 dnsHistory = []
1297 dnsHistoryError = message 1344 dnsHistoryError = message
1298 if outcome.source != .cached {
1299 _ = await UsageCreditService.shared.consume(.dnsHistory)
1300 }
1301 case let .error(message): 1345 case let .error(message):
1302 dnsHistory = [] 1346 dnsHistory = []
1303 dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable") 1347 dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable")
@@ -1305,7 +1349,6 @@ final class DomainViewModel {
1305 1349
1306 dnsHistoryLoading = false 1350 dnsHistoryLoading = false
1307 _ = saveHistoryEntry(replaceLatest: true) 1351 _ = saveHistoryEntry(replaceLatest: true)
1308 await refreshUsageCredits()
1309 } 1352 }
1310 1353
1311 func loadExtendedSubdomains() async { 1354 func loadExtendedSubdomains() async {
@@ -1316,13 +1359,6 @@ final class DomainViewModel {
1316 } 1359 }
1317 guard extendedSubdomains.isEmpty else { return } 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 extendedSubdomainsLoading = true 1362 extendedSubdomainsLoading = true
1327 extendedSubdomainsError = nil 1363 extendedSubdomainsError = nil
1328 1364
@@ -1335,15 +1371,9 @@ final class DomainViewModel {
1335 case let .success(results): 1371 case let .success(results):
1336 extendedSubdomains = results 1372 extendedSubdomains = results
1337 extendedSubdomainsError = nil 1373 extendedSubdomainsError = nil
1338 if outcome.source != .cached {
1339 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
1340 }
1341 case let .empty(message): 1374 case let .empty(message):
1342 extendedSubdomains = [] 1375 extendedSubdomains = []
1343 extendedSubdomainsError = message 1376 extendedSubdomainsError = message
1344 if outcome.source != .cached {
1345 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
1346 }
1347 case let .error(message): 1377 case let .error(message):
1348 extendedSubdomains = [] 1378 extendedSubdomains = []
1349 extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable") 1379 extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable")
@@ -1351,7 +1381,6 @@ final class DomainViewModel {
1351 1381
1352 extendedSubdomainsLoading = false 1382 extendedSubdomainsLoading = false
1353 _ = saveHistoryEntry(replaceLatest: true) 1383 _ = saveHistoryEntry(replaceLatest: true)
1354 await refreshUsageCredits()
1355 } 1384 }
1356 1385
1357 func refreshUsageCredits() async { 1386 func refreshUsageCredits() async {
@@ -1525,14 +1554,12 @@ final class DomainViewModel {
1525 } 1554 }
1526 1555
1527 guard snapshot.statusMessage == nil else { 1556 guard snapshot.statusMessage == nil else {
1528 await refreshUsageCredits()
1529 return history.first(where: { $0.id == snapshot.historyEntryID }) 1557 return history.first(where: { $0.id == snapshot.historyEntryID })
1530 } 1558 }
1531 1559
1532 let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain) 1560 let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain)
1533 let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true) 1561 let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true)
1534 DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain) 1562 DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain)
1535 await refreshUsageCredits()
1536 DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain) 1563 DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain)
1537 return entry 1564 return entry
1538 } 1565 }
@@ -2246,6 +2273,12 @@ final class DomainViewModel {
2246 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false) 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 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { 2282 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
2250 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { 2283 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2251 return 2284 return
@@ -2533,14 +2566,9 @@ final class DomainViewModel {
2533 return 2566 return
2534 } 2567 }
2535 2568
2536 let entry: HistoryEntry? 2569 let entry = payload.snapshot.historyEntryID.flatMap { id in
2537 if payload.snapshot.statusMessage == nil { 2570 history.first(where: { $0.id == id })
2538 entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) 2571 } ?? 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 }
2544 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) 2572 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
2545 let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment 2573 let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
2546 let quickStatus: String 2574 let quickStatus: String
@@ -3568,7 +3596,7 @@ final class DomainViewModel {
3568 } 3596 }
3569 } 3597 }
3570 if !DataAccessService.hasAccess(to: .extendedSubdomains) { 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
3enum FeatureTier: String, Codable, CaseIterable, Identifiable { 3enum FeatureTier: String, Codable, CaseIterable, Identifiable {
4 case free 4 case free
5 case pro 5 case pro
6 case dataPlus 6 case proPlus = "dataPlus"
7 7
8 var id: String { rawValue } 8 var id: String { rawValue }
9 9
@@ -13,8 +13,8 @@ enum FeatureTier: String, Codable, CaseIterable, Identifiable {
13 return "Free" 13 return "Free"
14 case .pro: 14 case .pro:
15 return "Pro" 15 return "Pro"
16 case .dataPlus: 16 case .proPlus:
17 return "Data+" 17 return "Pro+"
18 } 18 }
19 } 19 }
20} 20}
@@ -114,9 +114,9 @@ enum FeatureAccessService {
114 workflowLimit: nil, 114 workflowLimit: nil,
115 batchSizeLimit: nil 115 batchSizeLimit: nil
116 ) 116 )
117 case .dataPlus: 117 case .proPlus:
118 return FeatureEntitlements( 118 return FeatureEntitlements(
119 tier: .dataPlus, 119 tier: .proPlus,
120 capabilities: Set(FeatureCapability.allCases), 120 capabilities: Set(FeatureCapability.allCases),
121 trackedDomainLimit: effectivelyUnlimitedTrackedDomains, 121 trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
122 workflowLimit: nil, 122 workflowLimit: nil,
@@ -158,7 +158,7 @@ enum FeatureAccessService {
158 case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports: 158 case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports:
159 return "Available in Pro" 159 return "Available in Pro"
160 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: 160 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
161 return "Available in Data+" 161 return "Available in Pro+"
162 case .limitedTracking: 162 case .limitedTracking:
163 return "Tracking is limited on Free" 163 return "Tracking is limited on Free"
164 case .singleLookup, .basicHistory: 164 case .singleLookup, .basicHistory:
@@ -220,7 +220,7 @@ enum FeatureAccessService {
220 let title: String 220 let title: String
221 switch capability { 221 switch capability {
222 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: 222 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
223 title = "Available in Data+" 223 title = "Available in Pro+"
224 default: 224 default:
225 title = "Available in Pro" 225 title = "Available in Pro"
226 } 226 }
DomainDig/HistoryView.swift +2 −1
@@ -22,7 +22,8 @@ struct HistoryView: View {
22 title: "No History Yet", 22 title: "No History Yet",
23 message: "History stores local snapshots of previous inspections so you can revisit and compare them later.", 23 message: "History stores local snapshots of previous inspections so you can revisit and compare them later.",
24 suggestion: "Run a lookup to create your first saved snapshot.", 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 .listRowBackground(Color(.systemGray6).opacity(0.5)) 28 .listRowBackground(Color(.systemGray6).opacity(0.5))
28 } else { 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 func notifySweepComplete(summary: BatchSweepSummary) async { 110 func notifySweepComplete(summary: BatchSweepSummary) async {
82 let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged" 111 let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
83 await schedule( 112 await schedule(
DomainDig/Models.swift +261 −31
@@ -728,6 +728,179 @@ struct CollaborationMetadata: Codable, Equatable {
728 } 728 }
729} 729}
730 730
731enum 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
765struct 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
783enum 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
827struct 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
857struct 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
882struct 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
731struct TrackedDomain: Codable, Identifiable, Equatable { 904struct TrackedDomain: Codable, Identifiable, Equatable {
732 let id: UUID 905 let id: UUID
733 var domain: String 906 var domain: String
@@ -744,6 +917,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
744 var certificateDaysRemaining: Int? 917 var certificateDaysRemaining: Int?
745 var lastMonitoredAt: Date? 918 var lastMonitoredAt: Date?
746 var lastAlertAt: Date? 919 var lastAlertAt: Date?
920 var monitoringState: MonitoringState
921 var pendingMonitoringAlerts: [MonitoringPendingAlert]
747 var collaboration: CollaborationMetadata? 922 var collaboration: CollaborationMetadata?
748 923
749 init( 924 init(
@@ -762,6 +937,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
762 certificateDaysRemaining: Int? = nil, 937 certificateDaysRemaining: Int? = nil,
763 lastMonitoredAt: Date? = nil, 938 lastMonitoredAt: Date? = nil,
764 lastAlertAt: Date? = nil, 939 lastAlertAt: Date? = nil,
940 monitoringState: MonitoringState = MonitoringState(),
941 pendingMonitoringAlerts: [MonitoringPendingAlert] = [],
765 collaboration: CollaborationMetadata? = nil 942 collaboration: CollaborationMetadata? = nil
766 ) { 943 ) {
767 self.id = id 944 self.id = id
@@ -779,6 +956,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
779 self.certificateDaysRemaining = certificateDaysRemaining 956 self.certificateDaysRemaining = certificateDaysRemaining
780 self.lastMonitoredAt = lastMonitoredAt 957 self.lastMonitoredAt = lastMonitoredAt
781 self.lastAlertAt = lastAlertAt 958 self.lastAlertAt = lastAlertAt
959 self.monitoringState = monitoringState
960 self.pendingMonitoringAlerts = pendingMonitoringAlerts
782 self.collaboration = collaboration 961 self.collaboration = collaboration
783 } 962 }
784 963
@@ -799,35 +978,12 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
799 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining) 978 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
800 lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt) 979 lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
801 lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt) 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 collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration) 983 collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
803 } 984 }
804} 985}
805 986
806enum 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
831enum MonitoringScope: String, Codable, CaseIterable, Identifiable { 987enum MonitoringScope: String, Codable, CaseIterable, Identifiable {
832 case allTracked 988 case allTracked
833 case selectedOnly 989 case selectedOnly
@@ -913,28 +1069,102 @@ enum MonitoringRunTrigger: String, Codable {
913} 1069}
914 1070
915struct MonitoringSettings: Codable, Equatable { 1071struct MonitoringSettings: Codable, Equatable {
916 var isEnabled: Bool 1072 private enum CodingKeys: String, CodingKey {
917 var frequency: MonitoringFrequency 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 var scope: MonitoringScope 1083 var scope: MonitoringScope
919 var selectedDomainIDs: [UUID] 1084 var selectedDomainIDs: [UUID]
920 var alertFilter: MonitoringAlertFilter 1085 var alertFilter: MonitoringAlertFilter
921 var alertsEnabled: Bool 1086 var alertsEnabled: Bool
922 1087
923 init( 1088 init(
924 isEnabled: Bool = false, 1089 config: MonitoringConfig = MonitoringConfig(),
925 frequency: MonitoringFrequency = .daily,
926 scope: MonitoringScope = .allTracked, 1090 scope: MonitoringScope = .allTracked,
927 selectedDomainIDs: [UUID] = [], 1091 selectedDomainIDs: [UUID] = [],
928 alertFilter: MonitoringAlertFilter = .criticalAndWarnings, 1092 alertFilter: MonitoringAlertFilter = .criticalAndWarnings,
929 alertsEnabled: Bool = false 1093 alertsEnabled: Bool = false
930 ) { 1094 ) {
931 self.isEnabled = isEnabled 1095 self.config = config
932 self.frequency = frequency
933 self.scope = scope 1096 self.scope = scope
934 self.selectedDomainIDs = selectedDomainIDs 1097 self.selectedDomainIDs = selectedDomainIDs
935 self.alertFilter = alertFilter 1098 self.alertFilter = alertFilter
936 self.alertsEnabled = alertsEnabled 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
940struct MonitoringDomainResult: Codable, Identifiable, Equatable { 1170struct MonitoringDomainResult: Codable, Identifiable, Equatable {
DomainDig/MonitoringView.swift +17 −2
@@ -17,7 +17,14 @@ struct MonitoringView: View {
17 VStack(alignment: .leading, spacing: 8) { 17 VStack(alignment: .leading, spacing: 8) {
18 LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only") 18 LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only")
19 LabeledContent("Domains", value: "\(monitoredDomainsCount)") 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 LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off") 28 LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off")
22 29
23 if let monitoringStatusMessage = viewModel.monitoringStatusMessage, 30 if let monitoringStatusMessage = viewModel.monitoringStatusMessage,
@@ -42,7 +49,8 @@ struct MonitoringView: View {
42 title: "No Monitoring Runs Yet", 49 title: "No Monitoring Runs Yet",
43 message: "Monitoring history appears here after manual or background runs finish.", 50 message: "Monitoring history appears here after manual or background runs finish.",
44 suggestion: "Enable monitoring in Settings or run a manual monitoring sweep.", 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 .listRowBackground(Color(.systemGray6).opacity(0.5)) 56 .listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -102,6 +110,13 @@ struct MonitoringView: View {
102 .background(tint.opacity(0.16)) 110 .background(tint.opacity(0.16))
103 .clipShape(Capsule()) 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
107struct MonitoringLogDetailView: View { 122struct MonitoringLogDetailView: View {
DomainDig/PaywallView.swift +6 −6
@@ -10,7 +10,7 @@ struct PaywallView: View {
10 NavigationStack { 10 NavigationStack {
11 List { 11 List {
12 Section { 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 .font(appDensity.font(.body, design: .default)) 14 .font(appDensity.font(.body, design: .default))
15 .foregroundStyle(.secondary) 15 .foregroundStyle(.secondary)
16 .fixedSize(horizontal: false, vertical: true) 16 .fixedSize(horizontal: false, vertical: true)
@@ -25,7 +25,7 @@ struct PaywallView: View {
25 featureRow("Advanced exports") 25 featureRow("Advanced exports")
26 } 26 }
27 27
28 Section("What Data+ Unlocks") { 28 Section("What Pro+ Unlocks") {
29 featureRow("Ownership history") 29 featureRow("Ownership history")
30 featureRow("DNS history") 30 featureRow("DNS history")
31 featureRow("Extended subdomains") 31 featureRow("Extended subdomains")
@@ -125,10 +125,10 @@ struct PaywallView: View {
125 return "Pro Monthly" 125 return "Pro Monthly"
126 case PurchaseService.yearlyProductID: 126 case PurchaseService.yearlyProductID:
127 return "Pro Yearly" 127 return "Pro Yearly"
128 case PurchaseService.dataPlusMonthlyProductID: 128 case PurchaseService.proPlusMonthlyProductID:
129 return "Data+ Monthly" 129 return "Pro+ Monthly"
130 case PurchaseService.dataPlusYearlyProductID: 130 case PurchaseService.proPlusYearlyProductID:
131 return "Data+ Yearly" 131 return "Pro+ Yearly"
132 default: 132 default:
133 return product.displayName 133 return product.displayName
134 } 134 }
DomainDig/PurchaseService.swift +66 −11
@@ -17,18 +17,30 @@ final class PurchaseService {
17 static let shared = PurchaseService() 17 static let shared = PurchaseService()
18 static let monthlyProductID = "domaindig.pro.monthly" 18 static let monthlyProductID = "domaindig.pro.monthly"
19 static let yearlyProductID = "domaindig.pro.yearly" 19 static let yearlyProductID = "domaindig.pro.yearly"
20 static let dataPlusMonthlyProductID = "domaindig.dataplus.monthly" 20 static let proPlusMonthlyProductID = "domaindig.dataplus.monthly"
21 static let dataPlusYearlyProductID = "domaindig.dataplus.yearly" 21 static let proPlusYearlyProductID = "domaindig.dataplus.yearly"
22 static let productIDs = [ 22 static let productIDs = [
23 monthlyProductID, 23 monthlyProductID,
24 yearlyProductID, 24 yearlyProductID,
25 dataPlusMonthlyProductID, 25 proPlusMonthlyProductID,
26 dataPlusYearlyProductID 26 proPlusYearlyProductID
27 ] 27 ]
28 28
29 private static let entitlementCacheKey = "purchase.cachedEntitlement" 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 static var cachedEntitlement: CachedEntitlement? { 37 static var cachedEntitlement: CachedEntitlement? {
38 #if DEBUG
39 if let forcedEntitlement = debugForcedEntitlement {
40 return forcedEntitlement
41 }
42 #endif
43
32 guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } 44 guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
33 return try? JSONDecoder().decode(CachedEntitlement.self, from: data) 45 return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
34 } 46 }
@@ -51,6 +63,7 @@ final class PurchaseService {
51 private init() { 63 private init() {
52 currentTier = Self.cachedTier 64 currentTier = Self.cachedTier
53 activeProductID = Self.cachedEntitlement?.activeProductID 65 activeProductID = Self.cachedEntitlement?.activeProductID
66 applyDebugOverrideIfNeeded()
54 updatesTask = observeTransactionUpdates() 67 updatesTask = observeTransactionUpdates()
55 Task { 68 Task {
56 await refreshProducts() 69 await refreshProducts()
@@ -62,8 +75,8 @@ final class PurchaseService {
62 currentTier != .free 75 currentTier != .free
63 } 76 }
64 77
65 var hasDataPlusAccess: Bool { 78 var hasProPlusAccess: Bool {
66 currentTier == .dataPlus 79 currentTier == .proPlus
67 } 80 }
68 81
69 func refreshProducts() async { 82 func refreshProducts() async {
@@ -104,6 +117,7 @@ final class PurchaseService {
104 self.activeProductID = activeProductID 117 self.activeProductID = activeProductID
105 currentTier = tier(for: activeProductID) 118 currentTier = tier(for: activeProductID)
106 persistCurrentEntitlement() 119 persistCurrentEntitlement()
120 applyDebugOverrideIfNeeded()
107 } 121 }
108 122
109 func purchase(_ product: Product) async { 123 func purchase(_ product: Product) async {
@@ -120,7 +134,7 @@ final class PurchaseService {
120 apply(transaction: transaction) 134 apply(transaction: transaction)
121 await transaction.finish() 135 await transaction.finish()
122 await refreshEntitlements() 136 await refreshEntitlements()
123 statusMessage = currentTier == .dataPlus ? "Data+ is active." : "Pro is active." 137 statusMessage = currentTier == .proPlus ? "Pro+ is active." : "Pro is active."
124 case .userCancelled: 138 case .userCancelled:
125 break 139 break
126 case .pending: 140 case .pending:
@@ -190,6 +204,7 @@ final class PurchaseService {
190 activeProductID = transaction.productID 204 activeProductID = transaction.productID
191 currentTier = tier(for: transaction.productID) 205 currentTier = tier(for: transaction.productID)
192 persistCurrentEntitlement() 206 persistCurrentEntitlement()
207 applyDebugOverrideIfNeeded()
193 } 208 }
194 209
195 private func observeTransactionUpdates() -> Task<Void, Never> { 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 private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction { 246 private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
224 switch result { 247 switch result {
225 case .verified(let transaction): 248 case .verified(let transaction):
@@ -235,9 +258,9 @@ final class PurchaseService {
235 return 0 258 return 0
236 case Self.yearlyProductID: 259 case Self.yearlyProductID:
237 return 1 260 return 1
238 case Self.dataPlusMonthlyProductID: 261 case Self.proPlusMonthlyProductID:
239 return 2 262 return 2
240 case Self.dataPlusYearlyProductID: 263 case Self.proPlusYearlyProductID:
241 return 3 264 return 3
242 default: 265 default:
243 return Int.max 266 return Int.max
@@ -248,8 +271,8 @@ final class PurchaseService {
248 switch productID { 271 switch productID {
249 case Self.monthlyProductID, Self.yearlyProductID: 272 case Self.monthlyProductID, Self.yearlyProductID:
250 return .pro 273 return .pro
251 case Self.dataPlusMonthlyProductID, Self.dataPlusYearlyProductID: 274 case Self.proPlusMonthlyProductID, Self.proPlusYearlyProductID:
252 return .dataPlus 275 return .proPlus
253 default: 276 default:
254 return .free 277 return .free
255 } 278 }
@@ -268,4 +291,36 @@ final class PurchaseService {
268 let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) 291 let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
269 return message.isEmpty ? fallback : message 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 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") 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 NavigationStack { 37 NavigationStack {
45 SettingsView(viewModel: viewModel) 38 SettingsView(viewModel: viewModel)
46 } 39 }
47 .tabItem { 40 .tabItem {
48 Label("Settings", systemImage: "gearshape") 41 Label("More", systemImage: "ellipsis.circle")
49 } 42 }
50 } 43 }
51 .sheet(isPresented: Binding( 44 .sheet(isPresented: Binding(
DomainDig/TimelineView.swift +24 −36
@@ -12,6 +12,10 @@ struct TimelineView: View {
12 viewModel.timelineSections(for: domain) 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 var body: some View { 19 var body: some View {
16 List { 20 List {
17 ForEach(timelineSections) { section in 21 ForEach(timelineSections) { section in
@@ -61,10 +65,16 @@ struct TimelineView: View {
61 } 65 }
62 66
63 Button("Compare") { 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 focusedSectionID = viewModel.currentDiffTargetSectionID 75 focusedSectionID = viewModel.currentDiffTargetSectionID
66 } 76 }
67 .disabled(viewModel.selectedSnapshots.count != 2) 77 .disabled(compareButtonDisabled)
68 78
69 Menu("Export") { 79 Menu("Export") {
70 Button("Export TXT") { 80 Button("Export TXT") {
@@ -119,46 +129,24 @@ private struct TimelineRow: View {
119 129
120 HStack(spacing: 8) { 130 HStack(spacing: 8) {
121 AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability)) 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 if let riskScore = summary.riskScore { 132 if let riskScore = summary.riskScore {
143 AppStatusBadgeView( 133 Text("Risk \(riskScore)")
144 model: .init( 134 .lineLimit(1)
145 title: "Risk \(riskScore)", 135 .minimumScaleFactor(0.85)
146 systemImage: "exclamationmark.shield",
147 foregroundColor: riskScore >= 70 ? .red : .orange,
148 backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16)
149 )
150 )
151 } 136 }
152 } 137 }
138 .font(appDensity.font(.caption2))
139 .foregroundStyle(.secondary)
153 140
154 HStack(spacing: 8) { 141 HStack(spacing: 8) {
155 if let snapshotIndex = summary.snapshotIndex { 142 if let primaryIP = summary.primaryIP {
156 Text("#\(snapshotIndex)") 143 Text(primaryIP)
157 } 144 .lineLimit(1)
158 Text(summary.timestamp.formatted(.relative(presentation: .named))) 145 .truncationMode(.middle)
159 if summary.changeCount > 0 {
160 Text("\(summary.changeCount) changes")
161 } 146 }
147 Spacer(minLength: 8)
148 Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
149 .lineLimit(1)
162 } 150 }
163 .font(appDensity.font(.caption2)) 151 .font(appDensity.font(.caption2))
164 .foregroundStyle(.secondary) 152 .foregroundStyle(.secondary)
DomainDig/WatchlistView.swift +93 −3
@@ -6,6 +6,10 @@ struct WatchlistView: View {
6 @Environment(\.dismiss) private var dismiss 6 @Environment(\.dismiss) private var dismiss
7 @State private var purchaseService = PurchaseService.shared 7 @State private var purchaseService = PurchaseService.shared
8 @State private var showWorkflowAddSheet = false 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 private var pinnedDomains: [TrackedDomain] { 14 private var pinnedDomains: [TrackedDomain] {
11 viewModel.filteredTrackedDomains.filter(\.isPinned) 15 viewModel.filteredTrackedDomains.filter(\.isPinned)
@@ -53,7 +57,8 @@ struct WatchlistView: View {
53 title: "No Tracked Domains", 57 title: "No Tracked Domains",
54 message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.", 58 message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.",
55 suggestion: "Run an inspection and use the Track action on a domain you care about.", 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 .listRowBackground(Color(.systemGray6).opacity(0.5)) 64 .listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -82,8 +87,16 @@ struct WatchlistView: View {
82 .navigationTitle("Watchlist") 87 .navigationTitle("Watchlist")
83 .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains") 88 .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains")
84 .toolbar { 89 .toolbar {
85 if !viewModel.filteredTrackedDomains.isEmpty { 90 ToolbarItemGroup(placement: .topBarTrailing) {
86 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 Menu { 100 Menu {
88 Picker("Filter", selection: $viewModel.watchlistFilter) { 101 Picker("Filter", selection: $viewModel.watchlistFilter) {
89 ForEach(WatchlistFilterOption.allCases) { option in 102 ForEach(WatchlistFilterOption.allCases) { option in
@@ -139,6 +152,52 @@ struct WatchlistView: View {
139 .sheet(item: batchSummaryBinding) { summary in 152 .sheet(item: batchSummaryBinding) { summary in
140 BatchSweepSummaryView(viewModel: viewModel, summary: summary) 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 .sheet(isPresented: $showWorkflowAddSheet) { 201 .sheet(isPresented: $showWorkflowAddSheet) {
143 WorkflowBulkAddSheet( 202 WorkflowBulkAddSheet(
144 viewModel: viewModel, 203 viewModel: viewModel,
@@ -271,6 +330,24 @@ struct WatchlistView: View {
271 330
272 ExportPresenter.share(filename: filename, data: data) 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
276struct WatchlistRowView: View { 353struct WatchlistRowView: View {
@@ -468,6 +545,19 @@ struct TrackedDomainDetailView: View {
468 } 545 }
469 .listRowBackground(Color(.systemGray6).opacity(0.5)) 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 if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) { 561 if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
472 Section("Latest Change Summary") { 562 Section("Latest Change Summary") {
473 DomainChangeSummaryView(summary: summary) 563 DomainChangeSummaryView(summary: summary)
DomainDigCLI.swift +7 −42
@@ -36,7 +36,6 @@ struct DomainDigCLI {
36 let wantsDNSHistory = arguments.contains("--dns-history") 36 let wantsDNSHistory = arguments.contains("--dns-history")
37 let wantsExtendedSubdomains = arguments.contains("--extended-subdomains") 37 let wantsExtendedSubdomains = arguments.contains("--extended-subdomains")
38 let wantsPricing = arguments.contains("--pricing") 38 let wantsPricing = arguments.contains("--pricing")
39 let wantsUsage = arguments.contains("--show-usage")
40 let domains = arguments.filter { !$0.hasPrefix("-") } 39 let domains = arguments.filter { !$0.hasPrefix("-") }
41 40
42 let requestedDomains = domains 41 let requestedDomains = domains
@@ -52,8 +51,6 @@ struct DomainDigCLI {
52 let reportBuilder = DomainReportBuilder() 51 let reportBuilder = DomainReportBuilder()
53 var reports: [DomainReport] = [] 52 var reports: [DomainReport] = []
54 var seen = Set<String>() 53 var seen = Set<String>()
55 var usageImpact: [String] = []
56
57 for domain in requestedDomains { 54 for domain in requestedDomains {
58 let normalizedDomain = domain.lowercased() 55 let normalizedDomain = domain.lowercased()
59 guard seen.insert(normalizedDomain).inserted else { continue } 56 guard seen.insert(normalizedDomain).inserted else { continue }
@@ -63,8 +60,7 @@ struct DomainDigCLI {
63 wantsOwnershipHistory: wantsOwnershipHistory, 60 wantsOwnershipHistory: wantsOwnershipHistory,
64 wantsDNSHistory: wantsDNSHistory, 61 wantsDNSHistory: wantsDNSHistory,
65 wantsExtendedSubdomains: wantsExtendedSubdomains, 62 wantsExtendedSubdomains: wantsExtendedSubdomains,
66 wantsPricing: wantsPricing, 63 wantsPricing: wantsPricing
67 usageImpact: &usageImpact
68 ) 64 )
69 reports.append(reportBuilder.build(from: enrichedSnapshot)) 65 reports.append(reportBuilder.build(from: enrichedSnapshot))
70 } 66 }
@@ -83,9 +79,6 @@ struct DomainDigCLI {
83 title: "DomainDig Batch Report" 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 FileHandle.standardOutput.write(data) 82 FileHandle.standardOutput.write(data)
90 if data.last != 0x0A { 83 if data.last != 0x0A {
91 FileHandle.standardOutput.write(Data([0x0A])) 84 FileHandle.standardOutput.write(Data([0x0A]))
@@ -101,10 +94,9 @@ struct DomainDigCLI {
101 wantsOwnershipHistory: Bool, 94 wantsOwnershipHistory: Bool,
102 wantsDNSHistory: Bool, 95 wantsDNSHistory: Bool,
103 wantsExtendedSubdomains: Bool, 96 wantsExtendedSubdomains: Bool,
104 wantsPricing: Bool, 97 wantsPricing: Bool
105 usageImpact: inout [String]
106 ) async -> LookupSnapshot { 98 ) async -> LookupSnapshot {
107 guard FeatureAccessService.currentTier == .dataPlus else { 99 guard FeatureAccessService.currentTier == .proPlus else {
108 return snapshot 100 return snapshot
109 } 101 }
110 102
@@ -118,8 +110,7 @@ struct DomainDigCLI {
118 var domainPricing = snapshot.domainPricing 110 var domainPricing = snapshot.domainPricing
119 var domainPricingError = snapshot.domainPricingError 111 var domainPricingError = snapshot.domainPricingError
120 112
121 if wantsOwnershipHistory, 113 if wantsOwnershipHistory {
122 await UsageCreditService.shared.canUse(.ownershipHistory) {
123 let outcome = await ExternalDataService.shared.ownershipHistory( 114 let outcome = await ExternalDataService.shared.ownershipHistory(
124 domain: snapshot.domain, 115 domain: snapshot.domain,
125 currentOwnership: snapshot.ownership, 116 currentOwnership: snapshot.ownership,
@@ -129,23 +120,14 @@ struct DomainDigCLI {
129 case let .success(events): 120 case let .success(events):
130 ownershipHistory = events 121 ownershipHistory = events
131 ownershipHistoryError = nil 122 ownershipHistoryError = nil
132 if outcome.source != .cached {
133 _ = await UsageCreditService.shared.consume(.ownershipHistory)
134 usageImpact.append("ownership history -1")
135 }
136 case let .empty(message): 123 case let .empty(message):
137 ownershipHistoryError = message 124 ownershipHistoryError = message
138 if outcome.source != .cached {
139 _ = await UsageCreditService.shared.consume(.ownershipHistory)
140 usageImpact.append("ownership history -1")
141 }
142 case let .error(message): 125 case let .error(message):
143 ownershipHistoryError = message 126 ownershipHistoryError = message
144 } 127 }
145 } 128 }
146 129
147 if wantsDNSHistory, 130 if wantsDNSHistory {
148 await UsageCreditService.shared.canUse(.dnsHistory) {
149 let outcome = await ExternalDataService.shared.dnsHistory( 131 let outcome = await ExternalDataService.shared.dnsHistory(
150 domain: snapshot.domain, 132 domain: snapshot.domain,
151 dnsSections: snapshot.dnsSections, 133 dnsSections: snapshot.dnsSections,
@@ -155,23 +137,14 @@ struct DomainDigCLI {
155 case let .success(events): 137 case let .success(events):
156 dnsHistory = events 138 dnsHistory = events
157 dnsHistoryError = nil 139 dnsHistoryError = nil
158 if outcome.source != .cached {
159 _ = await UsageCreditService.shared.consume(.dnsHistory)
160 usageImpact.append("dns history -1")
161 }
162 case let .empty(message): 140 case let .empty(message):
163 dnsHistoryError = message 141 dnsHistoryError = message
164 if outcome.source != .cached {
165 _ = await UsageCreditService.shared.consume(.dnsHistory)
166 usageImpact.append("dns history -1")
167 }
168 case let .error(message): 142 case let .error(message):
169 dnsHistoryError = message 143 dnsHistoryError = message
170 } 144 }
171 } 145 }
172 146
173 if wantsExtendedSubdomains, 147 if wantsExtendedSubdomains {
174 await UsageCreditService.shared.canUse(.extendedSubdomains) {
175 let outcome = await ExternalDataService.shared.extendedSubdomains( 148 let outcome = await ExternalDataService.shared.extendedSubdomains(
176 domain: snapshot.domain, 149 domain: snapshot.domain,
177 existing: snapshot.subdomains 150 existing: snapshot.subdomains
@@ -180,16 +153,8 @@ struct DomainDigCLI {
180 case let .success(results): 153 case let .success(results):
181 extendedSubdomains = results 154 extendedSubdomains = results
182 extendedSubdomainsError = nil 155 extendedSubdomainsError = nil
183 if outcome.source != .cached {
184 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
185 usageImpact.append("extended subdomains -1")
186 }
187 case let .empty(message): 156 case let .empty(message):
188 extendedSubdomainsError = message 157 extendedSubdomainsError = message
189 if outcome.source != .cached {
190 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
191 usageImpact.append("extended subdomains -1")
192 }
193 case let .error(message): 158 case let .error(message):
194 extendedSubdomainsError = message 159 extendedSubdomainsError = message
195 } 160 }
@@ -538,7 +503,7 @@ struct DomainDigCLI {
538 503
539 private static var usageText: String { 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 domaindig history <domain> [--json] 507 domaindig history <domain> [--json]
543 domaindig diff <domain> --from <id> --to <id> [--json] 508 domaindig diff <domain> --from <id> --to <id> [--json]
544 domaindig monitor [--json] 509 domaindig monitor [--json]