summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-22 13:47:37 -0500
committerChristian Cleberg <[email protected]>2026-04-22 13:47:37 -0500
commit6c23daaaba2a954220ece2afa7193dbdcad55c22 (patch)
treefebfca71eba8a025374d993e7a39fdb13194af25
parentdcf4135ec376d357b8fafc0101a75b674e76329a (diff)
downloaddomain-dig-6c23daaaba2a954220ece2afa7193dbdcad55c22.tar.gz
domain-dig-6c23daaaba2a954220ece2afa7193dbdcad55c22.tar.bz2
domain-dig-6c23daaaba2a954220ece2afa7193dbdcad55c22.zip
feat(v3.2.0): add Data+ tier and external data integrations
- introduce Data+ tier for advanced data features - add ownership history and DNS history - expand subdomain discovery with external sources - add domain pricing insights - implement local usage/credit system - integrate external data service layer with rate limiting - extend export and CLI for Data+ features
-rw-r--r--DomainDig/AppVersion.swift4
-rw-r--r--DomainDig/ContentView.swift438
-rw-r--r--DomainDig/DataAccessService.swift2
-rw-r--r--DomainDig/DomainViewModel.swift338
-rw-r--r--DomainDig/ExternalDataService.swift627
-rw-r--r--DomainDig/FeatureAccessService.swift16
-rw-r--r--DomainDig/HistoryView.swift3
-rw-r--r--DomainDig/Models.swift158
-rw-r--r--DomainDig/PaywallView.swift13
-rw-r--r--DomainDig/PurchaseService.swift36
-rw-r--r--DomainDig/RDAPService.swift5
-rw-r--r--DomainDig/SubdomainDiscoveryService.swift2
-rw-r--r--DomainDig/UsageCreditService.swift73
-rw-r--r--DomainDigCLI.swift208
-rw-r--r--DomainInspectionService.swift8
-rw-r--r--DomainReportBuilder.swift10
-rw-r--r--DomainReportExporter.swift75
-rw-r--r--LookupSnapshot.swift16
18 files changed, 1935 insertions, 97 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index d9f3fc7..f28a017 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -1,7 +1,7 @@
import Foundation
enum AppVersion {
- static var current: String {
- "3.1.0"
+ nonisolated static var current: String {
+ "3.2.0"
}
}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index e6e7a41..a4e8db3 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -71,62 +71,11 @@ struct ContentView: View {
.padding(.top, appDensity.metrics.cardSpacing)
}
}
- DomainSectionView(
- isCollapsed: sectionCollapsedBinding(.domain),
- rows: viewModel.domainRows,
- suggestions: viewModel.suggestionRows,
- showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
- availabilityLoading: viewModel.availabilityLoading,
- suggestionsLoading: viewModel.suggestionsLoading,
- provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
- confidence: viewModel.currentSnapshot.availabilityConfidence,
- snapshotNote: viewModel.currentSnapshot.note,
- trackedDomain: viewModel.currentTrackedDomain,
- workflows: viewModel.currentDomainWorkflows,
- trackingLimitMessage: viewModel.trackingLimitMessage,
- onTrack: {
- _ = viewModel.trackCurrentDomain()
- },
- onTogglePinned: {
- guard let trackedDomain = viewModel.currentTrackedDomain else { return }
- viewModel.togglePinned(for: trackedDomain)
- },
- onEditNote: {
- guard let trackedDomain = viewModel.currentTrackedDomain else { return }
- trackingNoteDraft = trackedDomain.note ?? ""
- editingTrackedDomain = trackedDomain
- },
- onAddToWorkflow: {
- showingCurrentDomainWorkflowSheet = true
- },
- onOpenWorkflow: { workflow in
- navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
- },
- onRunWorkflow: { workflow in
- viewModel.rerunCurrentDomain(in: workflow)
- }
- )
+ domainOverviewSection
.padding(.top, appDensity.metrics.sectionSpacing)
- OwnershipSectionView(
- isCollapsed: sectionCollapsedBinding(.ownership),
- rows: viewModel.ownershipRows,
- loading: viewModel.ownershipLoading,
- error: viewModel.ownershipError,
- provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
- confidence: viewModel.currentSnapshot.ownershipConfidence,
- showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
- )
+ ownershipSection
.padding(.top, appDensity.metrics.sectionSpacing)
- SubdomainsSectionView(
- isCollapsed: sectionCollapsedBinding(.subdomains),
- rows: viewModel.subdomainRows,
- groups: viewModel.currentSubdomainGroups,
- loading: viewModel.subdomainsLoading,
- error: viewModel.subdomainsError,
- provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
- confidence: viewModel.currentSnapshot.subdomainConfidence,
- showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
- )
+ subdomainsSection
.padding(.top, appDensity.metrics.sectionSpacing)
if !viewModel.currentDiffSections.isEmpty {
DomainDiffView(
@@ -137,17 +86,7 @@ struct ContentView: View {
)
.padding(.top, appDensity.metrics.sectionSpacing)
}
- DNSSectionView(
- isCollapsed: sectionCollapsedBinding(.dns),
- dnssecLabel: viewModel.dnssecLabel,
- patternSummary: viewModel.currentDNSPatterns,
- sections: viewModel.dnsRows,
- ptrMessage: viewModel.ptrMessage,
- loading: viewModel.dnsLoading || viewModel.ptrLoading,
- dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
- ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
- sectionError: viewModel.dnsError
- )
+ dnsSection
.padding(.top, appDensity.metrics.sectionSpacing)
WebSectionView(
isCollapsed: sectionCollapsedBinding(.web),
@@ -240,6 +179,9 @@ struct ContentView: View {
.onAppear {
domainFieldFocused = true
}
+ .task {
+ await viewModel.refreshUsageCredits()
+ }
.onChange(of: viewModel.searchedDomain) { _, _ in
collapsedSections = defaultCollapsedSections
}
@@ -372,6 +314,117 @@ struct ContentView: View {
.padding(.vertical, appDensity.metrics.sectionSpacing)
}
+ private var domainOverviewSection: some View {
+ let trackedDomain = viewModel.currentTrackedDomain
+ let workflows = viewModel.currentDomainWorkflows
+
+ return DomainSectionView(
+ isCollapsed: sectionCollapsedBinding(.domain),
+ rows: viewModel.domainRows,
+ suggestions: viewModel.suggestionRows,
+ showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
+ availabilityLoading: viewModel.availabilityLoading,
+ suggestionsLoading: viewModel.suggestionsLoading,
+ provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
+ confidence: viewModel.currentSnapshot.availabilityConfidence,
+ snapshotNote: viewModel.currentSnapshot.note,
+ trackedDomain: trackedDomain,
+ workflows: workflows,
+ trackingLimitMessage: viewModel.trackingLimitMessage,
+ pricingLoading: viewModel.domainPricingLoading,
+ pricingError: viewModel.domainPricingError,
+ showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
+ onTrack: {
+ _ = viewModel.trackCurrentDomain()
+ },
+ onTogglePinned: {
+ guard let trackedDomain else { return }
+ viewModel.togglePinned(for: trackedDomain)
+ },
+ onEditNote: {
+ guard let trackedDomain else { return }
+ trackingNoteDraft = trackedDomain.note ?? ""
+ editingTrackedDomain = trackedDomain
+ },
+ onAddToWorkflow: {
+ showingCurrentDomainWorkflowSheet = true
+ },
+ onOpenWorkflow: { workflow in
+ navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
+ },
+ onRunWorkflow: { workflow in
+ viewModel.rerunCurrentDomain(in: workflow)
+ }
+ )
+ }
+
+ private var ownershipSection: some View {
+ OwnershipSectionView(
+ isCollapsed: sectionCollapsedBinding(.ownership),
+ rows: viewModel.ownershipRows,
+ loading: viewModel.ownershipLoading,
+ error: viewModel.ownershipError,
+ provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
+ confidence: viewModel.currentSnapshot.ownershipConfidence,
+ showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory),
+ history: viewModel.ownershipHistory,
+ historyLoading: viewModel.ownershipHistoryLoading,
+ historyError: viewModel.ownershipHistoryError,
+ historyCreditStatus: viewModel.ownershipHistoryCreditStatus,
+ onLoadHistory: {
+ Task {
+ await viewModel.loadOwnershipHistory()
+ }
+ }
+ )
+ }
+
+ private var subdomainsSection: some View {
+ SubdomainsSectionView(
+ isCollapsed: sectionCollapsedBinding(.subdomains),
+ rows: viewModel.subdomainRows,
+ groups: viewModel.currentSubdomainGroups,
+ loading: viewModel.subdomainsLoading,
+ error: viewModel.subdomainsError,
+ provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
+ confidence: viewModel.currentSnapshot.subdomainConfidence,
+ showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains),
+ extendedCount: viewModel.extendedSubdomains.count,
+ extendedLoading: viewModel.extendedSubdomainsLoading,
+ extendedError: viewModel.extendedSubdomainsError,
+ extendedCreditStatus: viewModel.extendedSubdomainsCreditStatus,
+ onLoadExtended: {
+ Task {
+ await viewModel.loadExtendedSubdomains()
+ }
+ }
+ )
+ }
+
+ private var dnsSection: some View {
+ DNSSectionView(
+ isCollapsed: sectionCollapsedBinding(.dns),
+ dnssecLabel: viewModel.dnssecLabel,
+ patternSummary: viewModel.currentDNSPatterns,
+ sections: viewModel.dnsRows,
+ ptrMessage: viewModel.ptrMessage,
+ loading: viewModel.dnsLoading || viewModel.ptrLoading,
+ dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
+ ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
+ sectionError: viewModel.dnsError,
+ history: viewModel.dnsHistory,
+ historyLoading: viewModel.dnsHistoryLoading,
+ historyError: viewModel.dnsHistoryError,
+ showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .dnsHistory),
+ historyCreditStatus: viewModel.dnsHistoryCreditStatus,
+ onLoadHistory: {
+ Task {
+ await viewModel.loadDNSHistory()
+ }
+ }
+ )
+ }
+
private var actionButtons: some View {
HStack {
Spacer()
@@ -1192,6 +1245,9 @@ struct DomainSectionView: View {
let trackedDomain: TrackedDomain?
let workflows: [DomainWorkflow]
let trackingLimitMessage: String?
+ let pricingLoading: Bool
+ let pricingError: String?
+ let showsPricingPlaceholder: Bool
let onTrack: () -> Void
let onTogglePinned: () -> Void
let onEditNote: (() -> Void)?
@@ -1313,12 +1369,24 @@ struct DomainSectionView: View {
}
}
}
+ if pricingLoading {
+ ProgressView("Loading external pricing…")
+ .appLoadingStyle()
+ .padding(.top, 4)
+ } else if let pricingError {
+ MessageRowView(text: pricingError, isError: false)
+ .padding(.top, 4)
+ } else if showsPricingPlaceholder {
+ MessageRowView(text: "Pricing signals available in Data+", isError: false)
+ .padding(.top, 4)
+ }
}
}
}
}
struct OwnershipSectionView: View {
+ @Environment(\.appDensity) private var appDensity
@Binding var isCollapsed: Bool
let rows: [InfoRowViewData]
let loading: Bool
@@ -1326,6 +1394,39 @@ struct OwnershipSectionView: View {
let provenance: SectionProvenance?
let confidence: ConfidenceLevel?
let showsHistoryPlaceholder: Bool
+ let history: [DomainOwnershipHistoryEvent]
+ let historyLoading: Bool
+ let historyError: String?
+ let historyCreditStatus: UsageCreditStatus?
+ let onLoadHistory: (() -> Void)?
+
+ init(
+ isCollapsed: Binding<Bool>,
+ rows: [InfoRowViewData],
+ loading: Bool,
+ error: String?,
+ provenance: SectionProvenance?,
+ confidence: ConfidenceLevel?,
+ showsHistoryPlaceholder: Bool,
+ history: [DomainOwnershipHistoryEvent] = [],
+ historyLoading: Bool = false,
+ historyError: String? = nil,
+ historyCreditStatus: UsageCreditStatus? = nil,
+ onLoadHistory: (() -> Void)? = nil
+ ) {
+ _isCollapsed = isCollapsed
+ self.rows = rows
+ self.loading = loading
+ self.error = error
+ self.provenance = provenance
+ self.confidence = confidence
+ self.showsHistoryPlaceholder = showsHistoryPlaceholder
+ self.history = history
+ self.historyLoading = historyLoading
+ self.historyError = historyError
+ self.historyCreditStatus = historyCreditStatus
+ self.onLoadHistory = onLoadHistory
+ }
var body: some View {
CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
@@ -1342,9 +1443,41 @@ struct OwnershipSectionView: View {
MessageRowView(text: error, isError: error != "Unavailable")
.padding(.top, 4)
}
- if showsHistoryPlaceholder {
- MessageRowView(text: "Ownership history (coming soon)", isError: false)
- .padding(.top, 4)
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("History")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ Spacer()
+ if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
+ Button("Load (\(historyCreditStatus.remaining) left)") {
+ onLoadHistory()
+ }
+ .buttonStyle(.bordered)
+ .font(appDensity.font(.caption2))
+ }
+ }
+ if historyLoading {
+ ProgressView("Loading history…")
+ .appLoadingStyle()
+ } else if !history.isEmpty {
+ ForEach(history) { event in
+ VStack(alignment: .leading, spacing: 3) {
+ Text(event.date.formatted(date: .abbreviated, time: .omitted))
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ Text(event.summary)
+ .font(appDensity.font(.caption))
+ Text(event.source)
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+ } else if let historyError {
+ MessageRowView(text: historyError, isError: false)
+ } else if showsHistoryPlaceholder {
+ MessageRowView(text: "Ownership history available in Data+", isError: false)
+ }
}
}
}
@@ -1362,6 +1495,41 @@ struct SubdomainsSectionView: View {
let provenance: SectionProvenance?
let confidence: ConfidenceLevel?
let showsExtendedPlaceholder: Bool
+ let extendedCount: Int
+ let extendedLoading: Bool
+ let extendedError: String?
+ let extendedCreditStatus: UsageCreditStatus?
+ let onLoadExtended: (() -> Void)?
+
+ init(
+ isCollapsed: Binding<Bool>,
+ rows: [SubdomainRowViewData],
+ groups: [SubdomainGroup],
+ loading: Bool,
+ error: String?,
+ provenance: SectionProvenance?,
+ confidence: ConfidenceLevel?,
+ showsExtendedPlaceholder: Bool,
+ extendedCount: Int = 0,
+ extendedLoading: Bool = false,
+ extendedError: String? = nil,
+ extendedCreditStatus: UsageCreditStatus? = nil,
+ onLoadExtended: (() -> Void)? = nil
+ ) {
+ _isCollapsed = isCollapsed
+ self.rows = rows
+ self.groups = groups
+ self.loading = loading
+ self.error = error
+ self.provenance = provenance
+ self.confidence = confidence
+ self.showsExtendedPlaceholder = showsExtendedPlaceholder
+ self.extendedCount = extendedCount
+ self.extendedLoading = extendedLoading
+ self.extendedError = extendedError
+ self.extendedCreditStatus = extendedCreditStatus
+ self.onLoadExtended = onLoadExtended
+ }
var body: some View {
CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
@@ -1373,10 +1541,17 @@ struct SubdomainsSectionView: View {
} else if rows.isEmpty {
MessageRowView(text: error ?? "No passive subdomains found", isError: false)
if showsExtendedPlaceholder {
- MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
+ MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false)
.padding(.top, 4)
}
} else {
+ if let extendedCreditStatus, let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
+ Button("Load extended results (\(extendedCreditStatus.remaining) left)") {
+ onLoadExtended()
+ }
+ .buttonStyle(.bordered)
+ .font(appDensity.font(.caption2))
+ }
if !groups.isEmpty {
Text("Groups")
.font(appDensity.font(.caption2))
@@ -1411,8 +1586,18 @@ struct SubdomainsSectionView: View {
}
}
}
- if showsExtendedPlaceholder {
- MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
+ if extendedLoading {
+ ProgressView("Loading extended subdomains…")
+ .appLoadingStyle()
+ .padding(.top, 4)
+ } else if extendedCount > 0 {
+ MessageRowView(text: "\(extendedCount) extended results included", isError: false)
+ .padding(.top, 4)
+ } else if let extendedError {
+ MessageRowView(text: extendedError, isError: false)
+ .padding(.top, 4)
+ } else if showsExtendedPlaceholder {
+ MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false)
.padding(.top, 4)
}
}
@@ -1422,6 +1607,7 @@ struct SubdomainsSectionView: View {
}
struct DNSSectionView: View {
+ @Environment(\.appDensity) private var appDensity
@Binding var isCollapsed: Bool
let dnssecLabel: String?
let patternSummary: DNSPatternSummary?
@@ -1431,6 +1617,46 @@ struct DNSSectionView: View {
let dnsProvenance: SectionProvenance?
let ptrProvenance: SectionProvenance?
let sectionError: String?
+ let history: [DNSHistoryEvent]
+ let historyLoading: Bool
+ let historyError: String?
+ let showsHistoryPlaceholder: Bool
+ let historyCreditStatus: UsageCreditStatus?
+ let onLoadHistory: (() -> Void)?
+
+ init(
+ isCollapsed: Binding<Bool>,
+ dnssecLabel: String?,
+ patternSummary: DNSPatternSummary?,
+ sections: [DNSRecordSectionViewData],
+ ptrMessage: SectionMessageViewData?,
+ loading: Bool,
+ dnsProvenance: SectionProvenance?,
+ ptrProvenance: SectionProvenance?,
+ sectionError: String?,
+ history: [DNSHistoryEvent] = [],
+ historyLoading: Bool = false,
+ historyError: String? = nil,
+ showsHistoryPlaceholder: Bool = false,
+ historyCreditStatus: UsageCreditStatus? = nil,
+ onLoadHistory: (() -> Void)? = nil
+ ) {
+ _isCollapsed = isCollapsed
+ self.dnssecLabel = dnssecLabel
+ self.patternSummary = patternSummary
+ self.sections = sections
+ self.ptrMessage = ptrMessage
+ self.loading = loading
+ self.dnsProvenance = dnsProvenance
+ self.ptrProvenance = ptrProvenance
+ self.sectionError = sectionError
+ self.history = history
+ self.historyLoading = historyLoading
+ self.historyError = historyError
+ self.showsHistoryPlaceholder = showsHistoryPlaceholder
+ self.historyCreditStatus = historyCreditStatus
+ self.onLoadHistory = onLoadHistory
+ }
var body: some View {
CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
@@ -1491,6 +1717,50 @@ struct DNSSectionView: View {
MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
}
}
+
+ CardView(allowsHorizontalScroll: false) {
+ HStack {
+ Text("History")
+ .font(appDensity.font(.subheadline, weight: .semibold))
+ .foregroundStyle(.cyan)
+ Spacer()
+ if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
+ Button("Load (\(historyCreditStatus.remaining) left)") {
+ onLoadHistory()
+ }
+ .buttonStyle(.bordered)
+ .font(appDensity.font(.caption2))
+ }
+ }
+ if historyLoading {
+ ProgressView("Loading DNS history…")
+ .appLoadingStyle()
+ } else if !history.isEmpty {
+ ForEach(history) { event in
+ VStack(alignment: .leading, spacing: 3) {
+ Text(event.date.formatted(date: .abbreviated, time: .omitted))
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ Text(event.summary)
+ .font(appDensity.font(.caption))
+ if !event.aRecords.isEmpty {
+ Text("A: \(event.aRecords.joined(separator: ", "))")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ if !event.nameservers.isEmpty {
+ Text("NS: \(event.nameservers.joined(separator: ", "))")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ } else if let historyError {
+ MessageRowView(text: historyError, isError: false)
+ } else if showsHistoryPlaceholder {
+ MessageRowView(text: "DNS history available in Data+", isError: false)
+ }
+ }
}
}
}
@@ -2220,11 +2490,11 @@ struct SettingsView: View {
}
}
- Section("Pro") {
+ Section("Tier") {
LabeledContent("Status", value: purchaseService.currentTier.title)
if purchaseService.currentTier == .free {
- Button("Upgrade to Pro") {
+ Button("Upgrade") {
viewModel.isPaywallPresented = true
}
} else {
@@ -2255,6 +2525,23 @@ struct SettingsView: View {
}
}
+ 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)
@@ -2264,7 +2551,7 @@ struct SettingsView: View {
Section("About") {
LabeledContent("Version", value: appVersion)
LabeledContent("Storage", value: "Local-only")
- LabeledContent("Report Schema", value: "3.1.0")
+ LabeledContent("Report Schema", value: "3.2.0")
}
}
.navigationTitle("Settings")
@@ -2304,6 +2591,9 @@ struct SettingsView: View {
let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
resolverOption = DNSResolverOption.option(for: currentResolverURL)
customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
+ Task {
+ await viewModel.refreshUsageCredits()
+ }
}
.onChange(of: resolverOption) { _, newValue in
guard let presetURL = newValue.urlString else {
diff --git a/DomainDig/DataAccessService.swift b/DomainDig/DataAccessService.swift
index d0254ac..98e215d 100644
--- a/DomainDig/DataAccessService.swift
+++ b/DomainDig/DataAccessService.swift
@@ -10,7 +10,7 @@ enum DataAccessService {
case .extendedSubdomains:
return FeatureAccessService.hasAccess(to: .extendedSubdomains)
case .domainPricing:
- return false
+ return FeatureAccessService.hasAccess(to: .domainPricing)
}
}
}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 6ec7e64..83d282c 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -147,6 +147,9 @@ final class DomainViewModel {
var ownershipResult: DomainOwnership?
var ownershipLoading = false
var ownershipError: String?
+ var ownershipHistory: [DomainOwnershipHistoryEvent] = []
+ var ownershipHistoryLoading = false
+ var ownershipHistoryError: String?
var ptrRecord: String?
var ptrLoading = false
@@ -159,6 +162,16 @@ final class DomainViewModel {
var subdomains: [DiscoveredSubdomain] = []
var subdomainsLoading = false
var subdomainsError: String?
+ var extendedSubdomains: [DiscoveredSubdomain] = []
+ var extendedSubdomainsLoading = false
+ var extendedSubdomainsError: String?
+ var dnsHistory: [DNSHistoryEvent] = []
+ var dnsHistoryLoading = false
+ var dnsHistoryError: String?
+ var domainPricing: DomainPricingInsight?
+ var domainPricingLoading = false
+ var domainPricingError: String?
+ var usageCredits: [UsageCreditFeature: UsageCreditStatus] = DomainViewModel.defaultUsageCredits()
var portScanResults: [PortScanResult] = []
var portScanLoading = false
@@ -263,8 +276,12 @@ final class DomainViewModel {
if sslLoading || hstsLoading { labels.append("TLS") }
if httpHeadersLoading { labels.append("HTTP") }
if ownershipLoading { labels.append("Ownership") }
+ if ownershipHistoryLoading { labels.append("Ownership History") }
if emailSecurityLoading { labels.append("Email") }
if subdomainsLoading { labels.append("Subdomains") }
+ if extendedSubdomainsLoading { labels.append("Extended Subdomains") }
+ if dnsHistoryLoading { labels.append("DNS History") }
+ if domainPricingLoading { labels.append("Pricing") }
if redirectChainLoading { labels.append("Redirects") }
if reachabilityLoading { labels.append("Reachability") }
if ipGeolocationLoading { labels.append("Geolocation") }
@@ -438,12 +455,20 @@ final class DomainViewModel {
emailSecurityError: emailSecurityError,
ownership: ownershipResult,
ownershipError: ownershipError,
+ ownershipHistory: ownershipHistory,
+ ownershipHistoryError: ownershipHistoryError,
ptrRecord: ptrRecord,
ptrError: ptrError,
redirectChain: redirectChain,
redirectChainError: redirectChainError,
subdomains: subdomains,
subdomainsError: subdomainsError,
+ extendedSubdomains: extendedSubdomains,
+ extendedSubdomainsError: extendedSubdomainsError,
+ dnsHistory: dnsHistory,
+ dnsHistoryError: dnsHistoryError,
+ domainPricing: domainPricing,
+ domainPricingError: domainPricingError,
portScanResults: allPortScanResults,
portScanError: combinedPortScanError,
changeSummary: currentChangeSummary,
@@ -482,6 +507,23 @@ final class DomainViewModel {
currentReport?.web
}
+ var ownershipHistoryCreditStatus: UsageCreditStatus {
+ usageCredits[.ownershipHistory] ?? Self.fallbackCreditStatus(for: .ownershipHistory)
+ }
+
+ var dnsHistoryCreditStatus: UsageCreditStatus {
+ usageCredits[.dnsHistory] ?? Self.fallbackCreditStatus(for: .dnsHistory)
+ }
+
+ var extendedSubdomainsCreditStatus: UsageCreditStatus {
+ usageCredits[.extendedSubdomains] ?? Self.fallbackCreditStatus(for: .extendedSubdomains)
+ }
+
+ var combinedSubdomains: [DiscoveredSubdomain] {
+ let existingHosts = Set(subdomains.map { $0.hostname.lowercased() })
+ return subdomains + extendedSubdomains.filter { !existingHosts.contains($0.hostname.lowercased()) }
+ }
+
var summaryFields: [SummaryFieldViewData] {
Self.summaryFields(from: currentSnapshot)
}
@@ -527,7 +569,7 @@ final class DomainViewModel {
}
var subdomainRows: [SubdomainRowViewData] {
- Self.subdomainRows(from: currentSnapshot)
+ Self.subdomainRows(from: combinedSubdomains)
}
var reachabilityRows: [ReachabilityRowViewData] {
@@ -934,6 +976,151 @@ final class DomainViewModel {
return String(data: data, encoding: .utf8)
}
+ func loadOwnershipHistory() async {
+ guard !searchedDomain.isEmpty else { return }
+ guard DataAccessService.hasAccess(to: .ownershipHistory) else {
+ upgradePrompt = FeatureAccessService.upgradePrompt(for: .ownershipHistory)
+ return
+ }
+ 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
+
+ let outcome = await ExternalDataService.shared.ownershipHistory(
+ domain: searchedDomain,
+ currentOwnership: ownershipResult,
+ historyEntries: history
+ )
+
+ switch outcome.value {
+ 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")
+ }
+
+ ownershipHistoryLoading = false
+ _ = saveHistoryEntry(replaceLatest: true)
+ await refreshUsageCredits()
+ }
+
+ func loadDNSHistory() async {
+ guard !searchedDomain.isEmpty else { return }
+ guard DataAccessService.hasAccess(to: .dnsHistory) else {
+ upgradePrompt = FeatureAccessService.upgradePrompt(for: .dnsHistory)
+ return
+ }
+ 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
+
+ let outcome = await ExternalDataService.shared.dnsHistory(
+ domain: searchedDomain,
+ dnsSections: dnsSections,
+ historyEntries: history
+ )
+
+ switch outcome.value {
+ 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")
+ }
+
+ dnsHistoryLoading = false
+ _ = saveHistoryEntry(replaceLatest: true)
+ await refreshUsageCredits()
+ }
+
+ func loadExtendedSubdomains() async {
+ guard !searchedDomain.isEmpty else { return }
+ guard DataAccessService.hasAccess(to: .extendedSubdomains) else {
+ upgradePrompt = FeatureAccessService.upgradePrompt(for: .extendedSubdomains)
+ return
+ }
+ 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
+
+ let outcome = await ExternalDataService.shared.extendedSubdomains(
+ domain: searchedDomain,
+ existing: subdomains
+ )
+
+ switch outcome.value {
+ 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")
+ }
+
+ extendedSubdomainsLoading = false
+ _ = saveHistoryEntry(replaceLatest: true)
+ await refreshUsageCredits()
+ }
+
+ func refreshUsageCredits() async {
+ let statuses = await UsageCreditService.shared.allStatuses()
+ usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) })
+ }
+
func exportBatchText() -> String {
DomainReportExporter.batchText(
for: currentBatchReports(),
@@ -1019,11 +1206,18 @@ final class DomainViewModel {
lastLookupDurationMs = snapshot.totalLookupDurationMs
refreshingTrackedDomainID = nil
+ if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil {
+ await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
+ }
+
guard snapshot.statusMessage == nil else {
+ await refreshUsageCredits()
return history.first(where: { $0.id == snapshot.historyEntryID })
}
- return saveHistoryEntry(replaceLatest: false)
+ let entry = saveHistoryEntry(replaceLatest: false)
+ await refreshUsageCredits()
+ return entry
}
private func applySnapshot(_ snapshot: LookupSnapshot) {
@@ -1066,12 +1260,20 @@ final class DomainViewModel {
emailSecurityError = snapshot.emailSecurityError
ownershipResult = snapshot.ownership
ownershipError = snapshot.ownershipError
+ ownershipHistory = snapshot.ownershipHistory
+ ownershipHistoryError = snapshot.ownershipHistoryError
ptrRecord = snapshot.ptrRecord
ptrError = snapshot.ptrError
redirectChain = snapshot.redirectChain
redirectChainError = snapshot.redirectChainError
subdomains = snapshot.subdomains
subdomainsError = snapshot.subdomainsError
+ extendedSubdomains = snapshot.extendedSubdomains
+ extendedSubdomainsError = snapshot.extendedSubdomainsError
+ dnsHistory = snapshot.dnsHistory
+ dnsHistoryError = snapshot.dnsHistoryError
+ domainPricing = snapshot.domainPricing
+ domainPricingError = snapshot.domainPricingError
portScanResults = snapshot.portScanResults.filter { $0.kind == .standard }
customPortResults = snapshot.portScanResults.filter { $0.kind == .custom }
portScanError = snapshot.portScanError
@@ -1087,9 +1289,13 @@ final class DomainViewModel {
ipGeolocationLoading = false
emailSecurityLoading = false
ownershipLoading = false
+ ownershipHistoryLoading = false
ptrLoading = false
redirectChainLoading = false
subdomainsLoading = false
+ extendedSubdomainsLoading = false
+ dnsHistoryLoading = false
+ domainPricingLoading = false
portScanLoading = false
customPortScanLoading = false
}
@@ -1144,12 +1350,20 @@ final class DomainViewModel {
emailSecurityError: previousSnapshot.emailSecurityError,
ownership: previousSnapshot.ownership,
ownershipError: previousSnapshot.ownershipError,
+ ownershipHistory: previousSnapshot.ownershipHistory,
+ ownershipHistoryError: previousSnapshot.ownershipHistoryError,
ptrRecord: previousSnapshot.ptrRecord,
ptrError: previousSnapshot.ptrError,
redirectChain: previousSnapshot.redirectChain,
redirectChainError: previousSnapshot.redirectChainError,
subdomains: previousSnapshot.subdomains,
subdomainsError: previousSnapshot.subdomainsError,
+ extendedSubdomains: previousSnapshot.extendedSubdomains,
+ extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
+ dnsHistory: previousSnapshot.dnsHistory,
+ dnsHistoryError: previousSnapshot.dnsHistoryError,
+ domainPricing: previousSnapshot.domainPricing,
+ domainPricingError: previousSnapshot.domainPricingError,
portScanResults: previousSnapshot.portScanResults,
portScanError: previousSnapshot.portScanError,
changeSummary: previousSnapshot.changeSummary,
@@ -1515,9 +1729,13 @@ final class DomainViewModel {
emailSecurity: snapshot.emailSecurity,
mtaSts: snapshot.emailSecurity?.mtaSts,
ownership: snapshot.ownership,
+ ownershipHistory: snapshot.ownershipHistory,
ptrRecord: snapshot.ptrRecord,
redirectChain: snapshot.redirectChain,
subdomains: snapshot.subdomains,
+ extendedSubdomains: snapshot.extendedSubdomains,
+ dnsHistory: snapshot.dnsHistory,
+ domainPricing: snapshot.domainPricing,
portScanResults: snapshot.portScanResults,
hstsPreloaded: snapshot.hstsPreloaded,
availabilityResult: snapshot.availabilityResult,
@@ -1549,9 +1767,13 @@ final class DomainViewModel {
ipGeolocationError: snapshot.ipGeolocationError,
emailSecurityError: snapshot.emailSecurityError,
ownershipError: snapshot.ownershipError,
+ ownershipHistoryError: snapshot.ownershipHistoryError,
ptrError: snapshot.ptrError,
redirectChainError: snapshot.redirectChainError,
subdomainsError: snapshot.subdomainsError,
+ extendedSubdomainsError: snapshot.extendedSubdomainsError,
+ dnsHistoryError: snapshot.dnsHistoryError,
+ domainPricingError: snapshot.domainPricingError,
portScanError: snapshot.portScanError
)
@@ -2038,6 +2260,9 @@ final class DomainViewModel {
ownershipResult = nil
ownershipError = nil
ownershipLoading = false
+ ownershipHistory = []
+ ownershipHistoryError = nil
+ ownershipHistoryLoading = false
ptrRecord = nil
ptrError = nil
ptrLoading = false
@@ -2047,6 +2272,15 @@ final class DomainViewModel {
subdomains = []
subdomainsError = nil
subdomainsLoading = false
+ extendedSubdomains = []
+ extendedSubdomainsError = nil
+ extendedSubdomainsLoading = false
+ dnsHistory = []
+ dnsHistoryError = nil
+ dnsHistoryLoading = false
+ domainPricing = nil
+ domainPricingError = nil
+ domainPricingLoading = false
portScanResults = []
portScanError = nil
portScanLoading = false
@@ -2072,9 +2306,13 @@ final class DomainViewModel {
ipGeolocationLoading = loading
emailSecurityLoading = loading
ownershipLoading = loading
+ ownershipHistoryLoading = false
ptrLoading = loading
redirectChainLoading = loading
subdomainsLoading = loading
+ extendedSubdomainsLoading = false
+ dnsHistoryLoading = false
+ domainPricingLoading = false
portScanLoading = loading
}
@@ -2248,12 +2486,20 @@ final class DomainViewModel {
emailSecurityError: nil,
ownership: nil,
ownershipError: nil,
+ ownershipHistory: [],
+ ownershipHistoryError: nil,
ptrRecord: nil,
ptrError: nil,
redirectChain: [],
redirectChainError: nil,
subdomains: [],
subdomainsError: nil,
+ extendedSubdomains: [],
+ extendedSubdomainsError: nil,
+ dnsHistory: [],
+ dnsHistoryError: nil,
+ domainPricing: nil,
+ domainPricingError: nil,
portScanResults: [],
portScanError: nil,
changeSummary: trackedDomain.lastChangeSummary,
@@ -2435,6 +2681,30 @@ final class DomainViewModel {
at: 3
)
}
+ if let pricing = snapshot.domainPricing {
+ rows.append(
+ InfoRowViewData(
+ label: "External Price",
+ value: pricing.estimatedPrice ?? "Unavailable",
+ tone: .secondary
+ )
+ )
+ if let premiumIndicator = pricing.premiumIndicator {
+ rows.append(
+ InfoRowViewData(
+ label: "Premium",
+ value: premiumIndicator ? "Yes" : "No",
+ tone: premiumIndicator ? .warning : .secondary
+ )
+ )
+ }
+ if let resaleSignal = pricing.resaleSignal {
+ rows.append(InfoRowViewData(label: "Resale", value: resaleSignal, tone: .secondary))
+ }
+ if let auctionSignal = pricing.auctionSignal {
+ rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary))
+ }
+ }
if let certificateStatus = certificateBadgeLabel(from: snapshot) {
rows.insert(
InfoRowViewData(
@@ -2460,6 +2730,15 @@ final class DomainViewModel {
}
}
+ static func subdomainRows(from subdomains: [DiscoveredSubdomain]) -> [SubdomainRowViewData] {
+ subdomains.map { subdomain in
+ SubdomainRowViewData(
+ hostname: subdomain.hostname,
+ isInteresting: subdomain.isExtended || isInterestingSubdomain(subdomain.hostname)
+ )
+ }
+ }
+
static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
snapshot.dnsSections.map { section in
DNSRecordSectionViewData(
@@ -3098,6 +3377,61 @@ final class DomainViewModel {
return formatter
}()
+ private func refreshDomainPricing(for domain: String, persistAfterFetch: Bool) async {
+ domainPricingLoading = true
+ let outcome = await ExternalDataService.shared.pricing(domain: domain)
+
+ switch outcome.value {
+ case let .success(pricing):
+ domainPricing = pricing
+ domainPricingError = nil
+ case let .empty(message):
+ domainPricing = nil
+ domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
+ case let .error(message):
+ domainPricing = nil
+ domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
+ }
+
+ domainPricingLoading = false
+
+ if persistAfterFetch {
+ _ = saveHistoryEntry(replaceLatest: true)
+ }
+ }
+
+ private func conciseExternalMessage(_ message: String, fallback: String) -> String {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty {
+ return fallback
+ }
+ if trimmed.localizedCaseInsensitiveContains("rate") {
+ return "Rate limited. Try again later."
+ }
+ if trimmed.localizedCaseInsensitiveContains("invalid") {
+ return "External data was invalid."
+ }
+ if trimmed.localizedCaseInsensitiveContains("network") {
+ return "External data is offline."
+ }
+ return trimmed
+ }
+
+ private static func defaultUsageCredits() -> [UsageCreditFeature: UsageCreditStatus] {
+ Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
+ (feature, fallbackCreditStatus(for: feature))
+ })
+ }
+
+ private static func fallbackCreditStatus(for feature: UsageCreditFeature) -> UsageCreditStatus {
+ UsageCreditStatus(
+ feature: feature,
+ remaining: feature.defaultAllowance,
+ total: feature.defaultAllowance,
+ resetContext: "Resets with app version \(AppVersion.current)"
+ )
+ }
+
private static func csvEscaped(_ value: String) -> String {
let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\""
diff --git a/DomainDig/ExternalDataService.swift b/DomainDig/ExternalDataService.swift
new file mode 100644
index 0000000..5b4bf66
--- /dev/null
+++ b/DomainDig/ExternalDataService.swift
@@ -0,0 +1,627 @@
+import Foundation
+
+actor ExternalDataService {
+ static let shared = ExternalDataService()
+
+ private enum RequestKey: Hashable {
+ case ownershipHistory(String)
+ case dnsHistory(String)
+ case extendedSubdomains(String)
+ case pricing(String)
+ }
+
+ private enum RateLimitBucket: Hashable {
+ case history
+ case subdomains
+ case pricing
+
+ var minimumSpacing: TimeInterval {
+ switch self {
+ case .history:
+ return 1.0
+ case .subdomains:
+ return 1.5
+ case .pricing:
+ return 1.0
+ }
+ }
+ }
+
+ private enum CachedPayload {
+ case ownershipHistory(ServiceResult<[DomainOwnershipHistoryEvent]>)
+ case dnsHistory(ServiceResult<[DNSHistoryEvent]>)
+ case extendedSubdomains(ServiceResult<[DiscoveredSubdomain]>)
+ case pricing(ServiceResult<DomainPricingInsight>)
+ }
+
+ private struct CacheEntry {
+ let payload: CachedPayload
+ let expiresAt: Date
+ }
+
+ private struct Configuration {
+ let ownershipHistoryURL: String?
+ let dnsHistoryURL: String?
+ let extendedSubdomainsURL: String?
+ let pricingURL: String?
+ }
+
+ private let ttl: TimeInterval = 900
+ private let session: URLSession = .shared
+ private var cache: [RequestKey: CacheEntry] = [:]
+ private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:]
+ private var nextAllowedAt: [RateLimitBucket: Date] = [:]
+
+ func clearCache() {
+ cache.removeAll()
+ inFlight.values.forEach { $0.cancel() }
+ inFlight.removeAll()
+ nextAllowedAt.removeAll()
+ }
+
+ func ownershipHistory(
+ domain: String,
+ currentOwnership: DomainOwnership?,
+ historyEntries: [HistoryEntry]
+ ) async -> CachedLookupResult<ServiceResult<[DomainOwnershipHistoryEvent]>> {
+ let normalizedDomain = Self.normalize(domain)
+ return await execute(
+ key: .ownershipHistory(normalizedDomain),
+ rateLimitBucket: .history,
+ extract: { payload in
+ guard case let .ownershipHistory(result) = payload else { return nil }
+ return result
+ },
+ operation: { [configuration = configuration()] in
+ let localEvents = Self.localOwnershipHistory(
+ domain: normalizedDomain,
+ currentOwnership: currentOwnership,
+ historyEntries: historyEntries
+ )
+ let externalEvents = await self.fetchOwnershipHistory(domain: normalizedDomain, configuration: configuration)
+ return .ownershipHistory(Self.mergeOwnershipHistory(localEvents: localEvents, externalEvents: externalEvents))
+ }
+ )
+ }
+
+ func dnsHistory(
+ domain: String,
+ dnsSections: [DNSSection],
+ historyEntries: [HistoryEntry]
+ ) async -> CachedLookupResult<ServiceResult<[DNSHistoryEvent]>> {
+ let normalizedDomain = Self.normalize(domain)
+ return await execute(
+ key: .dnsHistory(normalizedDomain),
+ rateLimitBucket: .history,
+ extract: { payload in
+ guard case let .dnsHistory(result) = payload else { return nil }
+ return result
+ },
+ operation: { [configuration = configuration()] in
+ let localEvents = Self.localDNSHistory(
+ domain: normalizedDomain,
+ dnsSections: dnsSections,
+ historyEntries: historyEntries
+ )
+ let externalEvents = await self.fetchDNSHistory(domain: normalizedDomain, configuration: configuration)
+ return .dnsHistory(Self.mergeDNSHistory(localEvents: localEvents, externalEvents: externalEvents))
+ }
+ )
+ }
+
+ func extendedSubdomains(
+ domain: String,
+ existing: [DiscoveredSubdomain]
+ ) async -> CachedLookupResult<ServiceResult<[DiscoveredSubdomain]>> {
+ let normalizedDomain = Self.normalize(domain)
+ return await execute(
+ key: .extendedSubdomains(normalizedDomain),
+ rateLimitBucket: .subdomains,
+ extract: { payload in
+ guard case let .extendedSubdomains(result) = payload else { return nil }
+ return result
+ },
+ operation: { [configuration = configuration()] in
+ var merged = existing
+ switch await SubdomainDiscoveryService.discover(for: normalizedDomain, limit: 100) {
+ case let .success(results):
+ merged = Self.mergeSubdomains(primary: merged, additional: results.map {
+ DiscoveredSubdomain(hostname: $0.hostname, source: $0.source ?? "crt.sh", isExtended: true)
+ })
+ case .empty, .error:
+ break
+ }
+
+ if let external = await self.fetchExtendedSubdomains(domain: normalizedDomain, configuration: configuration) {
+ merged = Self.mergeSubdomains(primary: merged, additional: external)
+ }
+
+ if merged.count <= existing.count {
+ return .extendedSubdomains(.empty("No extended subdomains available"))
+ }
+
+ let onlyExtended = merged.filter(\.isExtended)
+ return .extendedSubdomains(.success(onlyExtended.sorted { $0.hostname < $1.hostname }))
+ }
+ )
+ }
+
+ func pricing(domain: String) async -> CachedLookupResult<ServiceResult<DomainPricingInsight>> {
+ let normalizedDomain = Self.normalize(domain)
+ return await execute(
+ key: .pricing(normalizedDomain),
+ rateLimitBucket: .pricing,
+ extract: { payload in
+ guard case let .pricing(result) = payload else { return nil }
+ return result
+ },
+ operation: { [configuration = configuration()] in
+ .pricing(await self.fetchPricing(domain: normalizedDomain, configuration: configuration))
+ }
+ )
+ }
+
+ private func execute<T>(
+ key: RequestKey,
+ rateLimitBucket: RateLimitBucket,
+ extract: @escaping (CachedPayload) -> T?,
+ operation: @escaping @Sendable () async -> CachedPayload
+ ) async -> CachedLookupResult<T> {
+ if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) {
+ return CachedLookupResult(value: value, source: .cached)
+ }
+
+ if let task = inFlight[key], let value = extract(await task.value) {
+ return CachedLookupResult(value: value, source: .mixed)
+ }
+
+ let task = Task<CachedPayload, Never> {
+ await self.enforceRateLimit(for: rateLimitBucket)
+ return await operation()
+ }
+ inFlight[key] = task
+
+ let payload = await task.value
+ cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl))
+ inFlight[key] = nil
+
+ guard let value = extract(payload) else {
+ fatalError("ExternalDataService payload extraction mismatch")
+ }
+
+ return CachedLookupResult(value: value, source: .live)
+ }
+
+ private func enforceRateLimit(for bucket: RateLimitBucket) async {
+ let now = Date()
+ if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now {
+ let delay = nextAllowed.timeIntervalSince(now)
+ if delay > 0 {
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ }
+ }
+ nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing)
+ }
+
+ private func configuration() -> Configuration {
+ let defaults = UserDefaults.standard
+ return Configuration(
+ ownershipHistoryURL: defaults.string(forKey: "externalData.ownershipHistoryURL")
+ ?? Bundle.main.object(forInfoDictionaryKey: "ExternalOwnershipHistoryURL") as? String,
+ dnsHistoryURL: defaults.string(forKey: "externalData.dnsHistoryURL")
+ ?? Bundle.main.object(forInfoDictionaryKey: "ExternalDNSHistoryURL") as? String,
+ extendedSubdomainsURL: defaults.string(forKey: "externalData.extendedSubdomainsURL")
+ ?? Bundle.main.object(forInfoDictionaryKey: "ExternalExtendedSubdomainsURL") as? String,
+ pricingURL: defaults.string(forKey: "externalData.pricingURL")
+ ?? Bundle.main.object(forInfoDictionaryKey: "ExternalPricingURL") as? String
+ )
+ }
+
+ private func fetchOwnershipHistory(
+ domain: String,
+ configuration: Configuration
+ ) async -> [DomainOwnershipHistoryEvent] {
+ guard let template = configuration.ownershipHistoryURL,
+ let url = Self.url(from: template, domain: domain) else {
+ return []
+ }
+
+ switch await requestData(url: url) {
+ case let .success(data):
+ return parseOwnershipHistoryEvents(from: data)
+ case .empty, .error:
+ return []
+ }
+ }
+
+ private func fetchDNSHistory(
+ domain: String,
+ configuration: Configuration
+ ) async -> [DNSHistoryEvent] {
+ guard let template = configuration.dnsHistoryURL,
+ let url = Self.url(from: template, domain: domain) else {
+ return []
+ }
+
+ switch await requestData(url: url) {
+ case let .success(data):
+ return parseDNSHistoryEvents(from: data)
+ case .empty, .error:
+ return []
+ }
+ }
+
+ private func fetchExtendedSubdomains(
+ domain: String,
+ configuration: Configuration
+ ) async -> [DiscoveredSubdomain]? {
+ guard let template = configuration.extendedSubdomainsURL,
+ let url = Self.url(from: template, domain: domain) else {
+ return nil
+ }
+
+ switch await requestData(url: url) {
+ case let .success(data):
+ return parseSubdomains(from: data)
+ case .empty, .error:
+ return nil
+ }
+ }
+
+ private func fetchPricing(
+ domain: String,
+ configuration: Configuration
+ ) async -> ServiceResult<DomainPricingInsight> {
+ guard let template = configuration.pricingURL,
+ let url = Self.url(from: template, domain: domain) else {
+ return .empty("External pricing unavailable")
+ }
+
+ switch await requestData(url: url) {
+ case let .success(data):
+ guard let pricing = parsePricing(from: data) else {
+ return .error("Invalid external response")
+ }
+ return .success(pricing)
+ case let .empty(message):
+ return .empty(message)
+ case let .error(message):
+ return .error(message)
+ }
+ }
+
+ private func requestData(url: URL) async -> ServiceResult<Data> {
+ for attempt in 0..<2 {
+ do {
+ let (data, response) = try await session.data(for: URLRequest(url: url, timeoutInterval: 8))
+ guard let httpResponse = response as? HTTPURLResponse else {
+ return .error("External data unavailable")
+ }
+
+ switch httpResponse.statusCode {
+ case 200:
+ return .success(data)
+ case 204, 404:
+ return .empty("No external data available")
+ case 429:
+ return .error("Rate limit reached")
+ case 500...599 where attempt == 0:
+ continue
+ default:
+ return .error("External provider failed")
+ }
+ } catch is DecodingError {
+ return .error("Invalid external response")
+ } catch {
+ if attempt == 0 {
+ continue
+ }
+ return .error(error.localizedDescription)
+ }
+ }
+
+ return .error("External provider failed")
+ }
+
+ private func parseOwnershipHistoryEvents(from data: Data) -> [DomainOwnershipHistoryEvent] {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let rawEvents = json["events"] as? [[String: Any]] else {
+ return []
+ }
+
+ return rawEvents.compactMap { event in
+ guard let dateString = event["date"] as? String,
+ let date = Self.iso8601DateFormatter.date(from: dateString) else {
+ return nil
+ }
+
+ return DomainOwnershipHistoryEvent(
+ date: date,
+ summary: event["summary"] as? String ?? "Ownership change observed",
+ registrar: event["registrar"] as? String,
+ registrant: event["registrant"] as? String,
+ nameservers: event["nameservers"] as? [String] ?? [],
+ source: event["source"] as? String ?? "Configured external history feed",
+ isExternal: true
+ )
+ }
+ }
+
+ private func parseDNSHistoryEvents(from data: Data) -> [DNSHistoryEvent] {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let rawEvents = json["events"] as? [[String: Any]] else {
+ return []
+ }
+
+ return rawEvents.compactMap { event in
+ guard let dateString = event["date"] as? String,
+ let date = Self.iso8601DateFormatter.date(from: dateString) else {
+ return nil
+ }
+
+ return DNSHistoryEvent(
+ date: date,
+ summary: event["summary"] as? String ?? "DNS change observed",
+ aRecords: event["a_records"] as? [String] ?? [],
+ nameservers: event["nameservers"] as? [String] ?? [],
+ source: event["source"] as? String ?? "Configured external history feed",
+ isExternal: true
+ )
+ }
+ }
+
+ private func parseSubdomains(from data: Data) -> [DiscoveredSubdomain] {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let rawSubdomains = json["subdomains"] as? [[String: Any]] else {
+ return []
+ }
+
+ return rawSubdomains.compactMap { item in
+ guard let hostname = item["hostname"] as? String else { return nil }
+ return DiscoveredSubdomain(
+ hostname: hostname,
+ source: item["source"] as? String ?? "Configured external subdomain feed",
+ isExtended: true
+ )
+ }
+ }
+
+ private func parsePricing(from data: Data) -> DomainPricingInsight? {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ return nil
+ }
+
+ return DomainPricingInsight(
+ estimatedPrice: json["estimated_price"] as? String,
+ premiumIndicator: json["premium"] as? Bool,
+ resaleSignal: json["resale_signal"] as? String,
+ auctionSignal: json["auction_signal"] as? String,
+ source: json["source"] as? String ?? "Configured external pricing feed",
+ collectedAt: Date()
+ )
+ }
+
+ private static func localOwnershipHistory(
+ domain: String,
+ currentOwnership: DomainOwnership?,
+ historyEntries: [HistoryEntry]
+ ) -> [DomainOwnershipHistoryEvent] {
+ let domainHistory = historyEntries
+ .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+ .sorted { $0.timestamp < $1.timestamp }
+
+ var events: [DomainOwnershipHistoryEvent] = []
+ var previousOwnership: DomainOwnership?
+
+ for entry in domainHistory {
+ guard let ownership = entry.ownership else { continue }
+ let summary = ownershipSummaryChange(previous: previousOwnership, current: ownership)
+ if let summary {
+ events.append(
+ DomainOwnershipHistoryEvent(
+ date: entry.timestamp,
+ summary: summary,
+ registrar: ownership.registrar,
+ registrant: ownership.registrant,
+ nameservers: ownership.nameservers,
+ source: "Local observations",
+ isExternal: false
+ )
+ )
+ }
+ previousOwnership = ownership
+ }
+
+ if let currentOwnership, events.isEmpty {
+ events.append(
+ DomainOwnershipHistoryEvent(
+ date: Date(),
+ summary: "Current ownership snapshot",
+ registrar: currentOwnership.registrar,
+ registrant: currentOwnership.registrant,
+ nameservers: currentOwnership.nameservers,
+ source: "Local observations",
+ isExternal: false
+ )
+ )
+ }
+
+ return events.sorted { $0.date > $1.date }
+ }
+
+ private static func localDNSHistory(
+ domain: String,
+ dnsSections: [DNSSection],
+ historyEntries: [HistoryEntry]
+ ) -> [DNSHistoryEvent] {
+ let domainHistory = historyEntries
+ .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+ .sorted { $0.timestamp < $1.timestamp }
+
+ var events: [DNSHistoryEvent] = []
+ var previousARecords: [String] = []
+ var previousNameservers: [String] = []
+
+ for entry in domainHistory {
+ let aRecords = Self.dnsValues(for: .A, in: entry.dnsSections)
+ let nameservers = Self.dnsValues(for: .NS, in: entry.dnsSections)
+ let summary = dnsSummaryChange(
+ previousARecords: previousARecords,
+ currentARecords: aRecords,
+ previousNameservers: previousNameservers,
+ currentNameservers: nameservers
+ )
+
+ if let summary {
+ events.append(
+ DNSHistoryEvent(
+ date: entry.timestamp,
+ summary: summary,
+ aRecords: aRecords,
+ nameservers: nameservers,
+ source: "Local observations",
+ isExternal: false
+ )
+ )
+ }
+
+ previousARecords = aRecords
+ previousNameservers = nameservers
+ }
+
+ if events.isEmpty {
+ let currentARecords = Self.dnsValues(for: .A, in: dnsSections)
+ let currentNameservers = Self.dnsValues(for: .NS, in: dnsSections)
+ if !currentARecords.isEmpty || !currentNameservers.isEmpty {
+ events.append(
+ DNSHistoryEvent(
+ date: Date(),
+ summary: "Current DNS snapshot",
+ aRecords: currentARecords,
+ nameservers: currentNameservers,
+ source: "Local observations",
+ isExternal: false
+ )
+ )
+ }
+ }
+
+ return events.sorted { $0.date > $1.date }
+ }
+
+ private static func mergeOwnershipHistory(
+ localEvents: [DomainOwnershipHistoryEvent],
+ externalEvents: [DomainOwnershipHistoryEvent]
+ ) -> ServiceResult<[DomainOwnershipHistoryEvent]> {
+ let merged = (externalEvents + localEvents)
+ .sorted { $0.date > $1.date }
+ .reduce(into: [DomainOwnershipHistoryEvent]()) { partialResult, event in
+ let duplicate = partialResult.contains {
+ $0.date == event.date
+ && $0.summary == event.summary
+ && $0.registrar == event.registrar
+ && $0.nameservers == event.nameservers
+ }
+ if !duplicate {
+ partialResult.append(event)
+ }
+ }
+
+ return merged.isEmpty ? .empty("No ownership history available") : .success(merged)
+ }
+
+ private static func mergeDNSHistory(
+ localEvents: [DNSHistoryEvent],
+ externalEvents: [DNSHistoryEvent]
+ ) -> ServiceResult<[DNSHistoryEvent]> {
+ let merged = (externalEvents + localEvents)
+ .sorted { $0.date > $1.date }
+ .reduce(into: [DNSHistoryEvent]()) { partialResult, event in
+ let duplicate = partialResult.contains {
+ $0.date == event.date
+ && $0.summary == event.summary
+ && $0.aRecords == event.aRecords
+ && $0.nameservers == event.nameservers
+ }
+ if !duplicate {
+ partialResult.append(event)
+ }
+ }
+
+ return merged.isEmpty ? .empty("No DNS history available") : .success(merged)
+ }
+
+ private static func mergeSubdomains(
+ primary: [DiscoveredSubdomain],
+ additional: [DiscoveredSubdomain]
+ ) -> [DiscoveredSubdomain] {
+ var seen = Set(primary.map { $0.hostname.lowercased() })
+ var merged = primary
+
+ for subdomain in additional where seen.insert(subdomain.hostname.lowercased()).inserted {
+ merged.append(subdomain)
+ }
+
+ return merged.sorted { $0.hostname < $1.hostname }
+ }
+
+ private static func ownershipSummaryChange(previous: DomainOwnership?, current: DomainOwnership) -> String? {
+ guard let previous else {
+ return "Initial ownership observation"
+ }
+
+ var changes: [String] = []
+ if previous.registrar != current.registrar {
+ changes.append("Registrar changed")
+ }
+ if previous.registrant != current.registrant, current.registrant != nil {
+ changes.append("Ownership changed")
+ }
+ if previous.nameservers != current.nameservers {
+ changes.append("Nameservers changed")
+ }
+
+ return changes.isEmpty ? nil : changes.joined(separator: " • ")
+ }
+
+ private static func dnsSummaryChange(
+ previousARecords: [String],
+ currentARecords: [String],
+ previousNameservers: [String],
+ currentNameservers: [String]
+ ) -> String? {
+ var changes: [String] = []
+ if previousARecords != currentARecords, !currentARecords.isEmpty {
+ changes.append("A records changed")
+ }
+ if previousNameservers != currentNameservers, !currentNameservers.isEmpty {
+ changes.append("NS records changed")
+ }
+ if previousARecords.isEmpty && previousNameservers.isEmpty && (!currentARecords.isEmpty || !currentNameservers.isEmpty) {
+ changes.append("Initial DNS observation")
+ }
+ return changes.isEmpty ? nil : changes.joined(separator: " • ")
+ }
+
+ private static func url(from template: String, domain: String) -> URL? {
+ URL(string: template.replacingOccurrences(of: "{domain}", with: domain))
+ }
+
+ private static func normalize(_ domain: String) -> String {
+ domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ }
+
+ private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] {
+ sections
+ .first(where: { $0.recordType == type })?
+ .records
+ .map(\.value)
+ .sorted() ?? []
+ }
+
+ private static let iso8601DateFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }()
+}
diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift
index d23f348..535ba7c 100644
--- a/DomainDig/FeatureAccessService.swift
+++ b/DomainDig/FeatureAccessService.swift
@@ -29,6 +29,7 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
case ownershipHistory
case dnsHistory
case extendedSubdomains
+ case domainPricing
var id: String { rawValue }
@@ -52,6 +53,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
return "DNS history"
case .extendedSubdomains:
return "Extended subdomains"
+ case .domainPricing:
+ return "Domain pricing"
}
}
}
@@ -139,7 +142,7 @@ enum FeatureAccessService {
switch capability {
case .workflows, .batchOperations, .advancedExports:
return "Available in Pro"
- case .ownershipHistory, .dnsHistory, .extendedSubdomains:
+ case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
return "Available in Data+"
case .limitedTracking:
return "Tracking is limited on Free"
@@ -199,8 +202,15 @@ enum FeatureAccessService {
}
static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext {
- UpgradePromptContext(
- title: "Available in Pro",
+ let title: String
+ switch capability {
+ case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
+ title = "Available in Data+"
+ default:
+ title = "Available in Pro"
+ }
+ return UpgradePromptContext(
+ title: title,
message: upgradeMessage(for: capability),
capability: capability
)
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index e3a77f4..6eb6777 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -198,6 +198,9 @@ struct HistoryDetailView: View {
trackedDomain: trackedDomain,
workflows: viewModel.workflowsContaining(domain: entry.domain),
trackingLimitMessage: nil,
+ pricingLoading: false,
+ pricingError: snapshot.domainPricingError,
+ showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
onTrack: {
_ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
},
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 6b7c2c1..530a89e 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -370,6 +370,117 @@ enum DataCapability: String, Codable {
case domainPricing
}
+enum UsageCreditFeature: String, Codable, CaseIterable, Identifiable, Sendable {
+ case ownershipHistory
+ case dnsHistory
+ case extendedSubdomains
+
+ nonisolated var id: String { rawValue }
+
+ nonisolated var title: String {
+ switch self {
+ case .ownershipHistory:
+ return "Ownership history"
+ case .dnsHistory:
+ return "DNS history"
+ case .extendedSubdomains:
+ return "Extended subdomains"
+ }
+ }
+
+ nonisolated var defaultAllowance: Int {
+ switch self {
+ case .ownershipHistory, .dnsHistory:
+ return 8
+ case .extendedSubdomains:
+ return 12
+ }
+ }
+}
+
+struct UsageCreditStatus: Codable, Equatable, Sendable {
+ let feature: UsageCreditFeature
+ let remaining: Int
+ let total: Int
+ let resetContext: String
+
+ nonisolated var summary: String {
+ "\(remaining) uses remaining"
+ }
+
+ nonisolated var isExhausted: Bool {
+ remaining <= 0
+ }
+}
+
+struct DomainOwnershipHistoryEvent: Identifiable, Codable, Equatable, Sendable {
+ let id: UUID
+ let date: Date
+ let summary: String
+ let registrar: String?
+ let registrant: String?
+ let nameservers: [String]
+ let source: String
+ let isExternal: Bool
+
+ nonisolated init(
+ id: UUID = UUID(),
+ date: Date,
+ summary: String,
+ registrar: String? = nil,
+ registrant: String? = nil,
+ nameservers: [String] = [],
+ source: String,
+ isExternal: Bool
+ ) {
+ self.id = id
+ self.date = date
+ self.summary = summary
+ self.registrar = registrar
+ self.registrant = registrant
+ self.nameservers = nameservers
+ self.source = source
+ self.isExternal = isExternal
+ }
+}
+
+struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
+ let id: UUID
+ let date: Date
+ let summary: String
+ let aRecords: [String]
+ let nameservers: [String]
+ let source: String
+ let isExternal: Bool
+
+ nonisolated init(
+ id: UUID = UUID(),
+ date: Date,
+ summary: String,
+ aRecords: [String] = [],
+ nameservers: [String] = [],
+ source: String,
+ isExternal: Bool
+ ) {
+ self.id = id
+ self.date = date
+ self.summary = summary
+ self.aRecords = aRecords
+ self.nameservers = nameservers
+ self.source = source
+ self.isExternal = isExternal
+ }
+}
+
+struct DomainPricingInsight: Codable, Equatable, Sendable {
+ let estimatedPrice: String?
+ let premiumIndicator: Bool?
+ let resaleSignal: String?
+ let auctionSignal: String?
+ let source: String
+ let collectedAt: Date
+}
+
enum HistoryDateFilter: String, CaseIterable, Identifiable {
case today
case last7Days
@@ -691,6 +802,7 @@ struct IPGeolocation: Codable {
struct DomainOwnership: Codable, Equatable {
let registrar: String?
+ let registrant: String?
let createdDate: Date?
let expirationDate: Date?
let status: [String]
@@ -699,6 +811,7 @@ struct DomainOwnership: Codable, Equatable {
init(
registrar: String? = nil,
+ registrant: String? = nil,
createdDate: Date? = nil,
expirationDate: Date? = nil,
status: [String] = [],
@@ -706,6 +819,7 @@ struct DomainOwnership: Codable, Equatable {
abuseEmail: String? = nil
) {
self.registrar = registrar
+ self.registrant = registrant
self.createdDate = createdDate
self.expirationDate = expirationDate
self.status = status
@@ -716,6 +830,7 @@ struct DomainOwnership: Codable, Equatable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
+ registrant = try container.decodeIfPresent(String.self, forKey: .registrant)
createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
@@ -724,9 +839,17 @@ struct DomainOwnership: Codable, Equatable {
}
}
-struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable {
+struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable {
var id: String { hostname }
let hostname: String
+ let source: String?
+ let isExtended: Bool
+
+ nonisolated init(hostname: String, source: String? = nil, isExtended: Bool = false) {
+ self.hostname = hostname
+ self.source = source
+ self.isExtended = isExtended
+ }
}
// MARK: - Email Security Models
@@ -857,9 +980,13 @@ struct HistoryEntry: Identifiable, Codable {
var emailSecurity: EmailSecurityResult?
var mtaSts: MTASTSResult?
var ownership: DomainOwnership?
+ var ownershipHistory: [DomainOwnershipHistoryEvent]
var ptrRecord: String?
var redirectChain: [RedirectHop]
var subdomains: [DiscoveredSubdomain]
+ var extendedSubdomains: [DiscoveredSubdomain]
+ var dnsHistory: [DNSHistoryEvent]
+ var domainPricing: DomainPricingInsight?
var portScanResults: [PortScanResult]
var hstsPreloaded: Bool?
var availabilityResult: DomainAvailabilityResult?
@@ -891,16 +1018,23 @@ struct HistoryEntry: Identifiable, Codable {
var ipGeolocationError: String?
var emailSecurityError: String?
var ownershipError: String?
+ var ownershipHistoryError: String?
var ptrError: String?
var redirectChainError: String?
var subdomainsError: String?
+ var extendedSubdomainsError: String?
+ var dnsHistoryError: String?
+ var domainPricingError: String?
var portScanError: String?
init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
+ ownershipHistory: [DomainOwnershipHistoryEvent] = [],
ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
+ extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
+ domainPricing: DomainPricingInsight? = nil,
portScanResults: [PortScanResult] = [],
hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
@@ -915,8 +1049,10 @@ struct HistoryEntry: Identifiable, Codable {
tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
reachabilityError: String? = nil, ipGeolocationError: String? = nil,
- emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil,
- redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: String? = nil) {
+ emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
+ ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
+ extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil,
+ domainPricingError: String? = nil, portScanError: String? = nil) {
self.domain = domain
self.timestamp = timestamp
self.trackedDomainID = trackedDomainID
@@ -929,9 +1065,13 @@ struct HistoryEntry: Identifiable, Codable {
self.emailSecurity = emailSecurity
self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
self.ownership = ownership
+ self.ownershipHistory = ownershipHistory
self.ptrRecord = ptrRecord
self.redirectChain = redirectChain
self.subdomains = subdomains
+ self.extendedSubdomains = extendedSubdomains
+ self.dnsHistory = dnsHistory
+ self.domainPricing = domainPricing
self.portScanResults = portScanResults
self.hstsPreloaded = hstsPreloaded
self.availabilityResult = availabilityResult
@@ -963,9 +1103,13 @@ struct HistoryEntry: Identifiable, Codable {
self.ipGeolocationError = ipGeolocationError
self.emailSecurityError = emailSecurityError
self.ownershipError = ownershipError
+ self.ownershipHistoryError = ownershipHistoryError
self.ptrError = ptrError
self.redirectChainError = redirectChainError
self.subdomainsError = subdomainsError
+ self.extendedSubdomainsError = extendedSubdomainsError
+ self.dnsHistoryError = dnsHistoryError
+ self.domainPricingError = domainPricingError
self.portScanError = portScanError
}
@@ -984,9 +1128,13 @@ struct HistoryEntry: Identifiable, Codable {
emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
+ ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? []
ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
+ extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? []
+ dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? []
+ domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing)
portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
@@ -1018,9 +1166,13 @@ struct HistoryEntry: Identifiable, Codable {
ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
+ ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError)
ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
+ extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError)
+ dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError)
+ domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError)
portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
}
diff --git a/DomainDig/PaywallView.swift b/DomainDig/PaywallView.swift
index a21448f..d0293a8 100644
--- a/DomainDig/PaywallView.swift
+++ b/DomainDig/PaywallView.swift
@@ -10,7 +10,7 @@ struct PaywallView: View {
NavigationStack {
List {
Section {
- Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.")
+ Text("Pro unlocks workflows, scale, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.")
.font(appDensity.font(.body, design: .default))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -23,6 +23,13 @@ struct PaywallView: View {
featureRow("Advanced exports")
}
+ Section("What Data+ Unlocks") {
+ featureRow("Ownership history")
+ featureRow("DNS history")
+ featureRow("Extended subdomains")
+ featureRow("External pricing signals")
+ }
+
Section("Subscription") {
if purchaseService.isLoadingProducts {
ProgressView("Loading pricing…")
@@ -116,6 +123,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"
default:
return product.displayName
}
diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift
index 3d19165..20ee818 100644
--- a/DomainDig/PurchaseService.swift
+++ b/DomainDig/PurchaseService.swift
@@ -17,7 +17,14 @@ final class PurchaseService {
static let shared = PurchaseService()
static let monthlyProductID = "domaindig.pro.monthly"
static let yearlyProductID = "domaindig.pro.yearly"
- static let productIDs = [monthlyProductID, yearlyProductID]
+ static let dataPlusMonthlyProductID = "domaindig.dataplus.monthly"
+ static let dataPlusYearlyProductID = "domaindig.dataplus.yearly"
+ static let productIDs = [
+ monthlyProductID,
+ yearlyProductID,
+ dataPlusMonthlyProductID,
+ dataPlusYearlyProductID
+ ]
private static let entitlementCacheKey = "purchase.cachedEntitlement"
@@ -52,7 +59,11 @@ final class PurchaseService {
}
var hasProAccess: Bool {
- currentTier == .pro
+ currentTier != .free
+ }
+
+ var hasDataPlusAccess: Bool {
+ currentTier == .dataPlus
}
func refreshProducts() async {
@@ -91,7 +102,7 @@ final class PurchaseService {
.productID
self.activeProductID = activeProductID
- currentTier = activeProductID == nil ? .free : .pro
+ currentTier = tier(for: activeProductID)
persistCurrentEntitlement()
}
@@ -109,7 +120,7 @@ final class PurchaseService {
apply(transaction: transaction)
await transaction.finish()
await refreshEntitlements()
- statusMessage = "Pro is active."
+ statusMessage = currentTier == .dataPlus ? "Data+ is active." : "Pro is active."
case .userCancelled:
break
case .pending:
@@ -177,7 +188,7 @@ final class PurchaseService {
}
activeProductID = transaction.productID
- currentTier = .pro
+ currentTier = tier(for: transaction.productID)
persistCurrentEntitlement()
}
@@ -224,11 +235,26 @@ final class PurchaseService {
return 0
case Self.yearlyProductID:
return 1
+ case Self.dataPlusMonthlyProductID:
+ return 2
+ case Self.dataPlusYearlyProductID:
+ return 3
default:
return Int.max
}
}
+ private func tier(for productID: String?) -> FeatureTier {
+ switch productID {
+ case Self.monthlyProductID, Self.yearlyProductID:
+ return .pro
+ case Self.dataPlusMonthlyProductID, Self.dataPlusYearlyProductID:
+ return .dataPlus
+ default:
+ return .free
+ }
+ }
+
private func storeMessage(for error: Error, fallback: String) -> String {
if let storeKitError = error as? StoreKitError {
switch storeKitError {
diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift
index 3556624..e7e2c93 100644
--- a/DomainDig/RDAPService.swift
+++ b/DomainDig/RDAPService.swift
@@ -25,6 +25,7 @@ enum RDAPService {
case let .success(response):
let ownership = DomainOwnership(
registrar: response.registrarName,
+ registrant: response.registrantName,
createdDate: response.createdDate,
expirationDate: response.expirationDate,
status: response.status,
@@ -113,6 +114,10 @@ private struct RDAPDomainResponse: Decodable, Sendable {
entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName
}
+ var registrantName: String? {
+ entities?.first(where: { $0.roles.contains("registrant") })?.bestDisplayName
+ }
+
var createdDate: Date? {
eventDate(for: ["registration", "registered"])
}
diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift
index 4e25cf0..7339648 100644
--- a/DomainDig/SubdomainDiscoveryService.swift
+++ b/DomainDig/SubdomainDiscoveryService.swift
@@ -59,7 +59,7 @@ enum SubdomainDiscoveryService {
guard seen.insert(sanitized).inserted else {
continue
}
- results.append(DiscoveredSubdomain(hostname: sanitized))
+ results.append(DiscoveredSubdomain(hostname: sanitized, source: "crt.sh"))
if results.count == limit {
return results
}
diff --git a/DomainDig/UsageCreditService.swift b/DomainDig/UsageCreditService.swift
new file mode 100644
index 0000000..48f40be
--- /dev/null
+++ b/DomainDig/UsageCreditService.swift
@@ -0,0 +1,73 @@
+import Foundation
+
+actor UsageCreditService {
+ static let shared = UsageCreditService()
+
+ private struct CreditLedger: Codable {
+ let appVersion: String
+ var remainingByFeature: [UsageCreditFeature: Int]
+ }
+
+ private let storageKey = "usageCredits.ledger"
+ private var ledger: CreditLedger
+
+ init(defaults: UserDefaults = .standard) {
+ if let data = defaults.data(forKey: storageKey),
+ let decoded = try? JSONDecoder().decode(CreditLedger.self, from: data),
+ decoded.appVersion == AppVersion.current {
+ ledger = decoded
+ } else {
+ ledger = Self.makeLedger()
+ if let data = try? JSONEncoder().encode(ledger) {
+ defaults.set(data, forKey: storageKey)
+ }
+ }
+ }
+
+ func status(for feature: UsageCreditFeature) -> UsageCreditStatus {
+ let total = feature.defaultAllowance
+ let remaining = ledger.remainingByFeature[feature] ?? total
+ return UsageCreditStatus(
+ feature: feature,
+ remaining: remaining,
+ total: total,
+ resetContext: "Resets with app version \(ledger.appVersion)"
+ )
+ }
+
+ func allStatuses() -> [UsageCreditStatus] {
+ UsageCreditFeature.allCases.map { status(for: $0) }
+ }
+
+ func canUse(_ feature: UsageCreditFeature) -> Bool {
+ status(for: feature).remaining > 0
+ }
+
+ @discardableResult
+ func consume(_ feature: UsageCreditFeature) -> UsageCreditStatus {
+ let current = ledger.remainingByFeature[feature] ?? feature.defaultAllowance
+ ledger.remainingByFeature[feature] = max(0, current - 1)
+ persist()
+ return status(for: feature)
+ }
+
+ func resetForCurrentVersion() {
+ ledger = Self.makeLedger()
+ persist()
+ }
+
+ private static func makeLedger() -> CreditLedger {
+ CreditLedger(
+ appVersion: AppVersion.current,
+ remainingByFeature: Dictionary(
+ uniqueKeysWithValues: UsageCreditFeature.allCases.map { ($0, $0.defaultAllowance) }
+ )
+ )
+ }
+
+ private func persist(defaults: UserDefaults = .standard) {
+ if let data = try? JSONEncoder().encode(ledger) {
+ defaults.set(data, forKey: storageKey)
+ }
+ }
+}
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
index d0a4d86..a6d2cf3 100644
--- a/DomainDigCLI.swift
+++ b/DomainDigCLI.swift
@@ -6,12 +6,17 @@ struct DomainDigCLI {
let arguments = Array(CommandLine.arguments.dropFirst())
guard let command = CommandLine.arguments.first else {
- fputs("usage: domaindig <domain> [--json]\n", stderr)
+ fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr)
Foundation.exit(1)
}
_ = command
let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
+ let wantsOwnershipHistory = arguments.contains("--ownership-history")
+ 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
@@ -19,18 +24,29 @@ struct DomainDigCLI {
.filter { !$0.isEmpty }
guard !requestedDomains.isEmpty else {
- fputs("usage: domaindig <domain> [--json]\n", stderr)
+ fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr)
Foundation.exit(1)
}
let inspectionService = DomainInspectionService()
+ 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 }
- reports.append(await inspectionService.inspect(domain: domain))
+ let snapshot = await inspectionService.inspectSnapshot(domain: domain)
+ let enrichedSnapshot = await enrichSnapshot(
+ snapshot,
+ wantsOwnershipHistory: wantsOwnershipHistory,
+ wantsDNSHistory: wantsDNSHistory,
+ wantsExtendedSubdomains: wantsExtendedSubdomains,
+ wantsPricing: wantsPricing,
+ usageImpact: &usageImpact
+ )
+ reports.append(reportBuilder.build(from: enrichedSnapshot))
}
do {
@@ -47,6 +63,9 @@ 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]))
@@ -56,4 +75,187 @@ struct DomainDigCLI {
Foundation.exit(1)
}
}
+
+ private static func enrichSnapshot(
+ _ snapshot: LookupSnapshot,
+ wantsOwnershipHistory: Bool,
+ wantsDNSHistory: Bool,
+ wantsExtendedSubdomains: Bool,
+ wantsPricing: Bool,
+ usageImpact: inout [String]
+ ) async -> LookupSnapshot {
+ guard FeatureAccessService.currentTier == .dataPlus else {
+ return snapshot
+ }
+
+ let historyEntries = loadHistoryEntries()
+ var ownershipHistory = snapshot.ownershipHistory
+ var ownershipHistoryError = snapshot.ownershipHistoryError
+ var dnsHistory = snapshot.dnsHistory
+ var dnsHistoryError = snapshot.dnsHistoryError
+ var extendedSubdomains = snapshot.extendedSubdomains
+ var extendedSubdomainsError = snapshot.extendedSubdomainsError
+ var domainPricing = snapshot.domainPricing
+ var domainPricingError = snapshot.domainPricingError
+
+ if wantsOwnershipHistory,
+ await UsageCreditService.shared.canUse(.ownershipHistory) {
+ let outcome = await ExternalDataService.shared.ownershipHistory(
+ domain: snapshot.domain,
+ currentOwnership: snapshot.ownership,
+ historyEntries: historyEntries
+ )
+ switch outcome.value {
+ 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) {
+ let outcome = await ExternalDataService.shared.dnsHistory(
+ domain: snapshot.domain,
+ dnsSections: snapshot.dnsSections,
+ historyEntries: historyEntries
+ )
+ switch outcome.value {
+ 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) {
+ let outcome = await ExternalDataService.shared.extendedSubdomains(
+ domain: snapshot.domain,
+ existing: snapshot.subdomains
+ )
+ switch outcome.value {
+ 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
+ }
+ }
+
+ if wantsPricing {
+ let outcome = await ExternalDataService.shared.pricing(domain: snapshot.domain)
+ switch outcome.value {
+ case let .success(pricing):
+ domainPricing = pricing
+ domainPricingError = nil
+ case let .empty(message), let .error(message):
+ domainPricingError = message
+ }
+ }
+
+ return LookupSnapshot(
+ historyEntryID: snapshot.historyEntryID,
+ domain: snapshot.domain,
+ timestamp: snapshot.timestamp,
+ trackedDomainID: snapshot.trackedDomainID,
+ note: snapshot.note,
+ appVersion: snapshot.appVersion,
+ resolverDisplayName: snapshot.resolverDisplayName,
+ resolverURLString: snapshot.resolverURLString,
+ dataSources: snapshot.dataSources,
+ provenanceBySection: snapshot.provenanceBySection,
+ availabilityConfidence: snapshot.availabilityConfidence,
+ ownershipConfidence: snapshot.ownershipConfidence,
+ subdomainConfidence: snapshot.subdomainConfidence,
+ emailSecurityConfidence: snapshot.emailSecurityConfidence,
+ geolocationConfidence: snapshot.geolocationConfidence,
+ errorDetails: snapshot.errorDetails,
+ isPartialSnapshot: snapshot.isPartialSnapshot,
+ validationIssues: snapshot.validationIssues,
+ totalLookupDurationMs: snapshot.totalLookupDurationMs,
+ dnsSections: snapshot.dnsSections,
+ dnsError: snapshot.dnsError,
+ availabilityResult: snapshot.availabilityResult,
+ suggestions: snapshot.suggestions,
+ sslInfo: snapshot.sslInfo,
+ sslError: snapshot.sslError,
+ hstsPreloaded: snapshot.hstsPreloaded,
+ httpHeaders: snapshot.httpHeaders,
+ httpSecurityGrade: snapshot.httpSecurityGrade,
+ httpStatusCode: snapshot.httpStatusCode,
+ httpResponseTimeMs: snapshot.httpResponseTimeMs,
+ httpProtocol: snapshot.httpProtocol,
+ http3Advertised: snapshot.http3Advertised,
+ httpHeadersError: snapshot.httpHeadersError,
+ reachabilityResults: snapshot.reachabilityResults,
+ reachabilityError: snapshot.reachabilityError,
+ ipGeolocation: snapshot.ipGeolocation,
+ ipGeolocationError: snapshot.ipGeolocationError,
+ emailSecurity: snapshot.emailSecurity,
+ emailSecurityError: snapshot.emailSecurityError,
+ ownership: snapshot.ownership,
+ ownershipError: snapshot.ownershipError,
+ ownershipHistory: ownershipHistory,
+ ownershipHistoryError: ownershipHistoryError,
+ ptrRecord: snapshot.ptrRecord,
+ ptrError: snapshot.ptrError,
+ redirectChain: snapshot.redirectChain,
+ redirectChainError: snapshot.redirectChainError,
+ subdomains: snapshot.subdomains,
+ subdomainsError: snapshot.subdomainsError,
+ extendedSubdomains: extendedSubdomains,
+ extendedSubdomainsError: extendedSubdomainsError,
+ dnsHistory: dnsHistory,
+ dnsHistoryError: dnsHistoryError,
+ domainPricing: domainPricing,
+ domainPricingError: domainPricingError,
+ portScanResults: snapshot.portScanResults,
+ portScanError: snapshot.portScanError,
+ changeSummary: snapshot.changeSummary,
+ resultSource: snapshot.resultSource,
+ cachedSections: snapshot.cachedSections,
+ statusMessage: snapshot.statusMessage
+ )
+ }
+
+ private static func loadHistoryEntries() -> [HistoryEntry] {
+ guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
+ let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
+ return []
+ }
+ return entries
+ }
}
diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift
index b906b83..a4a1172 100644
--- a/DomainInspectionService.swift
+++ b/DomainInspectionService.swift
@@ -313,12 +313,20 @@ struct DomainInspectionService {
emailSecurityError: emailSecurity.message,
ownership: ownership.value,
ownershipError: ownership.message,
+ ownershipHistory: [],
+ ownershipHistoryError: nil,
ptrRecord: ptrRecord.value,
ptrError: ptrRecord.message,
redirectChain: redirectChain.value,
redirectChainError: redirectChain.message,
subdomains: subdomains.value,
subdomainsError: subdomains.message,
+ extendedSubdomains: [],
+ extendedSubdomainsError: nil,
+ dnsHistory: [],
+ dnsHistoryError: nil,
+ domainPricing: nil,
+ domainPricingError: nil,
portScanResults: portScanResults.value,
portScanError: portScanResults.message,
changeSummary: nil,
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
index 0f42519..3ed88ac 100644
--- a/DomainReportBuilder.swift
+++ b/DomainReportBuilder.swift
@@ -21,11 +21,15 @@ struct DomainReport: Codable {
let emailConfidence: ConfidenceLevel?
let geolocationConfidence: ConfidenceLevel?
let ownership: DomainOwnership?
+ let ownershipHistory: [DomainOwnershipHistoryEvent]
let dns: DNSResultSummary
let web: WebResultSummary
let email: EmailSecuritySummary
let network: NetworkSummary
let subdomains: [String]
+ let extendedSubdomains: [String]
+ let dnsHistory: [DNSHistoryEvent]
+ let domainPricing: DomainPricingInsight?
let subdomainGroups: [SubdomainGroup]
let riskAssessment: DomainRiskAssessment
let insights: [String]
@@ -164,6 +168,7 @@ struct DomainReportBuilder {
emailConfidence: snapshot.emailSecurityConfidence,
geolocationConfidence: snapshot.geolocationConfidence,
ownership: snapshot.ownership,
+ ownershipHistory: snapshot.ownershipHistory,
dns: DNSResultSummary(
resolverDisplayName: snapshot.resolverDisplayName,
resolverURLString: snapshot.resolverURLString,
@@ -216,13 +221,16 @@ struct DomainReportBuilder {
portScanError: snapshot.portScanError
),
subdomains: snapshot.subdomains.map(\.hostname),
+ extendedSubdomains: snapshot.extendedSubdomains.map(\.hostname),
+ dnsHistory: snapshot.dnsHistory,
+ domainPricing: snapshot.domainPricing,
subdomainGroups: analysis.subdomainGroups,
riskAssessment: analysis.riskAssessment,
insights: analysis.insights,
changeSummary: changeSummary,
workflowContext: workflowContext,
metadata: DomainReportMetadata(
- schemaVersion: "3.0.0",
+ schemaVersion: "3.2.0",
resolverDisplayName: snapshot.resolverDisplayName,
resolverURLString: snapshot.resolverURLString,
appVersion: snapshot.appVersion,
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
index b7d4606..4fa7470 100644
--- a/DomainReportExporter.swift
+++ b/DomainReportExporter.swift
@@ -67,7 +67,9 @@ enum DomainReportExporter {
"TLS Status: \(report.web.tlsStatus)",
"HTTP: \(httpSummary(for: report))",
"Email: \(report.email.summary)",
- "Subdomains: \(report.subdomains.count)"
+ "Subdomains: \(report.subdomains.count)",
+ "Extended Subdomains: \(report.extendedSubdomains.count)",
+ "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")"
]
}
@@ -97,6 +99,7 @@ enum DomainReportExporter {
"Confidence: \(report.ownershipConfidence?.title ?? "N/A")",
"Created: \(ownershipDateLabel(report.ownership?.createdDate))",
"Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
+ "Registrant: \(report.ownership?.registrant ?? "Unavailable")",
"Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
"Status: \(joined(report.ownership?.status) ?? "Unavailable")",
"Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
@@ -112,6 +115,20 @@ enum DomainReportExporter {
return ownershipLines
}
+ appendSection("Ownership History", to: &lines) {
+ guard !report.ownershipHistory.isEmpty else {
+ return ["No ownership history available"]
+ }
+
+ return report.ownershipHistory.map { event in
+ [
+ textDateFormatter.string(from: event.date),
+ event.summary,
+ "source=\(event.source)"
+ ].joined(separator: " | ")
+ }
+ }
+
appendSection("DNS", to: &lines) {
var dnsLines = [
"Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
@@ -139,6 +156,22 @@ enum DomainReportExporter {
return dnsLines
}
+ appendSection("DNS History", to: &lines) {
+ guard !report.dnsHistory.isEmpty else {
+ return ["No DNS history available"]
+ }
+
+ return report.dnsHistory.map { event in
+ [
+ textDateFormatter.string(from: event.date),
+ event.summary,
+ "A=\(event.aRecords.joined(separator: " | ").nilIfEmpty ?? "-")",
+ "NS=\(event.nameservers.joined(separator: " | ").nilIfEmpty ?? "-")",
+ "source=\(event.source)"
+ ].joined(separator: " | ")
+ }
+ }
+
appendSection("Web", to: &lines) {
var webLines = [
"TLS Status: \(report.web.tlsStatus)",
@@ -254,9 +287,28 @@ enum DomainReportExporter {
values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
}
values.append(contentsOf: report.subdomains.map { "- \($0)" })
+ if !report.extendedSubdomains.isEmpty {
+ values.append("Extended:")
+ values.append(contentsOf: report.extendedSubdomains.map { "- \($0)" })
+ }
return values
}
+ appendSection("Pricing", to: &lines) {
+ guard let pricing = report.domainPricing else {
+ return ["External pricing unavailable"]
+ }
+
+ return [
+ "Estimated Price: \(pricing.estimatedPrice ?? "Unavailable")",
+ "Premium: \(pricing.premiumIndicator == true ? "Yes" : "No")",
+ "Resale: \(pricing.resaleSignal ?? "Unavailable")",
+ "Auction: \(pricing.auctionSignal ?? "Unavailable")",
+ "Source: \(pricing.source)",
+ "Collected: \(textDateFormatter.string(from: pricing.collectedAt))"
+ ]
+ }
+
appendSection("Changes", to: &lines) {
guard let changeSummary = report.changeSummary else {
return ["No comparison available"]
@@ -342,9 +394,18 @@ enum DomainReportExporter {
"email_grade",
"email_confidence",
"subdomain_count",
+ "extended_subdomain_count",
"subdomain_groups",
"subdomain_confidence",
"subdomains",
+ "extended_subdomains",
+ "ownership_history",
+ "dns_history",
+ "pricing_estimated",
+ "pricing_premium",
+ "pricing_resale_signal",
+ "pricing_auction_signal",
+ "pricing_source",
"open_ports",
"reachability_summary",
"geolocation_summary",
@@ -368,12 +429,15 @@ enum DomainReportExporter {
let httpStatus = report.web.statusCode.map(String.init) ?? ""
let subdomainCount = String(report.subdomains.count)
let subdomains = report.subdomains.joined(separator: " | ")
+ let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ")
let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ")
let insights = report.insights.joined(separator: " | ")
let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ")
let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ")
let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
+ let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
+ let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
return [
report.domain,
@@ -407,9 +471,18 @@ enum DomainReportExporter {
report.email.grade?.rawValue ?? "",
report.emailConfidence?.rawValue ?? "",
subdomainCount,
+ String(report.extendedSubdomains.count),
subdomainGroups,
report.subdomainConfidence?.rawValue ?? "",
subdomains,
+ extendedSubdomains,
+ ownershipHistory,
+ dnsHistory,
+ report.domainPricing?.estimatedPrice ?? "",
+ report.domainPricing?.premiumIndicator == true ? "true" : "false",
+ report.domainPricing?.resaleSignal ?? "",
+ report.domainPricing?.auctionSignal ?? "",
+ report.domainPricing?.source ?? "",
openPorts,
report.network.reachabilitySummary,
report.network.geolocationSummary,
diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift
index f51f546..a817805 100644
--- a/LookupSnapshot.swift
+++ b/LookupSnapshot.swift
@@ -42,12 +42,20 @@ struct LookupSnapshot {
let emailSecurityError: String?
let ownership: DomainOwnership?
let ownershipError: String?
+ let ownershipHistory: [DomainOwnershipHistoryEvent]
+ let ownershipHistoryError: String?
let ptrRecord: String?
let ptrError: String?
let redirectChain: [RedirectHop]
let redirectChainError: String?
let subdomains: [DiscoveredSubdomain]
let subdomainsError: String?
+ let extendedSubdomains: [DiscoveredSubdomain]
+ let extendedSubdomainsError: String?
+ let dnsHistory: [DNSHistoryEvent]
+ let dnsHistoryError: String?
+ let domainPricing: DomainPricingInsight?
+ let domainPricingError: String?
let portScanResults: [PortScanResult]
let portScanError: String?
let changeSummary: DomainChangeSummary?
@@ -104,12 +112,20 @@ extension HistoryEntry {
emailSecurityError: emailSecurityError,
ownership: ownership,
ownershipError: ownershipError,
+ ownershipHistory: ownershipHistory,
+ ownershipHistoryError: ownershipHistoryError,
ptrRecord: ptrRecord,
ptrError: ptrError,
redirectChain: redirectChain,
redirectChainError: redirectChainError,
subdomains: subdomains,
subdomainsError: subdomainsError,
+ extendedSubdomains: extendedSubdomains,
+ extendedSubdomainsError: extendedSubdomainsError,
+ dnsHistory: dnsHistory,
+ dnsHistoryError: dnsHistoryError,
+ domainPricing: domainPricing,
+ domainPricingError: domainPricingError,
portScanResults: portScanResults,
portScanError: portScanError,
changeSummary: changeSummary,