diff options
| author | Christian Cleberg <[email protected]> | 2026-04-22 13:47:37 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-22 13:47:37 -0500 |
| commit | 6c23daaaba2a954220ece2afa7193dbdcad55c22 (patch) | |
| tree | febfca71eba8a025374d993e7a39fdb13194af25 /DomainDig/DomainViewModel.swift | |
| parent | dcf4135ec376d357b8fafc0101a75b674e76329a (diff) | |
| download | domain-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
Diffstat (limited to 'DomainDig/DomainViewModel.swift')
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 338 |
1 files changed, 336 insertions, 2 deletions
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)\"" |
