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