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 {
231231 certificateWarningLevel: certificateLevel,
232232 certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""),
233233 lastMonitoredAt: parseDate(row["lastMonitoredAt"]),
234 lastAlertAt: parseDate(row["lastAlertAt"])
234 lastAlertAt: parseDate(row["lastAlertAt"]),
235 monitoringState: MonitoringState(),
236 pendingMonitoringAlerts: []
235237 )
236238 }
237239 }
@@ -1104,7 +1106,9 @@ enum DomainDataPortabilityService {
11041106 certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel),
11051107 certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining,
11061108 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)
11081112 )
11091113 }
11101114
@@ -1272,10 +1276,30 @@ enum DomainDataPortabilityService {
12721276 certificateWarningLevel: trackedDomain.certificateWarningLevel,
12731277 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
12741278 lastMonitoredAt: trackedDomain.lastMonitoredAt,
1275 lastAlertAt: trackedDomain.lastAlertAt
1279 lastAlertAt: trackedDomain.lastAlertAt,
1280 monitoringState: trackedDomain.monitoringState,
1281 pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts
12761282 )
12771283 }
12781284
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
12791303 private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
12801304 DomainWorkflow(
12811305 id: workflow.id,
DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -366,7 +366,7 @@
366366 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
367367 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
368368 CODE_SIGN_STYLE = Automatic;
369 CURRENT_PROJECT_VERSION = 29;
369 CURRENT_PROJECT_VERSION = 30;
370370 DEVELOPMENT_TEAM = ZCNAX3VL9D;
371371 ENABLE_PREVIEWS = YES;
372372 GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
383383 "$(inherited)",
384384 "@executable_path/Frameworks",
385385 );
386 MARKETING_VERSION = 3.7.0;
386 MARKETING_VERSION = 3.8.0;
387387 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
388388 PRODUCT_NAME = "$(TARGET_NAME)";
389389 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
403403 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
404404 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
405405 CODE_SIGN_STYLE = Automatic;
406 CURRENT_PROJECT_VERSION = 29;
406 CURRENT_PROJECT_VERSION = 30;
407407 DEVELOPMENT_TEAM = ZCNAX3VL9D;
408408 ENABLE_PREVIEWS = YES;
409409 GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
420420 "$(inherited)",
421421 "@executable_path/Frameworks",
422422 );
423 MARKETING_VERSION = 3.7.0;
423 MARKETING_VERSION = 3.8.0;
424424 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
425425 PRODUCT_NAME = "$(TARGET_NAME)";
426426 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 @@
1515 <integer>0</integer>
1616 </dict>
1717 </dict>
18 <key>SuppressBuildableAutocreation</key>
19 <dict>
20 <key>8B7800682F6090E300933221</key>
21 <dict>
22 <key>primary</key>
23 <true/>
24 </dict>
25 </dict>
1826</dict>
1927</plist>
DomainDig/BatchResultsView.swift +27 −1
@@ -72,12 +72,25 @@ struct BatchResultRowView: View {
7272 }
7373
7474 HStack(spacing: 10) {
75 AppStatusBadgeView(model: AppStatusFactory.availability(result.availability))
75 AppStatusBadgeView(model: availabilityBadgeModel)
7676 if let riskScore = result.riskScore, let riskLevel = result.riskLevel {
7777 Text("Risk \(riskScore) \(riskLevel.title)")
78 .lineLimit(1)
79 .minimumScaleFactor(0.85)
7880 }
81 }
82 .font(appDensity.font(.caption2))
83 .foregroundStyle(.secondary)
84
85 HStack(spacing: 10) {
7986 Text(result.primaryIP ?? "No IP")
87 .lineLimit(1)
88 .truncationMode(.middle)
89
90 Spacer(minLength: 8)
91
8092 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened))
93 .lineLimit(1)
8194 }
8295 .font(appDensity.font(.caption2))
8396 .foregroundStyle(.secondary)
@@ -116,6 +129,19 @@ struct BatchResultRowView: View {
116129 }
117130 }
118131
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
119145 private var quickStatusBadge: AppStatusBadgeModel {
120146 switch result.status {
121147 case .pending:
DomainDig/CloudSyncService.swift +22
@@ -1453,6 +1453,8 @@ final class CloudSyncService {
14531453 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
14541454 lastMonitoredAt: trackedDomain.lastMonitoredAt,
14551455 lastAlertAt: trackedDomain.lastAlertAt,
1456 monitoringState: trackedDomain.monitoringState,
1457 pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts,
14561458 collaboration: trackedDomain.collaboration
14571459 )
14581460 }
@@ -1476,6 +1478,8 @@ final class CloudSyncService {
14761478 certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining,
14771479 lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(),
14781480 lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(),
1481 monitoringState: preferredMonitoringState(lhs.monitoringState, rhs.monitoringState),
1482 pendingMonitoringAlerts: deduplicatedPendingAlerts(lhs.pendingMonitoringAlerts + rhs.pendingMonitoringAlerts),
14791483 collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration)
14801484 )
14811485 }
@@ -1501,6 +1505,24 @@ final class CloudSyncService {
15011505 return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
15021506 }
15031507
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
15041526 private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow {
15051527 let lhsRank = collaborationRank(lhs.collaboration)
15061528 let rhsRank = collaborationRank(rhs.collaboration)
DomainDig/ContentView.swift +532 −239
@@ -19,6 +19,11 @@ enum ResultSection: String, Hashable {
1919 case subdomains
2020}
2121
22private enum LookupInputField: Hashable {
23 case singleDomain
24 case bulkDomains
25}
26
2227private struct WorkflowNavigationTarget: Hashable {
2328 let workflowID: UUID
2429}
@@ -28,7 +33,7 @@ struct ContentView: View {
2833 @Bindable var viewModel: DomainViewModel
2934 @State private var purchaseService = PurchaseService.shared
3035 @State private var navigationPath = NavigationPath()
31 @FocusState private var domainFieldFocused: Bool
36 @FocusState private var focusedInputField: LookupInputField?
3237 @State private var customPortInput = ""
3338 @State private var customPortsExpanded = false
3439 @State private var trackingNoteDraft = ""
@@ -174,13 +179,20 @@ struct ContentView: View {
174179 }
175180 }
176181 }
182
183 ToolbarItemGroup(placement: .keyboard) {
184 Spacer()
185 Button("Dismiss Keyboard") {
186 focusedInputField = nil
187 }
188 }
177189 }
178190 .navigationDestination(for: WorkflowNavigationTarget.self) { target in
179191 WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
180192 }
181193 }
182194 .onAppear {
183 domainFieldFocused = true
195 focusedInputField = .singleDomain
184196 }
185197 .task {
186198 await viewModel.refreshUsageCredits()
@@ -190,7 +202,24 @@ struct ContentView: View {
190202 }
191203 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
192204 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()
194223 }
195224 .sheet(item: $editingTrackedDomain) { trackedDomain in
196225 NavigationStack {
@@ -257,11 +286,14 @@ struct ContentView: View {
257286 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
258287 .background(Color(.systemGray6))
259288 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
260 .focused($domainFieldFocused)
261 .onSubmit { viewModel.run() }
289 .focused($focusedInputField, equals: .singleDomain)
290 .onSubmit {
291 focusedInputField = nil
292 viewModel.run()
293 }
262294
263295 Button {
264 domainFieldFocused = false
296 focusedInputField = nil
265297 viewModel.run()
266298 } label: {
267299 Text("Run")
@@ -298,9 +330,10 @@ struct ContentView: View {
298330 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
299331 .background(Color(.systemGray6))
300332 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
333 .focused($focusedInputField, equals: .bulkDomains)
301334
302335 Button {
303 domainFieldFocused = false
336 focusedInputField = nil
304337 viewModel.runBulkLookup()
305338 } label: {
306339 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
@@ -568,7 +601,7 @@ struct ContentView: View {
568601 ForEach(viewModel.recentSearches, id: \.self) { domain in
569602 Button {
570603 viewModel.domain = domain
571 domainFieldFocused = false
604 focusedInputField = nil
572605 viewModel.run()
573606 } label: {
574607 Text(domain)
@@ -822,7 +855,7 @@ struct InsightsSummaryCardView: View {
822855 } else {
823856 ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
824857 HStack(alignment: .top, spacing: 8) {
825 Image(systemName: "sparkline")
858 Image(systemName: "chart.line.uptrend.xyaxis")
826859 .font(appDensity.font(.caption2))
827860 .foregroundStyle(.cyan)
828861 .padding(.top, 2)
@@ -1401,7 +1434,7 @@ struct DomainSectionView: View {
14011434 MessageRowView(text: pricingError, isError: false)
14021435 .padding(.top, 4)
14031436 } else if showsPricingPlaceholder {
1404 MessageRowView(text: "Pricing signals available in Data+", isError: false)
1437 MessageRowView(text: "Pricing signals available in Pro+", isError: false)
14051438 .padding(.top, 4)
14061439 }
14071440 }
@@ -1473,8 +1506,8 @@ struct OwnershipSectionView: View {
14731506 .font(appDensity.font(.caption))
14741507 .foregroundStyle(.secondary)
14751508 Spacer()
1476 if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1477 Button("Load (\(historyCreditStatus.remaining) left)") {
1509 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1510 Button("Load") {
14781511 onLoadHistory()
14791512 }
14801513 .buttonStyle(.bordered)
@@ -1500,7 +1533,7 @@ struct OwnershipSectionView: View {
15001533 } else if let historyError {
15011534 MessageRowView(text: historyError, isError: false)
15021535 } else if showsHistoryPlaceholder {
1503 MessageRowView(text: "Ownership history available in Data+", isError: false)
1536 MessageRowView(text: "Ownership history available in Pro+", isError: false)
15041537 }
15051538 }
15061539 }
@@ -1565,12 +1598,12 @@ struct SubdomainsSectionView: View {
15651598 } else if rows.isEmpty {
15661599 MessageRowView(text: error ?? "No passive subdomains found", isError: false)
15671600 if showsExtendedPlaceholder {
1568 MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false)
1601 MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
15691602 .padding(.top, 4)
15701603 }
15711604 } else {
1572 if let extendedCreditStatus, let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
1573 Button("Load extended results (\(extendedCreditStatus.remaining) left)") {
1605 if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
1606 Button("Load extended results") {
15741607 onLoadExtended()
15751608 }
15761609 .buttonStyle(.bordered)
@@ -1621,7 +1654,7 @@ struct SubdomainsSectionView: View {
16211654 MessageRowView(text: extendedError, isError: false)
16221655 .padding(.top, 4)
16231656 } 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)
16251658 .padding(.top, 4)
16261659 }
16271660 }
@@ -1748,8 +1781,8 @@ struct DNSSectionView: View {
17481781 .font(appDensity.font(.subheadline, weight: .semibold))
17491782 .foregroundStyle(.cyan)
17501783 Spacer()
1751 if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1752 Button("Load (\(historyCreditStatus.remaining) left)") {
1784 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1785 Button("Load") {
17531786 onLoadHistory()
17541787 }
17551788 .buttonStyle(.bordered)
@@ -1782,7 +1815,7 @@ struct DNSSectionView: View {
17821815 } else if let historyError {
17831816 MessageRowView(text: historyError, isError: false)
17841817 } else if showsHistoryPlaceholder {
1785 MessageRowView(text: "DNS history available in Data+", isError: false)
1818 MessageRowView(text: "DNS history available in Pro+", isError: false)
17861819 }
17871820 }
17881821 }
@@ -2444,36 +2477,94 @@ struct SettingsView: View {
24442477 @Environment(\.appDensity) private var appDensity
24452478 @Bindable var viewModel: DomainViewModel
24462479 @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
24522480
2453 @State private var resolverOption: DNSResolverOption = .cloudflare
2454 @State private var customResolverURL = DNSResolverOption.defaultURLString
2455 @State private var showClearHistoryConfirmation = false
2456 @State private var showClearCacheConfirmation = false
2457 @State private var showClearWorkflowsConfirmation = false
2458 @State private var showClearTrackedDomainsConfirmation = false
2459 @State private var importMode: DataPortabilityImportMode = .merge
2460 @State private var showBackupImporter = false
2461 @State private var showTrackedDomainsImporter = false
2462 @State private var showWorkflowsImporter = false
2463 @State private var pendingImportPreview: DataImportPreview?
2464 @State private var pendingImportError: String?
2465 @State private var showReplaceImportConfirmation = false
2481 var body: some View {
2482 let _ = purchaseService.currentTier
24662483
2467 private var customResolverError: String? {
2468 guard resolverOption == .custom else {
2469 return nil
2484 List {
2485 Section("Tier") {
2486 LabeledContent("Status", value: purchaseService.currentTier.title)
2487
2488 if purchaseService.currentTier == .free {
2489 Button("Upgrade") {
2490 viewModel.isPaywallPresented = true
2491 }
2492 } else {
2493 Button("Manage Subscription") {
2494 Task {
2495 await purchaseService.manageSubscription()
2496 }
2497 }
2498 }
2499
2500 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
2501 Task {
2502 await purchaseService.restorePurchases()
2503 }
2504 }
2505 .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
2506
2507 if let statusMessage = purchaseService.statusMessage {
2508 Text(statusMessage)
2509 .font(appDensity.font(.caption, design: .default))
2510 .foregroundStyle(.secondary)
2511 }
2512
2513 if let errorMessage = purchaseService.errorMessage {
2514 Text(errorMessage)
2515 .font(appDensity.font(.caption, design: .default))
2516 .foregroundStyle(.red)
2517 }
2518 }
2519
2520 Section("Preferences") {
2521 NavigationLink("Workflows") {
2522 WorkflowsView(viewModel: viewModel)
2523 }
2524
2525 NavigationLink("Display") {
2526 DisplaySettingsView()
2527 }
2528
2529 NavigationLink("History & Network") {
2530 HistoryNetworkSettingsView(viewModel: viewModel)
2531 }
2532 }
2533
2534 Section("Services") {
2535 NavigationLink("iCloud Sync") {
2536 CloudSyncSettingsView()
2537 }
2538
2539 NavigationLink("Monitoring") {
2540 MonitoringSettingsView(viewModel: viewModel)
2541 }
2542 }
2543
2544 Section("Data") {
2545 NavigationLink("Import & Export") {
2546 DataPortabilitySettingsView(viewModel: viewModel)
2547 }
2548
2549 NavigationLink("Data Management") {
2550 DataManagementSettingsView(viewModel: viewModel)
2551 }
2552 }
2553
2554 Section("About") {
2555 NavigationLink("App Info") {
2556 AboutSettingsView()
2557 }
2558 }
24702559 }
2471 return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
2560 .navigationTitle("More")
24722561 }
2562}
24732563
2474 var body: some View {
2475 let _ = purchaseService.currentTier
2564private struct DisplaySettingsView: View {
2565 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
24762566
2567 var body: some View {
24772568 Form {
24782569 Section("Display") {
24792570 Picker("Density", selection: $storedDensity) {
@@ -2482,7 +2573,27 @@ struct SettingsView: View {
24822573 }
24832574 }
24842575 }
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 }
24852594
2595 var body: some View {
2596 Form {
24862597 Section("History") {
24872598 Picker(
24882599 "Auto-prune",
@@ -2521,7 +2632,51 @@ struct SettingsView: View {
25212632 }
25222633 }
25232634 }
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
25242677
2678 var body: some View {
2679 Form {
25252680 Section("iCloud Sync") {
25262681 Toggle(
25272682 "Enable iCloud Sync",
@@ -2558,7 +2713,33 @@ struct SettingsView: View {
25582713 .foregroundStyle(.red)
25592714 }
25602715 }
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
25612727
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 {
25622743 Section("Monitoring") {
25632744 Toggle(
25642745 "Enable Background Monitoring",
@@ -2569,14 +2750,88 @@ struct SettingsView: View {
25692750 )
25702751
25712752 Picker(
2572 "Frequency",
2753 "Base Interval",
25732754 selection: Binding(
2574 get: { viewModel.monitoringSettings.frequency },
2575 set: { viewModel.setMonitoringFrequency($0) }
2755 get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) },
2756 set: { viewModel.setMonitoringBaseInterval($0) }
25762757 )
25772758 ) {
2578 ForEach(MonitoringFrequency.allCases) { frequency in
2579 Text(frequency.title).tag(frequency)
2759 ForEach(MonitoringBaseInterval.allCases) { interval in
2760 Text(interval.title).tag(interval)
2761 }
2762 }
2763
2764 Toggle(
2765 "Adaptive Monitoring",
2766 isOn: Binding(
2767 get: { viewModel.monitoringSettings.adaptiveEnabled },
2768 set: { viewModel.setMonitoringAdaptiveEnabled($0) }
2769 )
2770 )
2771
2772 Picker(
2773 "Sensitivity",
2774 selection: Binding(
2775 get: { viewModel.monitoringSettings.sensitivity },
2776 set: { viewModel.setMonitoringSensitivity($0) }
2777 )
2778 ) {
2779 ForEach(MonitoringSensitivity.allCases) { sensitivity in
2780 Text(sensitivity.title).tag(sensitivity)
2781 }
2782 }
2783
2784 let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22
2785 let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7
2786 Toggle(
2787 "Quiet Hours",
2788 isOn: Binding(
2789 get: { viewModel.monitoringSettings.quietHours != nil },
2790 set: { isEnabled in
2791 viewModel.setMonitoringQuietHours(
2792 startHour: quietHoursStart,
2793 endHour: quietHoursEnd,
2794 isEnabled: isEnabled
2795 )
2796 }
2797 )
2798 )
2799
2800 if viewModel.monitoringSettings.quietHours != nil {
2801 Picker(
2802 "Quiet Starts",
2803 selection: Binding(
2804 get: { quietHoursStart },
2805 set: { startHour in
2806 viewModel.setMonitoringQuietHours(
2807 startHour: startHour,
2808 endHour: quietHoursEnd,
2809 isEnabled: true
2810 )
2811 }
2812 )
2813 ) {
2814 ForEach(0..<24, id: \.self) { hour in
2815 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
2816 }
2817 }
2818
2819 Picker(
2820 "Quiet Ends",
2821 selection: Binding(
2822 get: { quietHoursEnd },
2823 set: { endHour in
2824 viewModel.setMonitoringQuietHours(
2825 startHour: quietHoursStart,
2826 endHour: endHour,
2827 isEnabled: true
2828 )
2829 }
2830 )
2831 ) {
2832 ForEach(0..<24, id: \.self) { hour in
2833 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
2834 }
25802835 }
25812836 }
25822837
@@ -2647,8 +2902,65 @@ struct SettingsView: View {
26472902 .foregroundStyle(.secondary)
26482903 }
26492904 }
2905 }
2906 .navigationTitle("Monitoring")
2907 .onAppear {
2908 viewModel.refreshMonitoringState()
2909 Task {
2910 await viewModel.refreshMonitoringAuthorizationStatus()
2911 }
2912 }
2913 }
26502914
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") {
26522964 Picker("Import Mode", selection: $importMode) {
26532965 ForEach(DataPortabilityImportMode.allCases) { mode in
26542966 Text(mode.title).tag(mode)
@@ -2664,7 +2976,9 @@ struct SettingsView: View {
26642976 }
26652977
26662978 Button("Import Backup") {
2667 showBackupImporter = true
2979 recordImportDebugStatus("Tapped Import Backup")
2980 pendingImportTarget = .backup
2981 activeImportTarget = .backup
26682982 }
26692983
26702984 Menu("Export Tracked Domains") {
@@ -2677,7 +2991,9 @@ struct SettingsView: View {
26772991 }
26782992
26792993 Button("Import Tracked Domains") {
2680 showTrackedDomainsImporter = true
2994 recordImportDebugStatus("Tapped Import Tracked Domains")
2995 pendingImportTarget = .trackedDomains
2996 activeImportTarget = .trackedDomains
26812997 }
26822998
26832999 Menu("Export Workflows") {
@@ -2690,13 +3006,17 @@ struct SettingsView: View {
26903006 }
26913007
26923008 Button("Import Workflows") {
2693 showWorkflowsImporter = true
3009 recordImportDebugStatus("Tapped Import Workflows")
3010 pendingImportTarget = .workflows
3011 activeImportTarget = .workflows
26943012 }
26953013
26963014 Button("Export History") {
26973015 exportPortableHistoryJSON()
26983016 }
3017 }
26993018
3019 Section("Local Data") {
27003020 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
27013021 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
27023022 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
@@ -2714,121 +3034,18 @@ struct SettingsView: View {
27143034 }
27153035 }
27163036
2717 Section("Data") {
2718 Button("Clear History", role: .destructive) {
2719 showClearHistoryConfirmation = true
2720 }
2721
2722 Button("Clear Cache", role: .destructive) {
2723 showClearCacheConfirmation = true
2724 }
2725
2726 Button("Clear Workflows", role: .destructive) {
2727 showClearWorkflowsConfirmation = true
2728 }
2729
2730 Button("Clear Tracked Domains", role: .destructive) {
2731 showClearTrackedDomainsConfirmation = true
2732 }
2733 }
2734
2735 Section("Tier") {
2736 LabeledContent("Status", value: purchaseService.currentTier.title)
2737
2738 if purchaseService.currentTier == .free {
2739 Button("Upgrade") {
2740 viewModel.isPaywallPresented = true
2741 }
2742 } else {
2743 Button("Manage Subscription") {
2744 Task {
2745 await purchaseService.manageSubscription()
2746 }
2747 }
2748 }
2749
2750 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
2751 Task {
2752 await purchaseService.restorePurchases()
2753 }
2754 }
2755 .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
2756
2757 if let statusMessage = purchaseService.statusMessage {
2758 Text(statusMessage)
3037 #if DEBUG
3038 if let importDebugStatus {
3039 Section("Import Debug") {
3040 Text(importDebugStatus)
27593041 .font(appDensity.font(.caption, design: .default))
27603042 .foregroundStyle(.secondary)
3043 .textSelection(.enabled)
27613044 }
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)")
27973045 }
3046 #endif
27983047 }
2799 .navigationTitle("Settings")
2800 .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
2801 Button("Clear", role: .destructive) {
2802 viewModel.clearHistory()
2803 }
2804 Button("Cancel", role: .cancel) {}
2805 } message: {
2806 Text("This removes saved lookup snapshots from this device.")
2807 }
2808 .alert("Clear cache?", isPresented: $showClearCacheConfirmation) {
2809 Button("Clear", role: .destructive) {
2810 viewModel.clearLookupCache()
2811 }
2812 Button("Cancel", role: .cancel) {}
2813 } message: {
2814 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
2815 }
2816 .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) {
2817 Button("Clear", role: .destructive) {
2818 viewModel.clearWorkflows()
2819 }
2820 Button("Cancel", role: .cancel) {}
2821 } message: {
2822 Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.")
2823 }
2824 .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) {
2825 Button("Clear", role: .destructive) {
2826 viewModel.clearTrackedDomains()
2827 }
2828 Button("Cancel", role: .cancel) {}
2829 } message: {
2830 Text("This removes the watchlist only. History and workflows stay intact.")
2831 }
3048 .navigationTitle("Import & Export")
28323049 .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
28333050 Button("Replace", role: .destructive) {
28343051 applyPendingImport()
@@ -2867,83 +3084,24 @@ struct SettingsView: View {
28673084 }
28683085 }
28693086 .fileImporter(
2870 isPresented: $showBackupImporter,
2871 allowedContentTypes: [UTType.json],
3087 isPresented: Binding(
3088 get: { activeImportTarget != nil },
3089 set: { if !$0 { activeImportTarget = nil } }
3090 ),
3091 allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json],
28723092 allowsMultipleSelection: false
28733093 ) { result in
2874 handleImportResult(result, expectedKind: .backup)
2875 }
2876 .fileImporter(
2877 isPresented: $showTrackedDomainsImporter,
2878 allowedContentTypes: [UTType.json, UTType.commaSeparatedText],
2879 allowsMultipleSelection: false
2880 ) { result in
2881 handleImportResult(result, expectedKind: .trackedDomains)
2882 }
2883 .fileImporter(
2884 isPresented: $showWorkflowsImporter,
2885 allowedContentTypes: [UTType.json, UTType.commaSeparatedText],
2886 allowsMultipleSelection: false
2887 ) { result in
2888 handleImportResult(result, expectedKind: .workflows)
2889 }
2890 .onAppear {
2891 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2892 resolverOption = DNSResolverOption.option(for: currentResolverURL)
2893 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
2894 viewModel.refreshMonitoringState()
2895 viewModel.refreshDataLifecycleSummary()
2896 Task {
2897 await viewModel.refreshUsageCredits()
2898 await viewModel.refreshMonitoringAuthorizationStatus()
2899 await cloudSyncService.refreshAvailability()
2900 }
2901 }
2902 .onChange(of: resolverOption) { _, newValue in
2903 guard let presetURL = newValue.urlString else {
2904 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2905 viewModel.persistCurrentAppSettings(
2906 resolverURLString: storedResolverURL,
2907 appDensityRawValue: storedDensity
2908 )
3094 guard let pendingImportTarget else {
3095 recordImportDebugStatus("fileImporter returned with no active target")
29093096 return
29103097 }
2911 storedResolverURL = presetURL
2912 viewModel.persistCurrentAppSettings(
2913 resolverURLString: storedResolverURL,
2914 appDensityRawValue: storedDensity
2915 )
2916 }
2917 .onChange(of: customResolverURL) { _, newValue in
2918 guard resolverOption == .custom else { return }
2919 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
2920 viewModel.persistCurrentAppSettings(
2921 resolverURLString: storedResolverURL,
2922 appDensityRawValue: storedDensity
2923 )
3098 recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)")
3099 handleImportResult(result, expectedKind: pendingImportTarget.expectedKind)
3100 self.pendingImportTarget = nil
3101 self.activeImportTarget = nil
29243102 }
2925 .onChange(of: storedDensity) { _, newValue in
2926 viewModel.persistCurrentAppSettings(
2927 resolverURLString: storedResolverURL,
2928 appDensityRawValue: newValue
2929 )
2930 }
2931 }
2932
2933 private var appVersion: String {
2934 AppVersion.current
2935 }
2936
2937 private var notificationAuthorizationLabel: String {
2938 switch viewModel.monitoringNotificationStatus {
2939 case .authorized, .provisional, .ephemeral:
2940 return "Allowed"
2941 case .denied:
2942 return "Denied"
2943 case .notDetermined:
2944 return "Not Requested"
2945 @unknown default:
2946 return "Unknown"
3103 .onAppear {
3104 viewModel.refreshDataLifecycleSummary()
29473105 }
29483106 }
29493107
@@ -2985,32 +3143,55 @@ struct SettingsView: View {
29853143 _ result: Result<[URL], Error>,
29863144 expectedKind: DataPortabilityImportKind
29873145 ) {
3146 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)")
3147 recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)")
29883148 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)")
29903157 let shouldStopAccessing = url.startAccessingSecurityScopedResource()
3158 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)")
3159 recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)")
29913160 defer {
29923161 if shouldStopAccessing {
29933162 url.stopAccessingSecurityScopedResource()
3163 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased")
29943164 }
29953165 }
29963166
29973167 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)")
29983170 let preview = try viewModel.prepareDataImport(
29993171 data: data,
30003172 fileName: url.lastPathComponent,
30013173 mode: importMode
30023174 )
3175 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)")
3176 recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)")
30033177
30043178 guard preview.kind == expectedKind else {
3005 pendingImportError = preview.kind == .backup
3179 let message = preview.kind == .backup
30063180 ? "That file is a full backup. Use Import Backup."
30073181 : "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)
30083185 return
30093186 }
30103187
3011 pendingImportPreview = preview
3188 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)")
3189 recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)")
3190 presentImportPreview(preview)
30123191 } catch {
3013 pendingImportError = error.localizedDescription
3192 DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)")
3193 recordImportDebugStatus("Import failed: \(error.localizedDescription)")
3194 presentImportError(error.localizedDescription)
30143195 }
30153196 }
30163197
@@ -3029,6 +3210,118 @@ struct SettingsView: View {
30293210 formatter.dateFormat = "yyyyMMdd_HHmmss"
30303211 return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)"
30313212 }
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 }
30323325}
30333326
30343327private struct DataImportPreviewSheet: View {
DomainDig/DomainDigUI.swift +16 −1
@@ -235,6 +235,21 @@ struct EmptyStateCardView: View {
235235 let message: String
236236 let suggestion: String
237237 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 }
238253
239254 var body: some View {
240255 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
@@ -253,7 +268,7 @@ struct EmptyStateCardView: View {
253268 }
254269 .frame(maxWidth: .infinity, alignment: .leading)
255270 .padding(appDensity.metrics.cardPadding)
256 .background(Color(.systemGray6).opacity(0.45))
271 .background(showsCardBackground ? Color(.systemGray6).opacity(0.45) : Color.clear)
257272 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
258273 }
259274}
DomainDig/DomainMonitoringService.swift +330 −48
@@ -1,5 +1,6 @@
11import Foundation
22import UserNotifications
3import CryptoKit
34
45#if canImport(BackgroundTasks)
56import BackgroundTasks
@@ -132,9 +133,10 @@ final class DomainMonitoringScheduler {
132133 }
133134 #endif
134135
136 let trackedDomains = MonitoringStorage.loadTrackedDomains()
135137 let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
136138 request.earliestBeginDate = Date(
137 timeIntervalSinceNow: max(settings.frequency.schedulingInterval, 15 * 60)
139 timeIntervalSinceNow: max(Self.nextScheduleInterval(settings: settings, trackedDomains: trackedDomains), 15 * 60)
138140 )
139141
140142 do {
@@ -149,6 +151,38 @@ final class DomainMonitoringScheduler {
149151 #endif
150152 }
151153
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
152186#if canImport(BackgroundTasks)
153187 private func handleAppRefresh(task: BGAppRefreshTask) {
154188 _ = syncSchedule()
@@ -174,6 +208,7 @@ final class DomainMonitoringService {
174208
175209 private let inspectionService = DomainInspectionService()
176210 private let maxHistoryEntries = 250
211 private let calendar = Calendar.current
177212
178213 func performMonitoring(
179214 trigger: MonitoringRunTrigger,
@@ -200,9 +235,9 @@ final class DomainMonitoringService {
200235
201236 var history = MonitoringStorage.loadHistoryEntries()
202237 var mutableTrackedDomains = trackedDomains
203 let eligibleDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains)
238 let monitoredDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains)
204239
205 guard !eligibleDomains.isEmpty else {
240 guard !monitoredDomains.isEmpty else {
206241 let log = MonitoringLog(
207242 timestamp: Date(),
208243 trigger: trigger,
@@ -216,6 +251,24 @@ final class DomainMonitoringService {
216251 return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log)
217252 }
218253
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
219272 let notificationsAuthorized: Bool
220273 if settings.alertsEnabled {
221274 notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts()
@@ -225,6 +278,7 @@ final class DomainMonitoringService {
225278 var results: [MonitoringDomainResult] = []
226279 var errors: [String] = []
227280 var alertsTriggered = 0
281 let now = Date()
228282
229283 for trackedDomain in eligibleDomains {
230284 guard !Task.isCancelled else {
@@ -252,33 +306,32 @@ final class DomainMonitoringService {
252306 let alertDescriptor = alertDescriptor(
253307 previousSnapshot: previousSnapshot,
254308 snapshot: snapshot,
255 entry: savedEntry
309 entry: savedEntry,
310 sensitivity: settings.sensitivity
256311 )
257312
258313 if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
259 mutableTrackedDomains[index].lastMonitoredAt = Date()
260 }
261
262 if notificationsAuthorized,
263 let alertDescriptor,
264 alertDescriptor.severity >= settings.alertFilter.minimumSeverity {
265 await LocalNotificationService.shared.notifyMonitoringAlert(
266 domain: trackedDomain.domain,
267 message: alertDescriptor.message,
268 severity: alertDescriptor.severity
314 mutableTrackedDomains[index].lastMonitoredAt = now
315 let outcome = await processAdaptiveMonitoringState(
316 trackedDomain: mutableTrackedDomains[index],
317 snapshot: snapshot,
318 previousSnapshot: previousSnapshot,
319 alertDescriptor: alertDescriptor,
320 settings: settings,
321 notificationsAuthorized: notificationsAuthorized,
322 now: now
269323 )
270 alertsTriggered += 1
271 if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
272 mutableTrackedDomains[index].lastAlertAt = Date()
273 }
324 mutableTrackedDomains[index] = outcome.trackedDomain
325 alertsTriggered += outcome.alertsTriggered
274326 }
275327
276328 let result = MonitoringDomainResult(
277329 domain: trackedDomain.domain,
278330 historyEntryID: savedEntry?.id ?? snapshot.historyEntryID,
279 checkedAt: Date(),
280 didChange: snapshot.statusMessage == nil && savedEntry?.changeSummary?.hasChanges == true,
331 checkedAt: now,
332 didChange: alertDescriptor != nil,
281333 summaryMessage: snapshot.statusMessage
334 ?? alertDescriptor?.message
282335 ?? savedEntry?.changeSummary?.message
283336 ?? "No meaningful changes",
284337 alertSeverity: alertDescriptor?.severity,
@@ -431,54 +484,283 @@ final class DomainMonitoringService {
431484 return entry
432485 }
433486
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
434613 private func alertDescriptor(
435614 previousSnapshot: LookupSnapshot?,
436615 snapshot: LookupSnapshot,
437 entry: HistoryEntry?
438 ) -> (severity: MonitoringAlertSeverity, message: String)? {
616 entry: HistoryEntry?,
617 sensitivity: MonitoringSensitivity
618 ) -> MonitoringAlertDescriptor? {
619 guard let previousSnapshot else { return nil }
620 let previousHash = monitoringHash(for: previousSnapshot, sensitivity: sensitivity)
621 let currentHash = monitoringHash(for: snapshot, sensitivity: sensitivity)
622 guard previousHash != currentHash else { return nil }
623
439624 let changedLabels = Set(
440 (previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [])
625 DomainDiffService.diff(from: previousSnapshot, to: snapshot)
441626 .flatMap(\.items)
442627 .filter(\.hasChanges)
443628 .map(\.label)
444629 )
445630
446 if changedLabels.contains("Availability") {
447 return (.critical, "Availability changed")
631 if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
632 return MonitoringAlertDescriptor(severity: .warning, message: "DNS records changed", changeHash: currentHash)
448633 }
449 if changedLabels.contains("Primary IP") {
450 return (.critical, "Primary IP changed")
634
635 let oldCertificateLevel = DomainDiffService.certificateWarningLevel(for: previousSnapshot)
636 let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
637 if snapshot.sslInfo?.issuer != previousSnapshot.sslInfo?.issuer
638 || snapshot.sslInfo?.validUntil != previousSnapshot.sslInfo?.validUntil
639 || (newCertificateLevel != .none && newCertificateLevel != oldCertificateLevel) {
640 return MonitoringAlertDescriptor(severity: .warning, message: "Certificate updated", changeHash: currentHash)
451641 }
452 if changedLabels.contains("Redirect Target") {
453 return (.critical, "Redirect target changed")
642
643 if sensitivity != .low,
644 previousSnapshot.redirectChain.map(\.url) != snapshot.redirectChain.map(\.url)
645 || previousSnapshot.redirectChain.map(\.statusCode) != snapshot.redirectChain.map(\.statusCode) {
646 return MonitoringAlertDescriptor(severity: .warning, message: "Redirect chain changed", changeHash: currentHash)
454647 }
455648
456 let ownershipLabels: Set<String> = [
457 "Registrar",
458 "Registration Date",
459 "Expiration Date",
460 "Ownership Status",
461 "Abuse Contact"
462 ]
463 if !changedLabels.isDisjoint(with: ownershipLabels) {
464 return (.warning, "Ownership changed")
649 if sensitivity != .low,
650 filteredHTTPHeaders(previousSnapshot.httpHeaders, sensitivity: sensitivity)
651 != filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity) {
652 return MonitoringAlertDescriptor(severity: .info, message: "HTTP headers changed", changeHash: currentHash)
465653 }
466 if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
467 return (.warning, "DNS changed")
654
655 if sensitivity == .high,
656 entry?.changeSummary?.hasChanges == true,
657 let message = entry?.changeSummary?.message {
658 return MonitoringAlertDescriptor(severity: .info, message: message, changeHash: currentHash)
468659 }
469660
470 let oldCertificateLevel = previousSnapshot.map { DomainDiffService.certificateWarningLevel(for: $0) } ?? .none
471 let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
472 if newCertificateLevel != .none, newCertificateLevel != oldCertificateLevel {
473 let daysRemaining = snapshot.sslInfo?.daysUntilExpiry ?? 0
474 return (.warning, "Certificate expires in \(daysRemaining) days")
661 return nil
662 }
663
664 private func monitoringHash(for snapshot: LookupSnapshot, sensitivity: MonitoringSensitivity) -> String {
665 struct DNSRecordPayload: Encodable {
666 let type: String
667 let records: [String]
668 let wildcardRecords: [String]
475669 }
476670
477 if entry?.changeSummary?.hasChanges == true, let message = entry?.changeSummary?.message {
478 return (.info, message)
671 struct CertificatePayload: Encodable {
672 let issuer: String?
673 let commonName: String?
674 let validUntil: Date?
479675 }
480676
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 }
482764 }
483765
484766 private static func resolvedSnapshotAfterFallback(
DomainDig/DomainViewModel.swift +83 −55
@@ -756,6 +756,7 @@ final class DomainViewModel {
756756 func clearHistory() {
757757 history.removeAll()
758758 persistHistory()
759 clearMonitoringLogs()
759760 refreshDataLifecycleSummary()
760761 }
761762
@@ -779,6 +780,7 @@ final class DomainViewModel {
779780 refreshingTrackedDomainID = nil
780781 persistTrackedDomains()
781782 sanitizeMonitoringSelection()
783 clearMonitoringLogs()
782784 refreshDataLifecycleSummary()
783785 }
784786
@@ -842,16 +844,45 @@ final class DomainViewModel {
842844 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
843845 }
844846
845 func setMonitoringFrequency(_ frequency: MonitoringFrequency) {
847 func setMonitoringBaseInterval(_ baseInterval: MonitoringBaseInterval) {
846848 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
847849 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
848850 return
849851 }
850 monitoringSettings.frequency = frequency
852 monitoringSettings.baseInterval = baseInterval.interval
851853 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
852854 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
853855 }
854856
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
855886 func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) {
856887 guard FeatureAccessService.hasAccess(to: .localAlerts) else {
857888 monitoringSettings.alertsEnabled = false
@@ -928,6 +959,30 @@ final class DomainViewModel {
928959 }
929960 }
930961
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
931986 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) {
932987 if useSnapshotResolver {
933988 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
@@ -968,6 +1023,22 @@ final class DomainViewModel {
9681023 clearLookupState()
9691024 }
9701025
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
9711042 func run() {
9721043 let target = trimmedDomain
9731044 guard !target.isEmpty else { return }
@@ -1222,13 +1293,6 @@ final class DomainViewModel {
12221293 }
12231294 guard ownershipHistory.isEmpty else { return }
12241295
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
12321296 ownershipHistoryLoading = true
12331297 ownershipHistoryError = nil
12341298
@@ -1242,15 +1306,9 @@ final class DomainViewModel {
12421306 case let .success(events):
12431307 ownershipHistory = events
12441308 ownershipHistoryError = nil
1245 if outcome.source != .cached {
1246 _ = await UsageCreditService.shared.consume(.ownershipHistory)
1247 }
12481309 case let .empty(message):
12491310 ownershipHistory = []
12501311 ownershipHistoryError = message
1251 if outcome.source != .cached {
1252 _ = await UsageCreditService.shared.consume(.ownershipHistory)
1253 }
12541312 case let .error(message):
12551313 ownershipHistory = []
12561314 ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable")
@@ -1258,7 +1316,6 @@ final class DomainViewModel {
12581316
12591317 ownershipHistoryLoading = false
12601318 _ = saveHistoryEntry(replaceLatest: true)
1261 await refreshUsageCredits()
12621319 }
12631320
12641321 func loadDNSHistory() async {
@@ -1269,13 +1326,6 @@ final class DomainViewModel {
12691326 }
12701327 guard dnsHistory.isEmpty else { return }
12711328
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
12791329 dnsHistoryLoading = true
12801330 dnsHistoryError = nil
12811331
@@ -1289,15 +1339,9 @@ final class DomainViewModel {
12891339 case let .success(events):
12901340 dnsHistory = events
12911341 dnsHistoryError = nil
1292 if outcome.source != .cached {
1293 _ = await UsageCreditService.shared.consume(.dnsHistory)
1294 }
12951342 case let .empty(message):
12961343 dnsHistory = []
12971344 dnsHistoryError = message
1298 if outcome.source != .cached {
1299 _ = await UsageCreditService.shared.consume(.dnsHistory)
1300 }
13011345 case let .error(message):
13021346 dnsHistory = []
13031347 dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable")
@@ -1305,7 +1349,6 @@ final class DomainViewModel {
13051349
13061350 dnsHistoryLoading = false
13071351 _ = saveHistoryEntry(replaceLatest: true)
1308 await refreshUsageCredits()
13091352 }
13101353
13111354 func loadExtendedSubdomains() async {
@@ -1316,13 +1359,6 @@ final class DomainViewModel {
13161359 }
13171360 guard extendedSubdomains.isEmpty else { return }
13181361
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
13261362 extendedSubdomainsLoading = true
13271363 extendedSubdomainsError = nil
13281364
@@ -1335,15 +1371,9 @@ final class DomainViewModel {
13351371 case let .success(results):
13361372 extendedSubdomains = results
13371373 extendedSubdomainsError = nil
1338 if outcome.source != .cached {
1339 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
1340 }
13411374 case let .empty(message):
13421375 extendedSubdomains = []
13431376 extendedSubdomainsError = message
1344 if outcome.source != .cached {
1345 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
1346 }
13471377 case let .error(message):
13481378 extendedSubdomains = []
13491379 extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable")
@@ -1351,7 +1381,6 @@ final class DomainViewModel {
13511381
13521382 extendedSubdomainsLoading = false
13531383 _ = saveHistoryEntry(replaceLatest: true)
1354 await refreshUsageCredits()
13551384 }
13561385
13571386 func refreshUsageCredits() async {
@@ -1525,14 +1554,12 @@ final class DomainViewModel {
15251554 }
15261555
15271556 guard snapshot.statusMessage == nil else {
1528 await refreshUsageCredits()
15291557 return history.first(where: { $0.id == snapshot.historyEntryID })
15301558 }
15311559
15321560 let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain)
15331561 let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true)
15341562 DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain)
1535 await refreshUsageCredits()
15361563 DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain)
15371564 return entry
15381565 }
@@ -2246,6 +2273,12 @@ final class DomainViewModel {
22462273 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)
22472274 }
22482275
2276 private func clearMonitoringLogs() {
2277 monitoringLogs.removeAll()
2278 monitoringStatusMessage = nil
2279 MonitoringStorage.saveLogs([])
2280 }
2281
22492282 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
22502283 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
22512284 return
@@ -2533,14 +2566,9 @@ final class DomainViewModel {
25332566 return
25342567 }
25352568
2536 let entry: HistoryEntry?
2537 if payload.snapshot.statusMessage == nil {
2538 entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
2539 } else {
2540 entry = payload.snapshot.historyEntryID.flatMap { id in
2541 history.first(where: { $0.id == id })
2542 }
2543 }
2569 let entry = payload.snapshot.historyEntryID.flatMap { id in
2570 history.first(where: { $0.id == id })
2571 } ?? saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
25442572 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
25452573 let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
25462574 let quickStatus: String
@@ -3568,7 +3596,7 @@ final class DomainViewModel {
35683596 }
35693597 }
35703598 if !DataAccessService.hasAccess(to: .extendedSubdomains) {
3571 lines.append(" Extended subdomain discovery (Data+)")
3599 lines.append(" Extended subdomain discovery (Pro+)")
35723600 }
35733601 }
35743602
DomainDig/FeatureAccessService.swift +7 −7
@@ -3,7 +3,7 @@ import Foundation
33enum FeatureTier: String, Codable, CaseIterable, Identifiable {
44 case free
55 case pro
6 case dataPlus
6 case proPlus = "dataPlus"
77
88 var id: String { rawValue }
99
@@ -13,8 +13,8 @@ enum FeatureTier: String, Codable, CaseIterable, Identifiable {
1313 return "Free"
1414 case .pro:
1515 return "Pro"
16 case .dataPlus:
17 return "Data+"
16 case .proPlus:
17 return "Pro+"
1818 }
1919 }
2020}
@@ -114,9 +114,9 @@ enum FeatureAccessService {
114114 workflowLimit: nil,
115115 batchSizeLimit: nil
116116 )
117 case .dataPlus:
117 case .proPlus:
118118 return FeatureEntitlements(
119 tier: .dataPlus,
119 tier: .proPlus,
120120 capabilities: Set(FeatureCapability.allCases),
121121 trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
122122 workflowLimit: nil,
@@ -158,7 +158,7 @@ enum FeatureAccessService {
158158 case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports:
159159 return "Available in Pro"
160160 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
161 return "Available in Data+"
161 return "Available in Pro+"
162162 case .limitedTracking:
163163 return "Tracking is limited on Free"
164164 case .singleLookup, .basicHistory:
@@ -220,7 +220,7 @@ enum FeatureAccessService {
220220 let title: String
221221 switch capability {
222222 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
223 title = "Available in Data+"
223 title = "Available in Pro+"
224224 default:
225225 title = "Available in Pro"
226226 }
DomainDig/HistoryView.swift +2 −1
@@ -22,7 +22,8 @@ struct HistoryView: View {
2222 title: "No History Yet",
2323 message: "History stores local snapshots of previous inspections so you can revisit and compare them later.",
2424 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
2627 )
2728 .listRowBackground(Color(.systemGray6).opacity(0.5))
2829 } else {
DomainDig/LocalNotificationService.swift +29
@@ -78,6 +78,35 @@ final class LocalNotificationService {
7878 )
7979 }
8080
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
81110 func notifySweepComplete(summary: BatchSweepSummary) async {
82111 let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
83112 await schedule(
DomainDig/Models.swift +261 −31
@@ -728,6 +728,179 @@ struct CollaborationMetadata: Codable, Equatable {
728728 }
729729}
730730
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
731904struct TrackedDomain: Codable, Identifiable, Equatable {
732905 let id: UUID
733906 var domain: String
@@ -744,6 +917,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
744917 var certificateDaysRemaining: Int?
745918 var lastMonitoredAt: Date?
746919 var lastAlertAt: Date?
920 var monitoringState: MonitoringState
921 var pendingMonitoringAlerts: [MonitoringPendingAlert]
747922 var collaboration: CollaborationMetadata?
748923
749924 init(
@@ -762,6 +937,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
762937 certificateDaysRemaining: Int? = nil,
763938 lastMonitoredAt: Date? = nil,
764939 lastAlertAt: Date? = nil,
940 monitoringState: MonitoringState = MonitoringState(),
941 pendingMonitoringAlerts: [MonitoringPendingAlert] = [],
765942 collaboration: CollaborationMetadata? = nil
766943 ) {
767944 self.id = id
@@ -779,6 +956,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
779956 self.certificateDaysRemaining = certificateDaysRemaining
780957 self.lastMonitoredAt = lastMonitoredAt
781958 self.lastAlertAt = lastAlertAt
959 self.monitoringState = monitoringState
960 self.pendingMonitoringAlerts = pendingMonitoringAlerts
782961 self.collaboration = collaboration
783962 }
784963
@@ -799,35 +978,12 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
799978 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
800979 lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
801980 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) ?? []
802983 collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
803984 }
804985}
805986
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
831987enum MonitoringScope: String, Codable, CaseIterable, Identifiable {
832988 case allTracked
833989 case selectedOnly
@@ -913,28 +1069,102 @@ enum MonitoringRunTrigger: String, Codable {
9131069}
9141070
9151071struct MonitoringSettings: Codable, Equatable {
916 var isEnabled: Bool
917 var frequency: MonitoringFrequency
1072 private enum CodingKeys: String, CodingKey {
1073 case config
1074 case isEnabled
1075 case frequency
1076 case scope
1077 case selectedDomainIDs
1078 case alertFilter
1079 case alertsEnabled
1080 }
1081
1082 var config: MonitoringConfig
9181083 var scope: MonitoringScope
9191084 var selectedDomainIDs: [UUID]
9201085 var alertFilter: MonitoringAlertFilter
9211086 var alertsEnabled: Bool
9221087
9231088 init(
924 isEnabled: Bool = false,
925 frequency: MonitoringFrequency = .daily,
1089 config: MonitoringConfig = MonitoringConfig(),
9261090 scope: MonitoringScope = .allTracked,
9271091 selectedDomainIDs: [UUID] = [],
9281092 alertFilter: MonitoringAlertFilter = .criticalAndWarnings,
9291093 alertsEnabled: Bool = false
9301094 ) {
931 self.isEnabled = isEnabled
932 self.frequency = frequency
1095 self.config = config
9331096 self.scope = scope
9341097 self.selectedDomainIDs = selectedDomainIDs
9351098 self.alertFilter = alertFilter
9361099 self.alertsEnabled = alertsEnabled
9371100 }
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 }
9381168}
9391169
9401170struct MonitoringDomainResult: Codable, Identifiable, Equatable {
DomainDig/MonitoringView.swift +17 −2
@@ -17,7 +17,14 @@ struct MonitoringView: View {
1717 VStack(alignment: .leading, spacing: 8) {
1818 LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only")
1919 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 )
2128 LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off")
2229
2330 if let monitoringStatusMessage = viewModel.monitoringStatusMessage,
@@ -42,7 +49,8 @@ struct MonitoringView: View {
4249 title: "No Monitoring Runs Yet",
4350 message: "Monitoring history appears here after manual or background runs finish.",
4451 suggestion: "Enable monitoring in Settings or run a manual monitoring sweep.",
45 systemImage: "waveform.path.ecg"
52 systemImage: "waveform.path.ecg",
53 showsCardBackground: false
4654 )
4755 }
4856 .listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -102,6 +110,13 @@ struct MonitoringView: View {
102110 .background(tint.opacity(0.16))
103111 .clipShape(Capsule())
104112 }
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 }
105120}
106121
107122struct MonitoringLogDetailView: View {
DomainDig/PaywallView.swift +6 −6
@@ -10,7 +10,7 @@ struct PaywallView: View {
1010 NavigationStack {
1111 List {
1212 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.")
1414 .font(appDensity.font(.body, design: .default))
1515 .foregroundStyle(.secondary)
1616 .fixedSize(horizontal: false, vertical: true)
@@ -25,7 +25,7 @@ struct PaywallView: View {
2525 featureRow("Advanced exports")
2626 }
2727
28 Section("What Data+ Unlocks") {
28 Section("What Pro+ Unlocks") {
2929 featureRow("Ownership history")
3030 featureRow("DNS history")
3131 featureRow("Extended subdomains")
@@ -125,10 +125,10 @@ struct PaywallView: View {
125125 return "Pro Monthly"
126126 case PurchaseService.yearlyProductID:
127127 return "Pro Yearly"
128 case PurchaseService.dataPlusMonthlyProductID:
129 return "Data+ Monthly"
130 case PurchaseService.dataPlusYearlyProductID:
131 return "Data+ Yearly"
128 case PurchaseService.proPlusMonthlyProductID:
129 return "Pro+ Monthly"
130 case PurchaseService.proPlusYearlyProductID:
131 return "Pro+ Yearly"
132132 default:
133133 return product.displayName
134134 }
DomainDig/PurchaseService.swift +66 −11
@@ -17,18 +17,30 @@ final class PurchaseService {
1717 static let shared = PurchaseService()
1818 static let monthlyProductID = "domaindig.pro.monthly"
1919 static let yearlyProductID = "domaindig.pro.yearly"
20 static let dataPlusMonthlyProductID = "domaindig.dataplus.monthly"
21 static let dataPlusYearlyProductID = "domaindig.dataplus.yearly"
20 static let proPlusMonthlyProductID = "domaindig.dataplus.monthly"
21 static let proPlusYearlyProductID = "domaindig.dataplus.yearly"
2222 static let productIDs = [
2323 monthlyProductID,
2424 yearlyProductID,
25 dataPlusMonthlyProductID,
26 dataPlusYearlyProductID
25 proPlusMonthlyProductID,
26 proPlusYearlyProductID
2727 ]
2828
2929 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
3036
3137 static var cachedEntitlement: CachedEntitlement? {
38 #if DEBUG
39 if let forcedEntitlement = debugForcedEntitlement {
40 return forcedEntitlement
41 }
42 #endif
43
3244 guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
3345 return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
3446 }
@@ -51,6 +63,7 @@ final class PurchaseService {
5163 private init() {
5264 currentTier = Self.cachedTier
5365 activeProductID = Self.cachedEntitlement?.activeProductID
66 applyDebugOverrideIfNeeded()
5467 updatesTask = observeTransactionUpdates()
5568 Task {
5669 await refreshProducts()
@@ -62,8 +75,8 @@ final class PurchaseService {
6275 currentTier != .free
6376 }
6477
65 var hasDataPlusAccess: Bool {
66 currentTier == .dataPlus
78 var hasProPlusAccess: Bool {
79 currentTier == .proPlus
6780 }
6881
6982 func refreshProducts() async {
@@ -104,6 +117,7 @@ final class PurchaseService {
104117 self.activeProductID = activeProductID
105118 currentTier = tier(for: activeProductID)
106119 persistCurrentEntitlement()
120 applyDebugOverrideIfNeeded()
107121 }
108122
109123 func purchase(_ product: Product) async {
@@ -120,7 +134,7 @@ final class PurchaseService {
120134 apply(transaction: transaction)
121135 await transaction.finish()
122136 await refreshEntitlements()
123 statusMessage = currentTier == .dataPlus ? "Data+ is active." : "Pro is active."
137 statusMessage = currentTier == .proPlus ? "Pro+ is active." : "Pro is active."
124138 case .userCancelled:
125139 break
126140 case .pending:
@@ -190,6 +204,7 @@ final class PurchaseService {
190204 activeProductID = transaction.productID
191205 currentTier = tier(for: transaction.productID)
192206 persistCurrentEntitlement()
207 applyDebugOverrideIfNeeded()
193208 }
194209
195210 private func observeTransactionUpdates() -> Task<Void, Never> {
@@ -220,6 +235,14 @@ final class PurchaseService {
220235 }
221236 }
222237
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
223246 private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
224247 switch result {
225248 case .verified(let transaction):
@@ -235,9 +258,9 @@ final class PurchaseService {
235258 return 0
236259 case Self.yearlyProductID:
237260 return 1
238 case Self.dataPlusMonthlyProductID:
261 case Self.proPlusMonthlyProductID:
239262 return 2
240 case Self.dataPlusYearlyProductID:
263 case Self.proPlusYearlyProductID:
241264 return 3
242265 default:
243266 return Int.max
@@ -248,8 +271,8 @@ final class PurchaseService {
248271 switch productID {
249272 case Self.monthlyProductID, Self.yearlyProductID:
250273 return .pro
251 case Self.dataPlusMonthlyProductID, Self.dataPlusYearlyProductID:
252 return .dataPlus
274 case Self.proPlusMonthlyProductID, Self.proPlusYearlyProductID:
275 return .proPlus
253276 default:
254277 return .free
255278 }
@@ -268,4 +291,36 @@ final class PurchaseService {
268291 let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
269292 return message.isEmpty ? fallback : message
270293 }
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
271326}
DomainDig/RootTabView.swift +1 −8
@@ -34,18 +34,11 @@ struct RootTabView: View {
3434 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
3535 }
3636
37 NavigationStack {
38 WorkflowsView(viewModel: viewModel)
39 }
40 .tabItem {
41 Label("Workflows", systemImage: "square.stack.3d.down.right")
42 }
43
4437 NavigationStack {
4538 SettingsView(viewModel: viewModel)
4639 }
4740 .tabItem {
48 Label("Settings", systemImage: "gearshape")
41 Label("More", systemImage: "ellipsis.circle")
4942 }
5043 }
5144 .sheet(isPresented: Binding(
DomainDig/TimelineView.swift +24 −36
@@ -12,6 +12,10 @@ struct TimelineView: View {
1212 viewModel.timelineSections(for: domain)
1313 }
1414
15 private var compareButtonDisabled: Bool {
16 viewModel.selectedSnapshots.count != 2 && viewModel.historyEntries(for: domain).count < 2
17 }
18
1519 var body: some View {
1620 List {
1721 ForEach(timelineSections) { section in
@@ -61,10 +65,16 @@ struct TimelineView: View {
6165 }
6266
6367 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 }
6575 focusedSectionID = viewModel.currentDiffTargetSectionID
6676 }
67 .disabled(viewModel.selectedSnapshots.count != 2)
77 .disabled(compareButtonDisabled)
6878
6979 Menu("Export") {
7080 Button("Export TXT") {
@@ -119,46 +129,24 @@ private struct TimelineRow: View {
119129
120130 HStack(spacing: 8) {
121131 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 }
142132 if let riskScore = summary.riskScore {
143 AppStatusBadgeView(
144 model: .init(
145 title: "Risk \(riskScore)",
146 systemImage: "exclamationmark.shield",
147 foregroundColor: riskScore >= 70 ? .red : .orange,
148 backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16)
149 )
150 )
133 Text("Risk \(riskScore)")
134 .lineLimit(1)
135 .minimumScaleFactor(0.85)
151136 }
152137 }
138 .font(appDensity.font(.caption2))
139 .foregroundStyle(.secondary)
153140
154141 HStack(spacing: 8) {
155 if let snapshotIndex = summary.snapshotIndex {
156 Text("#\(snapshotIndex)")
157 }
158 Text(summary.timestamp.formatted(.relative(presentation: .named)))
159 if summary.changeCount > 0 {
160 Text("\(summary.changeCount) changes")
142 if let primaryIP = summary.primaryIP {
143 Text(primaryIP)
144 .lineLimit(1)
145 .truncationMode(.middle)
161146 }
147 Spacer(minLength: 8)
148 Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
149 .lineLimit(1)
162150 }
163151 .font(appDensity.font(.caption2))
164152 .foregroundStyle(.secondary)
DomainDig/WatchlistView.swift +93 −3
@@ -6,6 +6,10 @@ struct WatchlistView: View {
66 @Environment(\.dismiss) private var dismiss
77 @State private var purchaseService = PurchaseService.shared
88 @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
913
1014 private var pinnedDomains: [TrackedDomain] {
1115 viewModel.filteredTrackedDomains.filter(\.isPinned)
@@ -53,7 +57,8 @@ struct WatchlistView: View {
5357 title: "No Tracked Domains",
5458 message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.",
5559 suggestion: "Run an inspection and use the Track action on a domain you care about.",
56 systemImage: "eye"
60 systemImage: "eye",
61 showsCardBackground: false
5762 )
5863 }
5964 .listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -82,8 +87,16 @@ struct WatchlistView: View {
8287 .navigationTitle("Watchlist")
8388 .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains")
8489 .toolbar {
85 if !viewModel.filteredTrackedDomains.isEmpty {
86 ToolbarItemGroup(placement: .topBarTrailing) {
90 ToolbarItemGroup(placement: .topBarTrailing) {
91 Button {
92 addDomainError = nil
93 newTrackedDomain = ""
94 showAddDomainSheet = true
95 } label: {
96 Image(systemName: "plus")
97 }
98
99 if !viewModel.filteredTrackedDomains.isEmpty {
87100 Menu {
88101 Picker("Filter", selection: $viewModel.watchlistFilter) {
89102 ForEach(WatchlistFilterOption.allCases) { option in
@@ -139,6 +152,52 @@ struct WatchlistView: View {
139152 .sheet(item: batchSummaryBinding) { summary in
140153 BatchSweepSummaryView(viewModel: viewModel, summary: summary)
141154 }
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 }
142201 .sheet(isPresented: $showWorkflowAddSheet) {
143202 WorkflowBulkAddSheet(
144203 viewModel: viewModel,
@@ -271,6 +330,24 @@ struct WatchlistView: View {
271330
272331 ExportPresenter.share(filename: filename, data: data)
273332 }
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 }
274351}
275352
276353struct WatchlistRowView: View {
@@ -468,6 +545,19 @@ struct TrackedDomainDetailView: View {
468545 }
469546 .listRowBackground(Color(.systemGray6).opacity(0.5))
470547
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
471561 if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
472562 Section("Latest Change Summary") {
473563 DomainChangeSummaryView(summary: summary)
DomainDigCLI.swift +7 −42
@@ -36,7 +36,6 @@ struct DomainDigCLI {
3636 let wantsDNSHistory = arguments.contains("--dns-history")
3737 let wantsExtendedSubdomains = arguments.contains("--extended-subdomains")
3838 let wantsPricing = arguments.contains("--pricing")
39 let wantsUsage = arguments.contains("--show-usage")
4039 let domains = arguments.filter { !$0.hasPrefix("-") }
4140
4241 let requestedDomains = domains
@@ -52,8 +51,6 @@ struct DomainDigCLI {
5251 let reportBuilder = DomainReportBuilder()
5352 var reports: [DomainReport] = []
5453 var seen = Set<String>()
55 var usageImpact: [String] = []
56
5754 for domain in requestedDomains {
5855 let normalizedDomain = domain.lowercased()
5956 guard seen.insert(normalizedDomain).inserted else { continue }
@@ -63,8 +60,7 @@ struct DomainDigCLI {
6360 wantsOwnershipHistory: wantsOwnershipHistory,
6461 wantsDNSHistory: wantsDNSHistory,
6562 wantsExtendedSubdomains: wantsExtendedSubdomains,
66 wantsPricing: wantsPricing,
67 usageImpact: &usageImpact
63 wantsPricing: wantsPricing
6864 )
6965 reports.append(reportBuilder.build(from: enrichedSnapshot))
7066 }
@@ -83,9 +79,6 @@ struct DomainDigCLI {
8379 title: "DomainDig Batch Report"
8480 )
8581 }
86 if wantsUsage, !usageImpact.isEmpty {
87 FileHandle.standardError.write(Data(("Data+ usage impact: " + usageImpact.joined(separator: ", ") + "\n").utf8))
88 }
8982 FileHandle.standardOutput.write(data)
9083 if data.last != 0x0A {
9184 FileHandle.standardOutput.write(Data([0x0A]))
@@ -101,10 +94,9 @@ struct DomainDigCLI {
10194 wantsOwnershipHistory: Bool,
10295 wantsDNSHistory: Bool,
10396 wantsExtendedSubdomains: Bool,
104 wantsPricing: Bool,
105 usageImpact: inout [String]
97 wantsPricing: Bool
10698 ) async -> LookupSnapshot {
107 guard FeatureAccessService.currentTier == .dataPlus else {
99 guard FeatureAccessService.currentTier == .proPlus else {
108100 return snapshot
109101 }
110102
@@ -118,8 +110,7 @@ struct DomainDigCLI {
118110 var domainPricing = snapshot.domainPricing
119111 var domainPricingError = snapshot.domainPricingError
120112
121 if wantsOwnershipHistory,
122 await UsageCreditService.shared.canUse(.ownershipHistory) {
113 if wantsOwnershipHistory {
123114 let outcome = await ExternalDataService.shared.ownershipHistory(
124115 domain: snapshot.domain,
125116 currentOwnership: snapshot.ownership,
@@ -129,23 +120,14 @@ struct DomainDigCLI {
129120 case let .success(events):
130121 ownershipHistory = events
131122 ownershipHistoryError = nil
132 if outcome.source != .cached {
133 _ = await UsageCreditService.shared.consume(.ownershipHistory)
134 usageImpact.append("ownership history -1")
135 }
136123 case let .empty(message):
137124 ownershipHistoryError = message
138 if outcome.source != .cached {
139 _ = await UsageCreditService.shared.consume(.ownershipHistory)
140 usageImpact.append("ownership history -1")
141 }
142125 case let .error(message):
143126 ownershipHistoryError = message
144127 }
145128 }
146129
147 if wantsDNSHistory,
148 await UsageCreditService.shared.canUse(.dnsHistory) {
130 if wantsDNSHistory {
149131 let outcome = await ExternalDataService.shared.dnsHistory(
150132 domain: snapshot.domain,
151133 dnsSections: snapshot.dnsSections,
@@ -155,23 +137,14 @@ struct DomainDigCLI {
155137 case let .success(events):
156138 dnsHistory = events
157139 dnsHistoryError = nil
158 if outcome.source != .cached {
159 _ = await UsageCreditService.shared.consume(.dnsHistory)
160 usageImpact.append("dns history -1")
161 }
162140 case let .empty(message):
163141 dnsHistoryError = message
164 if outcome.source != .cached {
165 _ = await UsageCreditService.shared.consume(.dnsHistory)
166 usageImpact.append("dns history -1")
167 }
168142 case let .error(message):
169143 dnsHistoryError = message
170144 }
171145 }
172146
173 if wantsExtendedSubdomains,
174 await UsageCreditService.shared.canUse(.extendedSubdomains) {
147 if wantsExtendedSubdomains {
175148 let outcome = await ExternalDataService.shared.extendedSubdomains(
176149 domain: snapshot.domain,
177150 existing: snapshot.subdomains
@@ -180,16 +153,8 @@ struct DomainDigCLI {
180153 case let .success(results):
181154 extendedSubdomains = results
182155 extendedSubdomainsError = nil
183 if outcome.source != .cached {
184 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
185 usageImpact.append("extended subdomains -1")
186 }
187156 case let .empty(message):
188157 extendedSubdomainsError = message
189 if outcome.source != .cached {
190 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
191 usageImpact.append("extended subdomains -1")
192 }
193158 case let .error(message):
194159 extendedSubdomainsError = message
195160 }
@@ -538,7 +503,7 @@ struct DomainDigCLI {
538503
539504 private static var usageText: String {
540505 """
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]
542507 domaindig history <domain> [--json]
543508 domaindig diff <domain> --from <id> --to <id> [--json]
544509 domaindig monitor [--json]